| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081 |
- <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>
|