聊天室和会议初始化

This commit is contained in:
2025-12-20 18:52:33 +08:00
parent 62850717eb
commit 37224e3f95
21 changed files with 3273 additions and 22 deletions

View File

@@ -46,6 +46,33 @@ declare module 'shared/components/ai/knowledge/DocumentDetail.vue' {
export default DocumentDetail
}
declare module 'shared/components/chatRoom/ChatRoom.vue' {
import { DefineComponent } from 'vue'
interface ChatMessageVO {
messageId: string
senderId: string
senderName: string
senderAvatar: string
content: string
files: string[]
sendTime: string
}
const ChatRoom: DefineComponent<{
messages: ChatMessageVO[]
currentUserId: string
roomName?: string
meetingUrl?: string
showMeeting?: boolean
fileDownloadUrl?: string
}, {}, {}, {}, {}, {}, {}, {
header?: () => any
'action-area'?: () => any
}>
export default ChatRoom
}
// ========== API 模块 ==========
declare module 'shared/api' {
export const api: any

View File

@@ -0,0 +1,515 @@
// 品牌色
$brand-color: #0055AA;
$brand-color-light: #EBF5FF;
$brand-color-hover: #004488;
.chat-room-container {
display: flex;
height: 100%;
background: #f8fafc;
position: relative;
overflow: hidden;
font-family: 'Inter', 'Noto Sans SC', sans-serif;
}
// ==================== 聊天室列表侧边栏 ====================
.room-list-sidebar {
width: 320px;
height: 100%;
background: #fff;
border-right: 1px solid #e2e8f0;
display: flex;
flex-direction: column;
flex-shrink: 0;
.sidebar-header {
height: 56px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
border-bottom: 1px solid #f1f5f9;
.title {
font-weight: 600;
font-size: 16px;
color: #1e293b;
}
}
.search-box {
padding: 12px 16px;
border-bottom: 1px solid #f1f5f9;
.el-input {
--el-input-border-radius: 8px;
}
}
.room-list-container {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.room-item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px;
margin-bottom: 4px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
position: relative;
&:hover {
background: #f8fafc;
}
&.active {
background: $brand-color-light;
}
.room-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
background: linear-gradient(135deg, $brand-color 0%, $brand-color-hover 100%);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-weight: 600;
font-size: 16px;
flex-shrink: 0;
}
.room-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.room-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.room-name {
font-size: 14px;
font-weight: 500;
color: #374151;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.room-time {
font-size: 12px;
color: #94a3b8;
flex-shrink: 0;
}
.last-message {
font-size: 13px;
color: #64748b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.unread-badge {
position: absolute;
top: 10px;
right: 10px;
min-width: 18px;
height: 18px;
padding: 0 6px;
background: #ef4444;
color: white;
border-radius: 9px;
font-size: 11px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
}
}
.empty-tip {
padding: 32px 16px;
text-align: center;
color: #94a3b8;
font-size: 14px;
}
}
// ==================== 主聊天区域 ====================
.chat-main {
flex: 1;
display: flex;
flex-direction: column;
height: 100%;
background: #fff;
}
// ==================== 聊天室头部 ====================
.chat-header {
height: 64px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
border-bottom: 1px solid #e2e8f0;
background: #fff;
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.room-avatar-small {
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(135deg, $brand-color 0%, $brand-color-hover 100%);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-weight: 600;
font-size: 14px;
}
.room-title-group {
.room-name-text {
font-size: 16px;
font-weight: 600;
color: #1e293b;
}
.room-subtitle {
font-size: 12px;
color: #94a3b8;
margin-top: 2px;
}
}
.header-actions {
display: flex;
align-items: center;
gap: 8px;
}
}
// ==================== 消息容器 ====================
.messages-container {
flex: 1;
overflow-y: auto;
background: #f8fafc;
}
// ==================== 空状态 ====================
.empty-state {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 32px;
.empty-content {
text-align: center;
max-width: 400px;
.empty-icon {
width: 80px;
height: 80px;
background: $brand-color-light;
border-radius: 20px;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 16px;
color: $brand-color;
}
.empty-title {
font-size: 18px;
font-weight: 600;
color: #1e293b;
margin: 0 0 8px;
}
.empty-desc {
font-size: 14px;
color: #64748b;
margin: 0;
}
}
}
// ==================== 消息列表 ====================
.messages-list {
max-width: 900px;
margin: 0 auto;
padding: 24px 16px;
.message-row {
display: flex;
gap: 12px;
margin-bottom: 24px;
&.is-me {
flex-direction: row-reverse;
.message-bubble {
background: $brand-color;
color: #fff;
border-radius: 16px 16px 4px 16px;
.message-time {
text-align: right;
color: rgba(255, 255, 255, 0.7);
}
}
}
&.other {
.message-bubble {
background: #fff;
border: 1px solid #f1f5f9;
border-radius: 16px 16px 16px 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
}
}
.message-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
flex-shrink: 0;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.message-content-wrapper {
max-width: 70%;
display: flex;
flex-direction: column;
gap: 8px;
}
.message-bubble {
padding: 12px 16px;
.message-text {
font-size: 14px;
line-height: 1.6;
white-space: pre-wrap;
word-wrap: break-word;
}
}
.message-files {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
}
.file-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: rgba(255, 255, 255, 0.1);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
border: 1px solid rgba(255, 255, 255, 0.2);
&:hover {
background: rgba(255, 255, 255, 0.2);
}
.file-icon {
width: 32px;
height: 32px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.2);
display: flex;
align-items: center;
justify-content: center;
}
.file-info {
.file-name {
font-size: 13px;
font-weight: 500;
}
.file-size {
font-size: 11px;
opacity: 0.8;
margin-top: 2px;
}
}
}
.other .file-item {
background: #f8fafc;
border-color: #e2e8f0;
&:hover {
background: #f1f5f9;
}
.file-icon {
background: $brand-color-light;
color: $brand-color;
}
.file-info {
color: #374151;
}
}
.message-time {
font-size: 12px;
color: #94a3b8;
padding: 0 4px;
}
}
// ==================== 输入区域 ====================
.input-area {
padding: 16px 24px 24px;
background: #fff;
border-top: 1px solid #e2e8f0;
.action-buttons {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.input-wrapper {
max-width: 900px;
margin: 0 auto;
}
.input-card {
background: #f8fafc;
border-radius: 12px;
border: 1px solid #e2e8f0;
overflow: hidden;
transition: all 0.2s;
&:focus-within {
border-color: $brand-color;
background: #fff;
}
}
.input-row {
padding: 12px 16px;
}
.chat-textarea {
width: 100%;
border: none;
outline: none;
resize: none;
font-size: 14px;
color: #374151;
background: transparent;
line-height: 1.5;
min-height: 60px;
max-height: 150px;
&::placeholder {
color: #94a3b8;
}
}
.toolbar-row {
padding: 8px 16px;
display: flex;
justify-content: space-between;
align-items: center;
border-top: 1px solid #e2e8f0;
background: #fff;
}
.toolbar-left {
display: flex;
align-items: center;
gap: 4px;
}
.tool-btn {
padding: 8px;
color: #94a3b8;
background: transparent;
border: none;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
&:hover {
color: $brand-color;
background: $brand-color-light;
}
}
.send-btn {
padding: 8px 16px;
background: #e2e8f0;
color: #94a3b8;
border: none;
border-radius: 8px;
cursor: not-allowed;
transition: all 0.2s;
font-size: 14px;
font-weight: 500;
display: flex;
align-items: center;
gap: 6px;
&.active {
background: $brand-color;
color: #fff;
cursor: pointer;
box-shadow: 0 2px 8px rgba($brand-color, 0.3);
&:hover {
background: $brand-color-hover;
}
}
}
}
// ==================== 工单详情对话框 ====================
.workcase-dialog {
.el-dialog__header {
padding: 20px 24px;
border-bottom: 1px solid #e2e8f0;
}
.el-dialog__body {
padding: 24px;
}
}

View File

@@ -0,0 +1,302 @@
<template>
<div class="chat-room-container">
<!-- 聊天室列表侧边栏 -->
<aside class="room-list-sidebar">
<!-- 头部 -->
<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.roomId)"
>
<!-- 头像 -->
<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">{{ room.lastMessage || '暂无消息' }}</div>
</div>
<!-- 未读红点 -->
<div v-if="room.unreadCount > 0" class="unread-badge">
{{ room.unreadCount > 99 ? '99+' : room.unreadCount }}
</div>
</div>
</div>
</aside>
<!-- 主聊天区域 -->
<main class="chat-main">
<template v-if="currentRoomId">
<ChatRoom
:messages="messages"
:current-user-id="userId"
:room-name="currentRoom?.roomName"
:meeting-url="currentMeetingUrl"
:show-meeting="showMeetingIframe"
:file-download-url="FILE_DOWNLOAD_URL"
@send-message="handleSendMessage"
@start-meeting="startMeeting"
@download-file="downloadFile"
>
<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="showWorkcaseDetail = true">
<FileText :size="16" />
查看工单
</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"
>
<WorkcaseDetail :workcase-id="currentWorkcaseId" />
</ElDialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ElButton, ElInput, ElDialog } from 'element-plus'
import { Search, FileText, MessageSquare } from 'lucide-vue-next'
import ChatRoom from 'shared/components/chatRoom/ChatRoom.vue'
import WorkcaseDetail from '@/views/public/workcase/WorkcaseDetail/WorkcaseDetail.vue'
import { FILE_DOWNLOAD_URL } from '@/config'
interface ChatRoomVO {
roomId: string
workcaseId: string
roomName: string
guestName: string
lastMessage: string | null
lastMessageTime: string | null
unreadCount: number
}
interface ChatMessageVO {
messageId: string
senderId: string
senderName: string
senderAvatar: string
content: string
files: string[]
sendTime: string
}
// 当前用户ID
const userId = ref('CURRENT_USER_ID')
// 搜索文本
const searchText = ref('')
// 聊天室列表
const chatRooms = ref<ChatRoomVO[]>([
{
roomId: 'ROOM001',
workcaseId: 'WC001',
roomName: '工单#WC001 - 电源故障',
guestName: '张三',
lastMessage: '好的,谢谢您的帮助',
lastMessageTime: new Date().toISOString(),
unreadCount: 3
},
{
roomId: 'ROOM002',
workcaseId: 'WC002',
roomName: '工单#WC002 - 设备维修',
guestName: '李四',
lastMessage: '请问什么时候能来处理?',
lastMessageTime: new Date(Date.now() - 3600000).toISOString(),
unreadCount: 0
}
])
// 当前选中的聊天室ID
const currentRoomId = ref<string | null>(null)
// 当前聊天室
const currentRoom = computed(() =>
chatRooms.value.find(r => 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 =>
room.roomName.toLowerCase().includes(keyword) ||
room.guestName.toLowerCase().includes(keyword) ||
room.workcaseId.toLowerCase().includes(keyword)
)
})
// 消息列表
const messages = ref<ChatMessageVO[]>([])
// 工单详情对话框
const showWorkcaseDetail = ref(false)
// Jitsi Meet会议相关
const currentMeetingUrl = ref('')
const showMeetingIframe = ref(false)
// 选择聊天室
const selectRoom = (roomId: string) => {
currentRoomId.value = roomId
// TODO: 加载该聊天室的消息
loadMessages(roomId)
}
// 加载消息
const loadMessages = async (roomId: string) => {
// TODO: 调用API加载消息
messages.value = [
{
messageId: 'MSG001',
senderId: 'OTHER_USER',
senderName: '张三',
senderAvatar: 'avatar.jpg',
content: '你好,我的设备出现故障了',
files: [],
sendTime: new Date().toISOString()
},
{
messageId: 'MSG002',
senderId: userId.value,
senderName: '客服',
senderAvatar: 'avatar.jpg',
content: '您好,请问是什么故障?',
files: [],
sendTime: new Date().toISOString()
}
]
scrollToBottom()
}
// 处理发送消息从ChatRoom组件触发
const handleSendMessage = async (content: string, files: File[]) => {
if (!currentRoomId.value) return
// TODO: 上传文件获取fileIds
const fileIds: string[] = []
const newMessage: ChatMessageVO = {
messageId: 'MSG' + Date.now(),
senderId: userId.value,
senderName: '客服',
senderAvatar: 'avatar.jpg',
content,
files: fileIds,
sendTime: new Date().toISOString()
}
messages.value.push(newMessage)
// TODO: 通过WebSocket发送到服务器
console.log('发送消息:', { content, files })
}
// 下载文件
const downloadFile = (fileId: string) => {
// TODO: 下载文件
console.log('下载文件:', fileId)
}
// 发起会议
const startMeeting = async () => {
if (!currentRoomId.value) return
// TODO: 调用后端API创建Jitsi会议
// const meeting = await createMeeting(currentRoomId.value)
// 模拟会议URL
const meetingId = 'meeting-' + Date.now()
currentMeetingUrl.value = `https://meet.jit.si/${meetingId}`
showMeetingIframe.value = true
console.log('发起会议:', currentMeetingUrl.value)
}
// 滚动到底部
const scrollToBottom = () => {
// TODO: 滚动到ChatRoom组件底部
}
// 格式化时间(用于聊天室列表)
const formatTime = (time: string | null) => {
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' })
}
</script>
<style scoped lang="scss">
@import url("./ChatRoomView.scss");
</style>

View File

@@ -0,0 +1 @@
export * from './workcase';

View File

@@ -0,0 +1,11 @@
<template>
<div>
</div>
</template>
<script setup lang="ts">
</script>
<style scoped lang="scss">
@import url("./WorkcaseDetail.scss");
</style>

View File

@@ -0,0 +1 @@
export { default as WorkcaseDetail } from './WorkcaseDetail/WorkcaseDetail.vue';