返回笔记首页

16.2 客服系统实现

主题配置

一、技术实现方案

1.1 系统架构

plain
客服系统架构
  ├── 消息模块
  │   ├── 实时推送 (WebSocket)
  │   ├── 消息类型 (文本/图片/文件)
  │   ├── 历史加载 (分页查询)
  │   └── 消息存储 (IndexedDB)
  │
  ├── 会话模块
  │   ├── 会话列表
  │   ├── 会话状态管理
  │   └── 会话切换
  │
  └── 通知模块
      ├── 未读提醒
      ├── 桌面通知
      └── 消息提示音

1.2 技术栈

  • 前端框架: Vue 3 + Composition API
  • 通信: WebSocket + HTTP
  • 存储: IndexedDB (Dexie.js)
  • 文件上传: FormData + Axios
  • 通知: Notification API

二、实时消息推送

2.1 消息管理器

message-manager.js

javascript
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

javascript
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

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

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

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

javascript
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

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

javascript
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

javascript
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

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

javascript
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

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文件上传时间长,用户体验差,且容易失败。

解决方案

  1. 分片上传
javascript
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)
}
  1. 断点续传
  • 记录已上传的分片
  • 失败后从断点继续
  • 使用localStorage保存进度
  1. 并发控制
  • 同时上传3个分片
  • 失败自动重试3次

亮点1: IndexedDB历史消息缓存

创新点

  • 减少90%的历史消息请求
  • 离线可查看历史记录
  • 智能缓存策略
实现
javascript
// 智能加载策略
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%界面占用
  • 提升阅读体验
实现
javascript
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
}