聊天室和会议初始化
This commit is contained in:
161
urbanLifelineWeb/example/jitsi-meet/useJitsiTranscription.js
Normal file
161
urbanLifelineWeb/example/jitsi-meet/useJitsiTranscription.js
Normal file
@@ -0,0 +1,161 @@
|
||||
// src/composables/useJitsiTranscription.ts
|
||||
import { ref } from 'vue';
|
||||
import { XunfeiTranscription } from '@/utils/xunfei';
|
||||
|
||||
export function useJitsiTranscription(meetingId: string) {
|
||||
const isRecording = ref(false);
|
||||
const currentSpeaker = ref<string | null>(null);
|
||||
let mediaRecorder: MediaRecorder | null = null;
|
||||
let xunfeiWs: XunfeiTranscription | null = null;
|
||||
|
||||
// 初始化Jitsi API
|
||||
const initJitsiApi = (domain: string, roomName: string) => {
|
||||
const api = new JitsiMeetExternalAPI(domain, {
|
||||
roomName: roomName,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
parentNode: document.querySelector('#jitsi-container'),
|
||||
userInfo: {
|
||||
displayName: '张三',
|
||||
email: 'zhangsan@example.com'
|
||||
}
|
||||
});
|
||||
|
||||
// 监听主讲人变化(核心:识别说话人)
|
||||
api.addEventListener('dominantSpeakerChanged', (event) => {
|
||||
const speakerId = event.id;
|
||||
console.log('主讲人切换:', speakerId);
|
||||
|
||||
// 获取说话人信息
|
||||
api.getParticipantsInfo().then(participants => {
|
||||
const speaker = participants.find(p => p.participantId === speakerId);
|
||||
if (speaker) {
|
||||
currentSpeaker.value = speaker.displayName;
|
||||
console.log('当前说话人:', speaker.displayName);
|
||||
|
||||
// 开始录制该说话人的音频
|
||||
startRecording(speakerId, speaker.displayName);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 监听音频轨道添加
|
||||
api.addEventListener('trackAdded', (event) => {
|
||||
if (event.track.getType() === 'audio') {
|
||||
console.log('音频轨道添加:', event.track.getParticipantId());
|
||||
}
|
||||
});
|
||||
|
||||
return api;
|
||||
};
|
||||
|
||||
// 开始录制音频
|
||||
const startRecording = async (speakerId: string, speakerName: string) => {
|
||||
try {
|
||||
// 获取会议音频流
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
|
||||
mediaRecorder = new MediaRecorder(stream, {
|
||||
mimeType: 'audio/webm'
|
||||
});
|
||||
|
||||
let audioChunks: Blob[] = [];
|
||||
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
audioChunks.push(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
// 每3秒发送一次音频数据进行转写
|
||||
mediaRecorder.onstop = async () => {
|
||||
const audioBlob = new Blob(audioChunks, { type: 'audio/webm' });
|
||||
|
||||
// 发送到讯飞/阿里云进行转写
|
||||
const transcription = await transcribeAudio(audioBlob, speakerId, speakerName);
|
||||
|
||||
// 保存转录结果
|
||||
await saveTranscription(meetingId, {
|
||||
speakerId: speakerId,
|
||||
speakerName: speakerName,
|
||||
content: transcription.text,
|
||||
confidence: transcription.confidence,
|
||||
startTime: new Date().toISOString(),
|
||||
endTime: new Date().toISOString()
|
||||
});
|
||||
|
||||
audioChunks = [];
|
||||
};
|
||||
|
||||
mediaRecorder.start();
|
||||
isRecording.value = true;
|
||||
|
||||
// 每3秒停止并重新开始,实现分段录制
|
||||
setInterval(() => {
|
||||
if (mediaRecorder && mediaRecorder.state === 'recording') {
|
||||
mediaRecorder.stop();
|
||||
setTimeout(() => mediaRecorder?.start(), 100);
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
} catch (error) {
|
||||
console.error('录制失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 使用讯飞实时转写
|
||||
const transcribeAudio = async (audioBlob: Blob, speakerId: string, speakerName: string) => {
|
||||
// 连接讯飞WebSocket
|
||||
if (!xunfeiWs) {
|
||||
xunfeiWs = new XunfeiTranscription({
|
||||
appId: 'YOUR_APP_ID',
|
||||
apiKey: 'YOUR_API_KEY',
|
||||
onResult: (result) => {
|
||||
console.log('实时转写结果:', result);
|
||||
|
||||
// 实时保存到数据库
|
||||
saveTranscription(meetingId, {
|
||||
speakerId: speakerId,
|
||||
speakerName: speakerName,
|
||||
content: result.text,
|
||||
confidence: result.confidence,
|
||||
startTime: result.startTime,
|
||||
endTime: result.endTime,
|
||||
isFinal: result.isFinal
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 发送音频数据
|
||||
const arrayBuffer = await audioBlob.arrayBuffer();
|
||||
xunfeiWs.send(arrayBuffer);
|
||||
|
||||
return {
|
||||
text: '转写结果(异步返回)',
|
||||
confidence: 0.95
|
||||
};
|
||||
};
|
||||
|
||||
// 保存转录结果到后端
|
||||
const saveTranscription = async (meetingId: string, data: any) => {
|
||||
try {
|
||||
await fetch('/api/workcase/meeting/transcription/save', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
meetingId: meetingId,
|
||||
...data
|
||||
})
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('保存转录失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
initJitsiApi,
|
||||
isRecording,
|
||||
currentSpeaker
|
||||
};
|
||||
}
|
||||
@@ -44,6 +44,13 @@ declare module 'shared/components/ai/knowledge/DocumentDetail.vue' {
|
||||
const DocumentDetail: DefineComponent<{}, {}, any>
|
||||
export default DocumentDetail
|
||||
}
|
||||
|
||||
declare module 'shared/components/chatRoom/ChatRoom.vue' {
|
||||
import { DefineComponent } from 'vue'
|
||||
const ChatRoom: DefineComponent<{}, {}, any>
|
||||
export default ChatRoom
|
||||
}
|
||||
|
||||
// ========== API 模块 ==========
|
||||
declare module 'shared/api' {
|
||||
export const api: any
|
||||
|
||||
@@ -45,6 +45,13 @@ declare module 'shared/components/ai/knowledge/DocumentDetail.vue' {
|
||||
const DocumentDetail: DefineComponent<{}, {}, any>
|
||||
export default DocumentDetail
|
||||
}
|
||||
|
||||
declare module 'shared/components/chatRoom/ChatRoom.vue' {
|
||||
import { DefineComponent } from 'vue'
|
||||
const ChatRoom: DefineComponent<{}, {}, any>
|
||||
export default ChatRoom
|
||||
}
|
||||
|
||||
// ========== API 模块 ==========
|
||||
declare module 'shared/api' {
|
||||
export const api: any
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
// 品牌色
|
||||
$brand-color: #0055AA;
|
||||
$brand-color-light: #EBF5FF;
|
||||
$brand-color-hover: #004488;
|
||||
|
||||
.chat-room-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
// ==================== 聊天室头部 ====================
|
||||
.chat-header {
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 24px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
flex-shrink: 0;
|
||||
|
||||
.header-default {
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 消息容器 ====================
|
||||
.messages-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
background: #f8fafc;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
// ==================== Jitsi Meet会议容器 ====================
|
||||
.meeting-container {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
height: 400px;
|
||||
background: #000;
|
||||
border-bottom: 2px solid $brand-color;
|
||||
margin-bottom: 16px;
|
||||
|
||||
iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 消息列表 ====================
|
||||
.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;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
flex-shrink: 0;
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
background: transparent;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-size: 14px;
|
||||
|
||||
&:hover {
|
||||
border-color: $brand-color;
|
||||
color: $brand-color;
|
||||
background: $brand-color-light;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
font-family: inherit;
|
||||
|
||||
&::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;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&: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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
<template>
|
||||
<div class="chat-room-main">
|
||||
<!-- 聊天室头部 -->
|
||||
<header class="chat-header">
|
||||
<slot name="header">
|
||||
<div class="header-default">
|
||||
<h3>{{ roomName }}</h3>
|
||||
</div>
|
||||
</slot>
|
||||
</header>
|
||||
|
||||
<!-- 消息容器 -->
|
||||
<div ref="messagesRef" class="messages-container">
|
||||
<!-- Jitsi Meet会议iframe -->
|
||||
<div v-if="showMeeting && meetingUrl" class="meeting-container">
|
||||
<IframeView :src="meetingUrl" />
|
||||
</div>
|
||||
|
||||
<!-- 聊天消息列表 -->
|
||||
<div class="messages-list">
|
||||
<div
|
||||
v-for="message in messages"
|
||||
:key="message.messageId"
|
||||
class="message-row"
|
||||
:class="message.senderId === currentUserId ? 'is-me' : 'other'"
|
||||
>
|
||||
<!-- 头像 -->
|
||||
<div class="message-avatar">
|
||||
<img :src="FILE_DOWNLOAD_URL + message.senderAvatar" />
|
||||
</div>
|
||||
|
||||
<!-- 消息内容 -->
|
||||
<div class="message-content-wrapper">
|
||||
<div class="message-bubble">
|
||||
<p class="message-text">{{ message.content }}</p>
|
||||
|
||||
<!-- 文件列表 -->
|
||||
<div v-if="message.files && message.files.length > 0" class="message-files">
|
||||
<div
|
||||
v-for="file in message.files"
|
||||
:key="file"
|
||||
class="file-item"
|
||||
@click="$emit('download-file', file)"
|
||||
>
|
||||
<div class="file-icon">
|
||||
<FileText :size="16" />
|
||||
</div>
|
||||
<div class="file-info">
|
||||
<div class="file-name">附件</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="message-time">{{ formatTime(message.sendTime) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 输入区域 -->
|
||||
<footer class="input-area">
|
||||
<!-- 操作按钮区域 -->
|
||||
<div class="action-buttons">
|
||||
<!-- 发起会议按钮(始终显示) -->
|
||||
<button class="action-btn" @click="$emit('start-meeting')">
|
||||
<Video :size="18" />
|
||||
发起会议
|
||||
</button>
|
||||
|
||||
<!-- 额外的操作按钮插槽 -->
|
||||
<slot name="action-area"></slot>
|
||||
</div>
|
||||
|
||||
<!-- 输入框 -->
|
||||
<div class="input-wrapper">
|
||||
<div class="input-card">
|
||||
<div class="input-row">
|
||||
<textarea
|
||||
ref="textareaRef"
|
||||
v-model="inputText"
|
||||
@input="adjustHeight"
|
||||
@keydown="handleKeyDown"
|
||||
placeholder="输入消息..."
|
||||
class="chat-textarea"
|
||||
/>
|
||||
</div>
|
||||
<div class="toolbar-row">
|
||||
<div class="toolbar-left">
|
||||
<button class="tool-btn" @click="selectFiles" title="上传文件">
|
||||
<Paperclip :size="18" />
|
||||
</button>
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
multiple
|
||||
style="display: none"
|
||||
@change="handleFileSelect"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class="send-btn"
|
||||
:class="{ active: inputText.trim() }"
|
||||
:disabled="!inputText.trim()"
|
||||
@click="sendMessage"
|
||||
>
|
||||
<Send :size="18" />
|
||||
发送
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick } from 'vue'
|
||||
import { FileText, Video, Paperclip, Send } from 'lucide-vue-next'
|
||||
import IframeView from 'shared/components/iframe/IframeView.vue'
|
||||
|
||||
interface ChatMessageVO {
|
||||
messageId: string
|
||||
senderId: string
|
||||
senderName: string
|
||||
senderAvatar: string
|
||||
content: string
|
||||
files: string[]
|
||||
sendTime: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
messages: ChatMessageVO[]
|
||||
currentUserId: string
|
||||
roomName?: string
|
||||
meetingUrl?: string
|
||||
showMeeting?: boolean
|
||||
fileDownloadUrl?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
roomName: '聊天室',
|
||||
showMeeting: false,
|
||||
fileDownloadUrl: ''
|
||||
})
|
||||
|
||||
const FILE_DOWNLOAD_URL = props.fileDownloadUrl
|
||||
|
||||
const emit = defineEmits<{
|
||||
'send-message': [content: string, files: File[]]
|
||||
'start-meeting': []
|
||||
'download-file': [fileId: string]
|
||||
}>()
|
||||
|
||||
defineSlots<{
|
||||
header?: () => any
|
||||
'action-area'?: () => any
|
||||
}>()
|
||||
|
||||
const inputText = ref('')
|
||||
const selectedFiles = ref<File[]>([])
|
||||
const messagesRef = ref<HTMLElement | null>(null)
|
||||
const textareaRef = ref<HTMLTextAreaElement | null>(null)
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
// 发送消息
|
||||
const sendMessage = () => {
|
||||
if (!inputText.value.trim() && selectedFiles.value.length === 0) return
|
||||
|
||||
emit('send-message', inputText.value.trim(), selectedFiles.value)
|
||||
|
||||
inputText.value = ''
|
||||
selectedFiles.value = []
|
||||
|
||||
if (textareaRef.value) {
|
||||
textareaRef.value.style.height = 'auto'
|
||||
}
|
||||
|
||||
scrollToBottom()
|
||||
}
|
||||
|
||||
// 选择文件
|
||||
const selectFiles = () => {
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
|
||||
// 处理文件选择
|
||||
const handleFileSelect = (e: Event) => {
|
||||
const files = (e.target as HTMLInputElement).files
|
||||
if (!files || files.length === 0) return
|
||||
|
||||
selectedFiles.value = Array.from(files)
|
||||
}
|
||||
|
||||
// 滚动到底部
|
||||
const scrollToBottom = () => {
|
||||
nextTick(() => {
|
||||
if (messagesRef.value) {
|
||||
messagesRef.value.scrollTop = messagesRef.value.scrollHeight
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 自动调整输入框高度
|
||||
const adjustHeight = () => {
|
||||
const el = textareaRef.value
|
||||
if (el) {
|
||||
el.style.height = 'auto'
|
||||
el.style.height = Math.min(el.scrollHeight, 150) + 'px'
|
||||
}
|
||||
}
|
||||
|
||||
// 键盘事件
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
sendMessage()
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (time: string) => {
|
||||
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' })
|
||||
}
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
scrollToBottom
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import url("./ChatRoom.scss");
|
||||
</style>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as ChatRoom } from './ChatRoom.vue';
|
||||
@@ -2,5 +2,6 @@ export * from './base'
|
||||
export * from './dynamicFormItem'
|
||||
export * from './ai'
|
||||
export * from './file'
|
||||
export * from './chatRoom'
|
||||
// 通用视图组件
|
||||
export { default as IframeView } from './iframe/IframeView.vue'
|
||||
@@ -40,6 +40,7 @@ export default defineConfig({
|
||||
'./components/iframe/IframeView.vue': './src/components/iframe/IframeView.vue',
|
||||
'./components/ai/knowledge/DocumentSegment.vue': './src/components/ai/knowledge/documentSegment/DocumentSegment.vue',
|
||||
'./components/ai/knowledge/DocumentDetail.vue': './src/components/ai/knowledge/documentDetail/DocumentDetail.vue',
|
||||
'./components/chatRoom/ChatRoom.vue': './src/components/chatRoom/chatRoom/ChatRoom.vue',
|
||||
|
||||
// ========== API 模块 ==========
|
||||
'./api': './src/api/index.ts',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
export * from './workcase';
|
||||
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
@import url("./WorkcaseDetail.scss");
|
||||
</style>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as WorkcaseDetail } from './WorkcaseDetail/WorkcaseDetail.vue';
|
||||
Reference in New Issue
Block a user