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