Files
urbanLifeline/urbanLifelineWeb/packages/workcase/src/views/public/ChatRoom/ChatRoomView.vue
2025-12-27 19:23:33 +08:00

731 lines
26 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="chat-room-container">
<!-- 折叠状态的侧边栏 -->
<div v-if="!isSidebarOpen" class="sidebar-collapsed">
<button class="sidebar-toggle-btn" @click="toggleSidebar" title="展开聊天室列表">
<MessageCircle :size="20" />
</button>
<div class="sidebar-icons">
<button
v-for="room in filteredRooms.slice(0, 8)"
:key="room.roomId"
@click="selectRoom(room)"
class="sidebar-icon-btn"
:class="{ active: currentRoomId === room.roomId }"
:title="room.roomName"
>
{{ room.guestName?.substring(0, 1) || '?' }}
</button>
</div>
<button class="expand-btn" @click="toggleSidebar" title="展开聊天室列表">
<ChevronRight :size="18" />
</button>
</div>
<!-- 展开时的关闭按钮 -->
<button v-if="isSidebarOpen" class="sidebar-close-btn" @click="toggleSidebar">
<ChevronLeft :size="18" />
</button>
<!-- 聊天室列表侧边栏 -->
<aside class="room-list-sidebar" :class="{ open: isSidebarOpen }">
<div class="sidebar-inner">
<!-- 头部 -->
<div class="sidebar-header">
<span class="title">聊天室</span>
</div>
<!-- 搜索框 -->
<div class="search-box">
<ElInput
v-model="searchText"
placeholder="搜索工单号、来客姓名、电话..."
:prefix-icon="Search"
clearable
/>
</div>
<!-- chatRoom列表 -->
<div class="room-list-container">
<div v-if="filteredRooms.length === 0" class="empty-tip">
暂无聊天室
</div>
<div
v-for="room in filteredRooms"
:key="room.roomId"
class="room-item"
:class="{ active: currentRoomId === room.roomId }"
@click="selectRoom(room)"
>
<!-- 头像 -->
<div class="room-avatar">
{{ room.guestName?.substring(0, 1) || '?' }}
</div>
<!-- 信息 -->
<div class="room-info">
<div class="room-header">
<div class="room-name">{{ room.roomName }}</div>
<div class="room-time">{{ formatTime(room.lastMessageTime) }}</div>
</div>
<div class="last-message-row">
<div class="last-message">{{ room.lastMessage || '暂无消息' }}</div>
<!-- 未读红点 -->
<div v-if="(room.unreadCount ?? 0) > 0" class="unread-badge">
{{ (room.unreadCount ?? 0) > 99 ? '99+' : room.unreadCount }}
</div>
</div>
</div>
</div>
</div>
</div>
</aside>
<!-- 主聊天区域 -->
<main class="chat-main" :class="{ 'sidebar-open': isSidebarOpen }">
<template v-if="currentRoomId">
<ChatRoom
ref="chatRoomRef"
:messages="messages"
:current-user-id="loginDomain.user.userId"
:room-id="currentRoomId"
:workcase-id="currentWorkcaseId"
:room-name="currentRoom?.roomName"
:file-download-url="FILE_DOWNLOAD_URL"
:has-more="hasMore"
:loading-more="loadingMore"
@send-message="handleSendMessage"
@start-meeting="startMeeting"
@download-file="downloadFile"
@load-more="loadMoreMessages"
>
<template #header>
<div class="chat-room-header">
<div class="header-left">
<div class="room-avatar-small">
{{ currentRoom?.guestName?.substring(0, 1) }}
</div>
<div class="room-title-group">
<div class="room-name-text">{{ currentRoom?.roomName }}</div>
<div class="room-subtitle">
工单 #{{ currentRoom?.workcaseId }} · {{ currentRoom?.guestName }}
</div>
</div>
</div>
</div>
</template>
<template #action-area>
<ElButton type="primary" @click="handleWorkcaseAction">
<FileText :size="16" />
{{ currentWorkcaseId ? '查看工单' : '创建工单' }}
</ElButton>
</template>
</ChatRoom>
</template>
<!-- 空状态 -->
<div v-else class="empty-state">
<div class="empty-content">
<div class="empty-icon">
<MessageSquare :size="40" />
</div>
<h3 class="empty-title">选择一个聊天室开始对话</h3>
<p class="empty-desc">从左侧列表中选择一个聊天室查看消息</p>
</div>
</div>
</main>
<!-- 工单详情对话框 -->
<ElDialog
v-model="showWorkcaseDetail"
title="工单详情"
width="800px"
class="workcase-dialog"
destroy-on-close
>
<WorkcaseDetail
mode="view"
:workcase-id="currentWorkcaseId"
@cancel="showWorkcaseDetail = false"
/>
</ElDialog>
<!-- 工单创建对话框 -->
<ElDialog
v-model="showWorkcaseCreator"
title="创建工单"
width="800px"
class="workcase-dialog"
destroy-on-close
>
<WorkcaseDetail
mode="create"
:room-id="currentRoomId!"
@cancel="showWorkcaseCreator = false"
@created="onWorkcaseCreated"
/>
</ElDialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { ElButton, ElInput, ElDialog, ElMessage } from 'element-plus'
import { Search, FileText, MessageSquare, MessageCircle, ChevronLeft, ChevronRight } from 'lucide-vue-next'
import ChatRoom from './chatRoom/ChatRoom.vue'
import WorkcaseDetail from '@/views/public/workcase/WorkcaseDetail/WorkcaseDetail.vue'
import { workcaseChatAPI } from '@/api/workcase'
import { fileAPI } from 'shared/api/file'
import { FILE_DOWNLOAD_URL } from '@/config'
import type { ChatRoomVO, ChatRoomMessageVO, TbChatRoomMessageDTO, TbChatRoomMemberDTO } from '@/types/workcase'
import SockJS from 'sockjs-client'
import { Client } from '@stomp/stompjs'
// WebSocket配置 (通过Nginx代理访问网关再到workcase服务)
// SockJS URL (http://)
const getWsUrl = () => {
const token = JSON.parse(localStorage.getItem('token')).value || ''
const protocol = window.location.protocol
const host = window.location.host
return `${protocol}//${host}/api/urban-lifeline/workcase/ws/chat-sockjs?token=${encodeURIComponent(token)}`
}
// STOMP客户端
let stompClient: any = null
let roomSubscription: any = null
let listSubscription: any = null
// 当前用户ID从登录状态获取
const loginDomain = JSON.parse(localStorage.getItem('loginDomain')!)
// 侧边栏展开状态
const isSidebarOpen = ref(false)
// 切换侧边栏
const toggleSidebar = () => {
isSidebarOpen.value = !isSidebarOpen.value
}
// 搜索文本
const searchText = ref('')
const userType = true //web端固定这个
// 加载状态
const loading = ref(false)
const messageLoading = ref(false)
const loadingMore = ref(false)
// 自动填充加载状态(已禁用)
// const autoFilling = ref(false)
// 分页状态
const PAGE_SIZE = 20
const currentPage = ref(1)
const hasMore = ref(true)
// 聊天室列表
const chatRooms = ref<ChatRoomVO[]>([])
// 当前选中的聊天室ID
const currentRoomId = ref<string | null>(null)
// 当前聊天室
const currentRoom = computed(() =>
chatRooms.value.find((r: ChatRoomVO) => r.roomId === currentRoomId.value)
)
// 当前工单ID
const currentWorkcaseId = computed(() => currentRoom.value?.workcaseId || '')
// 过滤后的聊天室列表
const filteredRooms = computed(() => {
if (!searchText.value) return chatRooms.value
const keyword = searchText.value.toLowerCase()
return chatRooms.value.filter((room: ChatRoomVO) =>
room.roomName?.toLowerCase().includes(keyword) ||
room.guestName?.toLowerCase().includes(keyword) ||
room.workcaseId?.toLowerCase().includes(keyword)
)
})
// 消息列表
const messages = ref<ChatRoomMessageVO[]>([])
// 工单详情对话框
const showWorkcaseDetail = ref(false)
// 工单创建对话框
const showWorkcaseCreator = ref(false)
// ChatRoom组件引用
const chatRoomRef = ref<InstanceType<typeof ChatRoom> | null>(null)
// 获取聊天室列表
const fetchChatRooms = async () => {
loading.value = true
try {
const result = await workcaseChatAPI.getChatRoomPage({
filter: {
status: 'active'
},
pageParam: { page: 1, pageSize: 100, total: 0 }
})
if (result.success && result.pageDomain) {
chatRooms.value = result.pageDomain.dataList || []
}
} catch (error) {
console.error('获取聊天室列表失败:', error)
ElMessage.error('获取聊天室列表失败')
} finally {
loading.value = false
}
}
// 选择聊天室
const selectRoom = async (room: ChatRoomVO) => {
currentRoomId.value = room.roomId!
// 自动加入聊天室成员表(如果不存在)
try {
const memberData: TbChatRoomMemberDTO = {
roomId: room.roomId,
userId: loginDomain.user.userId,
userName: loginDomain.userInfo.username,
userType: 'staff'
}
await workcaseChatAPI.addChatRoomMember(memberData)
room.unreadCount = 0
} catch (error) {
// 已存在成员或其他错误,忽略
console.debug('加入聊天室:', error)
}
loadMessages(room.roomId!)
}
// 加载消息初始加载page1后端降序返回
const loadMessages = async (roomId: string) => {
messageLoading.value = true
currentPage.value = 1
hasMore.value = true
try {
const result = await workcaseChatAPI.getChatMessagePage({
filter: { roomId },
pageParam: { page: 1, pageSize: PAGE_SIZE }
})
if (result.success && result.pageDomain) {
const pageInfo = result.pageDomain.pageParam
const actualTotalPages = pageInfo?.totalPages || 1
hasMore.value = actualTotalPages > currentPage.value
// 后端降序返回,需要反转后显示(早的在上,新的在下)
const dataList = result.pageDomain.dataList || []
messages.value = [...dataList].reverse()
// 首次加载后自动填充消息直到出现滚动条(已禁用)
// await autoFillMessages(roomId)
}
// 加载完成后滚动到底部
scrollToBottom()
} catch (error) {
console.error('加载消息失败:', error)
ElMessage.error('加载消息失败')
} finally {
messageLoading.value = false
}
}
// 自动填充消息直到出现滚动条(已禁用)
/*
const autoFillMessages = async (roomId: string) => {
// autoFilling.value = true
console.log('[autoFill] 开始检查消息高度, hasMore:', hasMore.value, 'messages:', messages.value.length)
// 等待DOM渲染
await nextTick()
await new Promise(resolve => setTimeout(resolve, 300))
let attempts = 0
const maxAttempts = 20
while (hasMore.value && attempts < maxAttempts) {
attempts++
const container = chatRoomRef.value?.$el?.querySelector?.('.messages-container')
const messagesList = chatRoomRef.value?.$el?.querySelector?.('.messages-list')
if (!container || !messagesList) {
console.warn('[autoFill] 找不到容器或消息列表')
break
}
// 容器高度(可视区域)
const containerHeight = container.clientHeight
// 消息列表实际高度(内容)
const listHeight = messagesList.offsetHeight
const fillPercent = containerHeight > 0 ? Math.round(listHeight / containerHeight * 100) : 0
console.log(`[autoFill] 第${attempts}次检查 - 容器高度: ${containerHeight}px, 列表高度: ${listHeight}px, 填充率: ${fillPercent}%, 消息数: ${messages.value.length}`)
// 判断是否已经溢出(列表高度 >= 容器高度)
if (containerHeight > 0 && listHeight >= containerHeight) {
console.log(`[autoFill] ✓ 列表已溢出(${fillPercent}%),可以滚动!停止加载`)
break
}
// 内容不足,继续加载下一页
console.log('[autoFill] → 内容不足,继续加载历史消息...')
const nextPage = currentPage.value + 1
const result = await workcaseChatAPI.getChatMessagePage({
filter: { roomId },
pageParam: { page: nextPage, pageSize: PAGE_SIZE }
})
if (result.success && result.pageDomain) {
const pageInfo = result.pageDomain.pageParam
const actualTotalPages = pageInfo?.totalPages || 1
const dataList = result.pageDomain.dataList || []
if (dataList.length > 0) {
currentPage.value = nextPage
hasMore.value = actualTotalPages > currentPage.value
// 反转后插入到列表前面
const reversedList = [...dataList].reverse()
messages.value.unshift(...reversedList)
console.log(`[autoFill] ✓ 加载第${nextPage}页完成, 新增${dataList.length}条, 总消息数: ${messages.value.length}`)
await nextTick()
await new Promise(resolve => setTimeout(resolve, 200))
} else {
hasMore.value = false
break
}
} else {
break
}
}
if (attempts >= maxAttempts) {
console.warn('[autoFill] ⚠ 达到最大尝试次数')
}
console.log(`[autoFill] 自动填充结束 - 共尝试${attempts}次, 最终消息数: ${messages.value.length}, hasMore: ${hasMore.value}`)
// autoFilling.value = false
}
*/
// 加载更多历史消息(滚动到顶部触发)
const loadMoreMessages = async () => {
if (!currentRoomId.value || loadingMore.value || !hasMore.value) return
const nextPage = currentPage.value + 1
loadingMore.value = true
try {
const result = await workcaseChatAPI.getChatMessagePage({
filter: { roomId: currentRoomId.value },
pageParam: { page: nextPage, pageSize: PAGE_SIZE }
})
if (result.success && result.pageDomain) {
const pageInfo = result.pageDomain.pageParam
const actualTotalPages = pageInfo?.totalPages || 1
const dataList = result.pageDomain.dataList || []
if (dataList.length > 0) {
currentPage.value = nextPage
hasMore.value = actualTotalPages > currentPage.value
// 后端降序返回,反转后插入到列表前面
const reversedList = [...dataList].reverse()
messages.value.unshift(...reversedList)
} else {
hasMore.value = false
}
}
} catch (error) {
console.error('加载更多消息失败:', error)
} finally {
loadingMore.value = false
}
}
// 处理发送消息从ChatRoom组件触发
const handleSendMessage = async (content: string, files: File[]) => {
if (!currentRoomId.value) return
try {
// 上传文件获取fileIds
let fileIds: string[] = []
if (files.length > 0) {
const uploadResult = await fileAPI.batchUpload(files, 'chatroom')
if (uploadResult.success && uploadResult.dataList) {
fileIds = uploadResult.dataList.map((f: any) => f.fileId)
}
}
// 构造消息
const messageData: TbChatRoomMessageDTO = {
roomId: currentRoomId.value,
senderId: loginDomain.user.userId,
senderName: loginDomain.userInfo.username,
senderType: 'agent',
content,
files: fileIds,
messageType: 'text'
}
// 发送消息
const result = await workcaseChatAPI.sendMessage(messageData)
if (result.success && result.data) {
// 添加到消息列表
messages.value.push(result.data as ChatRoomMessageVO)
scrollToBottom()
} else {
ElMessage.error(result.message || '发送失败')
}
} catch (error) {
console.error('发送消息失败:', error)
ElMessage.error('发送消息失败')
}
}
// 下载文件
const downloadFile = (fileId: string) => {
window.open(`${FILE_DOWNLOAD_URL}/${fileId}`, '_blank')
}
// 处理工单操作(创建或查看)
const handleWorkcaseAction = () => {
if (currentWorkcaseId.value) {
// 已有工单,查看详情
showWorkcaseDetail.value = true
} else {
// 无工单,创建新工单
showWorkcaseCreator.value = true
}
}
// 工单创建成功
const onWorkcaseCreated = (workcaseId: string) => {
showWorkcaseCreator.value = false
// 更新当前聊天室的工单ID
if (currentRoom.value) {
currentRoom.value.workcaseId = workcaseId
}
// 刷新聊天室列表
fetchChatRooms()
ElMessage.success('工单创建成功')
}
// 发起会议
const startMeeting = async () => {
if (!currentRoomId.value) return
try {
// 先检查是否有活跃会议
const activeResult = await workcaseChatAPI.getActiveMeeting(currentRoomId.value)
if (activeResult.success && activeResult.data) {
// 已有活跃会议,直接加入
currentMeetingId.value = activeResult.data.meetingId!
const joinResult = await workcaseChatAPI.joinVideoMeeting(currentMeetingId.value!)
if (joinResult.success && joinResult.data?.iframeUrl) {
// 使用router跳转到JitsiMeetingView页面附加roomId参数用于返回
const meetingUrl = joinResult.data.iframeUrl + `&roomId=${currentRoomId.value}`
router.push(meetingUrl)
} else {
ElMessage.error(joinResult.message || '加入会议失败')
}
return
}
// 没有活跃会议,创建新会议
const createResult = await workcaseChatAPI.createVideoMeeting({
roomId: currentRoomId.value,
meetingName: currentRoom.value?.roomName || '视频会议'
})
if (createResult.success && createResult.data) {
currentMeetingId.value = createResult.data.meetingId!
// 开始会议
await workcaseChatAPI.startVideoMeeting(currentMeetingId.value!)
// 加入会议获取会议页面URL
const joinResult = await workcaseChatAPI.joinVideoMeeting(currentMeetingId.value!)
if (joinResult.success && joinResult.data?.iframeUrl) {
// 使用router跳转到JitsiMeetingView页面附加roomId参数用于返回
const meetingUrl = joinResult.data.iframeUrl + `&roomId=${currentRoomId.value}`
router.push(meetingUrl)
ElMessage.success('会议已创建')
} else {
ElMessage.error(joinResult.message || '获取会议链接失败')
}
} else {
ElMessage.error(createResult.message || '创建会议失败')
}
} catch (error) {
console.error('发起会议失败:', error)
ElMessage.error('发起会议失败')
}
}
// 滚动聊天消息到底部
const scrollToBottom = () => {
nextTick(() => {
chatRoomRef.value?.scrollToBottom()
})
}
// 格式化时间(用于聊天室列表)
const formatTime = (time: string | null | undefined) => {
if (!time) return ''
const date = new Date(time)
const now = new Date()
const diff = now.getTime() - date.getTime()
if (diff < 60000) return '刚刚'
if (diff < 3600000) return Math.floor(diff / 60000) + '分钟前'
if (diff < 86400000) return Math.floor(diff / 3600000) + '小时前'
return date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })
}
// ==================== WebSocket连接管理 ====================
// 初始化WebSocket连接支持SockJS降级
const initWebSocket = () => {
const token = localStorage.getItem('token') || ''
const wsUrl = getWsUrl()
console.log('WebSocket连接URL:', wsUrl)
// 创建STOMP客户端使用SockJS支持降级
stompClient = new Client({
webSocketFactory: () => new SockJS(wsUrl),
connectHeaders: {
Authorization: `Bearer ${token}`
},
reconnectDelay: 5000,
heartbeatIncoming: 4000,
heartbeatOutgoing: 4000,
debug: (str: string) => {
console.log('[STOMP]', str)
},
onConnect: () => {
console.log('WebSocket已连接')
// 订阅聊天室列表更新
subscribeToListUpdate()
// 如果当前有选中的聊天室,订阅该聊天室消息
if (currentRoomId.value) {
subscribeToRoom(currentRoomId.value)
}
},
onDisconnect: () => {
console.log('WebSocket已断开')
},
onStompError: (frame: any) => {
console.error('STOMP错误:', frame)
},
onWebSocketError: (event: any) => {
console.error('WebSocket错误:', event)
}
})
stompClient.activate()
}
// 订阅聊天室列表更新 (用于更新列表中的lastMessage和未读数)
const subscribeToListUpdate = () => {
if (!stompClient || !stompClient.connected) return
listSubscription = stompClient.subscribe('/topic/chat/list-update', async (message: any) => {
const chatMessage = JSON.parse(message.body)
// 更新对应聊天室的lastMessage和lastMessageTime
const roomIndex = chatRooms.value.findIndex((r: ChatRoomVO) => r.roomId === chatMessage.roomId)
if (roomIndex !== -1) {
// 查询当前用户在该聊天室的未读数
let unreadCount = 0
try {
const unreadResult = await workcaseChatAPI.getUnreadCount(
chatMessage.roomId,
loginDomain.user.userId
)
if (unreadResult.success && unreadResult.data !== undefined) {
unreadCount = unreadResult.data
}
} catch (error) {
console.error('查询未读数失败:', error)
}
chatRooms.value[roomIndex] = {
...chatRooms.value[roomIndex],
lastMessage: chatMessage.content,
lastMessageTime: chatMessage.sendTime,
unreadCount: unreadCount
}
// 将更新的聊天室移到列表顶部
const updatedRoom = chatRooms.value[roomIndex]
chatRooms.value.splice(roomIndex, 1)
chatRooms.value.unshift(updatedRoom)
}
})
}
// 订阅指定聊天室消息 (用于实时接收消息)
const subscribeToRoom = (roomId: string) => {
if (!stompClient || !stompClient.connected) return
// 先取消之前的订阅
if (roomSubscription) {
roomSubscription.unsubscribe()
roomSubscription = null
}
roomSubscription = stompClient.subscribe(`/topic/chat/${roomId}`, (message: any) => {
const chatMessage = JSON.parse(message.body) as ChatRoomMessageVO
// 避免重复添加自己发送的普通消息
// 但会议消息meet类型始终添加因为它是系统生成的通知
if (chatMessage.messageType === 'meet' || chatMessage.senderId !== loginDomain.user.userId) {
messages.value.push(chatMessage)
scrollToBottom()
}
})
}
// 断开WebSocket连接
const disconnectWebSocket = () => {
if (roomSubscription) {
roomSubscription.unsubscribe()
roomSubscription = null
}
if (listSubscription) {
listSubscription.unsubscribe()
listSubscription = null
}
if (stompClient) {
stompClient.deactivate()
stompClient = null
}
}
// 监听currentRoomId变化切换聊天室时重新订阅
watch(currentRoomId, (newRoomId) => {
if (newRoomId && stompClient?.connected) {
subscribeToRoom(newRoomId)
}
})
// 初始化
onMounted(() => {
fetchChatRooms()
initWebSocket()
})
// 组件卸载时断开连接
onUnmounted(() => {
disconnectWebSocket()
})
</script>
<style scoped lang="scss">
@import url("./ChatRoomView.scss");
</style>