1轮修复

This commit is contained in:
2026-01-20 16:17:39 +08:00
parent 0bf7361672
commit 8ab6107f25
23 changed files with 2587 additions and 612 deletions

View File

@@ -97,6 +97,13 @@
<view class="message-content" v-else>
<view class="bubble other-bubble">
<rich-text :nodes="renderMarkdown(msg.content || '')" class="message-rich-text"></rich-text>
<!-- 文件列表 -->
<view v-if="msg.files && msg.files.length > 0" class="message-files">
<view v-for="fileId in msg.files" :key="fileId" class="message-file-item" @tap="downloadFile(fileId)">
<text class="file-icon">📎</text>
<text class="file-name">{{ getFileNameFromId(fileId) }}</text>
</view>
</view>
</view>
<text class="message-time">{{ formatTime(msg.sendTime) }}</text>
</view>
@@ -112,6 +119,13 @@
<view class="message-content" v-else>
<view class="bubble self-bubble">
<text class="message-text">{{ msg.content }}</text>
<!-- 文件列表 -->
<view v-if="msg.files && msg.files.length > 0" class="message-files">
<view v-for="fileId in msg.files" :key="fileId" class="message-file-item" @tap="downloadFile(fileId)">
<text class="file-icon">📎</text>
<text class="file-name">{{ getFileNameFromId(fileId) }}</text>
</view>
</view>
</view>
<text class="message-time">{{ formatTime(msg.sendTime) }}</text>
</view>
@@ -126,10 +140,29 @@
<!-- 底部输入区 -->
<view class="footer">
<!-- 文件预览区域 -->
<view v-if="selectedFiles.length > 0" class="file-preview-container">
<view v-for="(file, index) in selectedFiles" :key="index" class="file-preview-item">
<view class="file-info">
<text class="file-name">{{ file.name }}</text>
<text class="file-size">{{ formatFileSize(file.size) }}</text>
</view>
<view class="file-actions">
<text class="file-delete" @tap="removeFile(index)">删除</text>
</view>
</view>
</view>
<!-- 输入和操作行 -->
<view class="input-row">
<view class="input-actions">
<view class="action-btn" @tap="chooseFile">
<text class="action-icon">📎</text>
</view>
</view>
<input class="chat-input" v-model="inputText" placeholder="输入消息..."
@confirm="sendMessage" />
<view class="send-btn" @tap="sendMessage">
<view class="send-btn" @tap="sendMessage" :class="{ disabled: isUploading || selectedFiles.length === 0 && !inputText.trim() }">
<text class="send-icon">➤</text>
</view>
</view>
@@ -148,6 +181,7 @@ import MeetingCard from '../../meeting/meetingCard/MeetingCard.uvue'
import CommentMessageCard from './CommentMessageCard/CommentMessageCard.uvue'
import type { ChatRoomMessageVO, CustomerVO, ChatMemberVO, TbChatRoomMessageDTO, VideoMeetingVO } from '@/types/workcase'
import { workcaseChatAPI } from '@/api/workcase'
import { fileAPI } from '@/api/file'
import { wsClient } from '@/utils/websocket'
import { WS_HOST } from '@/config'
// 响应式数据
@@ -167,6 +201,11 @@ const loadingMore = ref<boolean>(false)
const currentPage = ref<number>(1)
const hasMore = ref<boolean>(true)
// 文件上传相关变量
const uploadingFiles = ref<any[]>([])
const selectedFiles = ref<any[]>([])
const isUploading = ref<boolean>(false)
// 用户信息从storage获取
const currentUserId = ref<string>('')
const currentUserName = ref<string>('我')
@@ -516,7 +555,7 @@ function renderMarkdown(text: string): string {
// 发送消息
async function sendMessage() {
const text = inputText.value.trim()
if (!text || sending.value) return
if ((!text && selectedFiles.value.length === 0) || sending.value || isUploading.value) return
sending.value = true
const tempId = Date.now().toString()
@@ -530,13 +569,17 @@ async function sendMessage() {
senderName: currentUserName.value,
content: text,
sendTime: new Date().toISOString(),
status: 'sending'
status: 'sending',
files: []
}
messages.push(tempMsg)
inputText.value = ''
nextTick(() => scrollToBottom())
try {
// 上传文件
const fileIds = await uploadFiles()
// 调用API发送消息
const msgDTO: TbChatRoomMessageDTO = {
roomId: roomId.value,
@@ -544,7 +587,8 @@ async function sendMessage() {
senderType: 'guest',
senderName: currentUserName.value,
messageType: 'text',
content: text
content: text,
files: fileIds
}
const res = await workcaseChatAPI.sendMessage(msgDTO)
if (res.success && res.data) {
@@ -734,6 +778,140 @@ async function handleCommentSubmit(rating: number) {
}
}
// 文件操作相关函数
// 选择文件
async function chooseFile() {
try {
const res = await uni.chooseMessageFile({
count: 9, // 最多选择9个文件
type: 'file',
ext: ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'pdf', 'txt', 'jpg', 'jpeg', 'png', 'gif']
})
if (res.tempFiles && res.tempFiles.length > 0) {
// 添加到选中文件列表
selectedFiles.value = [...selectedFiles.value, ...res.tempFiles]
}
} catch (error) {
console.error('选择文件失败:', error)
uni.showToast({ title: '选择文件失败', icon: 'none' })
}
}
// 删除选中的文件
function removeFile(index: number) {
selectedFiles.value.splice(index, 1)
}
// 格式化文件大小
function formatFileSize(size: number): string {
if (size < 1024) {
return size + ' B'
} else if (size < 1024 * 1024) {
return (size / 1024).toFixed(1) + ' KB'
} else {
return (size / (1024 * 1024)).toFixed(1) + ' MB'
}
}
// 批量上传文件
async function uploadFiles(): Promise<string[]> {
const fileIds: string[] = []
if (selectedFiles.value.length === 0) return fileIds
isUploading.value = true
uni.showLoading({ title: '上传中...' })
try {
// 获取文件路径数组
const filePaths = selectedFiles.value.map(file => file.path)
// 使用fileAPI的批量上传方法
const result = await fileAPI.batchUploadFiles(filePaths, {
module: 'chatroom'
})
if (result.success && result.dataList) {
// 提取fileId数组
fileIds.push(...result.dataList.map(file => file.fileId!))
} else {
throw new Error(result.message || '文件上传失败')
}
return fileIds
} catch (error) {
console.error('文件上传失败:', error)
uni.showToast({ title: '文件上传失败', icon: 'none' })
throw error
} finally {
isUploading.value = false
uni.hideLoading()
selectedFiles.value = [] // 清空选中文件列表
}
}
// 文件下载和预览功能
// 从fileId中提取文件名
function getFileNameFromId(fileId: string): string {
// 简化实现实际项目中可以从fileId解析或通过API获取文件名
return fileId.split('/').pop() || fileId
}
// 下载文件
async function downloadFile(fileId: string) {
try {
// 使用fileAPI获取正确的文件下载URL
const downloadUrl = fileAPI.getDownloadUrl(fileId)
// 发起下载请求
uni.downloadFile({
url: downloadUrl,
header: {
Authorization: `Bearer ${uni.getStorageSync('token') || ''}`
},
success: (res) => {
if (res.statusCode === 200) {
// 保存文件到本地
uni.saveFile({
filePath: res.tempFilePath,
success: (saveRes) => {
uni.showToast({
title: '文件已保存',
icon: 'success'
})
// 打开文件
uni.openDocument({
filePath: saveRes.savedFilePath,
success: () => {
console.log('文件打开成功')
}
})
}
})
} else {
uni.showToast({
title: '文件下载失败',
icon: 'none'
})
}
},
fail: (error) => {
console.error('文件下载失败:', error)
uni.showToast({
title: '文件下载失败',
icon: 'none'
})
}
})
} catch (error) {
console.error('文件下载异常:', error)
uni.showToast({
title: '文件下载失败',
icon: 'none'
})
}
}
// 返回上一页
function goBack() {
uni.navigateBack()
@@ -822,4 +1000,166 @@ function handleNewMessage(message: ChatRoomMessageVO) {
<style lang="scss" scoped>
@import "./chatRoom.scss";
// 文件上传相关样式
.file-preview-container {
padding: 10px;
background-color: #f8f8f8;
border-radius: 8px;
margin-bottom: 10px;
}
.file-preview-item {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background-color: white;
border-radius: 6px;
margin-bottom: 8px;
border: 1px solid #e8e8e8;
&:last-child {
margin-bottom: 0;
}
}
.file-info {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: row;
}
.file-name {
font-size: 14px;
color: #333;
margin-right: 10px;
word-break: break-all;
}
.file-size {
font-size: 12px;
color: #999;
}
.file-actions {
margin-left: 10px;
}
.file-delete {
font-size: 12px;
color: #ff4d4f;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
&:hover {
background-color: rgba(255, 77, 79, 0.1);
}
}
// 输入区操作按钮
.input-row {
display: flex;
align-items: center;
gap: 10px;
}
.input-actions {
display: flex;
gap: 10px;
}
.action-btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 8px;
cursor: pointer;
border-radius: 8px;
&:hover {
background-color: #f0f0f0;
}
}
.action-icon {
font-size: 20px;
margin-bottom: 2px;
}
.action-text {
font-size: 12px;
color: #666;
}
// 发送按钮状态
.send-btn.disabled {
opacity: 0.5;
cursor: not-allowed;
}
// 消息中的文件列表
.message-files {
margin-top: 8px;
display: flex;
flex-direction: row;
gap: 8px;
}
.message-file-item {
display: flex;
flex-direction: row;
align-items: center;
padding: 8px 12px;
background-color: rgba(255, 255, 255, 0.8);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
&:hover {
background-color: rgba(255, 255, 255, 1);
transform: translateY(-1px);
}
}
.message-file-item .file-icon {
font-size: 16px;
margin-right: 8px;
}
.message-file-item .file-name {
font-size: 13px;
color: #1890ff;
word-break: break-all;
}
// 适配自己消息的文件样式
.self .message-files {
align-items: flex-end;
}
.self .message-file-item {
background-color: rgba(24, 144, 255, 0.1);
&:hover {
background-color: rgba(24, 144, 255, 0.2);
}
}
// 适配对方消息的文件样式
.other .message-files {
align-items: flex-start;
}
.other .message-file-item {
background-color: rgba(255, 255, 255, 0.8);
&:hover {
background-color: rgba(255, 255, 255, 1);
}
}
</style>

View File

@@ -2,293 +2,745 @@
<!-- #ifdef APP -->
<scroll-view style="flex:1">
<!-- #endif -->
<view class="page">
<!-- 自定义导航栏 -->
<view class="nav" :style="{ paddingTop: navPaddingTop + 'px', height: navHeight + 'px' }">
<view class="nav-back" @tap="goBack">
<view class="nav-back-icon"></view>
</view>
<text class="nav-title">我的聊天室</text>
<view class="nav-capsule"></view>
</view>
<!-- 聊天室列表 -->
<scroll-view class="list" scroll-y="true" :style="{ marginTop: navHeight + 'px' }">
<view class="room-card" v-for="(room, index) in chatRooms" :key="index" @tap="enterRoom(room)">
<view class="room-avatar">
<text class="avatar-text">{{ room.guestName?.charAt(0) || '客' }}</text>
<view class="page">
<!-- 自定义导航栏 -->
<view class="nav" :style="{ paddingTop: navPaddingTop + 'px', height: navTotalHeight + 'px' }">
<view class="nav-back" @tap="goBack">
<view class="nav-back-icon"></view>
</view>
<view class="room-info">
<view class="room-header">
<text class="room-name">{{ room.roomName || '聊天室' }}</text>
<text class="room-time">{{ formatTime(room.lastMessageTime) }}</text>
<text class="nav-title">我的聊天室</text>
<view class="nav-capsule"></view>
</view>
<!-- 用户筛选组件仅非guest用户可见 -->
<view class="filter-section" v-if="!isGuest" :style="{ marginTop: navTotalHeight + 'px' }">
<view class="filter-label">选择用户:</view>
<view class="filter-content">
<view class="guest-selector" @tap="showGuestSelector = true">
<text v-if="selectedGuestId" class="selected-guest">
{{ guestsList.find(g => g.userId === selectedGuestId)?.name || '请选择用户' }}
</text>
<text v-else class="placeholder">请选择用户</text>
<view class="selector-arrow">▼</view>
</view>
<view class="room-footer">
<text class="last-message">{{ getPlainTextPreview(room.lastMessage) }}</text>
<view class="unread-badge" v-if="room.unreadCount && room.unreadCount > 0">
<text class="badge-text">{{ room.unreadCount > 99 ? '99+' : room.unreadCount }}</text>
<view class="btn-group">
<view class="reset-btn" @tap="clearGuestSelect">重置</view>
<view class="query-btn" @tap="handleQuery">查询</view>
</view>
</view>
</view>
<!-- 聊天室列表 -->
<scroll-view class="list" scroll-y="true"
:style="{ marginTop: navTotalHeight + (isGuest ? 0 : 44) + 'px' }"
@scrolltolower="handleScrollToLower">
<view class="room-card" v-for="(room, index) in chatRooms" :key="index" @tap="enterRoom(room)">
<view class="room-avatar">
<text class="avatar-text">{{ room.guestName?.charAt(0) || '客' }}</text>
</view>
<view class="room-info">
<view class="room-header">
<text class="room-name">{{ room.roomName || '聊天室' }}</text>
<text class="room-time">{{ formatTime(room.lastMessageTime) }}</text>
</view>
<view class="room-footer">
<text class="last-message">{{ getPlainTextPreview(room.lastMessage) }}</text>
<view class="unread-badge" v-if="room.unreadCount && room.unreadCount > 0">
<text class="badge-text">{{ room.unreadCount > 99 ? '99+' : room.unreadCount }}</text>
</view>
</view>
</view>
<view class="room-status" :class="getStatusClass(room.status)">
<text class="status-dot"></text>
<text class="status-text">{{ getStatusText(room.status) }}</text>
</view>
</view>
<view class="room-status" :class="getStatusClass(room.status)">
<text class="status-dot"></text>
<text class="status-text">{{ getStatusText(room.status) }}</text>
</view>
</view>
<!-- 空状态 -->
<view class="empty-state" v-if="chatRooms.length === 0">
<text class="empty-icon">💬</text>
<text class="empty-text">暂无聊天室</text>
<text class="empty-hint">点击"联系人工"创建新聊天室</text>
<!-- 空状态 -->
<view class="empty-state" v-if="chatRooms.length === 0">
<text class="empty-icon">💬</text>
<text class="empty-text">暂无聊天室</text>
<text class="empty-hint">点击"联系人工"创建新聊天室</text>
</view>
<!-- 加载更多提示 -->
<view class="load-more" v-if="loading">
<text class="load-text">加载中...</text>
</view>
<view class="load-more" v-else-if="!hasMore && chatRooms.length > 0">
<text class="load-text">没有更多数据了</text>
</view>
</scroll-view>
<!-- 用户选择弹窗 -->
<view class="modal-mask" v-if="showGuestSelector" @tap="showGuestSelector = false"></view>
<view class="modal-content" v-if="showGuestSelector">
<view class="modal-header">
<text class="modal-title">选择用户</text>
</view>
<view class="modal-body">
<view class="guest-item" v-for="guest in guestsList" :key="guest.userId"
@tap="handleGuestSelect(guest)">
<text class="guest-name">{{ guest.name }} {{ guest.phone || '无电话' }}</text>
</view>
<view class="empty-item" v-if="guestsList.length === 0">
<text>暂无可选用户</text>
</view>
</view>
</view>
</scroll-view>
</view>
</view>
<!-- #ifdef APP -->
</scroll-view>
<!-- #endif -->
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { workcaseChatAPI } from '@/api'
import type { ChatRoomVO, TbChatRoomDTO, PageRequest, ChatRoomMessageVO } from '@/types'
import { wsClient } from '@/utils/websocket'
import { WS_HOST } from '@/config'
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { workcaseChatAPI } from '@/api'
import { guestAPI } from '@/api/sys/guest'
import type { ChatRoomVO, TbChatRoomDTO, PageRequest, ChatRoomMessageVO, TbGuestDTO } from '@/types'
import { wsClient } from '@/utils/websocket'
import { WS_HOST } from '@/config'
// 导航栏
const navPaddingTop = ref<number>(0)
const navHeight = ref<number>(44)
const capsuleHeight = ref<number>(32)
// 导航栏
const navPaddingTop = ref<number>(0)
const navHeight = ref<number>(44)
const navTotalHeight = ref<number>(44)
const capsuleHeight = ref<number>(32)
// 加载状态
const loading = ref<boolean>(false)
// 加载状态
const loading = ref<boolean>(false)
const loadingUsers = ref<boolean>(false)
// 聊天室列表
const chatRooms = ref<ChatRoomVO[]>([])
// 聊天室列表
const chatRooms = ref<ChatRoomVO[]>([])
// 生命周期
onMounted(() => {
const windowInfo = uni.getWindowInfo()
const statusBarHeight = windowInfo.statusBarHeight || 20
// #ifdef MP-WEIXIN
try {
const menuButton = uni.getMenuButtonBoundingClientRect()
navPaddingTop.value = menuButton.top
capsuleHeight.value = menuButton.height
navHeight.value = menuButton.bottom + 8
} catch (e) {
// 分页相关
const currentPage = ref<number>(1)
const pageSize = ref<number>(10)
const total = ref<number>(0)
const hasMore = ref<boolean>(true)
// 用户筛选相关
const isGuest = ref(true)
const guestsList = ref<TbGuestDTO[]>([])
const selectedGuestId = ref<string>('')
const showGuestSelector = ref<boolean>(false)
// 计算属性根据选中的用户ID筛选聊天室
const filteredChatRooms = computed(() => {
if (!selectedGuestId.value) {
return chatRooms.value
}
return chatRooms.value.filter(room => room.guestId === selectedGuestId.value)
})
// 生命周期
onMounted(async () => {
const windowInfo = uni.getWindowInfo()
const statusBarHeight = windowInfo.statusBarHeight || 20
// #ifdef MP-WEIXIN
try {
const menuButton = uni.getMenuButtonBoundingClientRect()
navPaddingTop.value = menuButton.top
capsuleHeight.value = menuButton.height
navHeight.value = menuButton.bottom + 8
} catch (e) {
navPaddingTop.value = statusBarHeight
navHeight.value = navPaddingTop.value + 44
}
// #endif
// #ifndef MP-WEIXIN
navPaddingTop.value = statusBarHeight
navHeight.value = navPaddingTop.value + 44
}
// #endif
// #ifndef MP-WEIXIN
navPaddingTop.value = statusBarHeight
navHeight.value = navPaddingTop.value + 44
// #endif
loadChatRooms()
initWebSocket()
})
// #endif
// 组件卸载时断开WebSocket
onUnmounted(() => {
disconnectWebSocket()
})
// 设置导航栏总高度
navTotalHeight.value = navHeight.value
// 加载聊天室列表
async function loadChatRooms() {
loading.value = true
try {
// 获取当前用户ID
const userId = uni.getStorageSync('userId') || ''
const pageRequest: PageRequest<TbChatRoomDTO> = {
filter: {
guestId: userId // 查询当前用户的聊天室
},
pageParam: {
pageNumber: 1,
pageSize: 50
}
}
const res = await workcaseChatAPI.getChatRoomPage(pageRequest)
if (res.success && res.pageDomain?.dataList) {
chatRooms.value = res.pageDomain.dataList
}
} catch (error) {
console.error('加载聊天室列表失败:', error)
uni.showToast({
title: '加载失败',
icon: 'none'
})
} finally {
loading.value = false
}
}
// 检查用户类型
checkUserType()
// 格式化时间(兼容 iOS
function formatTime(time?: string): string {
if (!time) return ''
// iOS 不支持 "yyyy-MM-dd HH:mm:ss" 格式,需要转换为 "yyyy-MM-ddTHH:mm:ss"
const iosCompatibleTime = time.replace(' ', 'T')
const date = new Date(iosCompatibleTime)
if (isNaN(date.getTime())) return ''
// 加载聊天室列表
loadChatRooms()
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) + '小时前'
if (diff < 172800000) return '昨天'
return `${date.getMonth() + 1}/${date.getDate()}`
}
// 去除markdown语法并截取前10个字符
function getPlainTextPreview(text?: string): string {
if (!text) return '暂无消息'
// 去除markdown语法
let plainText = text
// 去除代码块
.replace(/```[\s\S]*?```/g, '[代码]')
// 去除行内代码
.replace(/`([^`]+)`/g, '$1')
// 去除粗体
.replace(/\*\*([^\*]+)\*\*/g, '$1')
// 去除斜体
.replace(/\*([^\*]+)\*/g, '$1')
// 去除链接
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
// 去除标题标记
.replace(/^#{1,6}\s+/gm, '')
// 去除列表标记
.replace(/^[*-]\s+/gm, '')
// 去除多余的空白字符
.replace(/\s+/g, ' ')
.trim()
// 截取前10个字符
if (plainText.length > 10) {
return plainText.substring(0, 10) + '...'
}
return plainText
}
// 获取状态样式类
function getStatusClass(status?: string): string {
switch (status) {
case 'active': return 'status-active'
case 'waiting': return 'status-waiting'
case 'closed': return 'status-closed'
default: return 'status-waiting'
}
}
// 获取状态文本
function getStatusText(status?: string): string {
switch (status) {
case 'active': return '进行中'
case 'waiting': return '等待中'
case 'closed': return '已关闭'
default: return '未知'
}
}
// 进入聊天室
function enterRoom(room: ChatRoomVO) {
room.unreadCount = 0
uni.navigateTo({
url: `/pages/chatRoom/chatRoom/chatRoom?roomId=${room.roomId}&workcaseId=${room.workcaseId || ''}`
// 初始化WebSocket
initWebSocket()
})
}
// 返回上一页
function goBack() {
uni.navigateBack()
}
// 组件卸载时断开WebSocket
onUnmounted(() => {
disconnectWebSocket()
})
// ==================== WebSocket连接管理 ====================
// 初始化WebSocket连接
async function initWebSocket() {
try {
const token = uni.getStorageSync('token') || ''
if (!token) {
console.warn('[chatRoomList] 未找到token跳过WebSocket连接')
return
}
// 构建WebSocket URL
// 开发环境ws://localhost:8180 或 ws://192.168.x.x:8180
// 生产环境wss://your-domain.com
const protocol = 'wss:' // 开发环境使用ws生产环境改为wss
// 小程序使用原生WebSocket端点不是SockJS端点
const wsUrl = `${protocol}//${WS_HOST}/urban-lifeline/workcase/ws/chat?token=${encodeURIComponent(token)}`
console.log('[chatRoomList] 开始连接WebSocket')
await wsClient.connect(wsUrl, token)
// 订阅聊天室列表更新频道
wsClient.subscribe('/topic/chat/list-update', handleListUpdate)
console.log('[chatRoomList] WebSocket连接成功已订阅列表更新频道')
} catch (error) {
console.error('[chatRoomList] WebSocket连接失败:', error)
}
}
// 断开WebSocket连接
function disconnectWebSocket() {
try {
wsClient.disconnect()
console.log('[chatRoomList] WebSocket已断开')
} catch (error) {
console.error('[chatRoomList] 断开WebSocket失败:', error)
}
}
// 处理列表更新消息
async function handleListUpdate(message: ChatRoomMessageVO) {
console.log('[chatRoomList] 收到列表更新消息:', message)
// 更新对应聊天室的lastMessage和lastMessageTime
const roomIndex = chatRooms.value.findIndex((r: ChatRoomVO) => r.roomId === message.roomId)
if (roomIndex !== -1) {
// 查询当前用户在该聊天室的未读数
let unreadCount = 0
// 检查用户类型
function checkUserType() {
try {
const userInfo = uni.getStorageSync('userInfo')
const userId = typeof userInfo === 'string' ? JSON.parse(userInfo).userId : userInfo?.userId
if (userId) {
const unreadResult = await workcaseChatAPI.getUnreadCount(message.roomId, userId)
if (unreadResult.success && unreadResult.data !== undefined) {
unreadCount = unreadResult.data
}
const parsedUserInfo = typeof userInfo === 'string' ? JSON.parse(userInfo) : userInfo
isGuest.value = parsedUserInfo.status === 'guest'
// 如果是非guest用户加载可选人员列表
if (!isGuest.value) {
loadGuestsList()
}
} catch (error) {
console.error('[chatRoomList] 查询未读数失败:', error)
console.error('检查用户类型失败:', error)
isGuest.value = true
}
}
// 加载可选人员列表
async function loadGuestsList() {
loadingUsers.value = true
try {
const res = await guestAPI.listGuest()
if (res.success && res.dataList) {
guestsList.value = res.dataList
}
} catch (error) {
console.error('加载可选人员列表失败:', error)
} finally {
loadingUsers.value = false
}
}
// 加载聊天室列表
async function loadChatRooms(isLoadMore = false) {
// 如果正在加载或没有更多数据,直接返回
if (loading.value || (!isLoadMore && !hasMore.value)) return
loading.value = true
try {
// 获取当前用户ID
const userId = uni.getStorageSync('userId') || ''
// 计算当前页码
const currentPageNum = isLoadMore ? currentPage.value + 1 : 1
const pageRequest : PageRequest<TbChatRoomDTO> = {
filter: {
guestId: isGuest.value ? userId : selectedGuestId.value // 如果是guest用户查询自己的聊天室否则根据选中的用户ID查询
},
pageParam: {
page: currentPageNum,
pageSize: pageSize.value
}
}
const res = await workcaseChatAPI.getChatRoomPage(pageRequest)
if (res.success && res.pageDomain?.dataList) {
const newRooms = res.pageDomain.dataList
total.value = res.pageDomain?.pageParam?.total || 0
// 根据是否加载更多来处理数据
if (isLoadMore) {
// 加载更多:追加数据
chatRooms.value = [...chatRooms.value, ...newRooms]
currentPage.value++
} else {
// 刷新:替换数据
chatRooms.value = newRooms
currentPage.value = 1
}
// 判断是否还有更多数据
hasMore.value = chatRooms.value.length < total.value
}
} catch (error) {
console.error('加载聊天室列表失败:', error)
uni.showToast({
title: '加载失败',
icon: 'none'
})
// 如果是加载更多失败保持hasMore不变
if (!isLoadMore) {
hasMore.value = false
}
} finally {
loading.value = false
}
}
// 处理滚动到底部事件
function handleScrollToLower() {
if (hasMore.value && !loading.value) {
loadChatRooms(true)
}
}
// 处理用户选择
function handleGuestSelect(guest : TbGuestDTO) {
selectedGuestId.value = guest.userId || ''
showGuestSelector.value = false
// 重置分页状态
hasMore.value = true
loadChatRooms() // 根据选中的用户重新加载聊天室列表
}
// 清除用户选择
function clearGuestSelect() {
selectedGuestId.value = ''
// 重置分页状态
hasMore.value = true
loadChatRooms() // 重新加载所有聊天室列表
}
// 查询聊天室列表
function handleQuery() {
// 重置分页状态
hasMore.value = true
loadChatRooms() // 根据当前筛选条件重新加载聊天室列表
}
// 格式化时间(兼容 iOS
function formatTime(time ?: string) : string {
if (!time) return ''
// iOS 不支持 "yyyy-MM-dd HH:mm:ss" 格式,需要转换为 "yyyy-MM-ddTHH:mm:ss"
const iosCompatibleTime = time.replace(' ', 'T')
const date = new Date(iosCompatibleTime)
if (isNaN(date.getTime())) return ''
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) + '小时前'
if (diff < 172800000) return '昨天'
return `${date.getMonth() + 1}/${date.getDate()}`
}
// 去除markdown语法并截取前10个字符
function getPlainTextPreview(text ?: string) : string {
if (!text) return '暂无消息'
// 去除markdown语法
let plainText = text
// 去除代码块
.replace(/```[\s\S]*?```/g, '[代码]')
// 去除行内代码
.replace(/`([^`]+)`/g, '$1')
// 去除粗体
.replace(/\*\*([^\*]+)\*\*/g, '$1')
// 去除斜体
.replace(/\*([^\*]+)\*/g, '$1')
// 去除链接
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
// 去除标题标记
.replace(/^#{1,6}\s+/gm, '')
// 去除列表标记
.replace(/^[*-]\s+/gm, '')
// 去除多余的空白字符
.replace(/\s+/g, ' ')
.trim()
// 截取前10个字符
if (plainText.length > 10) {
return plainText.substring(0, 10) + '...'
}
return plainText
}
// 获取状态样式类
function getStatusClass(status ?: string) : string {
switch (status) {
case 'active': return 'status-active'
case 'waiting': return 'status-waiting'
case 'closed': return 'status-closed'
default: return 'status-waiting'
}
}
// 获取状态文本
function getStatusText(status ?: string) : string {
switch (status) {
case 'active': return '进行中'
case 'waiting': return '等待中'
case 'closed': return '已关闭'
default: return '未知'
}
}
// 进入聊天室
function enterRoom(room : ChatRoomVO) {
room.unreadCount = 0
uni.navigateTo({
url: `/pages/chatRoom/chatRoom/chatRoom?roomId=${room.roomId}&workcaseId=${room.workcaseId || ''}`
})
}
// 返回上一页
function goBack() {
uni.navigateBack()
}
// ==================== WebSocket连接管理 ====================
// 初始化WebSocket连接
async function initWebSocket() {
try {
const token = uni.getStorageSync('token') || ''
if (!token) {
console.warn('[chatRoomList] 未找到token跳过WebSocket连接')
return
}
// 构建WebSocket URL
// 开发环境ws://localhost:8180 或 ws://192.168.x.x:8180
// 生产环境wss://your-domain.com
const protocol = 'wss:' // 开发环境使用ws生产环境改为wss
// 小程序使用原生WebSocket端点不是SockJS端点
const wsUrl = `${protocol}//${WS_HOST}/urban-lifeline/workcase/ws/chat?token=${encodeURIComponent(token)}`
console.log('[chatRoomList] 开始连接WebSocket')
await wsClient.connect(wsUrl, token)
// 订阅聊天室列表更新频道
wsClient.subscribe('/topic/chat/list-update', handleListUpdate)
console.log('[chatRoomList] WebSocket连接成功已订阅列表更新频道')
} catch (error) {
console.error('[chatRoomList] WebSocket连接失败:', error)
}
}
// 断开WebSocket连接
function disconnectWebSocket() {
try {
wsClient.disconnect()
console.log('[chatRoomList] WebSocket已断开')
} catch (error) {
console.error('[chatRoomList] 断开WebSocket失败:', error)
}
}
// 处理列表更新消息
async function handleListUpdate(message : ChatRoomMessageVO) {
console.log('[chatRoomList] 收到列表更新消息:', message)
// 更新对应聊天室的lastMessage和lastMessageTime
const roomIndex = chatRooms.value.findIndex((r : ChatRoomVO) => r.roomId === message.roomId)
if (roomIndex !== -1) {
// 查询当前用户在该聊天室的未读数
let unreadCount = 0
try {
const userInfo = uni.getStorageSync('userInfo')
const userId = typeof userInfo === 'string' ? JSON.parse(userInfo).userId : userInfo?.userId
if (userId) {
const unreadResult = await workcaseChatAPI.getUnreadCount(message.roomId, userId)
if (unreadResult.success && unreadResult.data !== undefined) {
unreadCount = unreadResult.data
}
}
} catch (error) {
console.error('[chatRoomList] 查询未读数失败:', error)
}
chatRooms.value[roomIndex] = {
...chatRooms.value[roomIndex],
lastMessage: message.content || '',
lastMessageTime: message.sendTime || '',
unreadCount: unreadCount
}
// 将更新的聊天室移到列表顶部
const updatedRoom = chatRooms.value[roomIndex]
chatRooms.value.splice(roomIndex, 1)
chatRooms.value.unshift(updatedRoom)
}
chatRooms.value[roomIndex] = {
...chatRooms.value[roomIndex],
lastMessage: message.content || '',
lastMessageTime: message.sendTime || '',
unreadCount: unreadCount
}
// 将更新的聊天室移到列表顶部
const updatedRoom = chatRooms.value[roomIndex]
chatRooms.value.splice(roomIndex, 1)
chatRooms.value.unshift(updatedRoom)
}
}
</script>
<style lang="scss" scoped>
@import "./chatRoomList.scss";
@import "./chatRoomList.scss";
// 用户筛选组件样式
.filter-section {
position: fixed;
top: var(--nav-height);
left: 0;
right: 0;
height: 44px;
background: #fff;
border-bottom: 1px solid #eee;
padding: 0 16px;
display: flex;
flex-direction: row;
align-items: center;
z-index: 10;
}
.filter-label {
font-size: 14px;
color: #333;
margin-right: 8px;
}
.filter-content {
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
}
.guest-selector {
flex: 1;
height: 32px;
border: 1px solid #ddd;
border-radius: 16px;
padding: 0 12px;
display: flex;
align-items: center;
justify-content: space-between;
background: #f9f9f9;
cursor: pointer;
transition: all 0.3s ease;
}
.guest-selector:active {
background: #e9e9e9;
border-color: #bbb;
}
.selected-guest {
font-size: 14px;
color: #333;
}
.placeholder {
font-size: 14px;
color: #999;
}
.selector-arrow {
font-size: 12px;
color: #666;
transition: transform 0.3s ease;
}
.filter-section:active .selector-arrow {
transform: rotate(180deg);
}
/* 按钮组样式 */
.btn-group {
display: flex;
flex-direction: row;
gap: 8px;
}
.reset-btn,
.query-btn {
height: 32px;
padding: 0 16px;
border-radius: 16px;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
}
.reset-btn {
background: #f5f5f5;
border: 1px solid #ddd;
color: #666;
}
.reset-btn:active {
background: #e9e9e9;
border-color: #bbb;
}
.query-btn {
background: #1989fa;
border: 1px solid #1989fa;
color: #fff;
}
.query-btn:active {
background: #0066cc;
border-color: #0066cc;
}
// 弹窗样式
.modal-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
animation: fadeIn 0.3s ease;
}
.modal-content {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 80%;
max-width: 400px;
background: #fff;
border-radius: 20px;
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.25);
z-index: 1001;
animation: slideUp 0.3s ease;
overflow: hidden;
}
/* 弹窗动画 */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translate(-50%, -45%);
}
to {
opacity: 1;
transform: translate(-50%, -50%);
}
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 20px;
border-bottom: 1px solid #f0f0f0;
background: linear-gradient(135deg, #fafafa 0%, #f5f5f5 100%);
}
.modal-title {
font-size: 18px;
font-weight: 600;
color: #333;
}
.modal-close {
font-size: 24px;
color: #999;
cursor: pointer;
transition: all 0.3s ease;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
}
.modal-close:active {
background: #f0f0f0;
color: #666;
transform: rotate(90deg);
}
.modal-body {
max-height: 350px;
overflow-y: auto;
padding: 0;
}
/* 滚动条样式 */
.modal-body::-webkit-scrollbar {
width: 6px;
}
.modal-body::-webkit-scrollbar-track {
background: #f1f1f1;
}
.modal-body::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
.modal-body::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
.guest-item {
display: flex;
flex-direction: row;
padding: 16px 20px;
cursor: pointer;
transition: all 0.3s ease;
border-bottom: 1px solid #f5f5f5;
}
.guest-item:last-child {
border-bottom: none;
}
.guest-item:active {
background-color: #f8f8f8;
transform: translateX(5px);
}
.guest-item:active .guest-name {
color: #1989fa;
}
.guest-name {
font-size: 16px;
font-weight: 500;
color: #333;
margin-bottom: 6px;
transition: color 0.3s ease;
}
.guest-phone {
font-size: 14px;
color: #666;
display: flex;
align-items: center;
gap: 6px;
}
.empty-item {
padding: 30px 20px;
text-align: center;
color: #999;
font-size: 14px;
background: #fafafa;
}
.empty-item::before {
content: "👤";
font-size: 48px;
display: block;
margin-bottom: 12px;
opacity: 0.5;
}
/* 加载更多样式 */
.load-more {
padding: 20px;
text-align: center;
background: #fff;
border-top: 1px solid #eee;
margin-top: 0;
}
.load-text {
font-size: 14px;
color: #999;
}
</style>