Prechádzať zdrojové kódy

feat(kfxt): 实现客服聊天功能模块

- 初始化客服聊天页面基础结构与样式- 集成 WebSocket 实时通信功能
- 实现客户列表展示与搜索功能- 开发消息收发及历史消息加载功能
- 添加消息已读回执与状态管理
- 支持新客户提醒与浏览器通知
- 实现客户在线状态与输入状态显示- 添加消息滚动定位与新消息提示功能- 集成 Element Plus 组件库优化交互
- 完善消息时间格式化与用户标识逻辑
nahida 6 mesiacov pred
rodič
commit
c4620fd5e9
1 zmenil súbory, kde vykonal 1081 pridanie a 0 odobranie
  1. 1081 0
      src/views/kfxt/kf/index.vue

+ 1081 - 0
src/views/kfxt/kf/index.vue

@@ -0,0 +1,1081 @@
+<script setup>
+import {computed, nextTick, onMounted, ref, watch} from 'vue'
+import {Bell, Check, CheckCheck, ChevronDown, MessageCircle, Search, Send, X} from 'lucide-vue-next'
+import request from "@/utils/request.js";
+import {ElMessage, ElNotification} from "element-plus";
+import useUserStore from "@/store/modules/user.js";
+
+const userStore = useUserStore()
+const userId = computed(() => userStore.id)
+
+// 响应式数据
+const searchQuery = ref('')
+const selectedCustomer = ref(null)
+const newMessage = ref('')
+const currentMessages = ref([]) // 当前选中客户的消息列表
+const loadingMessages = ref(false) // 消息加载状态
+const loadingMoreMessages = ref(false) // 加载更多消息状态
+const hasMoreMessages = ref(true) // 是否还有更多消息
+const currentPage = ref(1)
+const pageSize = ref(20) // 每页消息数量
+
+// 滚动相关的响应式数据
+const messagesContainer = ref(null) // 消息容器的引用
+const isUserScrolled = ref(false) // 用户是否手动滚动了
+const newMessageCount = ref(0) // 新消息数量
+const showNewMessageTip = ref(false) // 是否显示新消息提示
+const lastMessageCount = ref(0) // 添加上次消息数量追踪
+
+// 新增:通知相关
+const hasNewCustomerMessage = ref(false) // 是否有新客户消息
+const newCustomerCount = ref(0) // 新客户数量
+const notificationPermission = ref(Notification.permission) // 浏览器通知权限
+
+let heartbeatTimer = null
+let ws = null
+
+const BASE_WS_URL = import.meta.env.VITE_APP_BASE_WS_URL
+
+// 客户数据结构(不包含messages字段)
+const customers = ref([])
+
+// 计算属性
+const filteredCustomers = computed(() => {
+  if (!searchQuery.value) {
+    return customers.value
+  }
+  return customers.value.filter(customer =>
+      customer.name.toLowerCase().includes(searchQuery.value.toLowerCase())
+  )
+})
+
+// 新增:请求通知权限
+const requestNotificationPermission = async () => {
+  if ('Notification' in window && Notification.permission === 'default') {
+    const permission = await Notification.requestPermission()
+    notificationPermission.value = permission
+  }
+}
+
+// 新增:显示浏览器通知
+const showBrowserNotification = (title, body, icon = null) => {
+  if (notificationPermission.value === 'granted') {
+    const notification = new Notification(title, {
+      body,
+      icon: icon || '/favicon.ico',
+      tag: 'chat-message' // 防止重复通知
+    })
+
+    // 点击通知时聚焦窗口
+    notification.onclick = () => {
+      window.focus()
+      notification.close()
+    }
+
+    // 3秒后自动关闭
+    setTimeout(() => notification.close(), 3000)
+  }
+}
+
+// 新增:播放提示音
+const playNotificationSound = () => {
+  try {
+    // 创建音频上下文播放提示音
+    const audioContext = new (window.AudioContext || window.webkitAudioContext)()
+    const oscillator = audioContext.createOscillator()
+    const gainNode = audioContext.createGain()
+
+    oscillator.connect(gainNode)
+    gainNode.connect(audioContext.destination)
+
+    oscillator.frequency.setValueAtTime(800, audioContext.currentTime)
+    oscillator.frequency.setValueAtTime(600, audioContext.currentTime + 0.1)
+
+    gainNode.gain.setValueAtTime(0.3, audioContext.currentTime)
+    gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.3)
+
+    oscillator.start(audioContext.currentTime)
+    oscillator.stop(audioContext.currentTime + 0.3)
+  } catch (error) {
+    console.log('无法播放提示音:', error)
+  }
+}
+
+// 新增:创建新客户记录
+const createNewCustomer = (wsData) => {
+  const newCustomer = {
+    id: wsData.sessionId || wsData.from,
+    name: wsData.from,
+    isOnline: true,
+    isTyping: false,
+    lastMessage: wsData.content,
+    lastMessageTime: formatTime(wsData.timestamp || new Date().toISOString()),
+    unreadCount: 0,
+    isNewCustomer: true // 标记为新客户
+  }
+
+  // 添加到客户列表顶部
+  customers.value.unshift(newCustomer)
+
+  console.log('[v0] 创建新客户:', newCustomer)
+  return newCustomer
+}
+
+// 滚动到底部的方法
+const scrollToBottom = (smooth = true) => {
+  nextTick(() => {
+    if (messagesContainer.value) {
+      // 使用 scrollTop 以兼容性更好
+      try {
+        messagesContainer.value.scrollTo({
+          top: messagesContainer.value.scrollHeight,
+          behavior: smooth ? 'smooth' : 'auto'
+        })
+      } catch (e) {
+        messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
+      }
+    }
+  })
+}
+
+// 检查是否在底部 - 修复判断逻辑
+const isAtBottom = () => {
+  if (!messagesContainer.value) return true
+  const { scrollTop, scrollHeight, clientHeight } = messagesContainer.value
+  // 减少容差值,提高判断精度
+  const atBottom = scrollHeight - scrollTop - clientHeight < 10
+  console.log("[v0] 检查是否在底部:", atBottom, { scrollTop, scrollHeight, clientHeight })
+  return atBottom
+}
+
+// 发送未读消息的已读回执
+const markUnreadMessagesAsRead = () => {
+  if (!selectedCustomer.value) return
+
+  const unreadMessages = currentMessages.value.filter(msg =>
+      msg.sender === 'customer' && msg.status !== 'read'
+  )
+  if (unreadMessages.length > 0) {
+
+    const unreadMsgIds = unreadMessages.map(msg => msg.id)
+
+    sendReadReceipt(selectedCustomer.value.id, unreadMsgIds)
+
+    // 前端立即更新状态
+    unreadMessages.forEach(msg => {
+      msg.status = 'read'
+    })
+
+    console.log("[v0] 标记消息为已读:", unreadMsgIds)
+  }
+}
+
+// 处理滚动事件 - 修复已读回执发送
+const handleScroll = () => {
+  if (!messagesContainer.value) return
+
+  const atBottom = isAtBottom()
+
+  if (atBottom) {
+    isUserScrolled.value = false
+    showNewMessageTip.value = false
+    newMessageCount.value = 0
+
+    // 当用户滑动到底部时,发送已读回执
+    markUnreadMessagesAsRead()
+
+    console.log("[v0] 用户回到底部,重置状态并发送已读回执")
+  } else {
+    isUserScrolled.value = true
+    console.log("[v0] 用户滚动离开底部")
+  }
+}
+
+// 回到底部按钮点击事件
+const goToBottom = () => {
+  console.log("[v0] 点击回到底部")
+  scrollToBottom()
+  showNewMessageTip.value = false
+  newMessageCount.value = 0
+  isUserScrolled.value = false
+
+  // 发送已读回执
+  markUnreadMessagesAsRead()
+}
+
+watch(currentMessages, (newMessages, oldMessages) => {
+  console.log("[v0] 消息列表变化", {
+    newCount: newMessages.length,
+    oldCount: oldMessages?.length || 0,
+    isUserScrolled: isUserScrolled.value
+  })
+
+  if (!oldMessages || oldMessages.length === 0) {
+    // 初始加载消息时,直接滚动到底部
+    console.log("[v0] 初始加载消息,滚动到底部")
+    lastMessageCount.value = newMessages.length
+    scrollToBottom(false)
+    return
+  }
+  if (newMessages.length > oldMessages.length) {
+    // 有新消息(从其他地方添加也会进来)
+    const newMsgCount = newMessages.length - oldMessages.length
+    console.log("[v0] 检测到新消息", newMsgCount, "条")
+
+    if (isUserScrolled.value) {
+      // 用户已滚动,显示新消息提示
+      if(!loadingMoreMessages){
+        newMessageCount.value += newMsgCount
+        showNewMessageTip.value = true
+      }
+    } else {
+      // 用户未滚动,自动滚动到底部
+      console.log("[v0] 用户未滚动,自动滚动到底部")
+      scrollToBottom()
+    }
+  }
+
+  lastMessageCount.value = newMessages.length
+}, {deep: true, immediate: false})
+
+// 格式化时间
+const formatTime = (createdAt) => {
+  const date = new Date(createdAt)
+  const now = new Date()
+  const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
+  const messageDate = new Date(date.getFullYear(), date.getMonth(), date.getDate())
+
+  if (messageDate.getTime() === today.getTime()) {
+    // 今天的消息只显示时间
+    return date.toLocaleTimeString('zh-CN', {
+      hour: '2-digit',
+      minute: '2-digit'
+    })
+  } else {
+    // 其他日期显示月日
+    return date.toLocaleDateString('zh-CN', {
+      month: '2-digit',
+      day: '2-digit'
+    })
+  }
+}
+
+// 转换消息数据格式(用于 API 返回)- 修改为优先使用服务器messageId
+const transformMessage = (apiMessage) => {
+  return {
+    // <CHANGE> 优先使用服务器返回的messageId,fallback到原有的id字段
+    id: apiMessage.messageId || apiMessage.id,
+    sessionId: apiMessage.sessionId,
+    sender: apiMessage.fromRole === 'admin' ? 'agent' : 'customer',
+    fromId: apiMessage.fromId,
+    fromRole: apiMessage.fromRole,
+    toId: apiMessage.toId,
+    content: apiMessage.content,
+    time: formatTime(apiMessage.createdAt),
+    status: apiMessage.status,
+    createdAt: apiMessage.createdAt
+  }
+}
+
+// 如果 websocket 消息的字段不固定,尝试从多个字段取 sessionId
+const resolveSessionId = (wsMsg) => {
+  return wsMsg.sessionId ?? wsMsg.session ?? wsMsg.from ?? wsMsg.to ?? null
+}
+
+// 统一处理收到的 websocket 聊天消息(关键函数)- 修改为优先使用messageId
+const handleIncomingMessage = async (wsData) => {
+  console.log(wsData);
+  console.log(formatTime(wsData.timestamp ?? new Date().toISOString()));
+  try {
+    // 构造前端消息对象(优先使用服务器字段)
+    const msg = {
+      // <CHANGE> 优先使用服务器返回的messageId,如果没有则使用id字段,最后才使用时间戳生成
+      id: wsData.messageId || wsData.id || (Date.now() + Math.floor(Math.random() * 10000)),
+      sessionId: resolveSessionId(wsData),
+      sender: wsData.role === 'admin' ? 'agent' : 'customer',
+      fromId: wsData.from,
+      fromRole: wsData.role,
+      toId: wsData.to,
+      content: wsData.content,
+      time: formatTime(wsData.timestamp ?? new Date().toISOString()),
+      status: wsData.status ?? 'sent', // 默认为sent,而不是read
+      // 格式化一下
+      createdAt: formatTime(wsData.timestamp ?? new Date().toISOString())
+    }
+
+    // 判定是否属于当前选中会话(优先用 sessionId,再用 id/name 兜底)
+    const belongToCurrent = selectedCustomer.value &&
+        (
+            (msg.sessionId != null && String(msg.sessionId) === String(selectedCustomer.value.id)) ||
+            (String(msg.fromId) === String(selectedCustomer.value.name)) ||
+            (String(msg.toId) === String(selectedCustomer.value.name))
+        )
+
+    // 找到目标会话
+    let target = customers.value.find(c =>
+        String(c.id) === String(msg.sessionId) ||
+        String(c.name) === String(msg.fromId) ||
+        String(c.name) === String(msg.toId)
+    )
+
+    // 新增:如果找不到目标客户且是客户发送的消息,创建新客户
+    if (!target && msg.sender === 'customer') {
+      target = createNewCustomer(wsData)
+
+      // 显示新客户消息通知
+      ElNotification({
+        title: '新客户消息',
+        message: `来自 ${wsData.from} 的消息:${wsData.content}`,
+        type: 'info',
+        duration: 5000,
+        position: 'top-right'
+      })
+
+      // 浏览器通知
+      showBrowserNotification(
+          '新客户消息',
+          `${wsData.from}: ${wsData.content}`
+      )
+
+      // 播放提示音
+      playNotificationSound()
+
+      // 更新新客户计数
+      newCustomerCount.value += 1
+      hasNewCustomerMessage.value = true
+
+      console.log('[v0] 收到新客户消息,已创建客户记录并发送通知')
+    }
+
+    if (target) {
+      target.lastMessage = msg.content
+      target.lastMessageTime = msg.createdAt
+      if (!belongToCurrent && msg.sender === 'customer') {
+        // 只有在非当前会话才累加未读数
+        target.unreadCount = (target.unreadCount || 0) + 1
+
+        // 如果不是当前选中的客户,显示消息通知
+        if (!belongToCurrent) {
+          ElMessage({
+            message: `${target.name} 发来新消息`,
+            type: 'info',
+            duration: 3000
+          })
+
+          // 浏览器通知(仅对现有客户的新消息)
+          if (!target.isNewCustomer) {
+            showBrowserNotification(
+                `${target.name} 的新消息`,
+                msg.content
+            )
+          }
+        }
+      }
+
+      // 清除新客户标记
+      if (target.isNewCustomer) {
+        target.isNewCustomer = false
+      }
+    } else {
+      console.warn('[v0] 收到属于未知会话的消息,sessionId:', msg.sessionId)
+    }
+
+    if (belongToCurrent) {
+      // 添加到当前对话
+      currentMessages.value.push(msg)
+
+      // 等待 DOM 更新后决定滚动或显示提示
+      await nextTick()
+
+      // 检查用户是否在底部
+      const userAtBottom = isAtBottom()
+
+      if (userAtBottom) {
+        scrollToBottom()
+        showNewMessageTip.value = false
+        newMessageCount.value = 0
+        isUserScrolled.value = false
+
+        // 如果是客户消息且用户在底部,立即发送已读回执
+        if (msg.sender === 'customer') {
+          sendReadReceipt(msg.sessionId, [msg.id])
+          msg.status = 'read'
+        }
+      } else {
+        // 用户不在底部,显示新消息提示
+        newMessageCount.value += 1
+        showNewMessageTip.value = true
+        isUserScrolled.value = true
+      }
+    }
+
+  } catch (e) {
+    console.error('[v0] handleIncomingMessage 错误', e)
+  }
+}
+
+// 新增:清除新客户消息提示
+const clearNewCustomerNotification = () => {
+  hasNewCustomerMessage.value = false
+  newCustomerCount.value = 0
+}
+
+// 获取指定客户的消息列表(支持分页)
+const getChatMessageListBySessionId = async (sessionId, pageNum = 1, isLoadMore = false) => {
+  if (isLoadMore) {
+    loadingMoreMessages.value = true
+  } else {
+    loadingMessages.value = true
+  }
+
+  try {
+    const res = await request.get("chat/getChatMessageListBySessionId", {
+      params: {
+        sessionId: sessionId,
+        pageNum: pageNum,
+        pageSize: pageSize.value
+      }
+    })
+
+    if (res.code !== 200) {
+      ElMessage.error(res.msg);
+      return { messages: [], total: 0 }
+    }
+
+    // 处理新的返回结构:{ messages: [], total: number }
+    const responseData = res.data || {}
+    const apiMessages = responseData.messages || []
+    const total = responseData.total || 0
+
+    // 转换消息格式
+    const messages = apiMessages.map(transformMessage)
+
+    // 检查是否还有更多消息 - 根据当前页数和总数计算
+    const totalPages = Math.ceil(total / pageSize.value)
+    hasMoreMessages.value = pageNum < totalPages
+
+    return { messages, total }
+  } catch (error) {
+    ElMessage.error('获取消息失败');
+    console.error('获取消息失败:', error)
+    return { messages: [], total: 0 }
+  } finally {
+    if (isLoadMore) {
+      loadingMoreMessages.value = false
+    } else {
+      loadingMessages.value = false
+    }
+  }
+}
+
+// 加载更多历史消息
+const loadMoreMessages = async () => {
+  if (!selectedCustomer.value || !hasMoreMessages.value || loadingMoreMessages.value) {
+    return
+  }
+
+  // 加载下一页
+  const nextPage = currentPage.value + 1
+
+  const result = await getChatMessageListBySessionId(
+      selectedCustomer.value.id,
+      nextPage,
+      true
+  )
+
+  if (result.messages.length > 0) {
+    // 先反转新获取的消息,保持与初始加载时的处理逻辑一致
+    const reversedMessages = [...result.messages].reverse()
+
+    // 将反转后的历史消息添加到列表开头
+    currentMessages.value = [...reversedMessages, ...currentMessages.value]
+    // 更新当前页码
+    currentPage.value = nextPage
+  }
+}
+
+// 选择客户方法 - 修改为处理新的返回结构
+const selectCustomer = async (customer) => {
+  selectedCustomer.value = customer
+  // 标记消息为已读
+  customer.unreadCount = 0
+  // 清除新客户标记
+  if (customer.isNewCustomer) {
+    customer.isNewCustomer = false
+  }
+  console.log('[v0] 选择客户:', customer.name)
+
+  // 重置滚动状态
+  isUserScrolled.value = false
+  showNewMessageTip.value = false
+  newMessageCount.value = 0
+
+  // 重置分页状态
+  hasMoreMessages.value = true
+  currentPage.value = 1 // 重置页码为1
+
+  // 获取该客户的最新消息列表(第一页)
+  const result = await getChatMessageListBySessionId(customer.id, 1)
+  currentMessages.value = result.messages
+
+  // 消息列表需要反转,因为后端按时间降序返回,前端需要按时间升序显示
+  currentMessages.value.reverse()
+
+  // 确保滚到底部(立即)
+  await nextTick()
+  scrollToBottom(false)
+
+  // 收集未读消息ID并发已读回执
+  const unreadMsgIds = currentMessages.value
+      .filter(m => m.sender === 'customer' && m.status !== 'read')
+      .map(m => m.id)
+
+  if (unreadMsgIds.length > 0) {
+    sendReadReceipt(customer.id, unreadMsgIds)
+    // 前端立即更新 UI
+    currentMessages.value.forEach(m => {
+      if (unreadMsgIds.includes(m.id)) m.status = 'read'
+    })
+  }
+}
+
+// 发送消息 - 修改为优先使用服务器返回的messageId
+const sendMessage = async () => {
+  if (!newMessage.value.trim() || !selectedCustomer.value) return
+
+  // <CHANGE> 创建临时消息对象,等待服务器返回真实的messageId
+  const tempMessage = {
+    id: `temp_${Date.now()}`, // 临时ID,等待服务器返回真实messageId
+    sessionId: selectedCustomer.value.id,
+    sender: 'agent',
+    fromRole: 'admin',
+    content: newMessage.value.trim(),
+    time: new Date().toLocaleTimeString('zh-CN', {
+      hour: '2-digit',
+      minute: '2-digit'
+    }),
+    status: 'sending', // 发送中状态
+    createdAt: new Date().toISOString()
+  }
+
+  console.log("[v0] 发送消息,添加到消息列表")
+  currentMessages.value.push(tempMessage)
+
+  // 立刻滚动到底部,保证用户看到自己刚发的消息
+  await nextTick()
+  scrollToBottom()
+
+  // 更新客户的最后消息信息
+  selectedCustomer.value.lastMessage = tempMessage.content
+  selectedCustomer.value.lastMessageTime = tempMessage.time
+
+  console.log('[v0] 发送消息:', tempMessage)
+
+  // 发送消息到服务器
+  try {
+    ws.send(JSON.stringify({
+      from: userId.value,
+      to: selectedCustomer.value.name,
+      content: tempMessage.content,
+      type: "chat",
+      role: "admin"
+    }))
+    tempMessage.status = 'sent'
+  } catch (error) {
+    ElMessage.error('发送消息失败');
+    console.error('发送消息失败:', error)
+    tempMessage.status = 'failed'
+  }
+
+  newMessage.value = ''
+
+  // 模拟消息状态更新(如果发送成功)
+  if (tempMessage.status !== 'failed') {
+    setTimeout(() => {
+      tempMessage.status = 'read'
+    }, 2000)
+  }
+}
+
+//建立WS连接
+const connectWs = async () => {
+  const res = await request.get("chat/getToken")
+  if (res.code !== 200) {
+    ElMessage.error(res.msg);
+    return
+  }
+  const token = res.data
+  const customInfo = {
+    token: token
+  };
+  const queryParams = new URLSearchParams();
+  Object.entries(customInfo).forEach(([key, value]) => {
+    queryParams.append(key, value);
+  });
+  const url = BASE_WS_URL + '?' + queryParams.toString();
+  ws = new WebSocket(url)
+  ws.onopen = () => {
+    console.log('WS连接已建立')
+    ws.send(JSON.stringify({from: userId.value, type: "login", role: "admin"}))
+    // 启动心跳:每 30 秒发送一次
+    heartbeatTimer = setInterval(() => {
+      if (ws.readyState === WebSocket.OPEN) {
+        ws.send(JSON.stringify({from: userId.value, type: "heartbeat", role: "admin"}))
+      }
+      //保持后台登录状态
+      request.get("/getInfo")
+    }, 30000)
+  }
+  ws.onclose = () => {
+    if (heartbeatTimer) {
+      clearInterval(heartbeatTimer)
+      heartbeatTimer = null
+    }
+    ElMessage.info("连接已经关闭可能的原因如下:1.超时、2.服务器异常、3.网络问题、4.没有权限访问")
+    console.log('WS连接已关闭')
+  }
+
+  ws.onmessage = (event) => {
+    try {
+      const data = JSON.parse(event.data)
+      console.log('[v0] 收到WebSocket消息:', data)
+
+      if (data.type === 'chat') {
+        // 不再直接 push,而交给统一处理函数
+        handleIncomingMessage(data)
+      } else if (data.type === 'clientOnline') {
+        // 处理客户上线消息 data.from 是上线用户ID
+        console.log('[v0] 客户上线:', data.from)
+
+        // 查找对应的客户并更新在线状态
+        const customer = customers.value.find(c =>
+            String(c.id) === String(data.from) ||
+            String(c.name) === String(data.from)
+        )
+
+        if (customer) {
+          customer.isOnline = true
+          console.log('[v0] 已更新客户在线状态:', customer.name, '-> 在线')
+
+          // 如果当前选中的就是这个客户,更新界面显示
+          if (selectedCustomer.value &&
+              (String(selectedCustomer.value.id) === String(data.from) ||
+                  String(selectedCustomer.value.name) === String(data.from))) {
+            selectedCustomer.value.isOnline = true
+          }
+        } else {
+          console.warn('[v0] 收到未知客户的上线消息:', data.from)
+        }
+
+      } else if (data.type === 'clientOffline') {
+        // 处理客户下线消息 data.from 是下线用户ID
+        console.log('[v0] 客户下线:', data.from)
+
+        // 查找对应的客户并更新在线状态
+        const customer = customers.value.find(c =>
+            String(c.id) === String(data.from) ||
+            String(c.name) === String(data.from)
+        )
+
+        if (customer) {
+          customer.isOnline = false
+          customer.isTyping = false // 下线时清除输入状态
+          console.log('[v0] 已更新客户在线状态:', customer.name, '-> 离线')
+
+          // 如果当前选中的就是这个客户,更新界面显示
+          if (selectedCustomer.value &&
+              (String(selectedCustomer.value.id) === String(data.from) ||
+                  String(selectedCustomer.value.name) === String(data.from))) {
+            selectedCustomer.value.isOnline = false
+            selectedCustomer.value.isTyping = false
+          }
+        } else {
+          console.warn('[v0] 收到未知客户的下线消息:', data.from)
+        }
+
+      } else if (data.type === 'typing') {
+        // 处理客户输入状态消息 data.from 是输入用户ID
+        console.log('[v0] 客户输入中:', data.from)
+
+        // 查找对应的客户并更新输入状态
+        const customer = customers.value.find(c =>
+            String(c.id) === String(data.from) ||
+            String(c.name) === String(data.from)
+        )
+
+        if (customer) {
+          customer.isTyping = true
+          console.log('[v0] 已更新客户输入状态:', customer.name, '-> 正在输入')
+
+          // 如果当前选中的就是这个客户,更新界面显示
+          if (selectedCustomer.value &&
+              (String(selectedCustomer.value.id) === String(data.from) ||
+                  String(selectedCustomer.value.name) === String(data.from))) {
+            selectedCustomer.value.isTyping = true
+          }
+        } else {
+          console.warn('[v0] 收到未知客户的输入消息:', data.from)
+        }
+
+      } else if (data.type === 'typingStop') {
+        // 处理客户输入状态消息 data.from 是输入用户ID
+        console.log('[v0] 客户输入结束:', data.from)
+
+        // 查找对应的客户并更新输入状态
+        const customer = customers.value.find(c =>
+            String(c.id) === String(data.from) ||
+            String(c.name) === String(data.from)
+        )
+
+        if (customer) {
+          customer.isTyping = false
+          console.log('[v0] 已更新客户输入状态:', customer.name, '-> 输入结束')
+
+          // 如果当前选中的就是这个客户,更新界面显示
+          if (selectedCustomer.value &&
+              (String(selectedCustomer.value.id) === String(data.from) ||
+                  String(selectedCustomer.value.name) === String(data.from))) {
+            selectedCustomer.value.isTyping = false
+          }
+        } else {
+          console.warn('[v0] 收到未知客户的输入结束消息:', data.from)
+        }
+
+      } else {
+        // 处理其他类型(login/status/typing/read 等),按需扩展
+        console.log('[v0] 非 chat 类型消息:', data.type)
+      }
+    } catch (error) {
+      console.error('解析WebSocket消息失败:', error)
+    }
+  }
+}
+
+const sendReadReceipt = (sessionId, messageIds, toId = null) => {
+
+  if (!sessionId || !messageIds || messageIds.length === 0) return
+  if (!ws || ws.readyState !== WebSocket.OPEN) return
+
+  const payload = {
+    type: 'read',
+    role: 'admin',
+    from: userId.value,
+    to: toId,
+    sessionId: sessionId,
+    messageIds: messageIds
+  }
+
+  ws.send(JSON.stringify(payload))
+  console.log('[v0] 已发送已读回执:', payload)
+}
+
+const getSessionList = async () => {
+  const res = await request.get("chat/getSessionListByUserId", {
+    params: {
+      adminId: userId.value
+    }
+  })
+  if (res.code !== 200) {
+    ElMessage.error(res.msg);
+    return
+  }
+
+  // 更新客户列表,不包含messages字段
+  customers.value = res.data.map(item => {
+    return {
+      id: item.id,
+      name: item.userId,
+      isOnline: item.isOnline,
+      isTyping: item.isTyping,
+      lastMessage: item.lastMessage,
+      lastMessageTime: item.lastMessageTime,
+      unreadCount: item.unreadCount,
+      isNewCustomer: false // 现有客户不是新客户
+    }
+  })
+  console.log('客户列表:', customers.value);
+}
+
+// 生命周期
+onMounted(() => {
+  connectWs()
+  getSessionList()
+  // 请求通知权限
+  requestNotificationPermission()
+})
+</script>
+
+<template>
+  <div class="h-[calc(100vh-6rem)] flex bg-gray-50">
+    <div class="w-80 bg-white border-r border-gray-200 flex flex-col">
+      <div class="p-4 border-b border-gray-200">
+        <div class="flex items-center justify-between mb-3">
+          <h2 class="text-lg font-semibold text-gray-900">客户列表</h2>
+          <div v-if="hasNewCustomerMessage" class="flex items-center space-x-2">
+            <Bell class="w-4 h-4 text-orange-500 animate-pulse"/>
+            <span class="text-xs text-orange-600 font-medium">
+              {{ newCustomerCount }} 个新客户
+            </span>
+            <button
+                @click="clearNewCustomerNotification"
+                class="text-xs text-gray-400 hover:text-gray-600"
+            >
+              ×
+            </button>
+          </div>
+        </div>
+        <el-input
+            v-model="searchQuery"
+            placeholder="搜索客户..."
+            :prefix-icon="Search"
+            clearable
+        />
+      </div>
+
+      <div class="flex-1 overflow-y-auto">
+        <div
+            v-for="customer in filteredCustomers"
+            :key="customer.id"
+            @click="selectCustomer(customer)"
+            :class="[
+            'p-4 border-b border-gray-100 cursor-pointer hover:bg-gray-50 transition-colors relative',
+            selectedCustomer?.id === customer.id ? 'bg-blue-50 border-blue-200' : '',
+            customer.isNewCustomer ? 'bg-orange-50 border-orange-200' : ''
+          ]"
+        >
+          <div v-if="customer.isNewCustomer" class="absolute top-2 right-2">
+            <span
+                class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-orange-100 text-orange-800">
+              新客户
+            </span>
+          </div>
+
+          <div class="flex items-center justify-between">
+            <div class="flex items-center space-x-3 flex-1 min-w-0">
+              <div class="flex-shrink-0">
+                <div
+                    :class="[
+                    'w-3 h-3 rounded-full',
+                    customer.isOnline ? 'bg-green-500' : 'bg-gray-400'
+                  ]"
+                ></div>
+              </div>
+
+              <div class="flex-1 min-w-0">
+                <div class="flex items-center justify-between">
+                  <h3 class="text-sm font-medium text-gray-900 truncate">
+                    {{ customer.name }}
+                  </h3>
+                  <span class="text-xs text-gray-500 flex-shrink-0 ml-2">
+                    {{ customer.lastMessageTime }}
+                  </span>
+                </div>
+                <p class="text-sm text-gray-500 truncate mt-1">
+                  {{ customer.lastMessage }}
+                </p>
+              </div>
+            </div>
+
+            <div v-if="customer.unreadCount > 0" class="flex-shrink-0 ml-2">
+              <span
+                  :class="[
+                  'inline-flex items-center justify-center w-5 h-5 text-xs font-medium text-white rounded-full',
+                  customer.isNewCustomer ? 'bg-orange-500' : 'bg-red-500'
+                ]">
+                {{ customer.unreadCount }}
+              </span>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <div class="flex-1 flex flex-col">
+      <div v-if="selectedCustomer" class="flex flex-col h-full">
+        <div class="p-4 bg-white border-b border-gray-200">
+          <div class="flex items-center justify-between">
+            <div class="flex items-center space-x-3">
+              <div
+                  :class="[
+                  'w-3 h-3 rounded-full',
+                  selectedCustomer.isOnline ? 'bg-green-500' : 'bg-gray-400'
+                ]"
+              ></div>
+              <div>
+                <h2 class="text-lg font-semibold text-gray-900">
+                  {{ selectedCustomer.name }}
+                  <span v-if="selectedCustomer.isNewCustomer"
+                        class="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-orange-100 text-orange-800">
+                    新客户
+                  </span>
+                </h2>
+                <p class="text-sm text-gray-500">
+                  {{ selectedCustomer.isOnline ? '在线' : '离线' }}
+                  <span v-if="selectedCustomer.isTyping && selectedCustomer.isOnline">
+                    · 正在输入...
+                  </span>
+                </p>
+              </div>
+            </div>
+          </div>
+        </div>
+
+        <div
+            ref="messagesContainer"
+            class="flex-1 overflow-y-auto p-4 space-y-4 relative"
+            @scroll="handleScroll"
+        >
+          <div v-if="hasMoreMessages && currentMessages.length > 0" class="flex justify-center">
+            <el-button
+                @click="loadMoreMessages"
+                :loading="loadingMoreMessages"
+                size="small"
+                type="text"
+            >
+              {{ loadingMoreMessages ? '加载中...' : '加载更多历史消息' }}
+            </el-button>
+          </div>
+
+          <div v-if="loadingMessages" class="flex justify-center items-center py-8">
+            <span class="ml-2 text-gray-500">加载消息中...</span>
+          </div>
+
+          <div
+              v-for="message in currentMessages"
+              :key="message.id"
+              :class="[
+              'flex',
+              message.sender === 'customer' ? 'justify-start' : 'justify-end'
+            ]"
+          >
+            <div
+                :class="[
+                'max-w-[85%] sm:max-w-[55%] px-3 py-2 sm:px-4 sm:py-2.5 rounded-2xl text-sm sm:text-base break-words whitespace-pre-wrap leading-relaxed',
+                message.sender === 'customer'
+                  ? 'bg-gray-200 text-gray-900'
+                  : 'bg-blue-500 text-white'
+              ]"
+            >
+              <p class="text-sm">{{ message.content }}</p>
+              <div class="flex items-center justify-between mt-1">
+                <span class="text-xs opacity-70">{{ message.time }}</span>
+                <div v-if="message.sender === 'agent'" class="flex items-center space-x-1 ml-2">
+                  <Check
+                      v-if="message.status === 'sent'"
+                      class="w-3 h-3 opacity-70"
+                  />
+                  <CheckCheck
+                      v-else-if="message.status === 'read'"
+                      class="w-3 h-3 text-blue-200"
+                  />
+                  <X
+                      v-else-if="message.status === 'failed'"
+                      class="w-3 h-3 text-red-300"
+                  />
+                </div>
+              </div>
+            </div>
+          </div>
+
+          <div
+              v-if="showNewMessageTip"
+              class="fixed bottom-20 left-1/2 transform -translate-x-1/2 z-10"
+          >
+            <div
+                @click="goToBottom"
+                class="bg-blue-500 text-white px-4 py-2 rounded-full shadow-lg cursor-pointer hover:bg-blue-600 transition-colors flex items-center space-x-2"
+            >
+              <span class="text-sm">{{ newMessageCount }} 条新消息</span>
+              <ChevronDown class="w-4 h-4"/>
+            </div>
+          </div>
+
+          <div
+              v-if="isUserScrolled && !showNewMessageTip"
+              class="fixed bottom-20 right-8 z-10"
+          >
+            <div
+                @click="goToBottom"
+                class="bg-gray-500 text-white p-2 rounded-full shadow-lg cursor-pointer hover:bg-gray-600 transition-colors"
+            >
+              <ChevronDown class="w-5 h-5"/>
+            </div>
+          </div>
+        </div>
+
+        <div class="p-4 bg-white border-t border-gray-200">
+          <div class="flex items-end space-x-2">
+            <div class="flex-1">
+              <el-input
+                  v-model="newMessage"
+                  type="textarea"
+                  :rows="1"
+                  placeholder="输入消息..."
+                  @keydown.enter.prevent="sendMessage"
+                  resize="none"
+              />
+            </div>
+            <el-button
+                type="primary"
+                @click="sendMessage"
+                :disabled="!newMessage.trim()"
+                class="flex-shrink-0"
+            >
+              <Send class="w-4 h-4"/>
+            </el-button>
+          </div>
+        </div>
+      </div>
+
+      <div v-else class="flex-1 flex items-center justify-center bg-gray-50">
+        <div class="text-center">
+          <MessageCircle class="w-16 h-16 text-gray-400 mx-auto mb-4"/>
+          <h3 class="text-lg font-medium text-gray-900 mb-2">选择一个客户开始聊天</h3>
+          <p class="text-gray-500">从左侧客户列表中选择一个客户来查看聊天记录</p>
+
+          <div v-if="hasNewCustomerMessage" class="mt-4 p-4 bg-orange-50 rounded-lg border border-orange-200">
+            <div class="flex items-center justify-center space-x-2 text-orange-600">
+              <Bell class="w-5 h-5 animate-pulse"/>
+              <span class="font-medium">有 {{ newCustomerCount }} 个新客户发来消息</span>
+            </div>
+            <p class="text-sm text-orange-500 mt-1">请查看左侧客户列表中的新客户</p>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+/* 自定义滚动条样式 */
+::-webkit-scrollbar {
+  width: 6px;
+}
+
+::-webkit-scrollbar-track {
+  background: #f1f1f1;
+}
+
+::-webkit-scrollbar-thumb {
+  background: #c1c1c1;
+  border-radius: 3px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+  background: #a8a8a8;
+}
+
+/* 新增:新客户动画效果 */
+@keyframes pulse {
+  0%, 100% {
+    opacity: 1;
+  }
+  50% {
+    opacity: 0.5;
+  }
+}
+
+.animate-pulse {
+  animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
+}
+</style>