feat: 在线客服(移动端)聊天静态界面

master
LCJ-MinYa 4 months ago
parent e025ee6f64
commit b6997d4158

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

@ -118,6 +118,10 @@ const titleArr = [
key: 'scssLiveTranslate',
title: 'SCSS → CSS 实时翻译器',
},
{
key: 'chat',
title: '聊天(智能客服)(移动端查看)',
},
];
// @/views/demo/**/*.vue

@ -0,0 +1,22 @@
import { defineStore } from 'pinia';
export default defineStore('chat', {
state: () => ({
messages: [
{
role: 'system',
content: '查询中,请稍后!',
cmd: 'common',
timestamp: new Date().getTime(),
},
],
}),
actions: {
addMessage(message: Array<[]>) {
this.messages.push({
...message,
timestamp: new Date().getTime(),
});
},
},
});

@ -0,0 +1,133 @@
<template>
<div class="chat-input">
<!-- 输入框 -->
<div class="input-box">
<input
type="text"
class="input"
:value="input"
readonly
placeholder=""
/>
</div>
<!-- 数字键盘 -->
<div class="keyboard-grid">
<div
v-for="(key, index) in keyboardKeys"
:key="index"
class="keyboard-key"
:class="{ 'keyboard-key-delete': key === 'delete', 'keyboard-key-confirm': key === 'confirm' }"
@click="handleInput(key)"
>
<span v-if="key === 'delete'">
<span>删除</span>
</span>
<span v-else-if="key === 'confirm'">发送</span>
<span v-else>{{ key }}</span>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
import useChatStore from '@/store/modules/chat';
const $store = useChatStore();
const addMessage = (message) => {
$store.addMessage({
role: 'user',
cmd: 'common',
senderName: '匿名',
content: message,
});
input.value = '';
};
const input = ref('');
const keyboardKeys = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '0', '#', 'delete', 'confirm'];
const handleInput = (char) => {
if (char === 'delete') {
input.value = input.value.slice(0, -1);
} else if (char === 'confirm') {
addMessage(input.value);
} else {
input.value += char;
}
};
</script>
<style scoped>
.chat-input {
position: relative;
height: 320px;
background: #fff;
display: flex;
flex-direction: column;
z-index: 9;
}
.input-box {
box-sizing: border-box;
position: relative;
width: 100%;
height: 70px;
padding: 15px;
}
.input {
box-sizing: border-box;
border-radius: 8px;
overflow: hidden;
display: block;
width: 100%;
height: 100%;
border: 2px solid transparent;
background:
linear-gradient(white, white) padding-box,
linear-gradient(45deg, #f6855c, #e29cf9, #5cacfe) border-box;
padding-left: 5px;
}
.keyboard-grid {
position: relative;
width: 100%;
height: 250px;
background: #d1d5db;
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-rows: repeat(4, 1fr);
grid-gap: 10px 6px;
padding: 10px 6px;
box-sizing: border-box;
}
.keyboard-grid .keyboard-key {
display: flex;
align-items: center;
justify-content: center;
font-size: 25px;
border-radius: 5px;
box-shadow: 0 1px 0 0 #898a8d;
background: #fff;
}
.keyboard-key-delete,
.keyboard-key-confirm {
grid-column: 4;
font-size: 18px !important;
}
.keyboard-key-delete {
grid-row: 1 / span 2;
}
.keyboard-key-confirm {
grid-row: 3 / span 2;
}
.keyboard-key-delete > span {
display: flex;
flex-direction: column;
align-items: center;
justify-self: center;
}
.keyboard-key-delete > span > span {
margin-top: 5px;
}
</style>

@ -0,0 +1,467 @@
<template>
<div class="chat-wrap">
<div class="chat-messages">
<div
v-for="(message, index) in messages"
:key="index"
:class="['chat-message', message.role]"
>
<template v-if="message.role === 'user'">
<div
class="chat-user-content"
v-if="message.cmd === 'common'"
>
<div class="chat-user-info">
<span>{{ handleTimestamp(message.timestamp) }}</span>
<span>{{ message.senderName }}</span>
</div>
<div class="chat-content">
<span>{{ message.content }}</span>
</div>
</div>
<img
class="chat-avatar"
src="@/assets/img/user.png"
/>
</template>
<template v-if="message.role === 'system'">
<img
class="chat-avatar"
src="@/assets/img/system.png"
/>
<template v-if="message.cmd === 'common'">
<div class="chat-content">
<span>{{ message.content }}</span>
</div>
</template>
<template v-else-if="message.cmd === 'select_case'">
<div class="chat-content">
<span>{{ message.content }}</span>
</div>
<div class="case-container">
<div class="case-item title">
<span>报案号后8位</span>
<span>报案时间</span>
</div>
<div
class="case-item content"
v-for="(item, index) in message.extra.caseList"
:key="index"
@click="chooseCase(item.caseNo)"
:class="{ disabled: currentCase, active: item.caseNo === currentCase }"
>
<span>{{ item.caseNo }}</span>
<span>{{ item.date }}</span>
</div>
<div
class="case-item button"
:class="{ disabled: currentCase }"
>
<div>其他案件</div>
</div>
</div>
</template>
<template
v-else-if="
message.cmd === 'select_loss_type' ||
message.cmd === 'select_repair_type' ||
message.cmd === 'select_localtion' ||
message.cmd === 'confirm_localtion'
"
>
<div class="chat-content">
<span>{{ message.content }}</span>
<div class="base-btn-container">
<div
v-for="(item, index) in message.extra.list"
:key="index"
class="item"
:class="{
disabled: getCurrentName(message.cmd),
choose: item.id === getCurrentName(message.cmd),
localtion: message.cmd === 'select_localtion',
}"
@click="chooseType(item.id, message.cmd)"
>
<div v-if="message.cmd === 'select_loss_type'">{{ item.id }}-{{ item.text }}</div>
<div v-else>{{ item.text }}</div>
</div>
</div>
</div>
</template>
</template>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, nextTick, onMounted } from 'vue';
import useChatStore from '@/store/modules/chat';
const $store = useChatStore();
const messages = computed(() => $store.messages);
const currentCase = ref(null);
const currentLossType = ref(null);
const currentRepairType = ref(null);
const currentLocation = ref(null);
const confirmLocation = ref(null);
const addMessage = (message) => {
$store.addMessage(message);
};
const getCurrentName = (cmd) => {
switch (cmd) {
case 'select_loss_type':
return currentLossType.value;
case 'select_repair_type':
return currentRepairType.value;
case 'select_localtion':
return currentLocation.value;
case 'confirm_localtion':
return confirmLocation.value;
}
};
const chooseCase = (caseNo) => {
currentCase.value = caseNo;
addMessage({
role: 'system',
cmd: 'select_loss_type',
content: '请根据页面选择定损类型本车定损请按1对方车定损请按2物损请按3人伤请按4',
extra: {
list: [
{
id: '1',
text: '本车定损',
},
{
id: '2',
text: '对方车定损',
},
{
id: '3',
text: '物损',
},
{
id: '4',
text: '人伤',
},
],
},
});
};
const chooseType = (id, cmd) => {
if (cmd === 'select_loss_type') {
currentLossType.value = id;
addMessage({
role: 'user',
cmd: 'common',
content: id,
});
addMessage({
role: 'system',
cmd: 'select_repair_type',
content: '请选择您的车辆维修位置已在修理厂请按1有意向修理厂请按2无意向修理厂请按3',
extra: {
list: [
{
id: '1',
text: '已在修理厂',
},
{
id: '2',
text: '意向修理厂',
},
{
id: '3',
text: '无意向修理厂',
},
],
},
});
} else if (cmd === 'select_repair_type') {
currentRepairType.value = id;
addMessage({
role: 'user',
cmd: 'common',
content: id,
});
addMessage({
role: 'system',
cmd: 'select_localtion',
content: '请点击页面上定位功能,确认您的修车位置',
extra: {
list: [
{
id: '1',
text: '地理位置授权',
},
],
},
});
} else if (cmd === 'select_localtion') {
currentLocation.value = id;
addMessage({
role: 'system',
cmd: 'confirm_localtion',
content: '修车地址为京城大道100号正确请按1修改地址请按2',
extra: {
list: [
{
id: '1',
text: '正确',
},
{
id: '2',
text: '修改地址',
},
],
},
});
} else if (cmd === 'confirm_localtion') {
confirmLocation.value = id;
addMessage({
role: 'user',
cmd: 'common',
content: id,
});
addMessage({
role: 'system',
cmd: 'common',
content: '已安排定损员XX为您处理请保持电话畅通稍后定损员会联系您。祝您平安再见',
});
}
};
const handleTimestamp = (timestamp) => {
const date = new Date(timestamp);
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
return `${hours}:${minutes}`;
};
watch(
messages,
(newVal) => {
if (newVal.length > 0) {
nextTick(() => {
const container = document.querySelector('.chat-wrap');
if (container) {
container.scrollTop = container.scrollHeight;
}
});
}
},
{
deep: true,
}
);
onMounted(() => {
setTimeout(() => {
addMessage({
role: 'system',
cmd: 'select_case',
content: '请根据页面展示选择您要定损的按键',
extra: {
caseList: [
{
caseNo: '09152401',
date: '2025年07月25日12点',
},
{
caseNo: '09152402',
date: '2025年07月25日14点',
},
{
caseNo: '09152403',
date: '2025年07月25日16点',
},
{
caseNo: '09152404',
date: '2025年07月25日18点',
},
],
},
});
}, 500);
});
</script>
<style scoped>
.chat-wrap {
flex: 1;
overflow-y: auto;
box-sizing: border-box;
}
.chat-messages {
display: flex;
flex-direction: column;
min-height: 100%;
padding: 10px 0;
box-sizing: border-box;
}
.chat-message {
display: flex;
justify-content: flex-start;
padding: 8px;
flex-wrap: wrap;
}
.chat-message.system {
justify-content: flex-start;
}
.chat-message.user {
justify-content: flex-end;
}
.chat-avatar {
display: block;
width: 36px;
height: 36px;
border-radius: 50%;
}
.chat-content {
max-width: 70%;
display: inline-block;
background: #fff;
padding: 8px 12px;
margin: 16px 10px 0;
font-size: 14px;
color: #000;
line-height: 24px;
border-radius: 0px 10px 10px 10px;
}
.chat-user-content {
display: flex;
flex-direction: column;
align-items: flex-end;
}
.chat-user-content .chat-user-info {
margin: 0 10px;
font-size: 12px;
color: #a0a0a0;
}
.chat-user-content .chat-user-info > span:first-child {
margin-right: 10px;
}
.chat-user-content .chat-content {
background: #ffe9e0;
margin-top: 5px;
border-radius: 10px 0px 10px 10px;
}
.case-container {
box-sizing: border-box;
margin: 16px;
padding: 10px 10px 0;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
background: #fff;
border-radius: 8px;
}
.case-container .case-item {
width: 100%;
padding: 8px 0;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 14px;
}
.case-container .case-item.title {
color: rgba(0, 0, 0, 0.4);
}
.case-container .case-item.content > span:first-child {
font-size: 18px;
font-weight: bold;
}
.case-container .case-item.content > span:last-child {
font-size: 13px;
color: rgba(0, 0, 0, 0.6);
}
.case-container .case-item.content > span:last-child > .van-icon {
margin-left: 5px;
color: #f39354;
}
.case-container .case-item.content.active span {
color: #f39354 !important;
}
.case-container .case-item.content.disabled {
cursor: not-allowed;
pointer-events: none;
}
.case-container .case-item.content.disabled span {
color: rgba(0, 0, 0, 0.2);
cursor: not-allowed;
pointer-events: none;
opacity: 0.6;
}
.case-container .case-item.button {
flex-wrap: wrap;
justify-content: flex-start;
}
.case-container .case-item.button > div {
box-sizing: border-box;
width: calc(50% - 5px);
height: 34px;
border: 1px solid #f39354;
border-radius: 8px;
font-size: 14px;
color: #f39354;
line-height: 34px;
text-align: center;
margin-bottom: 10px;
}
.case-container .case-item.button > div:nth-child(2n) {
margin-left: 10px;
}
.case-container .case-item.button.disabled div {
border: 1px solid #e4e4e4;
color: rgba(0, 0, 0, 0.2);
cursor: not-allowed;
pointer-events: none;
opacity: 0.6;
}
.base-btn-container {
box-sizing: border-box;
width: 100%;
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
padding-top: 10px;
}
.base-btn-container .item {
box-sizing: border-box;
width: calc(50% - 5px);
height: 34px;
border: 1px solid #f39354;
border-radius: 8px;
font-size: 14px;
color: #f39354;
line-height: 34px;
text-align: center;
margin-bottom: 10px;
}
.base-btn-container .item:nth-child(2n) {
margin-left: 10px;
}
.base-btn-container .item.disabled {
border: 1px solid #e4e4e4;
color: rgba(0, 0, 0, 0.2);
cursor: not-allowed;
pointer-events: none;
opacity: 0.6;
}
.base-btn-container .item.choose {
background: #f3f3f3;
}
.base-btn-container .item.localtion {
width: 100%;
}
</style>

@ -0,0 +1,25 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
import ChatInput from './components/ChatInput.vue';
import ChatMessages from './components/ChatMessages.vue';
</script>
<template>
<div class="chat-container">
<!-- 聊天内容区域 -->
<ChatMessages />
<!-- 底部输入区域 -->
<ChatInput />
</div>
</template>
<style scoped>
.chat-container {
width: 100vw;
height: calc(100vh - 48px - 33px);
display: flex;
flex-direction: column;
background-color: #f7f8fa;
}
</style>
Loading…
Cancel
Save