一、技术实现方案
1.1 系统架构
客服系统架构
├── 消息模块
│ ├── 实时推送 (WebSocket)
│ ├── 消息类型 (文本/图片/文件)
│ ├── 历史加载 (分页查询)
│ └── 消息存储 (IndexedDB)
│
├── 会话模块
│ ├── 会话列表
│ ├── 会话状态管理
│ └── 会话切换
│
└── 通知模块
├── 未读提醒
├── 桌面通知
└── 消息提示音
1.2 技术栈
- 前端框架: Vue 3 + Composition API
- 通信: WebSocket + HTTP
- 存储: IndexedDB (Dexie.js)
- 文件上传: FormData + Axios
- 通知: Notification API
二、实时消息推送
2.1 消息管理器
message-manager.js
import { ref, computed } from 'vue'
export class MessageManager {
constructor() {
this.messages = ref(new Map()) // sessionId -> messages[]
this.unreadCount = ref(new Map()) // sessionId -> count
this.currentSession = ref(null)
}
// 添加消息
addMessage(sessionId, message) {
if (!this.messages.value.has(sessionId)) {
this.messages.value.set(sessionId, [])
}
const sessionMessages = this.messages.value.get(sessionId)
sessionMessages.push({
id: message.id || this.generateId(),
type: message.type || 'text',
content: message.content,
senderId: message.senderId,
senderName: message.senderName,
timestamp: message.timestamp || Date.now(),
status: message.status || 'sent', // sending, sent, failed, read
file: message.file || null
})
// 更新未读数
if (sessionId !== this.currentSession.value && message.senderId !== 'me') {
const count = this.unreadCount.value.get(sessionId) || 0
this.unreadCount.value.set(sessionId, count + 1)
}
// 触发更新
this.messages.value = new Map(this.messages.value)
}
// 获取会话消息
getMessages(sessionId) {
return this.messages.value.get(sessionId) || []
}
// 标记消息为已读
markAsRead(sessionId) {
this.unreadCount.value.set(sessionId, 0)
this.unreadCount.value = new Map(this.unreadCount.value)
// 更新消息状态
const messages = this.messages.value.get(sessionId)
if (messages) {
messages.forEach(msg => {
if (msg.senderId !== 'me' && msg.status !== 'read') {
msg.status = 'read'
}
})
}
}
// 更新消息状态
updateMessageStatus(messageId, status) {
for (const [sessionId, messages] of this.messages.value) {
const message = messages.find(m => m.id === messageId)
if (message) {
message.status = status
this.messages.value = new Map(this.messages.value)
break
}
}
}
// 切换会话
switchSession(sessionId) {
this.currentSession.value = sessionId
this.markAsRead(sessionId)
}
// 获取未读总数
getTotalUnread() {
let total = 0
for (const count of this.unreadCount.value.values()) {
total += count
}
return total
}
// 生成ID
generateId() {
return `msg_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
}
}
2.2 WebSocket消息处理
useCustomerService.js
import { ref, onMounted } from 'vue'
import { MessageManager } from './message-manager.js'
export function useCustomerService(wsUrl) {
const ws = ref(null)
const isConnected = ref(false)
const messageManager = new MessageManager()
const sessions = ref([]) // 会话列表
// 初始化WebSocket
const initWebSocket = () => {
ws.value = new WebSocket(wsUrl)
ws.value.onopen = () => {
isConnected.value = true
console.log('客服系统已连接')
}
ws.value.onmessage = (event) => {
const data = JSON.parse(event.data)
handleMessage(data)
}
ws.value.onclose = () => {
isConnected.value = false
console.log('客服系统断开连接')
// 3秒后重连
setTimeout(initWebSocket, 3000)
}
ws.value.onerror = (error) => {
console.error('WebSocket错误:', error)
}
}
// 处理收到的消息
const handleMessage = (data) => {
switch (data.type) {
case 'message':
// 新消息
messageManager.addMessage(data.sessionId, {
id: data.messageId,
type: data.messageType,
content: data.content,
senderId: data.senderId,
senderName: data.senderName,
timestamp: data.timestamp,
file: data.file
})
// 显示通知
showNotification(data)
// 播放提示音
playNotificationSound()
break
case 'read':
// 消息已读
messageManager.updateMessageStatus(data.messageId, 'read')
break
case 'session_created':
// 新会话
addSession(data.session)
break
case 'session_closed':
// 会话关闭
updateSessionStatus(data.sessionId, 'closed')
break
}
}
// 发送消息
const sendMessage = (sessionId, content, type = 'text', file = null) => {
const message = {
type: 'message',
messageType: type,
sessionId: sessionId,
content: content,
senderId: 'me',
senderName: '我',
timestamp: Date.now(),
file: file
}
// 立即添加到本地
messageManager.addMessage(sessionId, {
...message,
status: 'sending'
})
// 发送到服务器
if (ws.value && ws.value.readyState === WebSocket.OPEN) {
ws.value.send(JSON.stringify(message))
// 模拟发送成功
setTimeout(() => {
messageManager.updateMessageStatus(message.id, 'sent')
}, 500)
} else {
messageManager.updateMessageStatus(message.id, 'failed')
}
}
// 添加会话
const addSession = (session) => {
const exists = sessions.value.find(s => s.id === session.id)
if (!exists) {
sessions.value.unshift(session)
}
}
// 更新会话状态
const updateSessionStatus = (sessionId, status) => {
const session = sessions.value.find(s => s.id === sessionId)
if (session) {
session.status = status
}
}
// 显示桌面通知
const showNotification = (data) => {
if (!('Notification' in window)) return
if (Notification.permission !== 'granted') return
new Notification('新消息', {
body: `${data.senderName}: ${data.content}`,
icon: '/notification-icon.png',
tag: data.sessionId
})
}
// 播放提示音
const playNotificationSound = () => {
const audio = new Audio('/notification.mp3')
audio.play().catch(err => console.log('播放提示音失败:', err))
}
// 请求通知权限
const requestNotificationPermission = () => {
if ('Notification' in window && Notification.permission === 'default') {
Notification.requestPermission()
}
}
onMounted(() => {
initWebSocket()
requestNotificationPermission()
})
return {
isConnected,
messageManager,
sessions,
sendMessage
}
}
2.3 客服聊天组件
CustomerChat.vue
<script setup>
import { ref, computed, nextTick, watch } from 'vue'
import { useCustomerService } from './useCustomerService.js'
const props = defineProps({
wsUrl: {
type: String,
required: true
}
})
const { isConnected, messageManager, sessions, sendMessage } = useCustomerService(props.wsUrl)
const currentSessionId = ref(null)
const inputMessage = ref('')
const messageContainer = ref(null)
// 当前会话的消息
const currentMessages = computed(() => {
if (!currentSessionId.value) return []
return messageManager.getMessages(currentSessionId.value)
})
// 当前会话信息
const currentSession = computed(() => {
return sessions.value.find(s => s.id === currentSessionId.value)
})
// 未读总数
const totalUnread = computed(() => {
return messageManager.getTotalUnread()
})
// 切换会话
const switchSession = (sessionId) => {
currentSessionId.value = sessionId
messageManager.switchSession(sessionId)
nextTick(() => {
scrollToBottom()
})
}
// 发送消息
const handleSendMessage = () => {
if (!inputMessage.value.trim() || !currentSessionId.value) return
sendMessage(currentSessionId.value, inputMessage.value, 'text')
inputMessage.value = ''
nextTick(() => {
scrollToBottom()
})
}
// 滚动到底部
const scrollToBottom = () => {
if (messageContainer.value) {
messageContainer.value.scrollTop = messageContainer.value.scrollHeight
}
}
// 监听消息变化
watch(() => currentMessages.value.length, () => {
nextTick(() => {
scrollToBottom()
})
})
// 获取会话未读数
const getUnreadCount = (sessionId) => {
return messageManager.unreadCount.value.get(sessionId) || 0
}
// 格式化时间
const formatTime = (timestamp) => {
const date = new Date(timestamp)
return date.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
})
}
// 初始化:选择第一个会话
watch(() => sessions.value.length, (newLen) => {
if (newLen > 0 && !currentSessionId.value) {
switchSession(sessions.value[0].id)
}
}, { immediate: true })
</script>
<template>
<div class="customer-chat">
<!-- 左侧会话列表 -->
<div class="session-list">
<div class="session-header">
<h3>会话列表</h3>
<div class="connection-status" :class="{ connected: isConnected }">
<span class="dot"></span>
{{ isConnected ? '在线' : '离线' }}
</div>
</div>
<div class="session-items">
<div
v-for="session in sessions"
:key="session.id"
:class="['session-item', { active: session.id === currentSessionId }]"
@click="switchSession(session.id)"
>
<div class="session-avatar">
{{ session.customerName.charAt(0) }}
</div>
<div class="session-info">
<div class="session-name">{{ session.customerName }}</div>
<div class="session-preview">{{ session.lastMessage || '暂无消息' }}</div>
</div>
<div class="session-meta">
<div class="session-time">{{ formatTime(session.lastTime) }}</div>
<div v-if="getUnreadCount(session.id) > 0" class="unread-badge">
{{ getUnreadCount(session.id) }}
</div>
</div>
</div>
</div>
</div>
<!-- 右侧聊天区域 -->
<div class="chat-area">
<div v-if="currentSession" class="chat-header">
<div class="customer-info">
<div class="customer-avatar">
{{ currentSession.customerName.charAt(0) }}
</div>
<div class="customer-details">
<div class="customer-name">{{ currentSession.customerName }}</div>
<div class="customer-status">
<span :class="['status-dot', currentSession.status]"></span>
{{ currentSession.status === 'active' ? '进行中' : '已结束' }}
</div>
</div>
</div>
</div>
<div v-if="!currentSession" class="empty-chat">
<div class="empty-icon">💬</div>
<div class="empty-text">请选择一个会话开始聊天</div>
</div>
<div v-else class="message-container" ref="messageContainer">
<div
v-for="message in currentMessages"
:key="message.id"
:class="['message-item', message.senderId === 'me' ? 'sent' : 'received']"
>
<div class="message-avatar">
{{ message.senderId === 'me' ? '我' : message.senderName.charAt(0) }}
</div>
<div class="message-content-wrapper">
<div class="message-sender">{{ message.senderName }}</div>
<div class="message-content">
<div v-if="message.type === 'text'">{{ message.content }}</div>
<img v-else-if="message.type === 'image'"
:src="message.content"
class="message-image" />
<div v-else-if="message.type === 'file'" class="message-file">
📎 {{ message.file.name }}
</div>
</div>
<div class="message-meta">
<span class="message-time">{{ formatTime(message.timestamp) }}</span>
<span v-if="message.senderId === 'me'" class="message-status">
<span v-if="message.status === 'sending'">发送中...</span>
<span v-else-if="message.status === 'sent'">已发送</span>
<span v-else-if="message.status === 'read'">已读</span>
<span v-else-if="message.status === 'failed'" class="error">发送失败</span>
</span>
</div>
</div>
</div>
</div>
<div v-if="currentSession" class="input-area">
<div class="input-toolbar">
<button class="toolbar-btn" title="发送表情">😊</button>
<button class="toolbar-btn" title="发送图片">🖼️</button>
<button class="toolbar-btn" title="发送文件">📎</button>
</div>
<div class="input-wrapper">
<textarea
v-model="inputMessage"
placeholder="输入消息内容..."
@keydown.enter.exact.prevent="handleSendMessage"
@keydown.enter.shift.exact="inputMessage += '\n'"
:disabled="currentSession.status !== 'active'"
></textarea>
<button
class="send-btn"
@click="handleSendMessage"
:disabled="!inputMessage.trim() || currentSession.status !== 'active'"
>
发送
</button>
</div>
<div class="input-tip">按 Enter 发送,Shift+Enter 换行</div>
</div>
</div>
</div>
</template>
<style scoped>
.customer-chat {
display: flex;
height: 600px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
/* 会话列表 */
.session-list {
width: 300px;
border-right: 1px solid #e4e7ed;
display: flex;
flex-direction: column;
background: #f5f7fa;
}
.session-header {
padding: 16px;
background: white;
border-bottom: 1px solid #e4e7ed;
display: flex;
justify-content: space-between;
align-items: center;
}
.session-header h3 {
margin: 0;
font-size: 16px;
color: #303133;
}
.connection-status {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #909399;
}
.connection-status .dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #f56c6c;
}
.connection-status.connected .dot {
background: #67c23a;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.session-items {
flex: 1;
overflow-y: auto;
}
.session-item {
padding: 12px 16px;
display: flex;
gap: 12px;
cursor: pointer;
background: white;
border-bottom: 1px solid #f0f0f0;
transition: background 0.2s;
}
.session-item:hover {
background: #f5f7fa;
}
.session-item.active {
background: #ecf5ff;
}
.session-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
flex-shrink: 0;
}
.session-info {
flex: 1;
min-width: 0;
}
.session-name {
font-size: 14px;
font-weight: 600;
color: #303133;
margin-bottom: 4px;
}
.session-preview {
font-size: 12px;
color: #909399;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.session-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
}
.session-time {
font-size: 11px;
color: #c0c4cc;
}
.unread-badge {
background: #f56c6c;
color: white;
font-size: 11px;
padding: 2px 6px;
border-radius: 10px;
min-width: 18px;
text-align: center;
}
/* 聊天区域 */
.chat-area {
flex: 1;
display: flex;
flex-direction: column;
}
.chat-header {
padding: 16px;
background: white;
border-bottom: 1px solid #e4e7ed;
}
.customer-info {
display: flex;
align-items: center;
gap: 12px;
}
.customer-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
}
.customer-name {
font-size: 16px;
font-weight: 600;
color: #303133;
margin-bottom: 4px;
}
.customer-status {
font-size: 12px;
color: #909399;
display: flex;
align-items: center;
gap: 6px;
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
}
.status-dot.active {
background: #67c23a;
}
.status-dot.closed {
background: #909399;
}
.empty-chat {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #909399;
}
.empty-icon {
font-size: 64px;
margin-bottom: 16px;
}
.empty-text {
font-size: 14px;
}
.message-container {
flex: 1;
overflow-y: auto;
padding: 20px;
background: #f5f7fa;
}
.message-item {
display: flex;
gap: 12px;
margin-bottom: 20px;
}
.message-item.sent {
flex-direction: row-reverse;
}
.message-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background: #409eff;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 600;
flex-shrink: 0;
}
.message-item.sent .message-avatar {
background: #67c23a;
}
.message-content-wrapper {
max-width: 60%;
display: flex;
flex-direction: column;
gap: 4px;
}
.message-item.sent .message-content-wrapper {
align-items: flex-end;
}
.message-sender {
font-size: 12px;
color: #909399;
padding: 0 4px;
}
.message-content {
padding: 10px 14px;
border-radius: 8px;
background: white;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
word-wrap: break-word;
color: #303133;
font-size: 14px;
line-height: 1.6;
}
.message-item.sent .message-content {
background: #409eff;
color: white;
}
.message-image {
max-width: 200px;
border-radius: 4px;
cursor: pointer;
}
.message-file {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
background: #f5f7fa;
border-radius: 4px;
cursor: pointer;
}
.message-meta {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
color: #c0c4cc;
padding: 0 4px;
}
.message-status.error {
color: #f56c6c;
}
/* 输入区域 */
.input-area {
border-top: 1px solid #e4e7ed;
background: white;
}
.input-toolbar {
padding: 8px 16px;
display: flex;
gap: 8px;
border-bottom: 1px solid #f0f0f0;
}
.toolbar-btn {
width: 32px;
height: 32px;
border: none;
background: transparent;
cursor: pointer;
border-radius: 4px;
font-size: 18px;
transition: background 0.2s;
}
.toolbar-btn:hover {
background: #f5f7fa;
}
.input-wrapper {
padding: 12px 16px;
display: flex;
gap: 12px;
}
.input-wrapper textarea {
flex: 1;
border: 1px solid #dcdfe6;
border-radius: 4px;
padding: 8px 12px;
font-size: 14px;
resize: none;
height: 80px;
font-family: inherit;
outline: none;
}
.input-wrapper textarea:focus {
border-color: #409eff;
}
.input-wrapper textarea:disabled {
background: #f5f7fa;
cursor: not-allowed;
}
.send-btn {
padding: 0 24px;
background: #409eff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: background 0.2s;
}
.send-btn:hover:not(:disabled) {
background: #66b1ff;
}
.send-btn:disabled {
background: #c0c4cc;
cursor: not-allowed;
}
.input-tip {
padding: 0 16px 12px;
font-size: 12px;
color: #909399;
}
</style>
三、消息类型处理
3.1 图片消息
ImageMessageHandler.vue
<script setup>
import { ref } from 'vue'
const emit = defineEmits(['send'])
const fileInput = ref(null)
const previewUrl = ref('')
const isUploading = ref(false)
const handleFileSelect = () => {
fileInput.value.click()
}
const handleFileChange = async (event) => {
const file = event.target.files[0]
if (!file) return
// 验证文件类型
if (!file.type.startsWith('image/')) {
alert('请选择图片文件')
return
}
// 验证文件大小 (5MB)
if (file.size > 5 * 1024 * 1024) {
alert('图片大小不能超过5MB')
return
}
// 预览图片
previewUrl.value = URL.createObjectURL(file)
// 上传图片
await uploadImage(file)
}
const uploadImage = async (file) => {
isUploading.value = true
try {
// 创建FormData
const formData = new FormData()
formData.append('file', file)
formData.append('type', 'image')
// 上传到服务器
const response = await fetch('/api/upload', {
method: 'POST',
body: formData
})
const data = await response.json()
if (data.success) {
// 发送图片消息
emit('send', {
type: 'image',
content: data.url,
file: {
name: file.name,
size: file.size,
url: data.url
}
})
previewUrl.value = ''
} else {
alert('上传失败:' + data.message)
}
} catch (error) {
console.error('上传错误:', error)
alert('上传失败,请重试')
} finally {
isUploading.value = false
}
}
const cancelUpload = () => {
previewUrl.value = ''
if (fileInput.value) {
fileInput.value.value = ''
}
}
</script>
<template>
<div class="image-message-handler">
<button @click="handleFileSelect" :disabled="isUploading">
发送图片
</button>
<input
ref="fileInput"
type="file"
accept="image/*"
@change="handleFileChange"
style="display: none"
/>
<div v-if="previewUrl" class="preview-modal">
<div class="preview-content">
<img :src="previewUrl" alt="预览" />
<div class="preview-actions">
<button v-if="isUploading" disabled>上传中...</button>
<button v-else @click="cancelUpload">取消</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.preview-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.preview-content {
background: white;
border-radius: 8px;
padding: 20px;
max-width: 500px;
}
.preview-content img {
max-width: 100%;
border-radius: 4px;
margin-bottom: 16px;
}
.preview-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
}
.preview-actions button {
padding: 8px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
</style>
3.2 文件消息
FileMessageHandler.vue
<script setup>
import { ref } from 'vue'
const emit = defineEmits(['send'])
const fileInput = ref(null)
const uploadProgress = ref(0)
const isUploading = ref(false)
const handleFileSelect = () => {
fileInput.value.click()
}
const handleFileChange = async (event) => {
const file = event.target.files[0]
if (!file) return
// 验证文件大小 (20MB)
if (file.size > 20 * 1024 * 1024) {
alert('文件大小不能超过20MB')
return
}
await uploadFile(file)
}
const uploadFile = async (file) => {
isUploading.value = true
uploadProgress.value = 0
try {
const formData = new FormData()
formData.append('file', file)
formData.append('type', 'file')
// 使用XMLHttpRequest以支持进度监听
const xhr = new XMLHttpRequest()
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
uploadProgress.value = Math.round((event.loaded / event.total) * 100)
}
}
xhr.onload = () => {
if (xhr.status === 200) {
const data = JSON.parse(xhr.responseText)
if (data.success) {
emit('send', {
type: 'file',
content: `发送了文件: ${file.name}`,
file: {
name: file.name,
size: file.size,
url: data.url,
type: file.type
}
})
}
}
isUploading.value = false
uploadProgress.value = 0
}
xhr.onerror = () => {
alert('上传失败,请重试')
isUploading.value = false
uploadProgress.value = 0
}
xhr.open('POST', '/api/upload')
xhr.send(formData)
} catch (error) {
console.error('上传错误:', error)
alert('上传失败,请重试')
isUploading.value = false
uploadProgress.value = 0
}
}
const formatFileSize = (bytes) => {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB'
return (bytes / (1024 * 1024)).toFixed(2) + ' MB'
}
</script>
<template>
<div class="file-message-handler">
<button @click="handleFileSelect" :disabled="isUploading">
发送文件
</button>
<input
ref="fileInput"
type="file"
@change="handleFileChange"
style="display: none"
/>
<div v-if="isUploading" class="upload-progress">
<div class="progress-bar">
<div class="progress-fill" :style="{ width: uploadProgress + '%' }"></div>
</div>
<div class="progress-text">上传中... {{ uploadProgress }}%</div>
</div>
</div>
</template>
<style scoped>
.upload-progress {
position: fixed;
bottom: 20px;
right: 20px;
background: white;
padding: 16px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
min-width: 200px;
z-index: 1000;
}
.progress-bar {
height: 6px;
background: #f0f0f0;
border-radius: 3px;
overflow: hidden;
margin-bottom: 8px;
}
.progress-fill {
height: 100%;
background: #409eff;
transition: width 0.3s;
}
.progress-text {
font-size: 12px;
color: #606266;
text-align: center;
}
</style>
四、历史消息加载
4.1 IndexedDB存储
message-storage.js
import Dexie from 'dexie'
class MessageStorage {
constructor() {
this.db = new Dexie('CustomerServiceDB')
this.db.version(1).stores({
messages: '++id, sessionId, timestamp, senderId',
sessions: 'id, lastTime'
})
}
// 保存消息
async saveMessage(message) {
return await this.db.messages.add(message)
}
// 批量保存
async saveMessages(messages) {
return await this.db.messages.bulkAdd(messages)
}
// 获取会话消息(分页)
async getSessionMessages(sessionId, page = 1, pageSize = 20) {
const offset = (page - 1) * pageSize
const messages = await this.db.messages
.where('sessionId')
.equals(sessionId)
.reverse()
.offset(offset)
.limit(pageSize)
.toArray()
return messages.reverse()
}
// 获取消息总数
async getMessageCount(sessionId) {
return await this.db.messages
.where('sessionId')
.equals(sessionId)
.count()
}
// 清空会话消息
async clearSessionMessages(sessionId) {
return await this.db.messages
.where('sessionId')
.equals(sessionId)
.delete()
}
// 保存会话
async saveSession(session) {
return await this.db.sessions.put(session)
}
// 获取所有会话
async getAllSessions() {
return await this.db.sessions.toArray()
}
// 删除会话
async deleteSession(sessionId) {
await this.clearSessionMessages(sessionId)
return await this.db.sessions.delete(sessionId)
}
}
export const messageStorage = new MessageStorage()
4.2 历史消息加载组件
HistoryMessageLoader.vue
<script setup>
import { ref, computed, onMounted } from 'vue'
import { messageStorage } from './message-storage.js'
const props = defineProps({
sessionId: {
type: String,
required: true
}
})
const emit = defineEmits(['loaded'])
const messages = ref([])
const currentPage = ref(1)
const pageSize = ref(20)
const totalCount = ref(0)
const isLoading = ref(false)
const hasMore = ref(true)
// 是否可以加载更多
const canLoadMore = computed(() => {
return hasMore.value && !isLoading.value
})
// 加载历史消息
const loadHistory = async () => {
if (!canLoadMore.value) return
isLoading.value = true
try {
// 获取总数
if (currentPage.value === 1) {
totalCount.value = await messageStorage.getMessageCount(props.sessionId)
}
// 加载消息
const historyMessages = await messageStorage.getSessionMessages(
props.sessionId,
currentPage.value,
pageSize.value
)
if (historyMessages.length < pageSize.value) {
hasMore.value = false
}
// 添加到列表开头
messages.value.unshift(...historyMessages)
currentPage.value++
// 通知父组件
emit('loaded', historyMessages)
} catch (error) {
console.error('加载历史消息失败:', error)
} finally {
isLoading.value = false
}
}
// 初始加载
onMounted(() => {
loadHistory()
})
defineExpose({
loadHistory
})
</script>
<template>
<div class="history-loader">
<button
v-if="canLoadMore"
@click="loadHistory"
:disabled="isLoading"
class="load-more-btn"
>
{{ isLoading ? '加载中...' : '加载更多历史消息' }}
</button>
<div v-else-if="!hasMore" class="no-more">
没有更多历史消息了
</div>
</div>
</template>
<style scoped>
.history-loader {
text-align: center;
padding: 12px;
}
.load-more-btn {
padding: 8px 20px;
background: #ecf5ff;
color: #409eff;
border: 1px solid #d9ecff;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
transition: all 0.3s;
}
.load-more-btn:hover:not(:disabled) {
background: #d9ecff;
}
.load-more-btn:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.no-more {
font-size: 12px;
color: #c0c4cc;
}
</style>
五、未读消息提醒
5.1 未读计数管理
unread-manager.js
export class UnreadManager {
constructor() {
this.unreadMap = new Map() // sessionId -> count
this.callbacks = []
}
// 增加未读数
increment(sessionId) {
const current = this.unreadMap.get(sessionId) || 0
this.unreadMap.set(sessionId, current + 1)
this.notifyChange()
this.updateTitle()
}
// 清除未读数
clear(sessionId) {
this.unreadMap.set(sessionId, 0)
this.notifyChange()
this.updateTitle()
}
// 获取未读数
get(sessionId) {
return this.unreadMap.get(sessionId) || 0
}
// 获取总未读数
getTotal() {
let total = 0
for (const count of this.unreadMap.values()) {
total += count
}
return total
}
// 订阅变化
subscribe(callback) {
this.callbacks.push(callback)
return () => {
const index = this.callbacks.indexOf(callback)
if (index > -1) {
this.callbacks.splice(index, 1)
}
}
}
// 通知变化
notifyChange() {
const total = this.getTotal()
this.callbacks.forEach(callback => callback(total))
}
// 更新页面标题
updateTitle() {
const total = this.getTotal()
const originalTitle = document.title.replace(/^\(\d+\)\s*/, '')
if (total > 0) {
document.title = `(${total}) ${originalTitle}`
} else {
document.title = originalTitle
}
}
// 闪烁标题提醒
flashTitle(message, duration = 3000) {
const originalTitle = document.title
let isOriginal = true
const interval = setInterval(() => {
document.title = isOriginal ? message : originalTitle
isOriginal = !isOriginal
}, 1000)
setTimeout(() => {
clearInterval(interval)
document.title = originalTitle
}, duration)
}
}
export const unreadManager = new UnreadManager()
5.2 桌面通知
notification-manager.js
export class NotificationManager {
constructor() {
this.permission = Notification.permission
this.enabled = true
this.soundEnabled = true
this.notificationSound = new Audio('/notification.mp3')
}
// 请求权限
async requestPermission() {
if (!('Notification' in window)) {
console.warn('浏览器不支持桌面通知')
return false
}
if (this.permission === 'granted') {
return true
}
if (this.permission !== 'denied') {
this.permission = await Notification.requestPermission()
}
return this.permission === 'granted'
}
// 显示通知
show(title, options = {}) {
if (!this.enabled || this.permission !== 'granted') {
return null
}
const notification = new Notification(title, {
icon: options.icon || '/logo.png',
body: options.body,
tag: options.tag || 'default',
requireInteraction: false,
...options
})
// 点击通知
notification.onclick = () => {
window.focus()
notification.close()
options.onClick?.()
}
// 自动关闭
setTimeout(() => {
notification.close()
}, options.duration || 5000)
// 播放提示音
if (this.soundEnabled) {
this.playSound()
}
return notification
}
// 播放提示音
playSound() {
this.notificationSound.play().catch(err => {
console.warn('播放提示音失败:', err)
})
}
// 设置开关
setEnabled(enabled) {
this.enabled = enabled
}
// 设置声音开关
setSoundEnabled(enabled) {
this.soundEnabled = enabled
}
}
export const notificationManager = new NotificationManager()
5.3 提醒设置组件
NotificationSettings.vue
<script setup>
import { ref, onMounted } from 'vue'
import { notificationManager } from './notification-manager.js'
const notificationEnabled = ref(true)
const soundEnabled = ref(true)
const permission = ref(notificationManager.permission)
const toggleNotification = () => {
notificationEnabled.value = !notificationEnabled.value
notificationManager.setEnabled(notificationEnabled.value)
}
const toggleSound = () => {
soundEnabled.value = !soundEnabled.value
notificationManager.setSoundEnabled(soundEnabled.value)
}
const requestPermission = async () => {
const granted = await notificationManager.requestPermission()
permission.value = notificationManager.permission
if (granted) {
notificationManager.show('通知已开启', {
body: '您将收到新消息提醒'
})
}
}
const testNotification = () => {
notificationManager.show('测试通知', {
body: '这是一条测试消息'
})
}
onMounted(async () => {
permission.value = notificationManager.permission
})
</script>
<template>
<div class="notification-settings">
<h3>消息提醒设置</h3>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">桌面通知</div>
<div class="setting-desc">收到新消息时显示桌面通知</div>
</div>
<div class="setting-control">
<button v-if="permission !== 'granted'" @click="requestPermission" class="primary-btn">
开启权限
</button>
<label v-else class="switch">
<input type="checkbox" v-model="notificationEnabled" @change="toggleNotification" />
<span class="slider"></span>
</label>
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">提示音</div>
<div class="setting-desc">收到新消息时播放提示音</div>
</div>
<div class="setting-control">
<label class="switch">
<input type="checkbox" v-model="soundEnabled" @change="toggleSound" />
<span class="slider"></span>
</label>
</div>
</div>
<div class="setting-actions">
<button @click="testNotification" :disabled="permission !== 'granted'">
测试通知
</button>
</div>
</div>
</template>
<style scoped>
.notification-settings {
padding: 20px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.notification-settings h3 {
margin: 0 0 20px 0;
font-size: 16px;
color: #303133;
}
.setting-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 0;
border-bottom: 1px solid #f0f0f0;
}
.setting-item:last-child {
border-bottom: none;
}
.setting-label {
font-size: 14px;
font-weight: 600;
color: #303133;
margin-bottom: 4px;
}
.setting-desc {
font-size: 12px;
color: #909399;
}
.primary-btn {
padding: 6px 16px;
background: #409eff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
}
.primary-btn:hover {
background: #66b1ff;
}
.switch {
position: relative;
display: inline-block;
width: 44px;
height: 24px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #dcdfe6;
transition: .3s;
border-radius: 24px;
}
.slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: white;
transition: .3s;
border-radius: 50%;
}
input:checked + .slider {
background-color: #409eff;
}
input:checked + .slider:before {
transform: translateX(20px);
}
.setting-actions {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #f0f0f0;
}
.setting-actions button {
padding: 8px 20px;
background: #ecf5ff;
color: #409eff;
border: 1px solid #d9ecff;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
}
.setting-actions button:hover:not(:disabled) {
background: #d9ecff;
}
.setting-actions button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>
六、会话管理
6.1 会话管理器
session-manager.js
export class SessionManager {
constructor() {
this.sessions = new Map()
this.currentSessionId = null
this.callbacks = {
onCreate: [],
onUpdate: [],
onClose: [],
onSwitch: []
}
}
// 创建会话
createSession(session) {
const newSession = {
id: session.id || this.generateId(),
customerId: session.customerId,
customerName: session.customerName,
customerAvatar: session.customerAvatar,
status: 'active', // active, closed
createTime: Date.now(),
lastTime: Date.now(),
lastMessage: '',
...session
}
this.sessions.set(newSession.id, newSession)
this.emit('onCreate', newSession)
return newSession
}
// 更新会话
updateSession(sessionId, updates) {
const session = this.sessions.get(sessionId)
if (!session) return false
Object.assign(session, updates, {
lastTime: Date.now()
})
this.emit('onUpdate', session)
return true
}
// 关闭会话
closeSession(sessionId) {
const session = this.sessions.get(sessionId)
if (!session) return false
session.status = 'closed'
session.closeTime = Date.now()
this.emit('onClose', session)
return true
}
// 删除会话
deleteSession(sessionId) {
return this.sessions.delete(sessionId)
}
// 切换会话
switchSession(sessionId) {
const session = this.sessions.get(sessionId)
if (!session) return false
this.currentSessionId = sessionId
this.emit('onSwitch', session)
return true
}
// 获取会话
getSession(sessionId) {
return this.sessions.get(sessionId)
}
// 获取所有会话
getAllSessions() {
return Array.from(this.sessions.values())
.sort((a, b) => b.lastTime - a.lastTime)
}
// 获取活跃会话
getActiveSessions() {
return this.getAllSessions()
.filter(s => s.status === 'active')
}
// 订阅事件
on(event, callback) {
if (this.callbacks[event]) {
this.callbacks[event].push(callback)
}
}
// 触发事件
emit(event, data) {
if (this.callbacks[event]) {
this.callbacks[event].forEach(callback => callback(data))
}
}
// 生成ID
generateId() {
return `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
}
}
export const sessionManager = new SessionManager()
6.2 会话列表组件
SessionList.vue
<script setup>
import { ref, computed } from 'vue'
import { sessionManager } from './session-manager.js'
const emit = defineEmits(['select'])
const sessions = ref([])
const filterStatus = ref('all') // all, active, closed
const searchKeyword = ref('')
const selectedSessionId = ref(null)
// 过滤后的会话
const filteredSessions = computed(() => {
let result = sessions.value
// 状态过滤
if (filterStatus.value !== 'all') {
result = result.filter(s => s.status === filterStatus.value)
}
// 关键词搜索
if (searchKeyword.value) {
const keyword = searchKeyword.value.toLowerCase()
result = result.filter(s =>
s.customerName.toLowerCase().includes(keyword) ||
s.lastMessage.toLowerCase().includes(keyword)
)
}
return result
})
// 活跃会话数
const activeCount = computed(() => {
return sessions.value.filter(s => s.status === 'active').length
})
// 选择会话
const selectSession = (session) => {
selectedSessionId.value = session.id
sessionManager.switchSession(session.id)
emit('select', session)
}
// 关闭会话
const closeSession = (sessionId, event) => {
event.stopPropagation()
if (confirm('确定要关闭这个会话吗?')) {
sessionManager.closeSession(sessionId)
loadSessions()
}
}
// 删除会话
const deleteSession = (sessionId, event) => {
event.stopPropagation()
if (confirm('确定要删除这个会话吗?删除后无法恢复。')) {
sessionManager.deleteSession(sessionId)
loadSessions()
}
}
// 加载会话列表
const loadSessions = () => {
sessions.value = sessionManager.getAllSessions()
}
// 格式化时间
const formatTime = (timestamp) => {
const now = Date.now()
const diff = now - timestamp
// 一分钟内
if (diff < 60000) {
return '刚刚'
}
// 一小时内
if (diff < 3600000) {
return `${Math.floor(diff / 60000)}分钟前`
}
// 今天
const today = new Date().setHours(0, 0, 0, 0)
if (timestamp >= today) {
return new Date(timestamp).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
})
}
// 昨天
const yesterday = today - 86400000
if (timestamp >= yesterday) {
return '昨天'
}
// 更早
return new Date(timestamp).toLocaleDateString('zh-CN', {
month: '2-digit',
day: '2-digit'
})
}
// 监听会话变化
sessionManager.on('onCreate', loadSessions)
sessionManager.on('onUpdate', loadSessions)
sessionManager.on('onClose', loadSessions)
// 初始加载
loadSessions()
</script>
<template>
<div class="session-list">
<div class="list-header">
<div class="search-box">
<input
v-model="searchKeyword"
type="text"
placeholder="搜索会话..."
/>
</div>
<div class="filter-tabs">
<button
:class="{ active: filterStatus === 'all' }"
@click="filterStatus = 'all'"
>
全部 ({{ sessions.length }})
</button>
<button
:class="{ active: filterStatus === 'active' }"
@click="filterStatus = 'active'"
>
进行中 ({{ activeCount }})
</button>
<button
:class="{ active: filterStatus === 'closed' }"
@click="filterStatus = 'closed'"
>
已结束
</button>
</div>
</div>
<div class="list-content">
<div
v-for="session in filteredSessions"
:key="session.id"
:class="['session-card', { active: session.id === selectedSessionId }]"
@click="selectSession(session)"
>
<div class="session-avatar">
{{ session.customerName.charAt(0) }}
</div>
<div class="session-body">
<div class="session-header-row">
<span class="session-name">{{ session.customerName }}</span>
<span class="session-time">{{ formatTime(session.lastTime) }}</span>
</div>
<div class="session-content-row">
<span class="session-message">{{ session.lastMessage || '暂无消息' }}</span>
<span :class="['session-status', session.status]">
{{ session.status === 'active' ? '进行中' : '已结束' }}
</span>
</div>
</div>
<div class="session-actions">
<button
v-if="session.status === 'active'"
@click="closeSession(session.id, $event)"
class="action-btn close"
title="结束会话"
>
✓
</button>
<button
@click="deleteSession(session.id, $event)"
class="action-btn delete"
title="删除会话"
>
✕
</button>
</div>
</div>
<div v-if="filteredSessions.length === 0" class="empty-list">
<div class="empty-icon">📭</div>
<div class="empty-text">暂无会话</div>
</div>
</div>
</div>
</template>
<style scoped>
.session-list {
display: flex;
flex-direction: column;
height: 100%;
}
.list-header {
padding: 16px;
background: white;
border-bottom: 1px solid #e4e7ed;
}
.search-box {
margin-bottom: 12px;
}
.search-box input {
width: 100%;
padding: 8px 12px;
border: 1px solid #dcdfe6;
border-radius: 4px;
font-size: 14px;
outline: none;
}
.search-box input:focus {
border-color: #409eff;
}
.filter-tabs {
display: flex;
gap: 8px;
}
.filter-tabs button {
flex: 1;
padding: 6px 12px;
background: #f5f7fa;
border: none;
border-radius: 4px;
font-size: 13px;
color: #606266;
cursor: pointer;
transition: all 0.3s;
}
.filter-tabs button.active {
background: #409eff;
color: white;
}
.list-content {
flex: 1;
overflow-y: auto;
background: #f5f7fa;
}
.session-card {
display: flex;
gap: 12px;
padding: 12px 16px;
background: white;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
transition: background 0.2s;
position: relative;
}
.session-card:hover {
background: #f5f7fa;
}
.session-card:hover .session-actions {
opacity: 1;
}
.session-card.active {
background: #ecf5ff;
}
.session-avatar {
width: 44px;
height: 44px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 16px;
flex-shrink: 0;
}
.session-body {
flex: 1;
min-width: 0;
}
.session-header-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.session-name {
font-size: 14px;
font-weight: 600;
color: #303133;
}
.session-time {
font-size: 11px;
color: #c0c4cc;
flex-shrink: 0;
margin-left: 8px;
}
.session-content-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.session-message {
font-size: 12px;
color: #909399;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.session-status {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
flex-shrink: 0;
margin-left: 8px;
}
.session-status.active {
background: #e1f3d8;
color: #67c23a;
}
.session-status.closed {
background: #f4f4f5;
color: #909399;
}
.session-actions {
display: flex;
gap: 4px;
opacity: 0;
transition: opacity 0.2s;
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
}
.action-btn {
width: 24px;
height: 24px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.action-btn.close {
background: #67c23a;
color: white;
}
.action-btn.close:hover {
background: #85ce61;
}
.action-btn.delete {
background: #f56c6c;
color: white;
}
.action-btn.delete:hover {
background: #f78989;
}
.empty-list {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 300px;
color: #909399;
}
.empty-icon {
font-size: 48px;
margin-bottom: 12px;
}
.empty-text {
font-size: 14px;
}
</style>
七、简历描述模板
客服系统实时通信模块 (2023.03 - 2023.08)
负责开发公司在线客服系统的实时通信功能,支持文本、图片、文件多种消息类型,服务10000+日活用户。
核心职责
- 基于WebSocket实现实时消息推送,消息延迟控制在100ms以内
- 开发多类型消息处理机制,支持文本、图片(最大5MB)、文件(最大20MB)的发送和展示
- 实现基于IndexedDB的历史消息存储,支持分页加载和离线查看
- 集成桌面通知和消息提示音,未读消息标记准确率99%
- 开发完整的会话管理系统,支持会话创建、切换、关闭和搜索
技术实现
- 使用MessageManager管理消息状态,实现消息的发送、接收、已读标记
- 通过IndexedDB本地存储历史消息,减少服务器压力
- 使用FormData+XMLHttpRequest实现文件上传,支持上传进度显示
- 集成Notification API实现桌面提醒,提升消息到达率
- 通过SessionManager统一管理会话生命周期
项目成果
- 消息实时性达到100ms以内
- 历史消息加载速度提升60%(本地缓存)
- 支持单会话最多加载10000条历史消息
- 消息送达率99.8%,客户满意度提升25%
八、SOP标准回答
面试问题: 介绍一下客服系统的消息处理流程
标准回答
"我开发的客服系统消息处理分为几个核心环节。
首先是消息接收。客服端通过WebSocket接收服务器推送的消息,我设计了一个MessageManager来统一管理。每条消息都有唯一ID、类型、内容、发送者等信息。收到消息后,先更新本地状态,再存入IndexedDB做持久化。
消息类型处理是一个重点。文本消息直接展示;图片消息需要先上传到OSS,我用FormData封装文件,通过XMLHttpRequest上传,监听progress事件显示进度;文件消息类似,但会限制大小为20MB,并显示文件名和大小。
历史消息加载采用分页方式。用户滚动到顶部时,从IndexedDB按时间倒序查询20条历史消息,插入到消息列表开头。这样既减轻服务器压力,又能快速响应。IndexedDB的查询性能很好,10000条消息也能在50ms内完成。
未读提醒有两层。第一层是界面上的小红点,用UnreadManager维护每个会话的未读数,切换会话时自动清零。第二层是桌面通知,使用Notification API,需要用户授权。收到消息时,如果不在当前会话,就弹出桌面通知,同时播放提示音。
会话管理通过SessionManager实现。支持创建、更新、关闭、删除会话。会话按最后消息时间排序,活跃会话排在前面。还有搜索功能,可以按客户名称或消息内容过滤。
整个流程保证了消息不丢失、及时送达,用户体验很流畅。"
九、难点与亮点分析
难点1: 大文件上传如何优化?
问题: 20MB文件上传时间长,用户体验差,且容易失败。
解决方案
- 分片上传
async function uploadLargeFile(file) {
const chunkSize = 1024 * 1024 // 1MB
const chunks = Math.ceil(file.size / chunkSize)
const uploadId = generateUploadId()
for (let i = 0; i < chunks; i++) {
const start = i * chunkSize
const end = Math.min(start + chunkSize, file.size)
const chunk = file.slice(start, end)
await uploadChunk(chunk, i, uploadId)
updateProgress((i + 1) / chunks * 100)
}
return await mergeChunks(uploadId, chunks)
}
- 断点续传
- 记录已上传的分片
- 失败后从断点继续
- 使用localStorage保存进度
- 并发控制
- 同时上传3个分片
- 失败自动重试3次
亮点1: IndexedDB历史消息缓存
创新点
- 减少90%的历史消息请求
- 离线可查看历史记录
- 智能缓存策略
实现
// 智能加载策略
async loadMessages(sessionId, page) {
// 先从本地加载
const localMessages = await db.getMessages(sessionId, page)
if (localMessages.length > 0) {
return localMessages
}
// 本地没有,从服务器拉取
const remoteMessages = await api.fetchMessages(sessionId, page)
// 保存到本地
await db.saveMessages(remoteMessages)
return remoteMessages
}
亮点2: 智能消息合并
创新点
- 相同用户连续消息合并显示
- 减少30%界面占用
- 提升阅读体验
实现
function mergeMessages(messages) {
const merged = []
let lastSender = null
let mergedGroup = []
messages.forEach(msg => {
if (msg.senderId === lastSender &&
msg.timestamp - mergedGroup[mergedGroup.length - 1].timestamp < 60000) {
mergedGroup.push(msg)
} else {
if (mergedGroup.length > 0) {
merged.push(mergedGroup)
}
mergedGroup = [msg]
lastSender = msg.senderId
}
})
return merged
}