diff --git a/apps/web-antd/src/app.vue b/apps/web-antd/src/app.vue index 71950bb1..05d7a4f8 100644 --- a/apps/web-antd/src/app.vue +++ b/apps/web-antd/src/app.vue @@ -1,40 +1,39 @@ diff --git a/apps/web-antd/src/main.ts b/apps/web-antd/src/main.ts index 5d728a02..21cb193b 100644 --- a/apps/web-antd/src/main.ts +++ b/apps/web-antd/src/main.ts @@ -3,6 +3,9 @@ import { unmountGlobalLoading } from '@vben/utils'; import { overridesPreferences } from './preferences'; +// eslint-disable-next-line n/no-extraneous-import +import '@fortawesome/fontawesome-free/css/all.min.css'; + /** * 应用初始化完成之后再进行页面加载渲染 */ diff --git a/apps/web-antd/src/views/business/chat/api/index.ts b/apps/web-antd/src/views/business/chat/api/index.ts new file mode 100644 index 00000000..0cf2fa1f --- /dev/null +++ b/apps/web-antd/src/views/business/chat/api/index.ts @@ -0,0 +1,10 @@ +import { requestClient } from '#/api/request'; + +const prefix = 'chat-friends/'; +/** + * 获取当前登录的诊所信息 + * @param data + */ +export async function getChatFriendsListApi(data: any = {}) { + return requestClient.get(`${prefix}list`, { params: data }); +} diff --git a/apps/web-antd/src/views/business/chat/components/AudioMessage.vue b/apps/web-antd/src/views/business/chat/components/AudioMessage.vue new file mode 100644 index 00000000..eafbea3f --- /dev/null +++ b/apps/web-antd/src/views/business/chat/components/AudioMessage.vue @@ -0,0 +1,253 @@ + + + + + + + + + + + + + + + + + + {{ formatDuration(currentTime) }} + + + + + + + + + + -{{ formatDuration(remainingTime) }} + + + + + + + {{ formatDuration(audioDuration) }} + + + + + + + diff --git a/apps/web-antd/src/views/business/chat/components/CallInviteDialog.vue b/apps/web-antd/src/views/business/chat/components/CallInviteDialog.vue new file mode 100644 index 00000000..47735cf6 --- /dev/null +++ b/apps/web-antd/src/views/business/chat/components/CallInviteDialog.vue @@ -0,0 +1,495 @@ + + + + + + + {{ callerInfo.nick_name.charAt(0) }} + + + {{ callerInfo.nick_name }} + {{ callType === 'video' ? '视频通话' : '语音通话' }} + + + + + + {{ statusText }} + + + + + + + + + + + + 接听 + + + + + {{ isVisible && isIncoming ? '拒绝' : '取消' }} + + + + + + + {{ processingText }} + + + + + + + + diff --git a/apps/web-antd/src/views/business/chat/components/ChatArea.vue b/apps/web-antd/src/views/business/chat/components/ChatArea.vue new file mode 100644 index 00000000..37955b6c --- /dev/null +++ b/apps/web-antd/src/views/business/chat/components/ChatArea.vue @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + diff --git a/apps/web-antd/src/views/business/chat/components/ChatHeader.vue b/apps/web-antd/src/views/business/chat/components/ChatHeader.vue new file mode 100644 index 00000000..55c2d15a --- /dev/null +++ b/apps/web-antd/src/views/business/chat/components/ChatHeader.vue @@ -0,0 +1,434 @@ + + + + + + {{ chatStore.currentFriend?.nick_name.charAt(0) }} + + + + {{ chatStore.currentFriend?.nick_name }} + + + + {{ isFriendOnline ? '在线' : '离线' }} + + + {{ callStatusText }} + + + + + + + + + + + + + + + + + + + + + + + + + + 清空聊天记录 + + + + 查看资料 + + + + + + + + + + diff --git a/apps/web-antd/src/views/business/chat/components/ConnectionStatus.vue b/apps/web-antd/src/views/business/chat/components/ConnectionStatus.vue new file mode 100644 index 00000000..dfdd121a --- /dev/null +++ b/apps/web-antd/src/views/business/chat/components/ConnectionStatus.vue @@ -0,0 +1,60 @@ + + + + + + {{ chatStore.connectionStatusText }} + + diff --git a/apps/web-antd/src/views/business/chat/components/CustomInput.vue b/apps/web-antd/src/views/business/chat/components/CustomInput.vue new file mode 100644 index 00000000..aac29bab --- /dev/null +++ b/apps/web-antd/src/views/business/chat/components/CustomInput.vue @@ -0,0 +1,245 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web-antd/src/views/business/chat/components/CustomModal.vue b/apps/web-antd/src/views/business/chat/components/CustomModal.vue new file mode 100644 index 00000000..bee09d51 --- /dev/null +++ b/apps/web-antd/src/views/business/chat/components/CustomModal.vue @@ -0,0 +1,302 @@ + + + + + + + {{ title }} + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web-antd/src/views/business/chat/components/CustomTextarea.vue b/apps/web-antd/src/views/business/chat/components/CustomTextarea.vue new file mode 100644 index 00000000..8d406a30 --- /dev/null +++ b/apps/web-antd/src/views/business/chat/components/CustomTextarea.vue @@ -0,0 +1,311 @@ + + + + + + + + + + + + + + + + diff --git a/apps/web-antd/src/views/business/chat/components/EmojiPicker.vue b/apps/web-antd/src/views/business/chat/components/EmojiPicker.vue new file mode 100644 index 00000000..2df9c87e --- /dev/null +++ b/apps/web-antd/src/views/business/chat/components/EmojiPicker.vue @@ -0,0 +1,81 @@ + + + + + + + {{ emoji }} + + + + diff --git a/apps/web-antd/src/views/business/chat/components/EmptyState.vue b/apps/web-antd/src/views/business/chat/components/EmptyState.vue new file mode 100644 index 00000000..572491bb --- /dev/null +++ b/apps/web-antd/src/views/business/chat/components/EmptyState.vue @@ -0,0 +1,198 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 开始聊天吧! + + + 从左侧选择一个联系人开始对话,享受愉快的聊天体验 + + + + + + + + + diff --git a/apps/web-antd/src/views/business/chat/components/FileUploadPreview.vue b/apps/web-antd/src/views/business/chat/components/FileUploadPreview.vue new file mode 100644 index 00000000..47d265f2 --- /dev/null +++ b/apps/web-antd/src/views/business/chat/components/FileUploadPreview.vue @@ -0,0 +1,69 @@ + + + + + + + + + + + + + {{ + uploadPreview.nick_name + }} + + {{ + formatFileSize(uploadPreview.size) + }} + + + + + + + + + + 发送 + + + + + + 取消 + + + + + + + diff --git a/apps/web-antd/src/views/business/chat/components/FriendList.vue b/apps/web-antd/src/views/business/chat/components/FriendList.vue new file mode 100644 index 00000000..419d503e --- /dev/null +++ b/apps/web-antd/src/views/business/chat/components/FriendList.vue @@ -0,0 +1,353 @@ + + + + + + + {{ getListTitle() }} + + + + + + + + + + + + + + + + + {{ friend.nick_name.charAt(0) }} + + + + {{ friend.nick_name }} + + {{ friend.lastMessage || '点击开始聊天' }} + + + + + + {{ friend.lastMessageTime }} + + + {{ friend.unreadCount > 99 ? '99+' : friend.unreadCount }} + + + + + + + + + + + + + + diff --git a/apps/web-antd/src/views/business/chat/components/FriendsManagement.vue b/apps/web-antd/src/views/business/chat/components/FriendsManagement.vue new file mode 100644 index 00000000..e98b8007 --- /dev/null +++ b/apps/web-antd/src/views/business/chat/components/FriendsManagement.vue @@ -0,0 +1,265 @@ + + + + 好友管理 + + + + + + 全部好友 + 在线好友 + 离线好友 + + + + + + + + + {{ friend.nick_name.charAt(0) }} + + {{ friend.nick_name }} + ID: {{ friend.id }} + + + 聊天 + + + 详情 + + + + + + + + + + + + diff --git a/apps/web-antd/src/views/business/chat/components/GroupsManagement.vue b/apps/web-antd/src/views/business/chat/components/GroupsManagement.vue new file mode 100644 index 00000000..1d5b7743 --- /dev/null +++ b/apps/web-antd/src/views/business/chat/components/GroupsManagement.vue @@ -0,0 +1,280 @@ + + + + + 群聊管理 + + + 创建群聊 + + + + + + + + + + {{ group.nick_name.charAt(0) }} + + + {{ group.nick_name }} + {{ group.memberCount }} 人 + + + {{ group.description }} + + + 进入群聊 + + + 设置 + + + + + + + + + + + + diff --git a/apps/web-antd/src/views/business/chat/components/MediaPreview.vue b/apps/web-antd/src/views/business/chat/components/MediaPreview.vue new file mode 100644 index 00000000..a0bdaa15 --- /dev/null +++ b/apps/web-antd/src/views/business/chat/components/MediaPreview.vue @@ -0,0 +1,637 @@ + + + + + + + + + + + + + + + + + + + 视频预览 + + {{ formatDuration(currentTime) }} / {{ formatDuration(duration) }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web-antd/src/views/business/chat/components/MessageBubble.vue b/apps/web-antd/src/views/business/chat/components/MessageBubble.vue new file mode 100644 index 00000000..8b9466fe --- /dev/null +++ b/apps/web-antd/src/views/business/chat/components/MessageBubble.vue @@ -0,0 +1,464 @@ + + + + + {{ senderInfo.avatar }} + + + + + + + {{ senderInfo.nick_name }} + + + + + + + + + + + + + + + + + + + + + {{ formatDuration(message.duration) }} + + + + + + + + + + + + + {{ message.title || message.url }} + + {{ message.description }} + + {{ message.domain }} + + + + + + + {{ message.text }} + + + + + + + + + + + {{ getCallStatusText(message.callStatus) }} + + + 通话时长: {{ formatDuration(message.duration) }} + + + + + + + + + + + + {{ message.time }} + + + + + + + + + + diff --git a/apps/web-antd/src/views/business/chat/components/MessageInput.vue b/apps/web-antd/src/views/business/chat/components/MessageInput.vue new file mode 100644 index 00000000..23e73219 --- /dev/null +++ b/apps/web-antd/src/views/business/chat/components/MessageInput.vue @@ -0,0 +1,543 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web-antd/src/views/business/chat/components/MessageItem.vue b/apps/web-antd/src/views/business/chat/components/MessageItem.vue new file mode 100644 index 00000000..cf5e7bde --- /dev/null +++ b/apps/web-antd/src/views/business/chat/components/MessageItem.vue @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + {{ message.content }} + + + + + {{ message.time }} + + + + + + + diff --git a/apps/web-antd/src/views/business/chat/components/MessageList.vue b/apps/web-antd/src/views/business/chat/components/MessageList.vue new file mode 100644 index 00000000..bc3c1d4c --- /dev/null +++ b/apps/web-antd/src/views/business/chat/components/MessageList.vue @@ -0,0 +1,87 @@ + + + + + + + 开始与 {{ chatStore.currentFriend.nick_name }} 对话吧! + + + + + + + + + + + + + + + diff --git a/apps/web-antd/src/views/business/chat/components/MomentsView.vue b/apps/web-antd/src/views/business/chat/components/MomentsView.vue new file mode 100644 index 00000000..b087691f --- /dev/null +++ b/apps/web-antd/src/views/business/chat/components/MomentsView.vue @@ -0,0 +1,446 @@ + + + + 朋友圈 + + + + + + {{ userStore.currentUser?.nick_name?.charAt(0) }} + + + + + + + + 图片 + + + + 视频 + + + + 发布 + + + + + + + + + + {{ moment.author.nick_name.charAt(0) }} + + + {{ moment.author.nick_name }} + {{ moment.time }} + + + + + {{ moment.content }} + + + + + + + + + {{ moment.likes }} + + + + {{ moment.comments }} + + + + 分享 + + + + + + + + + + + diff --git a/apps/web-antd/src/views/business/chat/components/RecordingIndicator.vue b/apps/web-antd/src/views/business/chat/components/RecordingIndicator.vue new file mode 100644 index 00000000..bf9de4ff --- /dev/null +++ b/apps/web-antd/src/views/business/chat/components/RecordingIndicator.vue @@ -0,0 +1,19 @@ + + + + + 正在录音... 松开发送 + + + + + diff --git a/apps/web-antd/src/views/business/chat/components/SettingsModal.vue b/apps/web-antd/src/views/business/chat/components/SettingsModal.vue new file mode 100644 index 00000000..4deb755a --- /dev/null +++ b/apps/web-antd/src/views/business/chat/components/SettingsModal.vue @@ -0,0 +1,845 @@ + + + + + + + + {{ section.label }} + + + + + + + + 通用设置 + + + + + 主题模式 + 选择应用的外观主题 + + + + 亮色模式 + 暗色模式 + 跟随系统 + + + + + + + 语言设置 + 选择应用显示语言 + + + + 简体中文 + English + 日本語 + + + + + + + 字体大小 + 调整聊天界面的字体大小 + + + + {{ settings.fontSize }}px + + + + + + + + 通知设置 + + + + + 桌面通知 + 接收新消息的桌面通知 + + + + + + + + + + + + 声音提醒 + 新消息时播放提示音 + + + + + + + + + + + + 通话通知 + 接收语音和视频通话邀请 + + + + + + + + + + + + + + 隐私设置 + + + + + 在线状态 + 是否向其他用户显示在线状态 + + + + + + + + + + + + 已读回执 + 发送消息已读状态给对方 + + + + + + + + + + + + 自动下载 + 自动下载接收到的图片和文件 + + + + + + + + + + + + + + 关于应用 + + + + + + + + WebSocket 聊天系统 + 版本 1.0.0 + 基于 Vue 3 + WebSocket 构建的现代化聊天应用 + + + + + 主要功能 + + 实时消息传输 + 语音视频通话 + 文件传输 + 群聊功能 + 暗色模式 + 响应式设计 + + + + + + + 检查更新 + + + + 清除缓存 + + + + + + + + + + + + + + + + + diff --git a/apps/web-antd/src/views/business/chat/components/SideNavigation.vue b/apps/web-antd/src/views/business/chat/components/SideNavigation.vue new file mode 100644 index 00000000..57a544ba --- /dev/null +++ b/apps/web-antd/src/views/business/chat/components/SideNavigation.vue @@ -0,0 +1,387 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ item.label }} + + + {{ item.badge }} + + + + + + + + + + + + + + + diff --git a/apps/web-antd/src/views/business/chat/components/UserProfileModal.vue b/apps/web-antd/src/views/business/chat/components/UserProfileModal.vue new file mode 100644 index 00000000..a2222868 --- /dev/null +++ b/apps/web-antd/src/views/business/chat/components/UserProfileModal.vue @@ -0,0 +1,506 @@ + + + + + + + {{ userInfo.nick_name?.charAt(0) }} + + + {{ userInfo.nick_name }} + ID: {{ userInfo.id }} + + + {{ isOnline ? '在线' : '离线' }} + + + + + + + + 个人信息 + + + 昵称 + {{ userInfo.nick_name || '未设置' }} + + + 用户ID + {{ userInfo.id }} + + + 注册时间 + {{ formatDate(userInfo.createdAt) }} + + + 最后在线 + {{ formatDate(userInfo.lastSeen) }} + + + + + + + 聊天统计 + + + {{ chatStats.messageCount || 0 }} + 消息数量 + + + {{ chatStats.callCount || 0 }} + 通话次数 + + + {{ formatDuration(chatStats.totalCallTime || 0) }} + 通话时长 + + + + + + + + + + 发送消息 + + + + 语音通话 + + + + 视频通话 + + + + + + + + + diff --git a/apps/web-antd/src/views/business/chat/components/VideoCallComponent.vue b/apps/web-antd/src/views/business/chat/components/VideoCallComponent.vue new file mode 100644 index 00000000..b35b24ea --- /dev/null +++ b/apps/web-antd/src/views/business/chat/components/VideoCallComponent.vue @@ -0,0 +1,1620 @@ + + + + + + + {{ callerInfo.nick_name.charAt(0) }} + + + {{ callerInfo.nick_name }} + {{ connectionText }} + + + + + + + + + + + + + + + + + + + + {{ callerInfo.nick_name.charAt(0) }} + + + {{ callerInfo.nick_name }} + {{ formattedDuration }} + + + + + + + + + + + + + + + + {{ callerInfo.nick_name.charAt(0) }} + + + + + {{ callerInfo.nick_name }} + {{ formattedDuration }} + + + + + + + + + + + + + + + + + {{ callerInfo.nick_name.charAt(0) }} + + {{ callerInfo.nick_name }} + {{ connectionText }} + + + + + + + + + + {{ userStore.currentUser?.nick_name?.charAt(0) || 'U' }} + + + + + + + {{ formattedDuration }} + + + + + + 正在连接... + + + + + + 视频加载中... ({{ videoRetryCount }}/{{ maxVideoRetries }}) + + + + + 远程流: {{ remoteStream ? '✅' : '❌' }} + 主视频元素: {{ remoteVideo ? '✅' : '❌' }} + 预览视频元素: {{ remoteVideoPreview ? '✅' : '❌' }} + 回调数量: {{ videoCallbackCount }} + + 流ID: {{ remoteStream.id }} + 轨道数: {{ remoteStream.getTracks().length }} + + {{ track.kind }}: {{ track.readyState }} - {{ track.enabled ? '启用' : '禁用' }} + + + + + + + + + + + 对方忙线中 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 通话已结束 + {{ endingCountdown }}秒后关闭 + + + + + + + + diff --git a/apps/web-antd/src/views/business/chat/components/VirtualList.vue b/apps/web-antd/src/views/business/chat/components/VirtualList.vue new file mode 100644 index 00000000..ad58d6e5 --- /dev/null +++ b/apps/web-antd/src/views/business/chat/components/VirtualList.vue @@ -0,0 +1,129 @@ + + + + + + + + + + + + + + diff --git a/apps/web-antd/src/views/business/chat/composables/useFileUpload.ts b/apps/web-antd/src/views/business/chat/composables/useFileUpload.ts new file mode 100644 index 00000000..8b08f34e --- /dev/null +++ b/apps/web-antd/src/views/business/chat/composables/useFileUpload.ts @@ -0,0 +1,115 @@ +import {nextTick, ref} from 'vue'; + +import {message} from 'ant-design-vue'; + +export function useFileUpload() { + const uploadPreview = ref(null); + const imageInput = ref(null); + const videoInput = ref(null); + const currentFileType = ref(''); + + const triggerFileInput = (type) => { + currentFileType.value = type; + + nextTick(() => { + if (type === 'image' && imageInput.value) { + imageInput.value.value = ''; + imageInput.value.click(); + } else if (type === 'video' && videoInput.value) { + videoInput.value.value = ''; + videoInput.value.click(); + } + }); + }; + + const handleFileUpload = (event) => { + if (!event.target.files || event.target.files.length === 0) return; + + const file = event.target.files[0]; + + // 确定文件类型 + let detectedType = currentFileType.value; + if (event.target === imageInput.value) { + detectedType = 'image'; + } else if (event.target === videoInput.value) { + detectedType = 'video'; + } + + // 文件大小检查 + const maxSize = + detectedType === 'image' ? 10 * 1024 * 1024 : 50 * 1024 * 1024; + if (file.size > maxSize) { + const maxSizeMB = maxSize / 1024 / 1024; + message.error( + `文件大小超过限制!${detectedType === 'image' ? '图片' : '视频'}最大${maxSizeMB}MB`, + ); + event.target.value = ''; + return; + } + + // 文件类型检查 + const validImageTypes = [ + 'image/jpeg', + 'image/jpg', + 'image/png', + 'image/gif', + 'image/webp', + 'image/bmp', + ]; + const validVideoTypes = [ + 'video/mp4', + 'video/avi', + 'video/mov', + 'video/wmv', + 'video/flv', + 'video/webm', + 'video/mkv', + ]; + + if (detectedType === 'image' && !validImageTypes.includes(file.type)) { + message.error('请选择有效的图片格式 (JPEG, PNG, GIF, WebP, BMP)'); + event.target.value = ''; + return; + } + + if (detectedType === 'video' && !validVideoTypes.includes(file.type)) { + message.error( + '请选择有效的视频格式 (MP4, AVI, MOV, WMV, FLV, WebM, MKV)', + ); + event.target.value = ''; + return; + } + + const reader = new FileReader(); + reader.addEventListener('load', (e) => { + uploadPreview.value = { + type: detectedType, + url: e.target.result, + name: file.name, + size: file.size, + file, + }; + }); + + reader.onerror = (error) => { + console.error('文件读取失败:', error); + message.error('文件读取失败,请重试'); + }; + + reader.readAsDataURL(file); + event.target.value = ''; + }; + + const cancelUpload = () => { + uploadPreview.value = null; + }; + + return { + uploadPreview, + imageInput, + videoInput, + triggerFileInput, + handleFileUpload, + cancelUpload, + }; +} diff --git a/apps/web-antd/src/views/business/chat/composables/useMediaPreview.ts b/apps/web-antd/src/views/business/chat/composables/useMediaPreview.ts new file mode 100644 index 00000000..e94872e7 --- /dev/null +++ b/apps/web-antd/src/views/business/chat/composables/useMediaPreview.ts @@ -0,0 +1,34 @@ +import {ref} from "vue" + +const visible = ref(false) +const mediaData = ref(null) +const previewType = ref("image") + +export function useMediaPreview() { + const showPreview = (content, type = "image") => { + console.log("显示媒体预览:", content, type) + mediaData.value = content + previewType.value = type + visible.value = true + } + + const hidePreview = () => { + visible.value = false + mediaData.value = null + previewType.value = "image" + } + + return { + visible, + mediaData, + previewType, + showPreview, + hidePreview, + } +} + +// 导出一个函数供组件直接使用 +export default function previewMedia(content, type = "image") { + const {showPreview} = useMediaPreview() + showPreview(content, type) +} diff --git a/apps/web-antd/src/views/business/chat/composables/useRecording.ts b/apps/web-antd/src/views/business/chat/composables/useRecording.ts new file mode 100644 index 00000000..35fbe019 --- /dev/null +++ b/apps/web-antd/src/views/business/chat/composables/useRecording.ts @@ -0,0 +1,67 @@ +import {ref} from "vue" +import {message} from "ant-design-vue" + +export function useRecording() { + const isRecording = ref(false) + const mediaRecorder = ref(null) + const audioChunks = ref([]) + const recordingStartTime = ref(0) + + const startRecording = () => { + if (isRecording.value) return + + navigator.mediaDevices + .getUserMedia({audio: true}) + .then((stream) => { + isRecording.value = true + audioChunks.value = [] + recordingStartTime.value = Date.now() + + mediaRecorder.value = new MediaRecorder(stream) + + mediaRecorder.value.ondataavailable = (event) => { + audioChunks.value.push(event.data) + } + + mediaRecorder.value.onstop = () => { + const audioBlob = new Blob(audioChunks.value, {type: "audio/wav"}) + const audioUrl = URL.createObjectURL(audioBlob) + const duration = Math.floor((Date.now() - recordingStartTime.value) / 1000) + + console.log(' aaa', 'ssssssssssssssss') + // 触发音频消息发送事件 + const event = new CustomEvent("audioRecorded", { + detail: { + url: audioUrl, + duration: duration, + blob: audioBlob, + }, + }) + window.dispatchEvent(event) + + // 停止所有音频轨道 + stream.getTracks().forEach((track) => track.stop()) + } + + mediaRecorder.value.start() + }) + .catch((error) => { + console.error("录音失败:", error) + message.error("无法访问麦克风,请检查权限设置") + isRecording.value = false + }) + } + + const stopRecording = () => { + if (!isRecording.value || !mediaRecorder.value) return + + isRecording.value = false + mediaRecorder.value.stop() + } + + return { + isRecording, + startRecording, + stopRecording, + } +} diff --git a/apps/web-antd/src/views/business/chat/composables/useWebRTC.ts b/apps/web-antd/src/views/business/chat/composables/useWebRTC.ts new file mode 100644 index 00000000..88bde710 --- /dev/null +++ b/apps/web-antd/src/views/business/chat/composables/useWebRTC.ts @@ -0,0 +1,904 @@ +import { ref } from 'vue'; + +import { useChatStore } from '../stores/chat'; +import { useUserStore } from '../stores/user'; +import { callAPI } from '../utils/request.ts'; + +export function useWebRTC() { + const localStream = ref(null); + const remoteStream = ref(null); + const peerConnection = ref(null); + const isConnected = ref(false); + const isConnecting = ref(false); + + // ICE候选缓存和状态管理 + const pendingIceCandidates = ref([]); + const isRemoteDescriptionSet = ref(false); + const connectionAttempts = ref(0); + const maxConnectionAttempts = 3; + + // 修复问题1: 添加视频元素回调注册机制 + const videoElementCallbacks = ref(new Set()); + + const userStore = useUserStore(); + const chatStore = useChatStore(); + + // 改进ICE服务器配置 + const configuration = { + iceServers: [ + { urls: 'stun:stun.l.google.com:19302' }, + { urls: 'stun:stun1.l.google.com:19302' }, + { urls: 'stun:stun2.l.google.com:19302' }, + { urls: 'stun:stun3.l.google.com:19302' }, + { urls: 'stun:stun4.l.google.com:19302' }, + ], + iceTransportPolicy: 'all', + iceCandidatePoolSize: 10, + bundlePolicy: 'max-bundle', + rtcpMuxPolicy: 'require', + }; + + // 时间戳日志函数 + const logWithTime = (level, message, ...args) => { + const timestamp = new Date().toLocaleTimeString('zh-CN', { + hour12: false, + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + fractionalSecondDigits: 3, + }); + + switch (level) { + case 'error': { + console.error(`[${timestamp}] ${message}`, ...args); + break; + } + case 'log': { + console.log(`[${timestamp}] ${message}`, ...args); + break; + } + case 'warn': { + console.warn(`[${timestamp}] ${message}`, ...args); + break; + } + default: { + console.log(`[${timestamp}] ${message}`, ...args); + } + } + }; + + // 获取本地媒体流 + const getLocalMedia = async (constraints = { audio: true, video: true }) => { + try { + logWithTime('log', '🎥 开始获取本地媒体流', constraints); + + if (localStream.value) { + logWithTime('log', '🔄 停止现有本地媒体流'); + localStream.value.getTracks().forEach((track) => { + track.stop(); + logWithTime('log', `停止轨道: ${track.kind} - ${track.label}`); + }); + localStream.value = null; + } + + const stream = await navigator.mediaDevices.getUserMedia(constraints); + localStream.value = stream; + logWithTime( + 'log', + '✅ 本地媒体流获取成功', + stream.getTracks().map((t) => `${t.kind}:${t.label}`), + ); + return stream; + } catch (error) { + logWithTime('error', '❌ 获取本地媒体流失败:', error); + throw error; + } + }; + + // 处理缓存的ICE候选 + const processPendingIceCandidates = async () => { + if ( + !peerConnection.value || + !isRemoteDescriptionSet.value || + pendingIceCandidates.value.length === 0 + ) { + logWithTime( + 'log', + `🧊 跳过处理ICE候选 - PC:${!!peerConnection.value}, RD:${isRemoteDescriptionSet.value}, 候选数:${pendingIceCandidates.value.length}`, + ); + return; + } + + logWithTime( + 'log', + `🧊 开始处理 ${pendingIceCandidates.value.length} 个缓存的ICE候选`, + ); + + const candidates = [...pendingIceCandidates.value]; + pendingIceCandidates.value = []; + + for (const candidate of candidates) { + try { + if ( + peerConnection.value && + peerConnection.value.connectionState !== 'closed' + ) { + const iceCandidate = candidate.candidate + ? candidate + : new RTCIceCandidate(candidate); + await peerConnection.value.addIceCandidate(iceCandidate); + logWithTime( + 'log', + '✅ 缓存ICE候选添加成功:', + `${iceCandidate.candidate?.slice(0, 50)}...`, + ); + } + } catch (error) { + logWithTime('error', '❌ 缓存ICE候选添加失败:', error); + } + } + }; + + // 修复问题1: 注册视频元素更新回调 + const registerVideoCallback = (callback) => { + videoElementCallbacks.value.add(callback); + logWithTime( + 'log', + '📹 注册视频元素回调,当前回调数量:', + videoElementCallbacks.value.size, + ); + + // 如果已经有远程流,立即调用回调 + if (remoteStream.value) { + logWithTime('log', '📹 立即调用新注册的回调,因为远程流已存在'); + try { + callback(remoteStream.value); + } catch (error) { + logWithTime('error', '❌ 调用视频回调失败:', error); + } + } + + return () => { + videoElementCallbacks.value.delete(callback); + logWithTime('log', '📹 取消注册视频元素回调'); + }; + }; + + // 修复问题1: 通知所有注册的视频元素更新 + const notifyVideoElementsUpdate = (stream) => { + logWithTime( + 'log', + '📹 通知所有视频元素更新,回调数量:', + videoElementCallbacks.value.size, + ); + + videoElementCallbacks.value.forEach((callback) => { + try { + callback(stream); + } catch (error) { + logWithTime('error', '❌ 调用视频回调失败:', error); + } + }); + }; + + // 修复问题1: 改进远程轨道处理,直接通知组件更新 + const setupRemoteTrackHandling = (pc) => { + pc.ontrack = (event) => { + logWithTime('log', '📹 接收到远程轨道事件:', { + streams: event.streams?.length, + track: event.track?.kind, + trackId: event.track?.id, + trackState: event.track?.readyState, + trackEnabled: event.track?.enabled, + trackMuted: event.track?.muted, + }); + + if (event.streams && event.streams.length > 0) { + const stream = event.streams[0]; + const tracks = stream.getTracks(); + + if (tracks.length === 0) { + logWithTime('warn', '⚠️ 远程流没有轨道'); + return; + } + + logWithTime('log', '✅ 设置远程流:', { + id: stream.id, + tracks: tracks.map( + (t) => `${t.kind}:${t.id}:${t.readyState}:${t.enabled}:${t.muted}`, + ), + active: stream.active, + }); + + // 清理旧的远程流 + if (remoteStream.value && remoteStream.value.id !== stream.id) { + logWithTime('log', '🔄 清理旧的远程流'); + remoteStream.value.getTracks().forEach((track) => { + track.stop(); + logWithTime('log', `停止旧远程轨道: ${track.kind}`); + }); + } + + // 设置新的远程流 + remoteStream.value = stream; + + // 修复问题1: 直接通知所有注册的视频元素 + notifyVideoElementsUpdate(stream); + + // 监听流和轨道的状态变化 + stream.onactive = () => { + logWithTime('log', '📹 远程流变为活跃状态'); + notifyVideoElementsUpdate(stream); + }; + + stream.oninactive = () => { + logWithTime('warn', '⚠️ 远程流变为非活跃状态'); + }; + + stream.onaddtrack = (trackEvent) => { + logWithTime('log', '📹 远程流添加新轨道:', trackEvent.track.kind); + setupTrackListeners(trackEvent.track); + notifyVideoElementsUpdate(stream); + }; + + stream.onremovetrack = (trackEvent) => { + logWithTime('warn', '⚠️ 远程流移除轨道:', trackEvent.track.kind); + }; + + // 为每个轨道设置监听器 + tracks.forEach((track, index) => { + setupTrackListeners(track, index); + }); + + // 延迟通知,确保DOM已更新 + setTimeout(() => { + notifyVideoElementsUpdate(stream); + }, 100); + + setTimeout(() => { + notifyVideoElementsUpdate(stream); + }, 500); + } else if (event.track) { + // 如果没有流,尝试从轨道创建流 + logWithTime('log', '🔄 从轨道创建远程流'); + const newStream = new MediaStream([event.track]); + remoteStream.value = newStream; + setupTrackListeners(event.track); + + logWithTime('log', '✅ 从轨道创建的流:', { + id: newStream.id, + tracks: newStream.getTracks().map((t) => `${t.kind}:${t.id}`), + }); + + // 通知视频元素更新 + notifyVideoElementsUpdate(newStream); + + setTimeout(() => { + notifyVideoElementsUpdate(newStream); + }, 100); + } else { + logWithTime('warn', '⚠️ ontrack事件没有接收到有效的媒体流或轨道'); + } + }; + }; + + // 为轨道设置完整的状态监听 + const setupTrackListeners = (track, index = 0) => { + logWithTime( + 'log', + `设置轨道${index}监听器: ${track.kind} - ${track.readyState} - ${track.enabled}`, + ); + + track.addEventListener('ended', () => { + logWithTime('warn', `远程轨道结束: ${track.kind} - ${track.id}`); + // 轨道结束时尝试重新协商 + if ( + peerConnection.value && + peerConnection.value.connectionState === 'connected' + ) { + logWithTime('log', '轨道结束,可能需要重新协商'); + } + }); + + track.onmute = () => { + logWithTime('warn', `远程轨道静音: ${track.kind} - ${track.id}`); + }; + + track.onunmute = () => { + logWithTime('log', `远程轨道取消静音: ${track.kind} - ${track.id}`); + // 取消静音时触发视频更新 + if (track.kind === 'video' && remoteStream.value) { + notifyVideoElementsUpdate(remoteStream.value); + } + }; + + // 确保轨道是启用的 + if (!track.enabled) { + logWithTime('warn', `⚠️ 远程轨道未启用,尝试启用: ${track.kind}`); + track.enabled = true; + } + + // 定期检查轨道状态 + const checkInterval = setInterval(() => { + if (track.readyState === 'ended') { + clearInterval(checkInterval); + return; + } + + if ( + track.kind === 'video' && + track.readyState === 'live' && + track.enabled && + !track.muted && // 视频轨道状态良好,确保视频元素正在播放 + remoteStream.value + ) { + notifyVideoElementsUpdate(remoteStream.value); + } + }, 5000); + + // 清理定时器 + track.addEventListener('ended', () => { + clearInterval(checkInterval); + }); + }; + + // 连接重试机制 + const attemptReconnection = async (callId, peerId) => { + if (connectionAttempts.value >= maxConnectionAttempts) { + logWithTime('error', '❌ 达到最大重连次数,停止重试'); + chatStore.callStatus = 'failed'; + return false; + } + + connectionAttempts.value++; + logWithTime( + 'log', + `🔄 尝试重新连接 (${connectionAttempts.value}/${maxConnectionAttempts})`, + ); + + try { + // 清理现有连接 + cleanupPeerConnection(); + + // 重新创建连接 + createPeerConnection(callId, peerId); + + // 重置状态 + isRemoteDescriptionSet.value = false; + pendingIceCandidates.value = []; + + return true; + } catch (error) { + logWithTime('error', '❌ 重连失败:', error); + return false; + } + }; + + // 改进PeerConnection创建逻辑 + const createPeerConnection = (callId, peerId) => { + logWithTime('log', '🔗 创建PeerConnection', { callId, peerId }); + + if (peerConnection.value) { + logWithTime('log', '🔄 关闭现有PeerConnection'); + cleanupPeerConnection(); + } + + // 重置状态 + isConnected.value = false; + isConnecting.value = false; + isRemoteDescriptionSet.value = false; + pendingIceCandidates.value = []; + + const pc = new RTCPeerConnection(configuration); + peerConnection.value = pc; + + // 添加本地流到连接 + if (localStream.value) { + logWithTime('log', '📤 添加本地流轨道到PeerConnection'); + localStream.value.getTracks().forEach((track) => { + logWithTime('log', `添加轨道: ${track.kind} - ${track.label}`); + pc.addTrack(track, localStream.value); + }); + } + + // ICE候选事件处理 + pc.onicecandidate = async (event) => { + if (event.candidate) { + logWithTime( + 'log', + '📡 发送ICE候选:', + `${event.candidate.candidate?.slice(0, 50)}...`, + ); + try { + await callAPI.sendCandidate( + userStore.currentUser?.id, + peerId, + callId, + event.candidate, + 6, + ); + logWithTime('log', '✅ ICE候选发送成功'); + } catch (error) { + logWithTime('error', '❌ 发送ICE候选失败:', error); + } + } else { + logWithTime('log', '📡 ICE候选收集完成'); + } + }; + + // 设置改进的远程轨道处理 + setupRemoteTrackHandling(pc); + + // 连接状态监听 + pc.onconnectionstatechange = () => { + const state = pc.connectionState; + logWithTime('log', '🔗 连接状态变化:', state); + + switch (state) { + case 'closed': { + isConnected.value = false; + isConnecting.value = false; + logWithTime('log', '🔒 WebRTC连接已关闭'); + break; + } + + case 'connected': { + isConnected.value = true; + isConnecting.value = false; + connectionAttempts.value = 0; // 重置重试计数 + chatStore.callStatus = 'ongoing'; + chatStore.callConnectionStatus = '通话已连接'; + logWithTime('log', '✅ WebRTC连接已建立'); + break; + } + + case 'connecting': { + isConnecting.value = true; + chatStore.callConnectionStatus = '正在连接...'; + logWithTime('log', '🔄 WebRTC正在连接'); + break; + } + + case 'disconnected': { + isConnected.value = false; + isConnecting.value = false; + chatStore.callStatus = 'disconnected'; + chatStore.callConnectionStatus = '连接已断开'; + logWithTime('warn', '⚠️ WebRTC连接断开'); + + // 连接断开时尝试重连 + setTimeout(() => { + if (chatStore.callStatus === 'disconnected') { + attemptReconnection(callId, peerId); + } + }, 2000); + break; + } + + case 'failed': { + isConnected.value = false; + isConnecting.value = false; + chatStore.callStatus = 'failed'; + chatStore.callConnectionStatus = '连接失败'; + logWithTime('error', '❌ WebRTC连接失败'); + + // 连接失败时尝试重连 + setTimeout(() => { + if (chatStore.callStatus === 'failed') { + attemptReconnection(callId, peerId); + } + }, 3000); + break; + } + } + }; + + // ICE连接状态监听和重连逻辑 + pc.oniceconnectionstatechange = () => { + const iceState = pc.iceConnectionState; + logWithTime('log', '🧊 ICE连接状态变化:', iceState); + + switch (iceState) { + case 'checking': { + logWithTime('log', '🧊 ICE正在检查连接'); + break; + } + case 'closed': { + logWithTime('log', '🔒 ICE连接已关闭'); + break; + } + case 'completed': { + logWithTime('log', '✅ ICE连接完成'); + break; + } + case 'connected': { + logWithTime('log', '✅ ICE连接成功'); + connectionAttempts.value = 0; // 重置重试计数 + break; + } + case 'disconnected': { + logWithTime('warn', '⚠️ ICE连接断开'); + + // ICE断开时等待一段时间看是否能恢复 + setTimeout(() => { + if (pc.iceConnectionState === 'disconnected') { + logWithTime('log', '🔄 ICE连接断开超时,尝试重连'); + attemptReconnection(callId, peerId); + } + }, 5000); + break; + } + case 'failed': { + logWithTime('error', '❌ ICE连接失败'); + chatStore.callConnectionStatus = '网络连接失败'; + + // ICE连接失败时重新协商 + setTimeout(() => { + if (pc.iceConnectionState === 'failed') { + logWithTime('log', '🔄 ICE连接失败,尝试重新协商'); + // 通知需要重新协商 + const event = new CustomEvent('iceConnectionFailed', { + detail: { callId, peerId }, + }); + window.dispatchEvent(event); + } + }, 2000); + break; + } + } + }; + + // ICE收集状态监听 + pc.onicegatheringstatechange = () => { + logWithTime('log', '🧊 ICE收集状态:', pc.iceGatheringState); + }; + + // 信令状态监听 + pc.onsignalingstatechange = () => { + logWithTime('log', '📡 信令状态:', pc.signalingState); + }; + + return pc; + }; + + // 清理PeerConnection + const cleanupPeerConnection = () => { + if (!peerConnection.value) return; + + try { + const senders = peerConnection.value.getSenders(); + senders.forEach((sender) => { + if (sender.track) { + logWithTime('log', `停止发送器轨道: ${sender.track.kind}`); + sender.track.stop(); + } + }); + + const receivers = peerConnection.value.getReceivers(); + receivers.forEach((receiver) => { + if (receiver.track) { + logWithTime('log', `停止接收器轨道: ${receiver.track.kind}`); + receiver.track.stop(); + } + }); + } catch (error) { + logWithTime('error', '❌ 清理发送器/接收器时出错:', error); + } + + // 清理事件监听器 + peerConnection.value.onicecandidate = null; + peerConnection.value.ontrack = null; + peerConnection.value.onconnectionstatechange = null; + peerConnection.value.oniceconnectionstatechange = null; + peerConnection.value.onicegatheringstatechange = null; + peerConnection.value.onsignalingstatechange = null; + peerConnection.value.ondatachannel = null; + + peerConnection.value.close(); + peerConnection.value = null; + }; + + // 创建Offer + const createOffer = async (callId, peerId) => { + logWithTime('log', '📤 创建Offer', { callId, peerId }); + + if (!peerConnection.value) { + logWithTime('log', '🔗 PeerConnection不存在,先创建'); + createPeerConnection(callId, peerId); + } + + try { + const offerOptions = { + offerToReceiveAudio: true, + offerToReceiveVideo: true, + voiceActivityDetection: true, + iceRestart: connectionAttempts.value > 0, // 重连时启用ICE重启 + }; + + const offer = await peerConnection.value.createOffer(offerOptions); + await peerConnection.value.setLocalDescription(offer); + + logWithTime('log', '✅ Offer创建成功:', { + type: offer.type, + sdpLength: offer.sdp.length, + hasAudio: offer.sdp.includes('m=audio'), + hasVideo: offer.sdp.includes('m=video'), + iceRestart: offerOptions.iceRestart, + }); + + return offer; + } catch (error) { + logWithTime('error', '❌ 创建Offer失败:', error); + throw error; + } + }; + + // 创建Answer + const createAnswer = async (offer, callId, peerId) => { + logWithTime('log', '📥 处理Offer并创建Answer'); + + if (!peerConnection.value) { + logWithTime('log', '🔗 PeerConnection不存在,先创建'); + createPeerConnection(callId, peerId); + } + + try { + logWithTime('log', '📥 设置远程Offer'); + const remoteDesc = new RTCSessionDescription(offer); + await peerConnection.value.setRemoteDescription(remoteDesc); + + isRemoteDescriptionSet.value = true; + logWithTime('log', '✅ 远程描述设置完成,开始处理缓存的ICE候选'); + + await processPendingIceCandidates(); + + const answer = await peerConnection.value.createAnswer(); + await peerConnection.value.setLocalDescription(answer); + + logWithTime('log', '✅ Answer创建成功:', { + type: answer.type, + sdpLength: answer.sdp.length, + hasAudio: answer.sdp.includes('m=audio'), + hasVideo: answer.sdp.includes('m=video'), + }); + + return answer; + } catch (error) { + logWithTime('error', '❌ 创建Answer失败:', error); + throw error; + } + }; + + // 设置远程Answer + const setRemoteAnswer = async (answer) => { + logWithTime('log', '📥 设置远程Answer'); + + if (!peerConnection.value) { + logWithTime('error', '❌ PeerConnection不存在'); + throw new Error('PeerConnection不存在'); + } + + try { + const remoteDesc = new RTCSessionDescription(answer); + await peerConnection.value.setRemoteDescription(remoteDesc); + + isRemoteDescriptionSet.value = true; + logWithTime('log', '✅ 远程Answer设置成功,开始处理缓存的ICE候选'); + + await processPendingIceCandidates(); + } catch (error) { + logWithTime('error', '❌ 设置远程Answer失败:', error); + throw error; + } + }; + + // ICE候选添加逻辑 + const addIceCandidate = async (candidate) => { + logWithTime( + 'log', + '🧊 收到ICE候选:', + `${candidate.candidate?.slice(0, 50)}...`, + ); + + if (!candidate || !candidate.candidate) { + logWithTime('warn', '⚠️ 无效的ICE候选:', candidate); + return false; + } + + if (!peerConnection.value) { + logWithTime('warn', '⚠️ PeerConnection不存在,缓存ICE候选'); + pendingIceCandidates.value.push(candidate); + return false; + } + + if ( + peerConnection.value.connectionState === 'closed' || + peerConnection.value.connectionState === 'failed' + ) { + logWithTime( + 'warn', + '⚠️ PeerConnection状态异常,缓存ICE候选:', + peerConnection.value.connectionState, + ); + pendingIceCandidates.value.push(candidate); + return false; + } + + if (!isRemoteDescriptionSet.value) { + logWithTime('warn', '⚠️ 远程描述未设置,缓存ICE候选'); + pendingIceCandidates.value.push(candidate); + return false; + } + + try { + const iceCandidate = candidate.candidate + ? candidate + : new RTCIceCandidate(candidate); + await peerConnection.value.addIceCandidate(iceCandidate); + logWithTime('log', '✅ ICE候选添加成功'); + return true; + } catch (error) { + logWithTime('error', '❌ 添加ICE候选失败:', error); + return false; + } + }; + + // 切换摄像头 + const toggleCamera = (enable) => { + if (!localStream.value) { + logWithTime('warn', '⚠️ 本地流不存在,无法切换摄像头'); + return; + } + + const videoTracks = localStream.value.getVideoTracks(); + if (videoTracks.length > 0) { + videoTracks.forEach((track) => { + track.enabled = enable; + logWithTime( + 'log', + `📹 摄像头${enable ? '开启' : '关闭'}: ${track.label}`, + ); + }); + } else { + logWithTime('warn', '⚠️ 没有找到视频轨道'); + } + }; + + // 切换麦克风 + const toggleMicrophone = (enable) => { + if (!localStream.value) { + logWithTime('warn', '⚠️ 本地流不存在,无法切换麦克风'); + return; + } + + const audioTracks = localStream.value.getAudioTracks(); + if (audioTracks.length > 0) { + audioTracks.forEach((track) => { + track.enabled = enable; + logWithTime( + 'log', + `🎤 麦克风${enable ? '开启' : '关闭'}: ${track.label}`, + ); + }); + } else { + logWithTime('warn', '⚠️ 没有找到音频轨道'); + } + }; + + // 释放媒体资源 + const releaseMediaResources = () => { + logWithTime('log', '🔄 开始释放媒体资源'); + + if (localStream.value) { + logWithTime('log', '🔄 释放本地媒体流'); + localStream.value.getTracks().forEach((track) => { + logWithTime( + 'log', + `停止本地轨道: ${track.kind} - ${track.label} - 状态: ${track.readyState}`, + ); + track.stop(); + + setTimeout(() => { + if (track.readyState === 'ended') { + logWithTime('log', `✅ 轨道已完全停止: ${track.kind}`); + } else { + logWithTime( + 'warn', + `⚠️ 轨道未完全停止: ${track.kind} - ${track.readyState}`, + ); + try { + track.stop(); + } catch (error) { + logWithTime('error', '强制停止轨道失败:', error); + } + } + }, 200); + }); + localStream.value = null; + } + + if (remoteStream.value) { + logWithTime('log', '🔄 释放远程媒体流'); + remoteStream.value.getTracks().forEach((track) => { + logWithTime('log', `停止远程轨道: ${track.kind}`); + track.stop(); + }); + remoteStream.value = null; + + // 通知视频元素清理 + notifyVideoElementsUpdate(null); + } + + logWithTime('log', '✅ 媒体资源释放完成'); + }; + + // 关闭连接 + const closeConnection = () => { + logWithTime('log', '🔄 开始关闭WebRTC连接'); + + pendingIceCandidates.value = []; + isRemoteDescriptionSet.value = false; + connectionAttempts.value = 0; + logWithTime('log', '🧊 清空ICE候选缓存和状态'); + + releaseMediaResources(); + cleanupPeerConnection(); + + isConnected.value = false; + isConnecting.value = false; + + logWithTime('log', '✅ WebRTC连接已完全关闭,所有资源已释放'); + }; + + // 强制清理所有资源 + const forceCleanup = () => { + logWithTime('log', '🚨 强制清理所有WebRTC资源'); + + try { + pendingIceCandidates.value = []; + isRemoteDescriptionSet.value = false; + connectionAttempts.value = 0; + + // 清理所有回调 + videoElementCallbacks.value.clear(); + + closeConnection(); + + setTimeout(() => { + localStream.value = null; + remoteStream.value = null; + peerConnection.value = null; + isConnected.value = false; + isConnecting.value = false; + + logWithTime('log', '✅ 强制清理完成'); + }, 100); + } catch (error) { + logWithTime('error', '❌ 强制清理时出错:', error); + } + }; + + return { + localStream, + remoteStream, + peerConnection, + isConnected, + isConnecting, + pendingIceCandidates, + isRemoteDescriptionSet, + connectionAttempts, + getLocalMedia, + createPeerConnection, + createOffer, + createAnswer, + setRemoteAnswer, + addIceCandidate, + toggleCamera, + toggleMicrophone, + closeConnection, + releaseMediaResources, + forceCleanup, + attemptReconnection, + registerVideoCallback, // 修复问题1: 新增方法 + }; +} diff --git a/apps/web-antd/src/views/business/chat/composables/useWebSocket.ts b/apps/web-antd/src/views/business/chat/composables/useWebSocket.ts new file mode 100644 index 00000000..ec2c389d --- /dev/null +++ b/apps/web-antd/src/views/business/chat/composables/useWebSocket.ts @@ -0,0 +1,191 @@ +import { onMounted, onUnmounted, ref } from 'vue'; + +import { useChatStore } from '../stores/chat'; +import { useUserStore } from '../stores/user'; +// useVbenUserStore +export function useWebSocket() { + const userStore = useUserStore(); + const chatStore = useChatStore(); + + const socket = ref(); + const isConnected = ref(false); + const isConnecting = ref(false); + const reconnectAttempts = ref(0); + const maxReconnectAttempts = 5; + const reconnectDelay = ref(1000); + + const connectWebSocket = () => { + if (isConnecting.value || isConnected.value) return; + + isConnecting.value = true; + chatStore.connectionStatus = 'connecting'; + + try { + // const wsUrl = import.meta.env.VITE_WS_URL || "wss://g-ws.nailaoyun.cn/ws" + const wsUrl = import.meta.env.VITE_WS_URL || 'ws://localhost:12080/ws'; + // const wsUrl = import.meta.env.VITE_WS_URL || "wss://g-ws.nailaoyun.cn/ws" + socket.value = new WebSocket(wsUrl); + + socket.value.addEventListener('open', handleOpen); + socket.value.onmessage = handleMessage; + socket.value.addEventListener('close', handleClose); + socket.value.onerror = handleError; + } catch (error) { + console.error('WebSocket连接失败:', error); + isConnecting.value = false; + chatStore.connectionStatus = 'disconnected'; + } + }; + + const handleOpen = (event) => { + console.log('WebSocket连接已建立'); + isConnected.value = true; + isConnecting.value = false; + reconnectAttempts.value = 0; + reconnectDelay.value = 1000; + chatStore.connectionStatus = 'connected'; + chatStore.socket = socket.value; + console.log(userStore.currentUser, 'ssssssssss') + + // 绑定当前用户 + if (userStore.currentUser?.id) { + bindUser(userStore.currentUser.id); + } + }; + + const handleMessage = (event) => { + try { + const data = JSON.parse(event.data); + console.log('收到WebSocket消息:', data); + + // 优先处理通话信令(无论是否在当前聊天窗口) + if (data.call_id && data.call_status) { + handleCallSignal(data); + return; + } + + // 处理普通消息 - 添加来源判断防止循环 + if (data.sender_user_id && data.receiver_user_id) { + chatStore.handleIncomingMessage(data, userStore.currentUser?.id); + } + } catch (error) { + console.error('解析WebSocket消息失败:', error); + } + }; + + const handleClose = (event) => { + console.log('WebSocket连接已关闭:', event.code, event.reason); + isConnected.value = false; + isConnecting.value = false; + chatStore.connectionStatus = 'disconnected'; + chatStore.socket = null; + + // 自动重连 + if (reconnectAttempts.value < maxReconnectAttempts) { + setTimeout(() => { + reconnectAttempts.value++; + reconnectDelay.value = Math.min(reconnectDelay.value * 2, 30_000); + console.log( + `尝试重连 (${reconnectAttempts.value}/${maxReconnectAttempts})`, + ); + connectWebSocket(); + }, reconnectDelay.value); + } + }; + + const handleError = (error) => { + console.error('WebSocket错误:', error); + isConnecting.value = false; + chatStore.connectionStatus = 'disconnected'; + }; + + const handleCallSignal = (data) => { + console.log('收到通话信令:', data); + chatStore.handleCallSignal(data); + }; + + const bindUser = (userId) => { + if (!isConnected.value || !userId) return; + + const bindMessage = { + request_type: 'bind', + user_type: 'doctor', + sender_user_id: `doctor-${userId}`, + }; + + send(bindMessage); + }; + + const send = (message) => { + if (!isConnected.value || !socket.value) { + console.error('WebSocket未连接,无法发送消息'); + return false; + } + + try { + socket.value.send(JSON.stringify(message)); + console.log('发送消息:', message); + return true; + } catch (error) { + console.error('发送消息失败:', error); + return false; + } + }; + + const sendMessage = (receiverUserId, messageType, content) => { + const message = { + request_type: 'send_message', + sender_user_id: userStore.currentUser?.id, + receiver_user_id: receiverUserId, + message_type: messageType, + message_content: content, + }; + + return send(message); + }; + + const sendCallSignal = (signal) => { + return send({ + request_type: 'call_signal', + ...signal, + sender_user_id: userStore.currentUser?.id, + }); + }; + + const disconnect = () => { + if (socket.value) { + socket.value.close(); + socket.value = null; + } + isConnected.value = false; + isConnecting.value = false; + chatStore.connectionStatus = 'disconnected'; + chatStore.socket = null; + }; + + // 生成唯一ID + const generateCallId = () => { + return Date.now().toString() + Math.random().toString(36).slice(2, 11); + }; + + // onMounted(() => { + // connectWebSocket(); + // }); + + onUnmounted(() => { + disconnect(); + }); + + return { + socket, + isConnected, + isConnecting, + connectWebSocket, + disconnect, + send, + sendMessage, + sendCallSignal, + bindUser, + generateCallId, + }; +} diff --git a/apps/web-antd/src/views/business/chat/index.vue b/apps/web-antd/src/views/business/chat/index.vue new file mode 100644 index 00000000..82ba4a19 --- /dev/null +++ b/apps/web-antd/src/views/business/chat/index.vue @@ -0,0 +1,211 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web-antd/src/views/business/chat/stores/chat.ts b/apps/web-antd/src/views/business/chat/stores/chat.ts new file mode 100644 index 00000000..d899f318 --- /dev/null +++ b/apps/web-antd/src/views/business/chat/stores/chat.ts @@ -0,0 +1,1239 @@ +import {defineStore} from "pinia" +import {ref, computed, watch} from "vue" +import {chatDB} from "../utils/db" +import {useWebRTC} from "../composables/useWebRTC" +import {useUserStore} from "../stores/user" +import {sendCallSignal, callAPI} from "../utils/request.ts" +import {getChatFriendsListApi} from "#/views/business/chat/api"; + +export const useChatStore = defineStore("chat", () => { + const friends = ref([]) + const currentFriend = ref(null) + const messages = ref([]) + const connectionStatus = ref("disconnected") + const socket = ref(null) + + // 通话相关状态增强 + const currentCall = ref(null) + const incomingCall = ref(null) + const callStatus = ref("idle") // idle, calling, ringing, connecting, ongoing, ended, disconnected, hangup + const callError = ref(null) + const callConnectionStatus = ref("") + const callTimeout = ref(null) + + const webrtc = useWebRTC() + const userStore = useUserStore() + + // 计算属性 + const isConnected = computed(() => connectionStatus.value === "connected") + const isInCall = computed(() => ["calling", "ringing", "connecting", "ongoing"].includes(callStatus.value)) + const isCalling = computed(() => callStatus.value === "calling") + const isRinging = computed(() => callStatus.value === "ringing") + const isConnecting = computed(() => callStatus.value === "connecting") + const isOngoingCall = computed(() => callStatus.value === "ongoing") + const isDisconnected = computed(() => callStatus.value === "disconnected") + const isHangup = computed(() => callStatus.value === "hangup") + + // 时间戳日志函数 + const logWithTime = (level, message, ...args) => { + const timestamp = new Date().toLocaleTimeString("zh-CN", { + hour12: false, + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + fractionalSecondDigits: 3, + }) + + switch (level) { + case "log": + console.log(`[${timestamp}] ${message}`, ...args) + break + case "error": + console.error(`[${timestamp}] ${message}`, ...args) + break + case "warn": + console.warn(`[${timestamp}] ${message}`, ...args) + break + default: + console.log(`[${timestamp}] ${message}`, ...args) + } + } + + // 监听通话状态变化 + watch( + () => callStatus.value, + (newStatus) => { + logWithTime("log", "通话状态变化:", newStatus) + }, + ) + + // 重置所有通话状态 + const resetCallState = () => { + clearCallTimeout() + callStatus.value = "idle" + callError.value = null + callConnectionStatus.value = "" + currentCall.value = null + incomingCall.value = null + } + + // 清除通话超时 + const clearCallTimeout = () => { + if (callTimeout.value) { + clearTimeout(callTimeout.value) + callTimeout.value = null + } + } + + // 设置通话超时 + const setCallTimeout = (callback, duration = 30000) => { + clearCallTimeout() + callTimeout.value = setTimeout(() => { + callback() + clearCallTimeout() + }, duration) + } + + // 发送通话信令 + const sendCallSignalInternal = async (signal) => { + try { + logWithTime("log", "发送通话信令:", signal) + + if (!userStore.currentUser?.id) { + throw new Error("用户未登录") + } + + if (!signal.receiver_user_id) { + throw new Error("缺少接收方用户ID") + } + + if (!signal.call_id) { + throw new Error("缺少通话ID") + } + + if (!signal.call_status) { + throw new Error("缺少通话状态") + } + + const requestData = { + sender_user_id: userStore.currentUser.id, + receiver_user_id: signal.receiver_user_id, + message_type: signal.call_type || (signal.call_status === "invite" ? 6 : 7), + message_content: JSON.stringify(signal.data || {}), + call_id: signal.call_id, + call_status: signal.call_status, + } + + logWithTime("log", "发送通话信令请求数据:", requestData) + + const response = await sendCallSignal(requestData) + logWithTime("log", "发送通话信令成功:", signal) + return response + } catch (error) { + logWithTime("error", "发送通话信令失败:", error) + throw error + } + } + + // 初始化数据库 + const initDB = async () => { + try { + await chatDB.init() + logWithTime("log", "数据库初始化成功") + } catch (error) { + logWithTime("error", "数据库初始化失败:", error) + } + } + + // 加载好友列表 + const loadFriends = async (presetUsers, currentUserId) => { + try { + getChatFriendsListApi().then((res) => { + console.log('好友列表', res) + friends.value = res; + }); + + // let friendList = await chatDB.getFriends(currentUserId) + // + // if (friendList.length === 0) { + // friendList = presetUsers.filter((user) => user.id !== currentUserId) + // + // for (const friend of friendList) { + // await chatDB.saveFriend( + // { + // ...friend, + // lastMessage: "", + // unreadCount: 0, + // }, + // currentUserId, + // ) + // } + // } + // + // friends.value = friendList + } catch (error) { + logWithTime("error", "加载好友列表失败:", error) + const friendList = presetUsers.filter((user) => user.id !== currentUserId) + friendList.forEach((friend) => { + friend.lastMessage = "" + friend.unreadCount = 0 + }) + friends.value = friendList + } + } + + // 设置当前好友 + const setCurrentFriend = (friend) => { + currentFriend.value = friend + if (friend) { + friend.unreadCount = 0 + } + } + + // 切换当前聊天好友 + const switchFriend = async (friend, currentUserId) => { + currentFriend.value = friend + + friend.unreadCount = 0 + await chatDB.clearUnreadCount(friend.id) + + try { + const chatHistory = await chatDB.getChatHistory(currentUserId, friend.id) + messages.value = chatHistory.sort((a, b) => a.timestamp - b.timestamp) + } catch (error) { + logWithTime("error", "加载聊天记录失败:", error) + messages.value = [] + } + } + + // 添加消息 + const addMessage = async (message, currentUserId) => { + messages.value.push(message) + + const friend = friends.value.find((f) => f.id === message.senderId || f.id === currentFriend.value?.id) + if (friend) { + let lastMessage = message.content + if (message.type === "image") lastMessage = "[图片]" + if (message.type === "video") lastMessage = "[视频]" + if (message.type === "audio") lastMessage = "[语音]" + if (message.type === "video-call" || message.type === "audio-call") lastMessage = "[通话]" + + friend.lastMessage = lastMessage.length > 20 ? lastMessage.substring(0, 20) + "..." : lastMessage + + await chatDB.updateFriendLastMessage(friend.id, currentUserId, friend.lastMessage, 0) + } + + if (currentFriend.value && friend && friend.id === currentFriend.value.id) { + try { + await chatDB.saveMessage(message, currentUserId, currentFriend.value.id) + } catch (error) { + logWithTime("error", "保存消息失败:", error) + } + } + } + + // 处理接收到的消息 + const handleIncomingMessage = async (messageData, currentUserId) => { + // if (messageData.isFromHttp) { + // logWithTime("log", "忽略来自HTTP的消息,避免循环") + // return + // } + + const senderId = messageData.sender_user_id + const receiverId = messageData.receiver_user_id + + if (receiverId !== 'doctor-' + currentUserId) return + + if (messageData.call_id || messageData.call_status) { + handleCallSignal(messageData) + return + } + + const newMessage = { + type: getMessageType(messageData.message_type), + content: messageData.content, + time: new Date().toLocaleTimeString().slice(0, 5), + senderId: senderId, + read: false, + duration: messageData.duration || 0, + timestamp: Date.now(), + } + + try { + await chatDB.saveMessage(newMessage, currentUserId, senderId) + + if (currentFriend.value && senderId == currentFriend.value.id) { + newMessage.read = true + messages.value.push(newMessage) + } else { + const friend = friends.value.find((f) => f.id == senderId) + if (friend) { + friend.unreadCount = (friend.unreadCount || 0) + 1 + } + } + + const friend = friends.value.find((f) => f.id == senderId) + if (friend) { + let lastMessage = newMessage.content + if (newMessage.type === "image") lastMessage = "[图片]" + if (newMessage.type === "video") lastMessage = "[视频]" + if (newMessage.type === "audio") lastMessage = "[语音]" + if (newMessage.type === "video-call" || newMessage.type === "audio-call") lastMessage = "[通话]" + friend.lastMessage = lastMessage + + await chatDB.updateFriendLastMessage(friend.id, currentUserId, lastMessage, friend.unreadCount) + } + } catch (error) { + logWithTime("error", "处理接收消息失败:", error) + } + } + + // 根据消息类型编号获取类型字符串 + const getMessageType = (type) => { + const types = [ + "text", + "image", + "audio", + "video", + "prescription", + "medical-record", + "video-call", + "audio-call", + "file", + ] + return types[type] || "text" + } + + // 处理通话信令 + const handleCallSignal = (data) => { + logWithTime("log", "处理通话信令:", data) + logWithTime("log", "通话状态:", data.call_status) + + switch (data.call_status) { + case "invite": + handleIncomingCall(data) + break + case "accepted": + handleCallAccepted(data) + break + case "rejected": + handleCallRejected(data) + break + case "ended": + handleCallEnded(data) + break + case "offer": + handleOffer(data) + break + case "answer": + handleAnswer(data) + break + case "candidate": + handleIceCandidate(data) + break + case "no-answer": + handleCallNoAnswer(data) + break + case "connecting": + handleCallConnecting(data) + break + case "busy": + handleCallBusy(data) + break + case "hangup": + handleCallHangup(data) + break + case "disconnected": + handleCallDisconnected(data) + break + case "terminated": + handleCallTerminated(data) + break + case "failed": + handleCallFailed(data) + break + } + } + + // 修复问题2: 改进Offer处理,允许重新协商 + const handleOffer = async (data) => { + if (incomingCall.value && incomingCall.value.callId === data.call_id) { + // 修复问题1&2: 不再严格检查ongoing状态,允许重新协商 + if (callStatus.value === "ongoing" && webrtc.isConnected.value) { + logWithTime("log", "通话已稳定连接,忽略重复的 Offer") + return + } + + // 允许在connecting状态下重新处理offer(重新协商) + if (callStatus.value === "connecting" || callStatus.value === "failed") { + logWithTime("log", "连接中或失败状态,允许重新处理 Offer") + } + + try { + const offer = JSON.parse(data.content || data.message_content || "{}") + + logWithTime("log", "🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯") + logWithTime("log", "📥 收到对方 OFFER 信令:") + logWithTime("log", "📋 Offer 类型:", offer.type) + logWithTime("log", "📋 Offer SDP (前200字符):", offer.sdp ? offer.sdp.substring(0, 200) + "..." : "无SDP") + logWithTime("log", "📋 通话ID:", data.call_id) + logWithTime("log", "📋 发送方:", data.sender_user_id) + logWithTime("log", "📋 当前通话状态:", callStatus.value) + logWithTime("log", "📋 WebRTC连接状态:", webrtc.peerConnection.value?.connectionState) + logWithTime("log", "🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯") + + // 修复问题3: 如果连接失败或不存在,重新创建 + if ( + !webrtc.peerConnection.value || + webrtc.peerConnection.value.connectionState === "failed" || + webrtc.peerConnection.value.connectionState === "closed" + ) { + logWithTime("log", "重新创建 WebRTC 连接以处理 Offer") + webrtc.createPeerConnection(data.call_id, data.sender_user_id) + } + + const answer = await webrtc.createAnswer(offer, data.call_id, data.sender_user_id) + + logWithTime("log", "🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯") + logWithTime("log", "📤 准备发送 ANSWER 信令:") + logWithTime("log", "📋 Answer 类型:", answer.type) + logWithTime("log", "📋 Answer SDP (前200字符):", answer.sdp ? answer.sdp.substring(0, 200) + "..." : "无SDP") + logWithTime("log", "📋 通话ID:", data.call_id) + logWithTime("log", "📋 接收方:", data.sender_user_id) + logWithTime("log", "🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯") + + await callAPI.sendAnswer(userStore.currentUser.id, data.sender_user_id, data.call_id, answer, data.message_type) + + // 更新状态为连接中 + if (callStatus.value !== "ongoing") { + callStatus.value = "connecting" + callConnectionStatus.value = "正在建立连接..." + } + + logWithTime("log", "✅ 发送Answer成功") + } catch (error) { + logWithTime("error", "❌ 处理Offer失败:", error) + + // 修复问题3: 处理失败时尝试重连而不是直接拒绝 + if (webrtc.connectionAttempts.value < 3) { + logWithTime("log", "🔄 Offer处理失败,尝试重连") + setTimeout(() => { + webrtc.attemptReconnection(data.call_id, data.sender_user_id) + }, 2000) + } else { + rejectCall("failed") + } + } + } + } + + // 修复问题2: 改进Answer处理,允许重新协商 + const handleAnswer = async (data) => { + if (currentCall.value && currentCall.value.callId === data.call_id) { + // 修复问题1&2: 不再严格检查ongoing状态,允许重新协商 + if (callStatus.value === "ongoing" && webrtc.isConnected.value) { + logWithTime("log", "通话已稳定连接,忽略重复的 Answer") + return + } + + // 允许在connecting状态下重新处理answer(重新协商) + if (callStatus.value === "connecting" || callStatus.value === "failed") { + logWithTime("log", "连接中或失败状态,允许重新处理 Answer") + } + + try { + const answer = JSON.parse(data.content || data.message_content || "{}") + + logWithTime("log", "🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯") + logWithTime("log", "📥 收到对方 ANSWER 信令:") + logWithTime("log", "📋 Answer 类型:", answer.type) + logWithTime("log", "📋 Answer SDP (前200字符):", answer.sdp ? answer.sdp.substring(0, 200) + "..." : "无SDP") + logWithTime("log", "📋 通话ID:", data.call_id) + logWithTime("log", "📋 发送方:", data.sender_user_id) + logWithTime("log", "📋 当前通话状态:", callStatus.value) + logWithTime("log", "📋 WebRTC连接状态:", webrtc.peerConnection.value?.connectionState) + logWithTime("log", "🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯") + + await webrtc.setRemoteAnswer(answer) + + callStatus.value = "connecting" + callConnectionStatus.value = "正在建立连接..." + + logWithTime("log", "✅ Answer处理成功,开始建立连接") + } catch (error) { + logWithTime("error", "❌ 处理Answer失败:", error) + + // 修复问题3: 处理失败时尝试重连而不是直接结束 + if (webrtc.connectionAttempts.value < 3) { + logWithTime("log", "🔄 Answer处理失败,尝试重连") + setTimeout(() => { + webrtc.attemptReconnection(data.call_id, currentCall.value.peerId) + }, 2000) + } else { + endCall("failed") + } + } + } + } + + // 处理连接中状态 + const handleCallConnecting = (data) => { + callConnectionStatus.value = "正在连接..." + if (currentCall.value?.callId === data.call_id) { + callStatus.value = "connecting" + } + } + + // 修复问题3: 改进通话失败处理,增加重连逻辑 + const handleCallFailed = (data) => { + if ( + (currentCall.value && currentCall.value.callId === data.call_id) || + (incomingCall.value && incomingCall.value.callId === data.call_id) + ) { + callError.value = "failed" + callConnectionStatus.value = "连接失败" + + const peerId = currentCall.value?.peerId || incomingCall.value?.callerId + const friend = friends.value.find((f) => f.id === peerId) + + // 修复问题3: 失败时尝试重连 + if (webrtc.connectionAttempts.value < 3) { + logWithTime("log", "🔄 通话失败,尝试重连") + callConnectionStatus.value = `连接失败,正在重试 (${webrtc.connectionAttempts.value + 1}/3)` + + setTimeout(() => { + webrtc.attemptReconnection(data.call_id, peerId) + }, 3000) + } else { + // 达到最大重试次数,记录失败消息 + if (friend) { + const callMessage = { + type: + currentCall.value?.callType === "video" || incomingCall.value?.callType === "video" + ? "video-call" + : "audio-call", + content: "[连接失败]", + time: new Date().toLocaleTimeString().slice(0, 5), + senderId: peerId, + read: true, + timestamp: Date.now(), + duration: 0, + } + chatDB.saveMessage(callMessage, userStore.currentUser.id, friend.id) + + friend.lastMessage = "[连接失败]" + chatDB.updateFriendLastMessage(friend.id, userStore.currentUser.id, "[连接失败]", friend.unreadCount) + } + + setTimeout(() => { + resetCallState() + }, 5000) + } + } + } + + // 处理来电 + const handleIncomingCall = (data) => { + if (isInCall.value) { + logWithTime("log", "当前正在通话中,发送忙线状态") + callAPI.sendBusy(userStore.currentUser.id, data.sender_user_id, data.call_id, data.message_type) + + const friend = friends.value.find((f) => f.id === data.sender_user_id) + if (friend) { + friend.lastMessage = "[未接来电]" + chatDB.updateFriendLastMessage(friend.id, userStore.currentUser.id, "[未接来电]", friend.unreadCount) + + const callMessage = { + type: data.message_type === 6 ? "video-call" : "audio-call", + content: "[未接来电]", + time: new Date().toLocaleTimeString().slice(0, 5), + senderId: data.sender_user_id, + read: true, + timestamp: Date.now(), + duration: 0, + } + chatDB.saveMessage(callMessage, userStore.currentUser.id, friend.id) + } + + return + } + + resetCallState() + + incomingCall.value = { + callId: data.call_id, + callerId: data.sender_user_id, + callType: data.message_type === 6 ? "video" : "audio", + content: data.content, + } + callStatus.value = "ringing" + callError.value = null + callConnectionStatus.value = "收到来电" + + logWithTime("log", "收到来电:", incomingCall.value) + + setCallTimeout(() => { + if (callStatus.value === "ringing" && incomingCall.value) { + logWithTime("log", "来电超时,自动拒绝") + rejectCall("no-answer") + } + }, 30000) + } + + // 修复问题2&3: 改进通话接受处理,增加重连机制 + const handleCallAccepted = async (data) => { + if (currentCall.value && currentCall.value.callId === data.call_id) { + // 修复问题2: 允许在连接失败后重新接受 + if (callStatus.value === "ongoing" && webrtc.isConnected.value) { + logWithTime("log", "通话已稳定连接,忽略重复的 accepted 信令") + return + } + + callStatus.value = "connecting" + callError.value = null + callConnectionStatus.value = "对方已接听,正在连接..." + logWithTime("log", "通话已被接受,开始交换信令") + + setTimeout(() => { + if (callStatus.value === "connecting") { + callConnectionStatus.value = "对方已接听,正在建立连接..." + } + }, 1000) + + try { + if (!webrtc.localStream.value) { + logWithTime("log", "获取本地媒体用于创建Offer") + await webrtc.getLocalMedia({ + audio: true, + video: currentCall.value.callType === "video", + }) + } + + // 修复问题3: 如果连接失败,重新创建连接 + if ( + !webrtc.peerConnection.value || + webrtc.peerConnection.value.connectionState === "failed" || + webrtc.peerConnection.value.connectionState === "closed" + ) { + logWithTime("log", "重新创建 WebRTC 连接") + webrtc.createPeerConnection(currentCall.value.callId, currentCall.value.peerId) + } + + const offer = await webrtc.createOffer(currentCall.value.callId, currentCall.value.peerId) + + logWithTime("log", "🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯") + logWithTime("log", "📤 准备发送 OFFER 信令:") + logWithTime("log", "📋 Offer 类型:", offer.type) + logWithTime("log", "📋 Offer SDP (前200字符):", offer.sdp ? offer.sdp.substring(0, 200) + "..." : "无SDP") + logWithTime("log", "📋 通话ID:", currentCall.value.callId) + logWithTime("log", "📋 接收方:", currentCall.value.peerId) + logWithTime("log", "📋 连接尝试次数:", webrtc.connectionAttempts.value) + logWithTime("log", "🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯🎯") + + await callAPI.sendOffer( + userStore.currentUser.id, + currentCall.value.peerId, + currentCall.value.callId, + offer, + currentCall.value.callType === "video" ? 6 : 7, + ) + + logWithTime("log", "✅ 发送Offer成功") + } catch (error) { + logWithTime("error", "❌ 创建Offer失败:", error) + + // 修复问题3: 创建失败时尝试重连 + if (webrtc.connectionAttempts.value < 3) { + logWithTime("log", "🔄 创建Offer失败,尝试重连") + setTimeout(() => { + webrtc.attemptReconnection(currentCall.value.callId, currentCall.value.peerId) + // 重连后重新尝试处理accepted信令 + setTimeout(() => { + handleCallAccepted(data) + }, 1000) + }, 2000) + } else { + endCall("failed") + } + } + } + } + + // 处理通话拒绝 + const handleCallRejected = (data) => { + if (currentCall.value && currentCall.value.callId === data.call_id) { + resetCallState() + callConnectionStatus.value = "对方已拒绝" + logWithTime("log", "通话已被拒绝") + + const friend = friends.value.find((f) => f.id === currentCall.value.peerId) + if (friend) { + const callMessage = { + type: currentCall.value.callType === "video" ? "video-call" : "audio-call", + content: "[对方已拒绝]", + time: new Date().toLocaleTimeString().slice(0, 5), + senderId: userStore.currentUser?.id, + read: true, + timestamp: Date.now(), + duration: 0, + } + chatDB.saveMessage(callMessage, userStore.currentUser.id, friend.id) + + friend.lastMessage = "[对方已拒绝]" + chatDB.updateFriendLastMessage(friend.id, userStore.currentUser.id, "[对方已拒绝]", friend.unreadCount) + } + + setTimeout(() => { + callConnectionStatus.value = "" + }, 5000) + } + } + + // 处理通话结束 + const handleCallEnded = (data) => { + if ( + (currentCall.value && currentCall.value.callId === data.call_id) || + (incomingCall.value && incomingCall.value.callId === data.call_id) + ) { + const peerId = currentCall.value?.peerId || incomingCall.value?.callerId + const friend = friends.value.find((f) => f.id === peerId) + if (friend) { + const duration = + callStatus.value === "ongoing" + ? Math.floor((Date.now() - (currentCall.value?.startTime || incomingCall.value?.startTime)) / 1000) + : 0 + + const callMessage = { + type: + currentCall.value?.callType === "video" || incomingCall.value?.callType === "video" + ? "video-call" + : "audio-call", + content: + callStatus.value === "ongoing" + ? `[${currentCall.value?.callType === "video" ? "视频" : "语音"}通话] ${Math.floor(duration / 60)}分${duration % 60}秒` + : "[通话结束]", + time: new Date().toLocaleTimeString().slice(0, 5), + senderId: peerId, + read: true, + timestamp: Date.now(), + duration: duration, + } + chatDB.saveMessage(callMessage, userStore.currentUser.id, friend.id) + + friend.lastMessage = callMessage.content + chatDB.updateFriendLastMessage(friend.id, userStore.currentUser.id, callMessage.content, friend.unreadCount) + } + + resetCallState() + callConnectionStatus.value = "通话已结束" + logWithTime("log", "通话已结束") + + setTimeout(() => { + callConnectionStatus.value = "" + }, 5000) + } + } + + // 处理ICE候选 + const handleIceCandidate = (data) => { + if ( + (currentCall.value && currentCall.value.callId === data.call_id) || + (incomingCall.value && incomingCall.value.callId === data.call_id) + ) { + try { + const candidate = JSON.parse(data.content || data.message_content || "{}") + logWithTime("log", "收到ICE候选:", candidate) + + webrtc.addIceCandidate(candidate) + } catch (error) { + logWithTime("error", "处理ICE候选失败:", error) + } + } + } + + // 处理无人接听 + const handleCallNoAnswer = (data) => { + if (currentCall.value && currentCall.value.callId === data.call_id) { + const friend = friends.value.find((f) => f.id === currentCall.value.peerId) + if (friend) { + const callMessage = { + type: currentCall.value.callType === "video" ? "video-call" : "audio-call", + content: "[未接来电]", + time: new Date().toLocaleTimeString().slice(0, 5), + senderId: userStore.currentUser?.id, + read: true, + timestamp: Date.now(), + duration: 0, + } + chatDB.saveMessage(callMessage, userStore.currentUser.id, friend.id) + + friend.lastMessage = "[未接来电]" + chatDB.updateFriendLastMessage(friend.id, userStore.currentUser.id, "[未接来电]", friend.unreadCount) + } + + resetCallState() + callConnectionStatus.value = "对方无人接听" + logWithTime("log", "对方无人接听") + + setTimeout(() => { + callConnectionStatus.value = "" + }, 5000) + } + } + + // 处理对方忙线 + const handleCallBusy = (data) => { + if (currentCall.value && currentCall.value.callId === data.call_id) { + const friend = friends.value.find((f) => f.id === currentCall.value.peerId) + if (friend) { + const callMessage = { + type: currentCall.value.callType === "video" ? "video-call" : "audio-call", + content: "[对方忙线]", + time: new Date().toLocaleTimeString().slice(0, 5), + senderId: userStore.currentUser?.id, + read: true, + timestamp: Date.now(), + duration: 0, + } + chatDB.saveMessage(callMessage, userStore.currentUser.id, friend.id) + + friend.lastMessage = "[对方忙线]" + chatDB.updateFriendLastMessage(friend.id, userStore.currentUser.id, "[对方忙线]", friend.unreadCount) + } + + resetCallState() + callConnectionStatus.value = "对方忙线中" + logWithTime("log", "对方忙线中") + + setTimeout(() => { + callConnectionStatus.value = "" + }, 5000) + } + } + + // 处理对方挂断 + const handleCallHangup = (data) => { + const callId = data?.call_id || currentCall.value?.callId || incomingCall.value?.callId + const peerId = data?.sender_user_id || currentCall.value?.peerId || incomingCall.value?.callerId + + if (callId && peerId) { + const friend = friends.value.find((f) => f.id === peerId) + if (friend) { + const duration = + callStatus.value === "ongoing" + ? Math.floor((Date.now() - (currentCall.value?.startTime || incomingCall.value?.startTime)) / 1000) + : 0 + + const callMessage = { + type: currentCall.value?.callType || incomingCall.value?.callType === "video" ? "video-call" : "audio-call", + content: duration > 0 ? `[对方已挂断] ${Math.floor(duration / 60)}分${duration % 60}秒` : "[对方已挂断]", + time: new Date().toLocaleTimeString().slice(0, 5), + senderId: peerId, + read: true, + timestamp: Date.now(), + duration: duration, + } + chatDB.saveMessage(callMessage, userStore.currentUser.id, friend.id) + + friend.lastMessage = callMessage.content + chatDB.updateFriendLastMessage(friend.id, userStore.currentUser.id, friend.lastMessage, friend.unreadCount) + } + + callStatus.value = "hangup" + callConnectionStatus.value = "对方已挂断" + logWithTime("log", "对方已挂断通话") + + setTimeout(() => { + resetCallState() + }, 3000) + } + } + + // 处理对方掉线 + const handleCallDisconnected = (data) => { + const callId = data?.call_id || currentCall.value?.callId || incomingCall.value?.callId + const peerId = data?.sender_user_id || currentCall.value?.peerId || incomingCall.value?.callerId + + if (callId && peerId) { + const friend = friends.value.find((f) => f.id === peerId) + if (friend) { + const duration = + callStatus.value === "ongoing" + ? Math.floor((Date.now() - (currentCall.value?.startTime || incomingCall.value?.startTime)) / 1000) + : 0 + + const callMessage = { + type: currentCall.value?.callType || incomingCall.value?.callType === "video" ? "video-call" : "audio-call", + content: duration > 0 ? `[网络中断] ${Math.floor(duration / 60)}分${duration % 60}秒` : "[网络中断]", + time: new Date().toLocaleTimeString().slice(0, 5), + senderId: peerId, + read: true, + timestamp: Date.now(), + duration: duration, + } + chatDB.saveMessage(callMessage, userStore.currentUser.id, friend.id) + + friend.lastMessage = callMessage.content + chatDB.updateFriendLastMessage(friend.id, userStore.currentUser.id, friend.lastMessage, friend.unreadCount) + } + + callStatus.value = "disconnected" + callConnectionStatus.value = "网络连接已断开" + logWithTime("log", "网络连接已断开") + + setTimeout(() => { + resetCallState() + }, 3000) + } + } + + // 处理对方终止呼叫 + const handleCallTerminated = (data) => { + const callId = data.call_id + const peerId = data.sender_user_id + + if ( + (currentCall.value && currentCall.value.callId === callId) || + (incomingCall.value && incomingCall.value.callId === callId) + ) { + const friend = friends.value.find((f) => f.id === peerId) + if (friend) { + const callMessage = { + type: currentCall.value?.callType || incomingCall.value?.callType === "video" ? "video-call" : "audio-call", + content: "[对方已终止呼叫]", + time: new Date().toLocaleTimeString().slice(0, 5), + senderId: peerId, + read: true, + timestamp: Date.now(), + duration: 0, + } + chatDB.saveMessage(callMessage, userStore.currentUser.id, friend.id) + + friend.lastMessage = "[对方已终止呼叫]" + chatDB.updateFriendLastMessage(friend.id, userStore.currentUser.id, "[对方已终止呼叫]", friend.unreadCount) + } + + resetCallState() + callConnectionStatus.value = "对方已终止呼叫" + logWithTime("log", "对方已终止呼叫") + + setTimeout(() => { + callConnectionStatus.value = "" + }, 5000) + } + } + + // 发起通话 + const startCall = async (peerId, type) => { + if (isInCall.value) { + alert("您正在通话中,请先结束当前通话。") + return + } + + const friend = friends.value.find((f) => f.id === peerId) + if (!friend) { + alert("好友信息无效") + return + } + + if (!userStore.currentUser?.id) { + alert("用户未登录,无法发起通话") + return + } + + resetCallState() + + const callId = Date.now().toString() + Math.random().toString(36).substr(2, 9) + + currentCall.value = { + callId: callId, + peerId: peerId, + callType: type, + startTime: Date.now(), + status: "calling", + } + + callStatus.value = "calling" + callConnectionStatus.value = "等待对方接听..." + + // 立即获取本地媒体并显示 + try { + logWithTime("log", "发起通话,立即获取本地媒体...") + await webrtc.getLocalMedia({ + audio: true, + video: type === "video", + }) + logWithTime("log", "本地媒体获取成功,可以显示自己的摄像头") + } catch (error) { + logWithTime("warn", "获取本地媒体失败,但继续通话流程:", error) + } + + // 设置30秒超时 + setCallTimeout(() => { + if (callStatus.value === "calling") { + resetCallState() + callConnectionStatus.value = "对方无人接听" + logWithTime("log", "呼叫超时,对方无人接听") + + callAPI.sendNoAnswer(userStore.currentUser.id, peerId, callId, type === "video" ? 6 : 7) + + friend.lastMessage = "[未接来电]" + chatDB.updateFriendLastMessage(friend.id, userStore.currentUser.id, "[未接来电]", friend.unreadCount) + + const callMessage = { + type: type === "video" ? "video-call" : "audio-call", + content: "[未接来电]", + time: new Date().toLocaleTimeString().slice(0, 5), + senderId: userStore.currentUser?.id, + read: true, + timestamp: Date.now(), + duration: 0, + } + chatDB.saveMessage(callMessage, userStore.currentUser.id, friend.id) + + setTimeout(() => { + callConnectionStatus.value = "" + }, 5000) + } + }, 30000) + + // 发送通话请求 + try { + logWithTime("log", "准备发送通话邀请:", { + senderUserId: userStore.currentUser.id, + receiverUserId: peerId, + callId: callId, + callType: type === "video" ? 6 : 7, + }) + + await callAPI.invite(userStore.currentUser.id, peerId, callId, type === "video" ? 6 : 7) + + logWithTime("log", "发起通话成功:", currentCall.value) + } catch (error) { + logWithTime("error", "发起通话失败:", error) + resetCallState() + + let errorMessage = "发起通话失败" + if (error.response?.status === 400) { + errorMessage = "请求参数错误,请检查网络连接" + } else if (error.response?.status === 500) { + errorMessage = "服务器错误,请稍后重试" + } else if (error.message) { + errorMessage = error.message + } + + alert(errorMessage) + } + } + + // 接听来电 + const acceptCall = async () => { + if (!incomingCall.value) { + logWithTime("log", "没有来电,无法接听") + return + } + + if (callStatus.value === "connecting" || callStatus.value === "ongoing") { + logWithTime("log", "通话已在进行中,忽略重复接听") + return + } + + clearCallTimeout() + + logWithTime("log", "开始接听通话:", incomingCall.value) + + callStatus.value = "connecting" + callConnectionStatus.value = "正在连接..." + + try { + // 获取本地媒体 + try { + await webrtc.getLocalMedia({ + audio: true, + video: incomingCall.value.callType === "video", + }) + logWithTime("log", "接听时获取本地媒体成功") + } catch (mediaError) { + logWithTime("warn", "接听时获取本地媒体失败,但继续通话流程:", mediaError) + } + + // 发送接受信号 + await callAPI.accept( + userStore.currentUser.id, + incomingCall.value.callerId, + incomingCall.value.callId, + incomingCall.value.callType === "video" ? 6 : 7, + ) + + currentCall.value = { + callId: incomingCall.value.callId, + peerId: incomingCall.value.callerId, + callType: incomingCall.value.callType, + startTime: Date.now(), + status: "connecting", + } + + logWithTime("log", "接听通话成功,等待信令交换...") + } catch (error) { + logWithTime("error", "接听通话失败:", error) + rejectCall("failed") + } + } + + // 拒绝来电 + const rejectCall = async (reason = "rejected") => { + if (!incomingCall.value) { + logWithTime("log", "没有来电,无法拒绝") + return + } + + if (callStatus.value === "connecting" || callStatus.value === "ongoing") { + logWithTime("log", "通话已在进行中,无法拒绝") + return + } + + logWithTime("log", "开始拒绝通话:", reason) + + try { + if (reason === "rejected") { + await callAPI.reject( + userStore.currentUser.id, + incomingCall.value.callerId, + incomingCall.value.callId, + incomingCall.value.callType === "video" ? 6 : 7, + ) + } else { + await callAPI.sendFailed( + userStore.currentUser.id, + incomingCall.value.callerId, + incomingCall.value.callId, + incomingCall.value.callType === "video" ? 6 : 7, + ) + } + + const friend = friends.value.find((f) => f.id === incomingCall.value.callerId) + if (friend) { + const content = reason === "busy" ? "[对方忙线]" : reason === "no-answer" ? "[未接来电]" : "[已拒绝]" + + const callMessage = { + type: incomingCall.value.callType === "video" ? "video-call" : "audio-call", + content: content, + time: new Date().toLocaleTimeString().slice(0, 5), + senderId: userStore.currentUser?.id, + read: true, + timestamp: Date.now(), + duration: 0, + } + chatDB.saveMessage(callMessage, userStore.currentUser.id, friend.id) + + friend.lastMessage = content + chatDB.updateFriendLastMessage(friend.id, userStore.currentUser.id, content, friend.unreadCount) + } + + resetCallState() + callConnectionStatus.value = reason === "busy" ? "对方忙线" : reason === "no-answer" ? "无人接听" : "已拒绝" + + setTimeout(() => { + callConnectionStatus.value = "" + }, 5000) + + logWithTime("log", "拒绝通话成功") + } catch (error) { + logWithTime("error", "拒绝通话失败:", error) + } + } + + // 结束通话 + const endCall = async (reason = "ended") => { + const callId = currentCall.value?.callId || incomingCall.value?.callId + const peerId = currentCall.value?.peerId || incomingCall.value?.callerId + const callTypeVal = currentCall.value?.callType || incomingCall.value?.callType + + if (!callId || !peerId) { + resetCallState() + return + } + + try { + await callAPI.end(userStore.currentUser.id, peerId, callId, callTypeVal === "video" ? 6 : 7) + + const friend = friends.value.find((f) => f.id === peerId) + if (friend) { + const duration = + callStatus.value === "ongoing" + ? Math.floor((Date.now() - (currentCall.value?.startTime || incomingCall.value?.startTime)) / 1000) + : 0 + + let content = "" + if (reason === "no-answer") { + content = "[未接来电]" + } else if (duration > 0) { + content = `[${callTypeVal === "video" ? "视频" : "语音"}通话] ${Math.floor(duration / 60)}分${duration % 60}秒` + } else { + content = "[通话结束]" + } + + const callMessage = { + type: callTypeVal === "video" ? "video-call" : "audio-call", + content: content, + time: new Date().toLocaleTimeString().slice(0, 5), + senderId: userStore.currentUser?.id, + read: true, + timestamp: Date.now(), + duration: duration, + } + chatDB.saveMessage(callMessage, userStore.currentUser.id, friend.id) + + friend.lastMessage = content + chatDB.updateFriendLastMessage(friend.id, userStore.currentUser.id, content, friend.unreadCount) + } + + resetCallState() + callConnectionStatus.value = reason === "no-answer" ? "对方无人接听" : "通话结束" + + setTimeout(() => { + callConnectionStatus.value = "" + }, 5000) + + logWithTime("log", "结束通话成功") + } catch (error) { + logWithTime("error", "结束通话失败:", error) + resetCallState() + } + } + + return { + friends, + currentFriend, + messages, + connectionStatus, + socket, + currentCall, + incomingCall, + callStatus, + callError, + callConnectionStatus, + isConnected, + isInCall, + isCalling, + isRinging, + isConnecting, + isOngoingCall, + isDisconnected, + isHangup, + initDB, + loadFriends, + setCurrentFriend, + switchFriend, + addMessage, + handleIncomingMessage, + handleCallSignal, + startCall, + acceptCall, + rejectCall, + endCall, + resetCallState, + sendCallSignal: sendCallSignalInternal, + clearCallTimeout, + } +}) diff --git a/apps/web-antd/src/views/business/chat/stores/theme.ts b/apps/web-antd/src/views/business/chat/stores/theme.ts new file mode 100644 index 00000000..97d11cb7 --- /dev/null +++ b/apps/web-antd/src/views/business/chat/stores/theme.ts @@ -0,0 +1,38 @@ +import {defineStore} from "pinia" +import {ref} from "vue" + +export const useThemeStore = defineStore("theme", () => { + const isDarkMode = ref(false) + + const toggleTheme = () => { + isDarkMode.value = !isDarkMode.value + localStorage.setItem("chatTheme", isDarkMode.value ? "dark" : "light") + updateTheme() + } + + const updateTheme = () => { + if (isDarkMode.value) { + document.documentElement.setAttribute("data-theme", "dark") + document.documentElement.classList.add("dark") + } else { + document.documentElement.removeAttribute("data-theme") + document.documentElement.classList.remove("dark") + } + } + + const loadTheme = () => { + const savedTheme = localStorage.getItem("chatTheme") + if (savedTheme) { + isDarkMode.value = savedTheme === "dark" + } else { + isDarkMode.value = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches + } + updateTheme() + } + + return { + isDarkMode, + toggleTheme, + loadTheme, + } +}) diff --git a/apps/web-antd/src/views/business/chat/stores/user.ts b/apps/web-antd/src/views/business/chat/stores/user.ts new file mode 100644 index 00000000..6c3e2ae7 --- /dev/null +++ b/apps/web-antd/src/views/business/chat/stores/user.ts @@ -0,0 +1,114 @@ +import { computed, ref } from 'vue'; + +import { defineStore } from 'pinia'; + +export const useUserStore = defineStore('user', () => { + const currentUser = ref(); + const isLoggedIn = computed(() => !!currentUser.value); + + // 预设用户数据 + const presetUsers = ref([ + { + id: '1', + room_id: '10001', + name: '张三', + avatar: 'avatar1', + color: '#4cc9f0', + }, + { + id: '2', + room_id: '10002', + name: '李四', + avatar: 'avatar2', + color: '#ff6b6b', + }, + { + id: '3', + room_id: '10003', + name: '王五', + avatar: 'avatar3', + color: '#6a0dad', + }, + { + id: '4', + room_id: '10004', + name: '赵六', + avatar: 'avatar4', + color: '#20b2aa', + }, + { + id: '5', + room_id: '10005', + name: '钱七', + avatar: 'avatar5', + color: '#ffa500', + }, + { + id: '6', + room_id: '10006', + name: '孙八', + avatar: 'avatar6', + color: '#9acd32', + }, + { + id: '7', + room_id: '10007', + name: '周九', + avatar: 'avatar7', + color: '#ff1493', + }, + { + id: '8', + room_id: '10008', + name: '吴十', + avatar: 'avatar8', + color: '#4682b4', + }, + { + id: '9', + room_id: '10009', + name: '郑十一', + avatar: 'avatar9', + color: '#c71585', + }, + { + id: '10', + room_id: '100010', + name: '王十二', + avatar: 'avatar10', + color: '#2e8b57', + }, + ]); + + const login = (user: any) => { + currentUser.value = user; + localStorage.setItem('chatUser', JSON.stringify(user)); + }; + + const logout = () => { + currentUser.value = null; + localStorage.removeItem('chatUser'); + localStorage.removeItem('token'); + }; + + const checkSession = () => { + const savedUser = localStorage.getItem('chatUser'); + if (savedUser) { + currentUser.value = JSON.parse(savedUser); + } + }; + + const setClientId = (clientId: number | string) => { + currentUser.value.clientId = clientId; + }; + + return { + currentUser, + isLoggedIn, + presetUsers, + login, + logout, + checkSession, + setClientId, + }; +}); diff --git a/apps/web-antd/src/views/business/chat/utils/db.ts b/apps/web-antd/src/views/business/chat/utils/db.ts new file mode 100644 index 00000000..78e62d22 --- /dev/null +++ b/apps/web-antd/src/views/business/chat/utils/db.ts @@ -0,0 +1,153 @@ +// IndexedDB 数据库工具类 +class ChatDB { + constructor() { + this.dbName = 'ChatApp'; + this.version = 1; + this.db = null; + } + + // 清空未读消息 + async clearUnreadCount(friendId) { + const transaction = this.db.transaction(['friends'], 'readwrite'); + const store = transaction.objectStore('friends'); + + const request = store.get(friendId); + + return new Promise((resolve, reject) => { + request.onsuccess = () => { + const friend = request.result; + if (friend) { + friend.unreadCount = 0; + store.put(friend); + } + resolve(); + }; + request.onerror = () => reject(request.error); + }); + } + + // 获取聊天记录 + async getChatHistory(currentUserId, friendId) { + const transaction = this.db.transaction(['messages'], 'readonly'); + const store = transaction.objectStore('messages'); + const index = store.index('chatKey'); + + const chatKey = `${currentUserId}_${friendId}`; + const request = index.getAll(chatKey); + + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result || []); + request.onerror = () => reject(request.error); + }); + } + + // 获取好友列表 + async getFriends(currentUserId) { + const transaction = this.db.transaction(['friends'], 'readonly'); + const store = transaction.objectStore('friends'); + const index = store.index('userId'); + + const request = index.getAll(currentUserId); + + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result || []); + request.onerror = () => reject(request.error); + }); + } + + async init() { + return new Promise((resolve, reject) => { + const request = indexedDB.open(this.dbName, this.version); + + request.onerror = () => reject(request.error); + request.onsuccess = () => { + this.db = request.result; + resolve(this.db); + }; + + request.onupgradeneeded = (event) => { + const db = event.target.result; + + // 创建聊天记录表 + if (!db.objectStoreNames.contains('messages')) { + const messageStore = db.createObjectStore('messages', { + keyPath: 'id', + autoIncrement: true, + }); + messageStore.createIndex('chatKey', 'chatKey', { unique: false }); + messageStore.createIndex('timestamp', 'timestamp', { unique: false }); + } + + // 创建用户表 + if (!db.objectStoreNames.contains('users')) { + const userStore = db.createObjectStore('users', { keyPath: 'id' }); + } + + // 创建好友表 + if (!db.objectStoreNames.contains('friends')) { + const friendStore = db.createObjectStore('friends', { + keyPath: 'id', + }); + friendStore.createIndex('userId', 'userId', { unique: false }); + } + }; + }); + } + + // 保存好友信息 + async saveFriend(friend, currentUserId) { + const transaction = this.db.transaction(['friends'], 'readwrite'); + const store = transaction.objectStore('friends'); + + const friendData = { + ...friend, + userId: currentUserId, + updatedAt: Date.now(), + }; + + return store.put(friendData); + } + + // 保存消息 + async saveMessage(message, currentUserId, friendId) { + const transaction = this.db.transaction(['messages'], 'readwrite'); + const store = transaction.objectStore('messages'); + + const messageData = { + ...message, + chatKey: `${currentUserId}_${friendId}`, + timestamp: Date.now(), + }; + + return store.add(messageData); + } + + // 更新好友最后消息 + async updateFriendLastMessage( + friendId, + currentUserId, + lastMessage, + unreadCount = 0, + ) { + const transaction = this.db.transaction(['friends'], 'readwrite'); + const store = transaction.objectStore('friends'); + + const request = store.get(friendId); + + return new Promise((resolve, reject) => { + request.onsuccess = () => { + const friend = request.result; + if (friend) { + friend.lastMessage = lastMessage; + friend.unreadCount = unreadCount; + friend.updatedAt = Date.now(); + store.put(friend); + } + resolve(); + }; + request.onerror = () => reject(request.error); + }); + } +} + +export const chatDB = new ChatDB(); diff --git a/apps/web-antd/src/views/business/chat/utils/request.ts b/apps/web-antd/src/views/business/chat/utils/request.ts new file mode 100644 index 00000000..5270d8d4 --- /dev/null +++ b/apps/web-antd/src/views/business/chat/utils/request.ts @@ -0,0 +1,360 @@ +import { message } from 'ant-design-vue'; +import axios from 'axios'; + +// 创建 Axios 实例 +const service = axios.create({ + baseURL: '/im-api/', + timeout: 15_000, +}); + +// 请求拦截器 +service.interceptors.request.use( + (config) => { + const token = localStorage.getItem('token'); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }, + (error) => { + console.error('Request error:', error); + return Promise.reject(error); + }, +); + +// 响应拦截器 +service.interceptors.response.use( + (response) => { + const { code, result, message: msg } = response.data; + if (code === 0) { + return result; + } else { + if (code === 401) { + message.error(msg); + localStorage.removeItem('token'); + localStorage.removeItem('chatUser'); + window.location.href = '/login'; + return Promise.reject(new Error(msg)); + } + message.error(msg); + return Promise.reject(new Error(msg)); + } + }, + (error) => { + console.error('Response error:', error); + + // 详细的错误处理 + if (error.code === 'ECONNABORTED') { + console.log('请求超时,请检查网络连接'); + } else if (error.response?.status === 0 || error.message.includes('CORS')) { + console.log('CORS错误检测到,尝试备用方案...'); + handleCORSError(error.config); + } else if (error.response?.status >= 400 && error.response?.status < 500) { + console.log('客户端错误:', error.response.status, error.response.data); + } else if (error.response?.status >= 500) { + console.log('服务器错误:', error.response.status); + } else { + console.log('网络错误:', error.message); + } + + message.error('网络请求失败'); + return Promise.reject(error); + }, +); + +// CORS错误的备用处理方案 +const handleCORSError = (config) => { + console.log('执行CORS错误备用方案...'); + const apiUrl = config.url.replace('/api', ''); + + return fetch(apiUrl, { + method: config.method.toUpperCase(), + headers: { + 'Content-Type': 'application/json', + ...config.headers, + }, + mode: 'cors', + body: config.data ? JSON.stringify(config.data) : undefined, + }) + .then((response) => { + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + return response.json(); + }) + .catch((error) => { + console.error('备用方案也失败:', error); + throw error; + }); +}; + +// 封装请求方法 +export const get = (url, params = {}) => { + return service.get(url, { params }); +}; + +export const post = (url, data = {}) => { + return service.post(url, data); +}; + +export const upload = (url, file) => { + const formData = new FormData(); + formData.append('file', file); + return service.post(url, formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); +}; + +// 发送普通消息API +export const sendMessage = (data) => { + const apiUrl = '/im-api/send-to-user'; + + // 消息类型映射 - 根据用户提供的完整映射更新 + const messageTypeMap = { + text: 0, + image: 1, + audio: 2, + video: 3, + prescription: 4, + 'medical-record': 5, + 'video-call': 6, + 'audio-call': 7, + file: 8, + }; + + const requestData = { + room_id: data.roomId, + sender_user_id: 'doctor-' + data.senderId.toString(), + receiver_user_id: data.receiverId.toString(), + message_type: messageTypeMap[data.type] || 0, + message_content: data.content || '', + // 添加来源标记 + isFromHttp: true, + }; + + // 如果包含通话信令,添加call_id和call_status + if (data.callId) { + requestData.call_id = data.callId; + } + if (data.callStatus) { + requestData.call_status = data.callStatus; + } + + return axios + .post(apiUrl, requestData, { + headers: { + 'Content-Type': 'application/json', + }, + timeout: 15_000, + withCredentials: false, + }) + .then((response) => { + console.log('消息发送成功:', response.data); + return response.data; + }) + .catch((error) => { + console.error('消息发送失败:', error); + throw error; + }); +}; + +// 专门的通话信令发送方法 +export const sendCallSignal = (signalData) => { + const apiUrl = '/api/send-to-user'; + + // 验证必要参数 + if (!signalData.sender_user_id) { + throw new Error('缺少发送方用户ID'); + } + if (!signalData.receiver_user_id) { + throw new Error('缺少接收方用户ID'); + } + if (!signalData.call_id) { + throw new Error('缺少通话ID'); + } + if (!signalData.call_status) { + throw new Error('缺少通话状态'); + } + + // 构建通话信令请求数据 + const requestData = { + sender_user_id: signalData.sender_user_id, + receiver_user_id: signalData.receiver_user_id, + message_type: signalData.message_type || 6, // 默认视频通话 + message_content: + signalData.message_content || JSON.stringify(signalData.data || {}), + call_id: signalData.call_id, + call_status: signalData.call_status, + // 添加来源标记 + isFromHttp: true, + }; + + console.log('发送通话信令到API:', apiUrl, requestData); + + return axios + .post(apiUrl, requestData, { + headers: { + 'Content-Type': 'application/json', + }, + timeout: 15_000, + withCredentials: false, + }) + .then((response) => { + console.log('通话信令发送成功:', response.data); + return response.data; + }) + .catch((error) => { + console.error('通话信令发送失败:', error); + + // 提供更详细的错误信息 + if (error.response) { + console.error('响应错误:', error.response.data); + console.error('响应状态:', error.response.status); + console.error('响应头:', error.response.headers); + } else if (error.request) { + console.error('请求错误:', error.request); + } else { + console.error('配置错误:', error.message); + } + + throw error; + }); +}; + +// 通话相关的便捷方法 +export const callAPI = { + // 发起通话邀请 + invite: (senderUserId, receiverUserId, callId, callType = 6) => { + return sendCallSignal({ + sender_user_id: senderUserId, + receiver_user_id: receiverUserId, + call_id: callId, + call_status: 'invite', + message_type: callType, // 6=视频, 7=语音 + }); + }, + + // 接受通话 + accept: (senderUserId, receiverUserId, callId, callType = 6) => { + return sendCallSignal({ + sender_user_id: senderUserId, + receiver_user_id: receiverUserId, + call_id: callId, + call_status: 'accepted', + message_type: callType, + }); + }, + + // 拒绝通话 + reject: (senderUserId, receiverUserId, callId, callType = 6) => { + return sendCallSignal({ + sender_user_id: senderUserId, + receiver_user_id: receiverUserId, + call_id: callId, + call_status: 'rejected', + message_type: callType, + }); + }, + + // 结束通话 + end: (senderUserId, receiverUserId, callId, callType = 6) => { + return sendCallSignal({ + sender_user_id: senderUserId, + receiver_user_id: receiverUserId, + call_id: callId, + call_status: 'ended', + message_type: callType, + }); + }, + + // 发送WebRTC Offer + sendOffer: ( + senderUserId, + receiverUserId, + callId, + offerData, + callType = 6, + ) => { + return sendCallSignal({ + sender_user_id: senderUserId, + receiver_user_id: receiverUserId, + call_id: callId, + call_status: 'offer', + message_type: callType, + data: offerData, + }); + }, + + // 发送WebRTC Answer + sendAnswer: ( + senderUserId, + receiverUserId, + callId, + answerData, + callType = 6, + ) => { + return sendCallSignal({ + sender_user_id: senderUserId, + receiver_user_id: receiverUserId, + call_id: callId, + call_status: 'answer', + message_type: callType, + data: answerData, + }); + }, + + // 发送ICE候选 + sendCandidate: ( + senderUserId, + receiverUserId, + callId, + candidateData, + callType = 6, + ) => { + return sendCallSignal({ + sender_user_id: senderUserId, + receiver_user_id: receiverUserId, + call_id: callId, + call_status: 'candidate', + message_type: callType, + data: candidateData, + }); + }, + + // 发送忙线状态 + sendBusy: (senderUserId, receiverUserId, callId, callType = 6) => { + return sendCallSignal({ + sender_user_id: senderUserId, + receiver_user_id: receiverUserId, + call_id: callId, + call_status: 'busy', + message_type: callType, + }); + }, + + // 发送无人接听 + sendNoAnswer: (senderUserId, receiverUserId, callId, callType = 6) => { + return sendCallSignal({ + sender_user_id: senderUserId, + receiver_user_id: receiverUserId, + call_id: callId, + call_status: 'no-answer', + message_type: callType, + }); + }, + + // 发送通话失败 + sendFailed: (senderUserId, receiverUserId, callId, callType = 6) => { + return sendCallSignal({ + sender_user_id: senderUserId, + receiver_user_id: receiverUserId, + call_id: callId, + call_status: 'failed', + message_type: callType, + }); + }, +}; + +export default service; diff --git a/apps/web-antd/src/views/doctor/doctor/components/modal.vue b/apps/web-antd/src/views/doctor/doctor/components/modal.vue index 24a3205e..5be64cbc 100644 --- a/apps/web-antd/src/views/doctor/doctor/components/modal.vue +++ b/apps/web-antd/src/views/doctor/doctor/components/modal.vue @@ -51,42 +51,42 @@ const [Modal, modalApi] = useVbenModal({ console.log(values, 'ssssssssssss'); if (values) { isUpdate.value = update; - // getPharmacistEditInfoApi(values.id).then((res) => { - // formApi.setValues(res); - // }); - // formApi.setValues(values); + getPharmacistEditInfoApi(values.id).then((res) => { + formApi.setValues(res); + }); + formApi.setValues(values); - const img = 'https://img2.baidu.com/it/u=3046024791,55863177&fm=253&fmt=auto&app=138&f=JPEG'; - formApi.setValues({ - name: '测试药师', - mobile: '15110000000', - idcard: '134111000022225555', - type: 1, - sign_type: 2, - qualification: img, - practicing: img, - title: img, - card_up: img, - card_down: img, - sign_image: 'http://demo-1.nailaoyun.cn/p/image/qm1.png', - avatar: img, - }); + // const img = 'https://img2.baidu.com/it/u=3046024791,55863177&fm=253&fmt=auto&app=138&f=JPEG'; + // formApi.setValues({ + // name: '测试药师', + // mobile: '15110000000', + // idcard: '134111000022225555', + // type: 1, + // sign_type: 2, + // qualification: img, + // practicing: img, + // title: img, + // card_up: img, + // card_down: img, + // sign_image: 'http://demo-1.nailaoyun.cn/p/image/qm1.png', + // avatar: img, + // }); } else { - const img = 'https://img2.baidu.com/it/u=3046024791,55863177&fm=253&fmt=auto&app=138&f=JPEG'; - formApi.setValues({ - name: '测试药师', - mobile: '15110000000', - idcard: '134111000022225555', - type: 1, - sign_type: 2, - qualification: img, - practicing: img, - title: img, - card_up: img, - card_down: img, - sign_image: 'http://demo-1.nailaoyun.cn/p/image/qm1.png', - avatar: img, - }); + // const img = 'https://img2.baidu.com/it/u=3046024791,55863177&fm=253&fmt=auto&app=138&f=JPEG'; + // formApi.setValues({ + // name: '测试药师', + // mobile: '15110000000', + // idcard: '134111000022225555', + // type: 1, + // sign_type: 2, + // qualification: img, + // practicing: img, + // title: img, + // card_up: img, + // card_down: img, + // sign_image: 'http://demo-1.nailaoyun.cn/p/image/qm1.png', + // avatar: img, + // }); } } diff --git a/apps/web-antd/vite.config.mts b/apps/web-antd/vite.config.mts index c2b9eb12..dbd8ac8c 100644 --- a/apps/web-antd/vite.config.mts +++ b/apps/web-antd/vite.config.mts @@ -15,6 +15,15 @@ export default defineConfig(async () => { // target: 'http://api.xiaokang88.com/api/admin/', ws: true, }, + '/im-api': { + changeOrigin: true, + rewrite: (path) => path.replace(/^\/im-api/, ''), + // mock代理目标地址 + // target: 'https://api.xiaokang88.com/api/admin/', + target: 'http://localhost:12080/api/', + // target: 'http://api.xiaokang88.com/api/admin/', + ws: true, + }, }, }, }, diff --git a/package.json b/package.json index 0299297a..3696b0cf 100644 --- a/package.json +++ b/package.json @@ -111,7 +111,9 @@ ] }, "dependencies": { + "@fortawesome/fontawesome-free": "^6.7.2", "@vueup/vue-quill": "^1.2.0", + "axios": "^1.10.0", "html2canvas": "^1.4.1", "js-base64": "^3.7.7", "lodash-es": "^4.17.21", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d66251da..7aed9145 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,51 +12,387 @@ catalogs: '@changesets/cli': specifier: ^2.27.11 version: 2.27.11 + '@changesets/git': + specifier: ^3.0.2 + version: 3.0.2 + '@clack/prompts': + specifier: ^0.9.0 + version: 0.9.0 + '@commitlint/cli': + specifier: ^19.6.1 + version: 19.6.1 + '@commitlint/config-conventional': + specifier: ^19.6.0 + version: 19.6.0 + '@eslint/js': + specifier: ^9.17.0 + version: 9.17.0 + '@iconify/json': + specifier: ^2.2.286 + version: 2.2.286 + '@iconify/tailwind': + specifier: ^1.2.0 + version: 1.2.0 + '@iconify/vue': + specifier: ^4.2.0 + version: 4.2.0 + '@intlify/core-base': + specifier: ^10.0.5 + version: 10.0.5 + '@intlify/unplugin-vue-i18n': + specifier: ^6.0.1 + version: 6.0.1 + '@jspm/generator': + specifier: ^2.4.1 + version: 2.4.1 + '@manypkg/get-packages': + specifier: ^2.2.2 + version: 2.2.2 '@playwright/test': specifier: ^1.49.1 version: 1.49.1 + '@pnpm/workspace.read-manifest': + specifier: ^1000.0.1 + version: 1000.0.1 + '@stylistic/stylelint-plugin': + specifier: ^3.1.1 + version: 3.1.1 + '@tailwindcss/nesting': + specifier: 0.0.0-insiders.565cd3e + version: 0.0.0-insiders.565cd3e + '@tailwindcss/typography': + specifier: ^0.5.15 + version: 0.5.15 + '@tanstack/vue-query': + specifier: ^5.62.8 + version: 5.62.8 + '@tanstack/vue-store': + specifier: ^0.6.0 + version: 0.6.0 + '@types/archiver': + specifier: ^6.0.3 + version: 6.0.3 + '@types/eslint': + specifier: ^9.6.1 + version: 9.6.1 + '@types/html-minifier-terser': + specifier: ^7.0.2 + version: 7.0.2 + '@types/lodash.clonedeep': + specifier: ^4.5.9 + version: 4.5.9 + '@types/lodash.get': + specifier: ^4.4.9 + version: 4.4.9 + '@types/lodash.isequal': + specifier: ^4.5.8 + version: 4.5.8 '@types/node': specifier: ^22.10.2 version: 22.10.2 + '@types/nprogress': + specifier: ^0.2.3 + version: 0.2.3 + '@types/postcss-import': + specifier: ^14.0.3 + version: 14.0.3 + '@types/qrcode': + specifier: ^1.5.5 + version: 1.5.5 + '@types/sortablejs': + specifier: ^1.15.8 + version: 1.15.8 + '@typescript-eslint/eslint-plugin': + specifier: ^8.18.1 + version: 8.18.1 + '@typescript-eslint/parser': + specifier: ^8.18.1 + version: 8.18.1 + '@vee-validate/zod': + specifier: ^4.14.7 + version: 4.14.7 '@vitejs/plugin-vue': specifier: ^5.2.1 version: 5.2.1 '@vitejs/plugin-vue-jsx': specifier: ^4.1.1 version: 4.1.1 + '@vue/shared': + specifier: ^3.5.13 + version: 3.5.13 '@vue/test-utils': specifier: ^2.4.6 version: 2.4.6 + '@vueuse/core': + specifier: ^12.0.0 + version: 12.0.0 + '@vueuse/integrations': + specifier: ^12.0.0 + version: 12.0.0 + ant-design-vue: + specifier: ^4.2.6 + version: 4.2.6 + archiver: + specifier: ^7.0.1 + version: 7.0.1 autoprefixer: specifier: ^10.4.20 version: 10.4.20 + axios: + specifier: ^1.7.9 + version: 1.7.9 + axios-mock-adapter: + specifier: ^2.1.0 + version: 2.1.0 + cac: + specifier: ^6.7.14 + version: 6.7.14 + chalk: + specifier: ^5.4.0 + version: 5.4.0 + cheerio: + specifier: 1.0.0 + version: 1.0.0 + circular-dependency-scanner: + specifier: ^2.3.0 + version: 2.3.0 + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + commitlint-plugin-function-rules: + specifier: ^4.0.1 + version: 4.0.1 + consola: + specifier: ^3.3.0 + version: 3.3.0 cross-env: specifier: ^7.0.3 version: 7.0.3 cspell: specifier: ^8.17.1 version: 8.17.1 + cssnano: + specifier: ^7.0.6 + version: 7.0.6 + cz-git: + specifier: ^1.11.0 + version: 1.11.0 + czg: + specifier: ^1.11.0 + version: 1.11.0 + dayjs: + specifier: ^1.11.13 + version: 1.11.13 + defu: + specifier: ^6.1.4 + version: 6.1.4 + depcheck: + specifier: ^1.4.7 + version: 1.4.7 + dotenv: + specifier: ^16.4.7 + version: 16.4.7 + echarts: + specifier: ^5.5.1 + version: 5.5.1 + eslint: + specifier: ^9.17.0 + version: 9.17.0 + eslint-config-turbo: + specifier: ^2.3.3 + version: 2.3.3 + eslint-plugin-command: + specifier: ^0.2.7 + version: 0.2.7 + eslint-plugin-eslint-comments: + specifier: ^3.2.0 + version: 3.2.0 + eslint-plugin-import-x: + specifier: ^4.6.1 + version: 4.6.1 + eslint-plugin-jsdoc: + specifier: ^50.6.1 + version: 50.6.1 + eslint-plugin-jsonc: + specifier: ^2.18.2 + version: 2.18.2 + eslint-plugin-n: + specifier: ^17.15.1 + version: 17.15.1 + eslint-plugin-no-only-tests: + specifier: ^3.3.0 + version: 3.3.0 + eslint-plugin-perfectionist: + specifier: ^3.9.1 + version: 3.9.1 + eslint-plugin-prettier: + specifier: ^5.2.1 + version: 5.2.1 + eslint-plugin-regexp: + specifier: ^2.7.0 + version: 2.7.0 + eslint-plugin-unicorn: + specifier: ^56.0.1 + version: 56.0.1 + eslint-plugin-unused-imports: + specifier: ^4.1.4 + version: 4.1.4 + eslint-plugin-vitest: + specifier: ^0.5.4 + version: 0.5.4 + eslint-plugin-vue: + specifier: ^9.32.0 + version: 9.32.0 + execa: + specifier: ^9.5.2 + version: 9.5.2 + find-up: + specifier: ^7.0.0 + version: 7.0.0 + get-port: + specifier: ^7.1.0 + version: 7.1.0 + globals: + specifier: ^15.14.0 + version: 15.14.0 happy-dom: specifier: ^15.11.7 version: 15.11.7 + html-minifier-terser: + specifier: ^7.2.0 + version: 7.2.0 husky: specifier: ^9.1.7 version: 9.1.7 is-ci: specifier: ^4.1.0 version: 4.1.0 + jsonc-eslint-parser: + specifier: ^2.4.0 + version: 2.4.0 lint-staged: specifier: ^15.2.11 version: 15.2.11 + lodash.clonedeep: + specifier: ^4.5.0 + version: 4.5.0 + lodash.get: + specifier: ^4.4.2 + version: 4.4.2 + lodash.isequal: + specifier: ^4.5.0 + version: 4.5.0 + lucide-vue-next: + specifier: ^0.469.0 + version: 0.469.0 + nitropack: + specifier: ^2.10.4 + version: 2.10.4 + nprogress: + specifier: ^0.2.0 + version: 0.2.0 + ora: + specifier: ^8.1.1 + version: 8.1.1 + pinia-plugin-persistedstate: + specifier: ^4.2.0 + version: 4.2.0 + pkg-types: + specifier: ^1.2.1 + version: 1.2.1 playwright: specifier: ^1.49.1 version: 1.49.1 + postcss: + specifier: ^8.4.49 + version: 8.4.49 + postcss-antd-fixes: + specifier: ^0.2.0 + version: 0.2.0 + postcss-html: + specifier: ^1.7.0 + version: 1.7.0 + postcss-import: + specifier: ^16.1.0 + version: 16.1.0 + postcss-preset-env: + specifier: ^10.1.2 + version: 10.1.2 + postcss-scss: + specifier: ^4.0.9 + version: 4.0.9 + prettier: + specifier: ^3.4.2 + version: 3.4.2 + prettier-plugin-tailwindcss: + specifier: ^0.6.9 + version: 0.6.9 + publint: + specifier: ^0.2.12 + version: 0.2.12 + qrcode: + specifier: ^1.5.4 + version: 1.5.4 + radix-vue: + specifier: ^1.9.11 + version: 1.9.11 + resolve.exports: + specifier: ^2.0.3 + version: 2.0.3 rimraf: specifier: ^6.0.1 version: 6.0.1 + rollup: + specifier: ^4.28.1 + version: 4.28.1 + rollup-plugin-visualizer: + specifier: ^5.12.0 + version: 5.12.0 + sass: + specifier: 1.80.6 + version: 1.80.6 + sortablejs: + specifier: ^1.15.6 + version: 1.15.6 + stylelint: + specifier: ^16.12.0 + version: 16.12.0 + stylelint-config-recess-order: + specifier: ^5.1.1 + version: 5.1.1 + stylelint-config-recommended: + specifier: ^14.0.1 + version: 14.0.1 + stylelint-config-recommended-scss: + specifier: ^14.1.0 + version: 14.1.0 + stylelint-config-recommended-vue: + specifier: ^1.5.0 + version: 1.5.0 + stylelint-config-standard: + specifier: ^36.0.1 + version: 36.0.1 + stylelint-order: + specifier: ^6.0.4 + version: 6.0.4 + stylelint-prettier: + specifier: ^5.0.2 + version: 5.0.2 + stylelint-scss: + specifier: ^6.10.0 + version: 6.10.0 + tailwind-merge: + specifier: ^2.5.5 + version: 2.5.5 tailwindcss: specifier: ^3.4.17 version: 3.4.17 + tailwindcss-animate: + specifier: ^1.0.7 + version: 1.0.7 + theme-colors: + specifier: ^0.1.0 + version: 0.1.0 turbo: specifier: ^2.3.3 version: 2.3.3 @@ -66,15 +402,60 @@ catalogs: unbuild: specifier: ^3.0.1 version: 3.0.1 + vee-validate: + specifier: ^4.14.7 + version: 4.14.7 vite: specifier: ^6.0.5 version: 6.0.5 + vite-plugin-compression: + specifier: ^0.5.1 + version: 0.5.1 + vite-plugin-dts: + specifier: 4.2.1 + version: 4.2.1 + vite-plugin-html: + specifier: ^3.2.2 + version: 3.2.2 + vite-plugin-lazy-import: + specifier: ^1.0.7 + version: 1.0.7 + vite-plugin-pwa: + specifier: ^0.21.1 + version: 0.21.1 + vite-plugin-vue-devtools: + specifier: ^7.6.8 + version: 7.6.8 vitest: specifier: ^2.1.8 version: 2.1.8 + vue-eslint-parser: + specifier: ^9.4.3 + version: 9.4.3 + vue-i18n: + specifier: ^10.0.5 + version: 10.0.5 + vue-router: + specifier: ^4.5.0 + version: 4.5.0 vue-tsc: specifier: ^2.1.10 version: 2.1.10 + vxe-pc-ui: + specifier: ^4.3.40 + version: 4.3.40 + vxe-table: + specifier: ^4.9.33 + version: 4.9.33 + watermark-js-plus: + specifier: ^1.5.7 + version: 1.5.7 + zod: + specifier: ^3.24.1 + version: 3.24.1 + zod-defaults: + specifier: ^0.1.3 + version: 0.1.3 overrides: '@ast-grep/napi': ^0.31.1 @@ -88,9 +469,15 @@ importers: .: dependencies: + '@fortawesome/fontawesome-free': + specifier: ^6.7.2 + version: 6.7.2 '@vueup/vue-quill': specifier: ^1.2.0 version: 1.2.0(vue@3.5.13(typescript@5.6.3)) + axios: + specifier: ^1.10.0 + version: 1.10.0 html2canvas: specifier: ^1.4.1 version: 1.4.1 @@ -196,10 +583,10 @@ importers: version: 3.0.1(typescript@5.6.3)(vue-tsc@2.1.10(typescript@5.6.3))(vue@3.5.13(typescript@5.6.3)) vite: specifier: 'catalog:' - version: 6.0.5(@types/node@22.10.2)(jiti@2.4.2)(less@4.2.1)(terser@5.37.0)(yaml@2.6.1) + version: 6.0.5(@types/node@22.10.2)(jiti@2.4.2)(less@4.2.1)(sass@1.80.6)(terser@5.37.0)(yaml@2.6.1) vitest: specifier: 'catalog:' - version: 2.1.8(@types/node@22.10.2)(happy-dom@15.11.7)(less@4.2.1)(terser@5.37.0) + version: 2.1.8(@types/node@22.10.2)(happy-dom@15.11.7)(less@4.2.1)(sass@1.80.6)(terser@5.37.0) vue: specifier: ^3.5.13 version: 3.5.13(typescript@5.6.3) @@ -930,7 +1317,7 @@ importers: version: 12.0.0(typescript@5.7.2) '@vueuse/integrations': specifier: 'catalog:' - version: 12.0.0(async-validator@4.2.5)(axios@1.7.9)(change-case@5.4.4)(focus-trap@7.6.2)(nprogress@0.2.0)(qrcode@1.5.4)(sortablejs@1.15.6)(typescript@5.7.2) + version: 12.0.0(async-validator@4.2.5)(axios@1.10.0)(change-case@5.4.4)(focus-trap@7.6.2)(nprogress@0.2.0)(qrcode@1.5.4)(sortablejs@1.15.6)(typescript@5.7.2) qrcode: specifier: 'catalog:' version: 1.5.4 @@ -2804,6 +3191,10 @@ packages: '@floating-ui/vue@1.1.5': resolution: {integrity: sha512-ynL1p5Z+woPVSwgMGqeDrx6HrJfGIDzFyESFkyqJKilGW1+h/8yVY29Khn0LaU6wHBRwZ13ntG6reiHWK6jyzw==} + '@fortawesome/fontawesome-free@6.7.2': + resolution: {integrity: sha512-JUOtgFW6k9u4Y+xeIaEiLr3+cjoUPiAuLXoyKOJSia6Duzb7pq+A76P9ZdPDoAoxHdHzq6gE9/jKBGXlZT8FbA==} + engines: {node: '>=6'} + '@gar/promisify@1.1.3': resolution: {integrity: sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==} @@ -2867,20 +3258,20 @@ packages: resolution: {integrity: sha512-6GT1BJ852gZ0gItNZN2krX5QAmea+cmdjMvsWohArAZ3GmHdnNANEcF9JjPXAMRtQ6Ux5E269ymamg/+WU6tQA==} engines: {node: '>= 16'} - '@intlify/message-compiler@12.0.0-alpha.2': - resolution: {integrity: sha512-PD9C+oQbb7BF52hec0+vLnScaFkvnfX+R7zSbODYuRo/E2niAtGmHd0wPvEMsDhf9Z9b8f/qyDsVeZnD/ya9Ug==} + '@intlify/message-compiler@12.0.0-alpha.3': + resolution: {integrity: sha512-mDDTN3gfYOHhBnpnlby19UHyvMaOnzdlpsIrxUfs44R/vCATfn8pMOkE8PXD2t410xkocEj3FpDcC9XC/0v4Dg==} engines: {node: '>= 16'} '@intlify/shared@10.0.5': resolution: {integrity: sha512-bmsP4L2HqBF6i6uaMqJMcFBONVjKt+siGluRq4Ca4C0q7W2eMaVZr8iCgF9dKbcVXutftkC7D6z2SaSMmLiDyA==} engines: {node: '>= 16'} - '@intlify/shared@11.1.3': - resolution: {integrity: sha512-pTFBgqa/99JRA2H1qfyqv97MKWJrYngXBA/I0elZcYxvJgcCw3mApAoPW3mJ7vx3j+Ti0FyKUFZ4hWxdjKaxvA==} + '@intlify/shared@11.1.10': + resolution: {integrity: sha512-6ZW/f3Zzjxfa1Wh0tYQI5pLKUtU+SY7l70pEG+0yd0zjcsYcK0EBt6Fz30Dy0tZhEqemziQQy2aNU3GJzyrMUA==} engines: {node: '>= 16'} - '@intlify/shared@12.0.0-alpha.2': - resolution: {integrity: sha512-P2DULVX9nz3y8zKNqLw9Es1aAgQ1JGC+kgpx5q7yLmrnAKkPR5MybQWoEhxanefNJgUY5ehsgo+GKif59SrncA==} + '@intlify/shared@12.0.0-alpha.3': + resolution: {integrity: sha512-ryaNYBvxQjyJUmVuBBg+HHUsmGnfxcEUPR0NCeG4/K9N2qtyFE35C80S15IN6iYFE2MGWLN7HfOSyg0MXZIc9w==} engines: {node: '>= 16'} '@intlify/unplugin-vue-i18n@6.0.1': @@ -4137,6 +4528,9 @@ packages: peerDependencies: axios: '>= 0.17.0' + axios@1.10.0: + resolution: {integrity: sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==} + axios@1.7.9: resolution: {integrity: sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==} @@ -9505,7 +9899,7 @@ snapshots: '@babel/traverse': 7.26.4 '@babel/types': 7.26.3 convert-source-map: 2.0.0 - debug: 4.4.0 + debug: 4.4.0(supports-color@9.4.0) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -10166,7 +10560,7 @@ snapshots: '@babel/parser': 7.26.3 '@babel/template': 7.25.9 '@babel/types': 7.26.3 - debug: 4.4.0 + debug: 4.4.0(supports-color@9.4.0) globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -11302,6 +11696,8 @@ snapshots: - '@vue/composition-api' - vue + '@fortawesome/fontawesome-free@6.7.2': {} + '@gar/promisify@1.1.3': {} '@humanfs/core@0.19.1': {} @@ -11343,8 +11739,8 @@ snapshots: '@intlify/bundle-utils@10.0.0(vue-i18n@10.0.5(vue@3.5.13(typescript@5.7.2)))': dependencies: - '@intlify/message-compiler': 12.0.0-alpha.2 - '@intlify/shared': 12.0.0-alpha.2 + '@intlify/message-compiler': 12.0.0-alpha.3 + '@intlify/shared': 12.0.0-alpha.3 acorn: 8.14.0 escodegen: 2.1.0 estree-walker: 2.0.2 @@ -11365,23 +11761,23 @@ snapshots: '@intlify/shared': 10.0.5 source-map-js: 1.2.1 - '@intlify/message-compiler@12.0.0-alpha.2': + '@intlify/message-compiler@12.0.0-alpha.3': dependencies: - '@intlify/shared': 12.0.0-alpha.2 + '@intlify/shared': 12.0.0-alpha.3 source-map-js: 1.2.1 '@intlify/shared@10.0.5': {} - '@intlify/shared@11.1.3': {} + '@intlify/shared@11.1.10': {} - '@intlify/shared@12.0.0-alpha.2': {} + '@intlify/shared@12.0.0-alpha.3': {} '@intlify/unplugin-vue-i18n@6.0.1(@vue/compiler-dom@3.5.13)(eslint@9.17.0(jiti@2.4.2))(rollup@4.28.1)(typescript@5.7.2)(vue-i18n@10.0.5(vue@3.5.13(typescript@5.7.2)))(vue@3.5.13(typescript@5.7.2))': dependencies: '@eslint-community/eslint-utils': 4.4.1(eslint@9.17.0(jiti@2.4.2)) '@intlify/bundle-utils': 10.0.0(vue-i18n@10.0.5(vue@3.5.13(typescript@5.7.2))) - '@intlify/shared': 11.1.3 - '@intlify/vue-i18n-extensions': 7.0.0(@intlify/shared@11.1.3)(@vue/compiler-dom@3.5.13)(vue-i18n@10.0.5(vue@3.5.13(typescript@5.7.2)))(vue@3.5.13(typescript@5.7.2)) + '@intlify/shared': 11.1.10 + '@intlify/vue-i18n-extensions': 7.0.0(@intlify/shared@11.1.10)(@vue/compiler-dom@3.5.13)(vue-i18n@10.0.5(vue@3.5.13(typescript@5.7.2)))(vue@3.5.13(typescript@5.7.2)) '@rollup/pluginutils': 5.1.4(rollup@4.28.1) '@typescript-eslint/scope-manager': 8.18.1 '@typescript-eslint/typescript-estree': 8.18.1(typescript@5.7.2) @@ -11403,11 +11799,11 @@ snapshots: - supports-color - typescript - '@intlify/vue-i18n-extensions@7.0.0(@intlify/shared@11.1.3)(@vue/compiler-dom@3.5.13)(vue-i18n@10.0.5(vue@3.5.13(typescript@5.7.2)))(vue@3.5.13(typescript@5.7.2))': + '@intlify/vue-i18n-extensions@7.0.0(@intlify/shared@11.1.10)(@vue/compiler-dom@3.5.13)(vue-i18n@10.0.5(vue@3.5.13(typescript@5.7.2)))(vue@3.5.13(typescript@5.7.2))': dependencies: '@babel/parser': 7.26.3 optionalDependencies: - '@intlify/shared': 11.1.3 + '@intlify/shared': 11.1.10 '@vue/compiler-dom': 3.5.13 vue: 3.5.13(typescript@5.7.2) vue-i18n: 10.0.5(vue@3.5.13(typescript@5.7.2)) @@ -12391,7 +12787,7 @@ snapshots: '@babel/core': 7.26.0 '@babel/plugin-transform-typescript': 7.26.3(@babel/core@7.26.0) '@vue/babel-plugin-jsx': 1.2.5(@babel/core@7.26.0) - vite: 6.0.5(@types/node@22.10.2)(jiti@2.4.2)(less@4.2.1)(terser@5.37.0)(yaml@2.6.1) + vite: 6.0.5(@types/node@22.10.2)(jiti@2.4.2)(less@4.2.1)(sass@1.80.6)(terser@5.37.0)(yaml@2.6.1) vue: 3.5.13(typescript@5.6.3) transitivePeerDependencies: - supports-color @@ -12403,7 +12799,7 @@ snapshots: '@vitejs/plugin-vue@5.2.1(vite@6.0.5(@types/node@22.10.2)(jiti@2.4.2)(less@4.2.1)(terser@5.37.0)(yaml@2.6.1))(vue@3.5.13(typescript@5.6.3))': dependencies: - vite: 6.0.5(@types/node@22.10.2)(jiti@2.4.2)(less@4.2.1)(terser@5.37.0)(yaml@2.6.1) + vite: 6.0.5(@types/node@22.10.2)(jiti@2.4.2)(less@4.2.1)(sass@1.80.6)(terser@5.37.0)(yaml@2.6.1) vue: 3.5.13(typescript@5.6.3) '@vitest/expect@2.1.8': @@ -12420,15 +12816,6 @@ snapshots: magic-string: 0.30.17 optionalDependencies: vite: 5.4.11(@types/node@22.10.2)(less@4.2.1)(sass@1.80.6)(terser@5.37.0) - optional: true - - '@vitest/mocker@2.1.8(vite@5.4.11(@types/node@22.10.2)(less@4.2.1)(terser@5.37.0))': - dependencies: - '@vitest/spy': 2.1.8 - estree-walker: 3.0.3 - magic-string: 0.30.17 - optionalDependencies: - vite: 5.4.11(@types/node@22.10.2)(less@4.2.1)(terser@5.37.0) '@vitest/pretty-format@2.1.8': dependencies: @@ -12650,14 +13037,14 @@ snapshots: transitivePeerDependencies: - typescript - '@vueuse/integrations@12.0.0(async-validator@4.2.5)(axios@1.7.9)(change-case@5.4.4)(focus-trap@7.6.2)(nprogress@0.2.0)(qrcode@1.5.4)(sortablejs@1.15.6)(typescript@5.7.2)': + '@vueuse/integrations@12.0.0(async-validator@4.2.5)(axios@1.10.0)(change-case@5.4.4)(focus-trap@7.6.2)(nprogress@0.2.0)(qrcode@1.5.4)(sortablejs@1.15.6)(typescript@5.7.2)': dependencies: '@vueuse/core': 12.0.0(typescript@5.7.2) '@vueuse/shared': 12.0.0(typescript@5.7.2) vue: 3.5.13(typescript@5.7.2) optionalDependencies: async-validator: 4.2.5 - axios: 1.7.9 + axios: 1.10.0 change-case: 5.4.4 focus-trap: 7.6.2 nprogress: 0.2.0 @@ -12918,6 +13305,14 @@ snapshots: fast-deep-equal: 3.1.3 is-buffer: 2.0.5 + axios@1.10.0: + dependencies: + follow-redirects: 1.15.9 + form-data: 4.0.1 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + axios@1.7.9: dependencies: follow-redirects: 1.15.9 @@ -13694,10 +14089,6 @@ snapshots: dependencies: ms: 2.1.3 - debug@4.4.0: - dependencies: - ms: 2.1.3 - debug@4.4.0(supports-color@9.4.0): dependencies: ms: 2.1.3 @@ -15375,7 +15766,7 @@ snapshots: dependencies: chalk: 5.3.0 commander: 12.1.0 - debug: 4.4.0 + debug: 4.4.0(supports-color@9.4.0) execa: 8.0.1 lilconfig: 3.1.3 listr2: 8.2.5 @@ -18124,25 +18515,6 @@ snapshots: - sugarss - supports-color - terser - optional: true - - vite-node@2.1.8(@types/node@22.10.2)(less@4.2.1)(terser@5.37.0): - dependencies: - cac: 6.7.14 - debug: 4.4.0 - es-module-lexer: 1.5.4 - pathe: 1.1.2 - vite: 5.4.11(@types/node@22.10.2)(less@4.2.1)(terser@5.37.0) - transitivePeerDependencies: - - '@types/node' - - less - - lightningcss - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser vite-plugin-compression@0.5.1(vite@6.0.5(@types/node@22.10.2)(jiti@2.4.2)(less@4.2.1)(sass@1.80.6)(terser@5.37.0)(yaml@2.6.1)): dependencies: @@ -18264,18 +18636,6 @@ snapshots: less: 4.2.1 sass: 1.80.6 terser: 5.37.0 - optional: true - - vite@5.4.11(@types/node@22.10.2)(less@4.2.1)(terser@5.37.0): - dependencies: - esbuild: 0.24.0 - postcss: 8.4.49 - rollup: 4.28.1 - optionalDependencies: - '@types/node': 22.10.2 - fsevents: 2.3.3 - less: 4.2.1 - terser: 5.37.0 vite@6.0.5(@types/node@22.10.2)(jiti@2.4.2)(less@4.2.1)(sass@1.80.6)(terser@5.37.0)(yaml@2.6.1): dependencies: @@ -18291,19 +18651,6 @@ snapshots: terser: 5.37.0 yaml: 2.6.1 - vite@6.0.5(@types/node@22.10.2)(jiti@2.4.2)(less@4.2.1)(terser@5.37.0)(yaml@2.6.1): - dependencies: - esbuild: 0.24.0 - postcss: 8.4.49 - rollup: 4.28.1 - optionalDependencies: - '@types/node': 22.10.2 - fsevents: 2.3.3 - jiti: 2.4.2 - less: 4.2.1 - terser: 5.37.0 - yaml: 2.6.1 - vitest@2.1.8(@types/node@22.10.2)(happy-dom@15.11.7)(less@4.2.1)(sass@1.80.6)(terser@5.37.0): dependencies: '@vitest/expect': 2.1.8 @@ -18339,43 +18686,6 @@ snapshots: - sugarss - supports-color - terser - optional: true - - vitest@2.1.8(@types/node@22.10.2)(happy-dom@15.11.7)(less@4.2.1)(terser@5.37.0): - dependencies: - '@vitest/expect': 2.1.8 - '@vitest/mocker': 2.1.8(vite@5.4.11(@types/node@22.10.2)(less@4.2.1)(terser@5.37.0)) - '@vitest/pretty-format': 2.1.8 - '@vitest/runner': 2.1.8 - '@vitest/snapshot': 2.1.8 - '@vitest/spy': 2.1.8 - '@vitest/utils': 2.1.8 - chai: 5.1.2 - debug: 4.4.0 - expect-type: 1.1.0 - magic-string: 0.30.17 - pathe: 1.1.2 - std-env: 3.8.0 - tinybench: 2.9.0 - tinyexec: 0.3.1 - tinypool: 1.0.2 - tinyrainbow: 1.2.0 - vite: 5.4.11(@types/node@22.10.2)(less@4.2.1)(terser@5.37.0) - vite-node: 2.1.8(@types/node@22.10.2)(less@4.2.1)(terser@5.37.0) - why-is-node-running: 2.3.0 - optionalDependencies: - '@types/node': 22.10.2 - happy-dom: 15.11.7 - transitivePeerDependencies: - - less - - lightningcss - - msw - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser vscode-languageserver-textdocument@1.0.12: {}
{{ callType === 'video' ? '视频通话' : '语音通话' }}
+ + + {{ isFriendOnline ? '在线' : '离线' }} + + + {{ callStatusText }} + +
+ 从左侧选择一个联系人开始对话,享受愉快的聊天体验 +
ID: {{ friend.id }}
{{ group.memberCount }} 人
{{ group.description }}
开始与 {{ chatStore.currentFriend.nick_name }} 对话吧!
{{ moment.time }}
{{ moment.content }}
版本 1.0.0
基于 Vue 3 + WebSocket 构建的现代化聊天应用
ID: {{ userInfo.id }}