index.vue 34 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081
  1. <script setup>
  2. import {computed, nextTick, onMounted, ref, watch} from 'vue'
  3. import {Bell, Check, CheckCheck, ChevronDown, MessageCircle, Search, Send, X} from 'lucide-vue-next'
  4. import request from "@/utils/request.js";
  5. import {ElMessage, ElNotification} from "element-plus";
  6. import useUserStore from "@/store/modules/user.js";
  7. const userStore = useUserStore()
  8. const userId = computed(() => userStore.id)
  9. // 响应式数据
  10. const searchQuery = ref('')
  11. const selectedCustomer = ref(null)
  12. const newMessage = ref('')
  13. const currentMessages = ref([]) // 当前选中客户的消息列表
  14. const loadingMessages = ref(false) // 消息加载状态
  15. const loadingMoreMessages = ref(false) // 加载更多消息状态
  16. const hasMoreMessages = ref(true) // 是否还有更多消息
  17. const currentPage = ref(1)
  18. const pageSize = ref(20) // 每页消息数量
  19. // 滚动相关的响应式数据
  20. const messagesContainer = ref(null) // 消息容器的引用
  21. const isUserScrolled = ref(false) // 用户是否手动滚动了
  22. const newMessageCount = ref(0) // 新消息数量
  23. const showNewMessageTip = ref(false) // 是否显示新消息提示
  24. const lastMessageCount = ref(0) // 添加上次消息数量追踪
  25. // 新增:通知相关
  26. const hasNewCustomerMessage = ref(false) // 是否有新客户消息
  27. const newCustomerCount = ref(0) // 新客户数量
  28. const notificationPermission = ref(Notification.permission) // 浏览器通知权限
  29. let heartbeatTimer = null
  30. let ws = null
  31. const BASE_WS_URL = import.meta.env.VITE_APP_BASE_WS_URL
  32. // 客户数据结构(不包含messages字段)
  33. const customers = ref([])
  34. // 计算属性
  35. const filteredCustomers = computed(() => {
  36. if (!searchQuery.value) {
  37. return customers.value
  38. }
  39. return customers.value.filter(customer =>
  40. customer.name.toLowerCase().includes(searchQuery.value.toLowerCase())
  41. )
  42. })
  43. // 新增:请求通知权限
  44. const requestNotificationPermission = async () => {
  45. if ('Notification' in window && Notification.permission === 'default') {
  46. const permission = await Notification.requestPermission()
  47. notificationPermission.value = permission
  48. }
  49. }
  50. // 新增:显示浏览器通知
  51. const showBrowserNotification = (title, body, icon = null) => {
  52. if (notificationPermission.value === 'granted') {
  53. const notification = new Notification(title, {
  54. body,
  55. icon: icon || '/favicon.ico',
  56. tag: 'chat-message' // 防止重复通知
  57. })
  58. // 点击通知时聚焦窗口
  59. notification.onclick = () => {
  60. window.focus()
  61. notification.close()
  62. }
  63. // 3秒后自动关闭
  64. setTimeout(() => notification.close(), 3000)
  65. }
  66. }
  67. // 新增:播放提示音
  68. const playNotificationSound = () => {
  69. try {
  70. // 创建音频上下文播放提示音
  71. const audioContext = new (window.AudioContext || window.webkitAudioContext)()
  72. const oscillator = audioContext.createOscillator()
  73. const gainNode = audioContext.createGain()
  74. oscillator.connect(gainNode)
  75. gainNode.connect(audioContext.destination)
  76. oscillator.frequency.setValueAtTime(800, audioContext.currentTime)
  77. oscillator.frequency.setValueAtTime(600, audioContext.currentTime + 0.1)
  78. gainNode.gain.setValueAtTime(0.3, audioContext.currentTime)
  79. gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.3)
  80. oscillator.start(audioContext.currentTime)
  81. oscillator.stop(audioContext.currentTime + 0.3)
  82. } catch (error) {
  83. console.log('无法播放提示音:', error)
  84. }
  85. }
  86. // 新增:创建新客户记录
  87. const createNewCustomer = (wsData) => {
  88. const newCustomer = {
  89. id: wsData.sessionId || wsData.from,
  90. name: wsData.from,
  91. isOnline: true,
  92. isTyping: false,
  93. lastMessage: wsData.content,
  94. lastMessageTime: formatTime(wsData.timestamp || new Date().toISOString()),
  95. unreadCount: 0,
  96. isNewCustomer: true // 标记为新客户
  97. }
  98. // 添加到客户列表顶部
  99. customers.value.unshift(newCustomer)
  100. console.log('[v0] 创建新客户:', newCustomer)
  101. return newCustomer
  102. }
  103. // 滚动到底部的方法
  104. const scrollToBottom = (smooth = true) => {
  105. nextTick(() => {
  106. if (messagesContainer.value) {
  107. // 使用 scrollTop 以兼容性更好
  108. try {
  109. messagesContainer.value.scrollTo({
  110. top: messagesContainer.value.scrollHeight,
  111. behavior: smooth ? 'smooth' : 'auto'
  112. })
  113. } catch (e) {
  114. messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
  115. }
  116. }
  117. })
  118. }
  119. // 检查是否在底部 - 修复判断逻辑
  120. const isAtBottom = () => {
  121. if (!messagesContainer.value) return true
  122. const { scrollTop, scrollHeight, clientHeight } = messagesContainer.value
  123. // 减少容差值,提高判断精度
  124. const atBottom = scrollHeight - scrollTop - clientHeight < 10
  125. console.log("[v0] 检查是否在底部:", atBottom, { scrollTop, scrollHeight, clientHeight })
  126. return atBottom
  127. }
  128. // 发送未读消息的已读回执
  129. const markUnreadMessagesAsRead = () => {
  130. if (!selectedCustomer.value) return
  131. const unreadMessages = currentMessages.value.filter(msg =>
  132. msg.sender === 'customer' && msg.status !== 'read'
  133. )
  134. if (unreadMessages.length > 0) {
  135. const unreadMsgIds = unreadMessages.map(msg => msg.id)
  136. sendReadReceipt(selectedCustomer.value.id, unreadMsgIds)
  137. // 前端立即更新状态
  138. unreadMessages.forEach(msg => {
  139. msg.status = 'read'
  140. })
  141. console.log("[v0] 标记消息为已读:", unreadMsgIds)
  142. }
  143. }
  144. // 处理滚动事件 - 修复已读回执发送
  145. const handleScroll = () => {
  146. if (!messagesContainer.value) return
  147. const atBottom = isAtBottom()
  148. if (atBottom) {
  149. isUserScrolled.value = false
  150. showNewMessageTip.value = false
  151. newMessageCount.value = 0
  152. // 当用户滑动到底部时,发送已读回执
  153. markUnreadMessagesAsRead()
  154. console.log("[v0] 用户回到底部,重置状态并发送已读回执")
  155. } else {
  156. isUserScrolled.value = true
  157. console.log("[v0] 用户滚动离开底部")
  158. }
  159. }
  160. // 回到底部按钮点击事件
  161. const goToBottom = () => {
  162. console.log("[v0] 点击回到底部")
  163. scrollToBottom()
  164. showNewMessageTip.value = false
  165. newMessageCount.value = 0
  166. isUserScrolled.value = false
  167. // 发送已读回执
  168. markUnreadMessagesAsRead()
  169. }
  170. watch(currentMessages, (newMessages, oldMessages) => {
  171. console.log("[v0] 消息列表变化", {
  172. newCount: newMessages.length,
  173. oldCount: oldMessages?.length || 0,
  174. isUserScrolled: isUserScrolled.value
  175. })
  176. if (!oldMessages || oldMessages.length === 0) {
  177. // 初始加载消息时,直接滚动到底部
  178. console.log("[v0] 初始加载消息,滚动到底部")
  179. lastMessageCount.value = newMessages.length
  180. scrollToBottom(false)
  181. return
  182. }
  183. if (newMessages.length > oldMessages.length) {
  184. // 有新消息(从其他地方添加也会进来)
  185. const newMsgCount = newMessages.length - oldMessages.length
  186. console.log("[v0] 检测到新消息", newMsgCount, "条")
  187. if (isUserScrolled.value) {
  188. // 用户已滚动,显示新消息提示
  189. if(!loadingMoreMessages){
  190. newMessageCount.value += newMsgCount
  191. showNewMessageTip.value = true
  192. }
  193. } else {
  194. // 用户未滚动,自动滚动到底部
  195. console.log("[v0] 用户未滚动,自动滚动到底部")
  196. scrollToBottom()
  197. }
  198. }
  199. lastMessageCount.value = newMessages.length
  200. }, {deep: true, immediate: false})
  201. // 格式化时间
  202. const formatTime = (createdAt) => {
  203. const date = new Date(createdAt)
  204. const now = new Date()
  205. const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
  206. const messageDate = new Date(date.getFullYear(), date.getMonth(), date.getDate())
  207. if (messageDate.getTime() === today.getTime()) {
  208. // 今天的消息只显示时间
  209. return date.toLocaleTimeString('zh-CN', {
  210. hour: '2-digit',
  211. minute: '2-digit'
  212. })
  213. } else {
  214. // 其他日期显示月日
  215. return date.toLocaleDateString('zh-CN', {
  216. month: '2-digit',
  217. day: '2-digit'
  218. })
  219. }
  220. }
  221. // 转换消息数据格式(用于 API 返回)- 修改为优先使用服务器messageId
  222. const transformMessage = (apiMessage) => {
  223. return {
  224. // <CHANGE> 优先使用服务器返回的messageId,fallback到原有的id字段
  225. id: apiMessage.messageId || apiMessage.id,
  226. sessionId: apiMessage.sessionId,
  227. sender: apiMessage.fromRole === 'admin' ? 'agent' : 'customer',
  228. fromId: apiMessage.fromId,
  229. fromRole: apiMessage.fromRole,
  230. toId: apiMessage.toId,
  231. content: apiMessage.content,
  232. time: formatTime(apiMessage.createdAt),
  233. status: apiMessage.status,
  234. createdAt: apiMessage.createdAt
  235. }
  236. }
  237. // 如果 websocket 消息的字段不固定,尝试从多个字段取 sessionId
  238. const resolveSessionId = (wsMsg) => {
  239. return wsMsg.sessionId ?? wsMsg.session ?? wsMsg.from ?? wsMsg.to ?? null
  240. }
  241. // 统一处理收到的 websocket 聊天消息(关键函数)- 修改为优先使用messageId
  242. const handleIncomingMessage = async (wsData) => {
  243. console.log(wsData);
  244. console.log(formatTime(wsData.timestamp ?? new Date().toISOString()));
  245. try {
  246. // 构造前端消息对象(优先使用服务器字段)
  247. const msg = {
  248. // <CHANGE> 优先使用服务器返回的messageId,如果没有则使用id字段,最后才使用时间戳生成
  249. id: wsData.messageId || wsData.id || (Date.now() + Math.floor(Math.random() * 10000)),
  250. sessionId: resolveSessionId(wsData),
  251. sender: wsData.role === 'admin' ? 'agent' : 'customer',
  252. fromId: wsData.from,
  253. fromRole: wsData.role,
  254. toId: wsData.to,
  255. content: wsData.content,
  256. time: formatTime(wsData.timestamp ?? new Date().toISOString()),
  257. status: wsData.status ?? 'sent', // 默认为sent,而不是read
  258. // 格式化一下
  259. createdAt: formatTime(wsData.timestamp ?? new Date().toISOString())
  260. }
  261. // 判定是否属于当前选中会话(优先用 sessionId,再用 id/name 兜底)
  262. const belongToCurrent = selectedCustomer.value &&
  263. (
  264. (msg.sessionId != null && String(msg.sessionId) === String(selectedCustomer.value.id)) ||
  265. (String(msg.fromId) === String(selectedCustomer.value.name)) ||
  266. (String(msg.toId) === String(selectedCustomer.value.name))
  267. )
  268. // 找到目标会话
  269. let target = customers.value.find(c =>
  270. String(c.id) === String(msg.sessionId) ||
  271. String(c.name) === String(msg.fromId) ||
  272. String(c.name) === String(msg.toId)
  273. )
  274. // 新增:如果找不到目标客户且是客户发送的消息,创建新客户
  275. if (!target && msg.sender === 'customer') {
  276. target = createNewCustomer(wsData)
  277. // 显示新客户消息通知
  278. ElNotification({
  279. title: '新客户消息',
  280. message: `来自 ${wsData.from} 的消息:${wsData.content}`,
  281. type: 'info',
  282. duration: 5000,
  283. position: 'top-right'
  284. })
  285. // 浏览器通知
  286. showBrowserNotification(
  287. '新客户消息',
  288. `${wsData.from}: ${wsData.content}`
  289. )
  290. // 播放提示音
  291. playNotificationSound()
  292. // 更新新客户计数
  293. newCustomerCount.value += 1
  294. hasNewCustomerMessage.value = true
  295. console.log('[v0] 收到新客户消息,已创建客户记录并发送通知')
  296. }
  297. if (target) {
  298. target.lastMessage = msg.content
  299. target.lastMessageTime = msg.createdAt
  300. if (!belongToCurrent && msg.sender === 'customer') {
  301. // 只有在非当前会话才累加未读数
  302. target.unreadCount = (target.unreadCount || 0) + 1
  303. // 如果不是当前选中的客户,显示消息通知
  304. if (!belongToCurrent) {
  305. ElMessage({
  306. message: `${target.name} 发来新消息`,
  307. type: 'info',
  308. duration: 3000
  309. })
  310. // 浏览器通知(仅对现有客户的新消息)
  311. if (!target.isNewCustomer) {
  312. showBrowserNotification(
  313. `${target.name} 的新消息`,
  314. msg.content
  315. )
  316. }
  317. }
  318. }
  319. // 清除新客户标记
  320. if (target.isNewCustomer) {
  321. target.isNewCustomer = false
  322. }
  323. } else {
  324. console.warn('[v0] 收到属于未知会话的消息,sessionId:', msg.sessionId)
  325. }
  326. if (belongToCurrent) {
  327. // 添加到当前对话
  328. currentMessages.value.push(msg)
  329. // 等待 DOM 更新后决定滚动或显示提示
  330. await nextTick()
  331. // 检查用户是否在底部
  332. const userAtBottom = isAtBottom()
  333. if (userAtBottom) {
  334. scrollToBottom()
  335. showNewMessageTip.value = false
  336. newMessageCount.value = 0
  337. isUserScrolled.value = false
  338. // 如果是客户消息且用户在底部,立即发送已读回执
  339. if (msg.sender === 'customer') {
  340. sendReadReceipt(msg.sessionId, [msg.id])
  341. msg.status = 'read'
  342. }
  343. } else {
  344. // 用户不在底部,显示新消息提示
  345. newMessageCount.value += 1
  346. showNewMessageTip.value = true
  347. isUserScrolled.value = true
  348. }
  349. }
  350. } catch (e) {
  351. console.error('[v0] handleIncomingMessage 错误', e)
  352. }
  353. }
  354. // 新增:清除新客户消息提示
  355. const clearNewCustomerNotification = () => {
  356. hasNewCustomerMessage.value = false
  357. newCustomerCount.value = 0
  358. }
  359. // 获取指定客户的消息列表(支持分页)
  360. const getChatMessageListBySessionId = async (sessionId, pageNum = 1, isLoadMore = false) => {
  361. if (isLoadMore) {
  362. loadingMoreMessages.value = true
  363. } else {
  364. loadingMessages.value = true
  365. }
  366. try {
  367. const res = await request.get("chat/getChatMessageListBySessionId", {
  368. params: {
  369. sessionId: sessionId,
  370. pageNum: pageNum,
  371. pageSize: pageSize.value
  372. }
  373. })
  374. if (res.code !== 200) {
  375. ElMessage.error(res.msg);
  376. return { messages: [], total: 0 }
  377. }
  378. // 处理新的返回结构:{ messages: [], total: number }
  379. const responseData = res.data || {}
  380. const apiMessages = responseData.messages || []
  381. const total = responseData.total || 0
  382. // 转换消息格式
  383. const messages = apiMessages.map(transformMessage)
  384. // 检查是否还有更多消息 - 根据当前页数和总数计算
  385. const totalPages = Math.ceil(total / pageSize.value)
  386. hasMoreMessages.value = pageNum < totalPages
  387. return { messages, total }
  388. } catch (error) {
  389. ElMessage.error('获取消息失败');
  390. console.error('获取消息失败:', error)
  391. return { messages: [], total: 0 }
  392. } finally {
  393. if (isLoadMore) {
  394. loadingMoreMessages.value = false
  395. } else {
  396. loadingMessages.value = false
  397. }
  398. }
  399. }
  400. // 加载更多历史消息
  401. const loadMoreMessages = async () => {
  402. if (!selectedCustomer.value || !hasMoreMessages.value || loadingMoreMessages.value) {
  403. return
  404. }
  405. // 加载下一页
  406. const nextPage = currentPage.value + 1
  407. const result = await getChatMessageListBySessionId(
  408. selectedCustomer.value.id,
  409. nextPage,
  410. true
  411. )
  412. if (result.messages.length > 0) {
  413. // 先反转新获取的消息,保持与初始加载时的处理逻辑一致
  414. const reversedMessages = [...result.messages].reverse()
  415. // 将反转后的历史消息添加到列表开头
  416. currentMessages.value = [...reversedMessages, ...currentMessages.value]
  417. // 更新当前页码
  418. currentPage.value = nextPage
  419. }
  420. }
  421. // 选择客户方法 - 修改为处理新的返回结构
  422. const selectCustomer = async (customer) => {
  423. selectedCustomer.value = customer
  424. // 标记消息为已读
  425. customer.unreadCount = 0
  426. // 清除新客户标记
  427. if (customer.isNewCustomer) {
  428. customer.isNewCustomer = false
  429. }
  430. console.log('[v0] 选择客户:', customer.name)
  431. // 重置滚动状态
  432. isUserScrolled.value = false
  433. showNewMessageTip.value = false
  434. newMessageCount.value = 0
  435. // 重置分页状态
  436. hasMoreMessages.value = true
  437. currentPage.value = 1 // 重置页码为1
  438. // 获取该客户的最新消息列表(第一页)
  439. const result = await getChatMessageListBySessionId(customer.id, 1)
  440. currentMessages.value = result.messages
  441. // 消息列表需要反转,因为后端按时间降序返回,前端需要按时间升序显示
  442. currentMessages.value.reverse()
  443. // 确保滚到底部(立即)
  444. await nextTick()
  445. scrollToBottom(false)
  446. // 收集未读消息ID并发已读回执
  447. const unreadMsgIds = currentMessages.value
  448. .filter(m => m.sender === 'customer' && m.status !== 'read')
  449. .map(m => m.id)
  450. if (unreadMsgIds.length > 0) {
  451. sendReadReceipt(customer.id, unreadMsgIds)
  452. // 前端立即更新 UI
  453. currentMessages.value.forEach(m => {
  454. if (unreadMsgIds.includes(m.id)) m.status = 'read'
  455. })
  456. }
  457. }
  458. // 发送消息 - 修改为优先使用服务器返回的messageId
  459. const sendMessage = async () => {
  460. if (!newMessage.value.trim() || !selectedCustomer.value) return
  461. // <CHANGE> 创建临时消息对象,等待服务器返回真实的messageId
  462. const tempMessage = {
  463. id: `temp_${Date.now()}`, // 临时ID,等待服务器返回真实messageId
  464. sessionId: selectedCustomer.value.id,
  465. sender: 'agent',
  466. fromRole: 'admin',
  467. content: newMessage.value.trim(),
  468. time: new Date().toLocaleTimeString('zh-CN', {
  469. hour: '2-digit',
  470. minute: '2-digit'
  471. }),
  472. status: 'sending', // 发送中状态
  473. createdAt: new Date().toISOString()
  474. }
  475. console.log("[v0] 发送消息,添加到消息列表")
  476. currentMessages.value.push(tempMessage)
  477. // 立刻滚动到底部,保证用户看到自己刚发的消息
  478. await nextTick()
  479. scrollToBottom()
  480. // 更新客户的最后消息信息
  481. selectedCustomer.value.lastMessage = tempMessage.content
  482. selectedCustomer.value.lastMessageTime = tempMessage.time
  483. console.log('[v0] 发送消息:', tempMessage)
  484. // 发送消息到服务器
  485. try {
  486. ws.send(JSON.stringify({
  487. from: userId.value,
  488. to: selectedCustomer.value.name,
  489. content: tempMessage.content,
  490. type: "chat",
  491. role: "admin"
  492. }))
  493. tempMessage.status = 'sent'
  494. } catch (error) {
  495. ElMessage.error('发送消息失败');
  496. console.error('发送消息失败:', error)
  497. tempMessage.status = 'failed'
  498. }
  499. newMessage.value = ''
  500. // 模拟消息状态更新(如果发送成功)
  501. if (tempMessage.status !== 'failed') {
  502. setTimeout(() => {
  503. tempMessage.status = 'read'
  504. }, 2000)
  505. }
  506. }
  507. //建立WS连接
  508. const connectWs = async () => {
  509. const res = await request.get("chat/getToken")
  510. if (res.code !== 200) {
  511. ElMessage.error(res.msg);
  512. return
  513. }
  514. const token = res.data
  515. const customInfo = {
  516. token: token
  517. };
  518. const queryParams = new URLSearchParams();
  519. Object.entries(customInfo).forEach(([key, value]) => {
  520. queryParams.append(key, value);
  521. });
  522. const url = BASE_WS_URL + '?' + queryParams.toString();
  523. ws = new WebSocket(url)
  524. ws.onopen = () => {
  525. console.log('WS连接已建立')
  526. ws.send(JSON.stringify({from: userId.value, type: "login", role: "admin"}))
  527. // 启动心跳:每 30 秒发送一次
  528. heartbeatTimer = setInterval(() => {
  529. if (ws.readyState === WebSocket.OPEN) {
  530. ws.send(JSON.stringify({from: userId.value, type: "heartbeat", role: "admin"}))
  531. }
  532. //保持后台登录状态
  533. request.get("/getInfo")
  534. }, 30000)
  535. }
  536. ws.onclose = () => {
  537. if (heartbeatTimer) {
  538. clearInterval(heartbeatTimer)
  539. heartbeatTimer = null
  540. }
  541. ElMessage.info("连接已经关闭可能的原因如下:1.超时、2.服务器异常、3.网络问题、4.没有权限访问")
  542. console.log('WS连接已关闭')
  543. }
  544. ws.onmessage = (event) => {
  545. try {
  546. const data = JSON.parse(event.data)
  547. console.log('[v0] 收到WebSocket消息:', data)
  548. if (data.type === 'chat') {
  549. // 不再直接 push,而交给统一处理函数
  550. handleIncomingMessage(data)
  551. } else if (data.type === 'clientOnline') {
  552. // 处理客户上线消息 data.from 是上线用户ID
  553. console.log('[v0] 客户上线:', data.from)
  554. // 查找对应的客户并更新在线状态
  555. const customer = customers.value.find(c =>
  556. String(c.id) === String(data.from) ||
  557. String(c.name) === String(data.from)
  558. )
  559. if (customer) {
  560. customer.isOnline = true
  561. console.log('[v0] 已更新客户在线状态:', customer.name, '-> 在线')
  562. // 如果当前选中的就是这个客户,更新界面显示
  563. if (selectedCustomer.value &&
  564. (String(selectedCustomer.value.id) === String(data.from) ||
  565. String(selectedCustomer.value.name) === String(data.from))) {
  566. selectedCustomer.value.isOnline = true
  567. }
  568. } else {
  569. console.warn('[v0] 收到未知客户的上线消息:', data.from)
  570. }
  571. } else if (data.type === 'clientOffline') {
  572. // 处理客户下线消息 data.from 是下线用户ID
  573. console.log('[v0] 客户下线:', data.from)
  574. // 查找对应的客户并更新在线状态
  575. const customer = customers.value.find(c =>
  576. String(c.id) === String(data.from) ||
  577. String(c.name) === String(data.from)
  578. )
  579. if (customer) {
  580. customer.isOnline = false
  581. customer.isTyping = false // 下线时清除输入状态
  582. console.log('[v0] 已更新客户在线状态:', customer.name, '-> 离线')
  583. // 如果当前选中的就是这个客户,更新界面显示
  584. if (selectedCustomer.value &&
  585. (String(selectedCustomer.value.id) === String(data.from) ||
  586. String(selectedCustomer.value.name) === String(data.from))) {
  587. selectedCustomer.value.isOnline = false
  588. selectedCustomer.value.isTyping = false
  589. }
  590. } else {
  591. console.warn('[v0] 收到未知客户的下线消息:', data.from)
  592. }
  593. } else if (data.type === 'typing') {
  594. // 处理客户输入状态消息 data.from 是输入用户ID
  595. console.log('[v0] 客户输入中:', data.from)
  596. // 查找对应的客户并更新输入状态
  597. const customer = customers.value.find(c =>
  598. String(c.id) === String(data.from) ||
  599. String(c.name) === String(data.from)
  600. )
  601. if (customer) {
  602. customer.isTyping = true
  603. console.log('[v0] 已更新客户输入状态:', customer.name, '-> 正在输入')
  604. // 如果当前选中的就是这个客户,更新界面显示
  605. if (selectedCustomer.value &&
  606. (String(selectedCustomer.value.id) === String(data.from) ||
  607. String(selectedCustomer.value.name) === String(data.from))) {
  608. selectedCustomer.value.isTyping = true
  609. }
  610. } else {
  611. console.warn('[v0] 收到未知客户的输入消息:', data.from)
  612. }
  613. } else if (data.type === 'typingStop') {
  614. // 处理客户输入状态消息 data.from 是输入用户ID
  615. console.log('[v0] 客户输入结束:', data.from)
  616. // 查找对应的客户并更新输入状态
  617. const customer = customers.value.find(c =>
  618. String(c.id) === String(data.from) ||
  619. String(c.name) === String(data.from)
  620. )
  621. if (customer) {
  622. customer.isTyping = false
  623. console.log('[v0] 已更新客户输入状态:', customer.name, '-> 输入结束')
  624. // 如果当前选中的就是这个客户,更新界面显示
  625. if (selectedCustomer.value &&
  626. (String(selectedCustomer.value.id) === String(data.from) ||
  627. String(selectedCustomer.value.name) === String(data.from))) {
  628. selectedCustomer.value.isTyping = false
  629. }
  630. } else {
  631. console.warn('[v0] 收到未知客户的输入结束消息:', data.from)
  632. }
  633. } else {
  634. // 处理其他类型(login/status/typing/read 等),按需扩展
  635. console.log('[v0] 非 chat 类型消息:', data.type)
  636. }
  637. } catch (error) {
  638. console.error('解析WebSocket消息失败:', error)
  639. }
  640. }
  641. }
  642. const sendReadReceipt = (sessionId, messageIds, toId = null) => {
  643. if (!sessionId || !messageIds || messageIds.length === 0) return
  644. if (!ws || ws.readyState !== WebSocket.OPEN) return
  645. const payload = {
  646. type: 'read',
  647. role: 'admin',
  648. from: userId.value,
  649. to: toId,
  650. sessionId: sessionId,
  651. messageIds: messageIds
  652. }
  653. ws.send(JSON.stringify(payload))
  654. console.log('[v0] 已发送已读回执:', payload)
  655. }
  656. const getSessionList = async () => {
  657. const res = await request.get("chat/getSessionListByUserId", {
  658. params: {
  659. adminId: userId.value
  660. }
  661. })
  662. if (res.code !== 200) {
  663. ElMessage.error(res.msg);
  664. return
  665. }
  666. // 更新客户列表,不包含messages字段
  667. customers.value = res.data.map(item => {
  668. return {
  669. id: item.id,
  670. name: item.userId,
  671. isOnline: item.isOnline,
  672. isTyping: item.isTyping,
  673. lastMessage: item.lastMessage,
  674. lastMessageTime: item.lastMessageTime,
  675. unreadCount: item.unreadCount,
  676. isNewCustomer: false // 现有客户不是新客户
  677. }
  678. })
  679. console.log('客户列表:', customers.value);
  680. }
  681. // 生命周期
  682. onMounted(() => {
  683. connectWs()
  684. getSessionList()
  685. // 请求通知权限
  686. requestNotificationPermission()
  687. })
  688. </script>
  689. <template>
  690. <div class="h-[calc(100vh-6rem)] flex bg-gray-50">
  691. <div class="w-80 bg-white border-r border-gray-200 flex flex-col">
  692. <div class="p-4 border-b border-gray-200">
  693. <div class="flex items-center justify-between mb-3">
  694. <h2 class="text-lg font-semibold text-gray-900">客户列表</h2>
  695. <div v-if="hasNewCustomerMessage" class="flex items-center space-x-2">
  696. <Bell class="w-4 h-4 text-orange-500 animate-pulse"/>
  697. <span class="text-xs text-orange-600 font-medium">
  698. {{ newCustomerCount }} 个新客户
  699. </span>
  700. <button
  701. @click="clearNewCustomerNotification"
  702. class="text-xs text-gray-400 hover:text-gray-600"
  703. >
  704. ×
  705. </button>
  706. </div>
  707. </div>
  708. <el-input
  709. v-model="searchQuery"
  710. placeholder="搜索客户..."
  711. :prefix-icon="Search"
  712. clearable
  713. />
  714. </div>
  715. <div class="flex-1 overflow-y-auto">
  716. <div
  717. v-for="customer in filteredCustomers"
  718. :key="customer.id"
  719. @click="selectCustomer(customer)"
  720. :class="[
  721. 'p-4 border-b border-gray-100 cursor-pointer hover:bg-gray-50 transition-colors relative',
  722. selectedCustomer?.id === customer.id ? 'bg-blue-50 border-blue-200' : '',
  723. customer.isNewCustomer ? 'bg-orange-50 border-orange-200' : ''
  724. ]"
  725. >
  726. <div v-if="customer.isNewCustomer" class="absolute top-2 right-2">
  727. <span
  728. class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-orange-100 text-orange-800">
  729. 新客户
  730. </span>
  731. </div>
  732. <div class="flex items-center justify-between">
  733. <div class="flex items-center space-x-3 flex-1 min-w-0">
  734. <div class="flex-shrink-0">
  735. <div
  736. :class="[
  737. 'w-3 h-3 rounded-full',
  738. customer.isOnline ? 'bg-green-500' : 'bg-gray-400'
  739. ]"
  740. ></div>
  741. </div>
  742. <div class="flex-1 min-w-0">
  743. <div class="flex items-center justify-between">
  744. <h3 class="text-sm font-medium text-gray-900 truncate">
  745. {{ customer.name }}
  746. </h3>
  747. <span class="text-xs text-gray-500 flex-shrink-0 ml-2">
  748. {{ customer.lastMessageTime }}
  749. </span>
  750. </div>
  751. <p class="text-sm text-gray-500 truncate mt-1">
  752. {{ customer.lastMessage }}
  753. </p>
  754. </div>
  755. </div>
  756. <div v-if="customer.unreadCount > 0" class="flex-shrink-0 ml-2">
  757. <span
  758. :class="[
  759. 'inline-flex items-center justify-center w-5 h-5 text-xs font-medium text-white rounded-full',
  760. customer.isNewCustomer ? 'bg-orange-500' : 'bg-red-500'
  761. ]">
  762. {{ customer.unreadCount }}
  763. </span>
  764. </div>
  765. </div>
  766. </div>
  767. </div>
  768. </div>
  769. <div class="flex-1 flex flex-col">
  770. <div v-if="selectedCustomer" class="flex flex-col h-full">
  771. <div class="p-4 bg-white border-b border-gray-200">
  772. <div class="flex items-center justify-between">
  773. <div class="flex items-center space-x-3">
  774. <div
  775. :class="[
  776. 'w-3 h-3 rounded-full',
  777. selectedCustomer.isOnline ? 'bg-green-500' : 'bg-gray-400'
  778. ]"
  779. ></div>
  780. <div>
  781. <h2 class="text-lg font-semibold text-gray-900">
  782. {{ selectedCustomer.name }}
  783. <span v-if="selectedCustomer.isNewCustomer"
  784. class="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-orange-100 text-orange-800">
  785. 新客户
  786. </span>
  787. </h2>
  788. <p class="text-sm text-gray-500">
  789. {{ selectedCustomer.isOnline ? '在线' : '离线' }}
  790. <span v-if="selectedCustomer.isTyping && selectedCustomer.isOnline">
  791. · 正在输入...
  792. </span>
  793. </p>
  794. </div>
  795. </div>
  796. </div>
  797. </div>
  798. <div
  799. ref="messagesContainer"
  800. class="flex-1 overflow-y-auto p-4 space-y-4 relative"
  801. @scroll="handleScroll"
  802. >
  803. <div v-if="hasMoreMessages && currentMessages.length > 0" class="flex justify-center">
  804. <el-button
  805. @click="loadMoreMessages"
  806. :loading="loadingMoreMessages"
  807. size="small"
  808. type="text"
  809. >
  810. {{ loadingMoreMessages ? '加载中...' : '加载更多历史消息' }}
  811. </el-button>
  812. </div>
  813. <div v-if="loadingMessages" class="flex justify-center items-center py-8">
  814. <span class="ml-2 text-gray-500">加载消息中...</span>
  815. </div>
  816. <div
  817. v-for="message in currentMessages"
  818. :key="message.id"
  819. :class="[
  820. 'flex',
  821. message.sender === 'customer' ? 'justify-start' : 'justify-end'
  822. ]"
  823. >
  824. <div
  825. :class="[
  826. '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',
  827. message.sender === 'customer'
  828. ? 'bg-gray-200 text-gray-900'
  829. : 'bg-blue-500 text-white'
  830. ]"
  831. >
  832. <p class="text-sm">{{ message.content }}</p>
  833. <div class="flex items-center justify-between mt-1">
  834. <span class="text-xs opacity-70">{{ message.time }}</span>
  835. <div v-if="message.sender === 'agent'" class="flex items-center space-x-1 ml-2">
  836. <Check
  837. v-if="message.status === 'sent'"
  838. class="w-3 h-3 opacity-70"
  839. />
  840. <CheckCheck
  841. v-else-if="message.status === 'read'"
  842. class="w-3 h-3 text-blue-200"
  843. />
  844. <X
  845. v-else-if="message.status === 'failed'"
  846. class="w-3 h-3 text-red-300"
  847. />
  848. </div>
  849. </div>
  850. </div>
  851. </div>
  852. <div
  853. v-if="showNewMessageTip"
  854. class="fixed bottom-20 left-1/2 transform -translate-x-1/2 z-10"
  855. >
  856. <div
  857. @click="goToBottom"
  858. 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"
  859. >
  860. <span class="text-sm">{{ newMessageCount }} 条新消息</span>
  861. <ChevronDown class="w-4 h-4"/>
  862. </div>
  863. </div>
  864. <div
  865. v-if="isUserScrolled && !showNewMessageTip"
  866. class="fixed bottom-20 right-8 z-10"
  867. >
  868. <div
  869. @click="goToBottom"
  870. class="bg-gray-500 text-white p-2 rounded-full shadow-lg cursor-pointer hover:bg-gray-600 transition-colors"
  871. >
  872. <ChevronDown class="w-5 h-5"/>
  873. </div>
  874. </div>
  875. </div>
  876. <div class="p-4 bg-white border-t border-gray-200">
  877. <div class="flex items-end space-x-2">
  878. <div class="flex-1">
  879. <el-input
  880. v-model="newMessage"
  881. type="textarea"
  882. :rows="1"
  883. placeholder="输入消息..."
  884. @keydown.enter.prevent="sendMessage"
  885. resize="none"
  886. />
  887. </div>
  888. <el-button
  889. type="primary"
  890. @click="sendMessage"
  891. :disabled="!newMessage.trim()"
  892. class="flex-shrink-0"
  893. >
  894. <Send class="w-4 h-4"/>
  895. </el-button>
  896. </div>
  897. </div>
  898. </div>
  899. <div v-else class="flex-1 flex items-center justify-center bg-gray-50">
  900. <div class="text-center">
  901. <MessageCircle class="w-16 h-16 text-gray-400 mx-auto mb-4"/>
  902. <h3 class="text-lg font-medium text-gray-900 mb-2">选择一个客户开始聊天</h3>
  903. <p class="text-gray-500">从左侧客户列表中选择一个客户来查看聊天记录</p>
  904. <div v-if="hasNewCustomerMessage" class="mt-4 p-4 bg-orange-50 rounded-lg border border-orange-200">
  905. <div class="flex items-center justify-center space-x-2 text-orange-600">
  906. <Bell class="w-5 h-5 animate-pulse"/>
  907. <span class="font-medium">有 {{ newCustomerCount }} 个新客户发来消息</span>
  908. </div>
  909. <p class="text-sm text-orange-500 mt-1">请查看左侧客户列表中的新客户</p>
  910. </div>
  911. </div>
  912. </div>
  913. </div>
  914. </div>
  915. </template>
  916. <style scoped>
  917. /* 自定义滚动条样式 */
  918. ::-webkit-scrollbar {
  919. width: 6px;
  920. }
  921. ::-webkit-scrollbar-track {
  922. background: #f1f1f1;
  923. }
  924. ::-webkit-scrollbar-thumb {
  925. background: #c1c1c1;
  926. border-radius: 3px;
  927. }
  928. ::-webkit-scrollbar-thumb:hover {
  929. background: #a8a8a8;
  930. }
  931. /* 新增:新客户动画效果 */
  932. @keyframes pulse {
  933. 0%, 100% {
  934. opacity: 1;
  935. }
  936. 50% {
  937. opacity: 0.5;
  938. }
  939. }
  940. .animate-pulse {
  941. animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
  942. }
  943. </style>