diff --git a/src/App.vue b/src/App.vue index 461be06..7b7b685 100644 --- a/src/App.vue +++ b/src/App.vue @@ -78,6 +78,10 @@ onMounted(() => { window.addEventListener('resize', handleResize) // 确保监听启动 groupWebRTC.initListener() + // 1v1 通话信令必须在全局注册:CallWindow/来电逻辑都挂在 App 层, + // 原来绑在 ChatView 生命周期上,用户离开 /chat 路由后 ChatView 卸载就收不到来电、 + // 通话中导航离开会丢失对方的 hangup/candidate/answer(表现为"通话卡死""打不进来") + wsManager.onSignal(webrtcStore.webrtc.handleSignaling) // 注册朋友圈通知处理器 wsManager.onMomentNotification(handleMomentNotification) // 检查后端健康状态 @@ -86,6 +90,7 @@ onMounted(() => { onUnmounted(() => { window.removeEventListener('resize', handleResize) + wsManager.offSignal(webrtcStore.webrtc.handleSignaling) // 移除朋友圈通知处理器 wsManager.offMomentNotification(handleMomentNotification) }) diff --git a/src/api/modules/ai.ts b/src/api/modules/ai.ts new file mode 100644 index 0000000..8222ffe --- /dev/null +++ b/src/api/modules/ai.ts @@ -0,0 +1,65 @@ +/** + * AI 机器人相关 API(PC 侧) + * 配置读写/测试/机器人增删改仅管理员(id=1)可调用,列表所有登录用户可见 + */ +import request from '../request' + +/** AI 提供商配置(api_key 为脱敏值) */ +export interface AIConfig { + provider: string + base_url: string + api_key: string + model: string + enabled: boolean + /** 后端是否已保存过密钥(脱敏值无法判断) */ + has_key?: boolean +} + +/** AI 机器人 */ +export interface AIBot { + id: number + /** 关联的虚拟用户ID(bot_ 前缀) */ + user_id: string + name: string + avatar: string + /** 角色设定(system 提示词) */ + role_prompt: string + enabled: boolean + created_at?: string + updated_at?: string +} + +// 获取全局 AI 配置(api_key 脱敏返回) +export function getAIConfig() { + return request.get('/ai/config') +} + +// 保存全局 AI 配置(api_key 传空或脱敏值表示不修改密钥) +export function saveAIConfig(data: Partial) { + return request.post('/ai/config', data) +} + +// 测试 AI 配置连通性(发一条问候语,返回模型回复) +export function testAIConfig() { + return request.post<{ reply: string }>('/ai/config/test') +} + +// 机器人列表(all=1 时管理员可见停用中的机器人) +export function getBots(all = false) { + return request.get('/ai/bots', all ? { params: { all: '1' } } : undefined) +} + +// 创建机器人 +export function createBot(data: { name: string; avatar?: string; role_prompt?: string }) { + return request.post('/ai/bots', data) +} + +// 更新机器人(名称/头像/角色设定/启停) +export function updateBot(id: number, data: { name: string; avatar?: string; role_prompt?: string; enabled?: boolean }) { + return request.post(`/ai/bots/update/${id}`, data) +} + +// 删除机器人(连带删除虚拟用户与群成员关系) +export function deleteBot(id: number) { + return request.post(`/ai/bots/delete/${id}`) +} diff --git a/src/api/modules/auth.ts b/src/api/modules/auth.ts index 05534f3..1bb1066 100644 --- a/src/api/modules/auth.ts +++ b/src/api/modules/auth.ts @@ -30,3 +30,12 @@ export function checkToken() { return request.get('/check-token') } +// 登出(通知服务端使 token 失效/更新在线状态) +// 显式携带 token:登出流程随后会同步清空 localStorage,而 axios 拦截器在微任务中才读取 +// localStorage,届时 token 已被清掉,请求会以未认证状态发出而 401 +export function logout(token: string) { + return request.post('/logout', undefined, { + headers: { Authorization: `Bearer ${token}` }, + } as any) +} + diff --git a/src/api/modules/call.ts b/src/api/modules/call.ts index 35b1e8b..e505b76 100644 --- a/src/api/modules/call.ts +++ b/src/api/modules/call.ts @@ -2,10 +2,98 @@ import request from '../request' import type { ChatMessage } from '@/types/api' /** - * 通话记录相关 API + * 通话相关 API + * 说明:此前本文件只有 getCallHistory,但 useWebRTC 里引用了 + * joinCallRoom/leaveCallRoom/JoinCallRoomResponse,缺失会导致 vue-tsc 构建失败, + * 且运行时"加入/离开通话房间"整链路静默失效(FLV 拉流、Web↔小程序互通全废)。 + * 下列类型与函数严格对照后端 internal/api/call_handler.go 的请求/响应结构。 */ +/** ICE 服务器配置 */ +export interface ICEServerConfig { + urls: string[] + username?: string + credential?: string +} + +/** 参与者信息 */ +export interface ParticipantInfo { + user_id: string + platform: string + has_audio: boolean + has_video: boolean +} + +/** 拉流地址信息 */ +export interface PullURLInfo { + user_id: string + url: string + flv_url?: string +} + +/** 加入通话房间响应(对应后端 JoinCallRoomResponse) */ +export interface JoinCallRoomResponse { + room_id: string + platform: string + ice_servers?: ICEServerConfig[] // H5/App 用 (WebRTC 模式) + ws_push_url?: string // H5/App 用 (RTMP 模式 WebSocket 推流地址) + self_flv_url?: string // H5/App 自己的 HTTP-FLV 地址(供小程序拉流) + flv_pull_urls?: PullURLInfo[] // H5/App 拉取小程序流的 FLV 地址 + push_url?: string // 小程序用 RTMP 推流地址 + flv_url?: string // 小程序用自己的 HTTP-FLV 地址 + pull_urls?: PullURLInfo[] // 小程序用 RTMP 拉流地址 + participants: ParticipantInfo[] +} + +/** 通话房间信息响应 */ +export interface RoomInfoResponse { + room_id: string + call_type: string + is_group_call: boolean + participant_count: number + participants: ParticipantInfo[] +} + /** 获取通话记录(基于 call_id 消息) */ export function getCallHistory() { return request.get('/calls/history') } + +/** 创建通话房间 */ +export function createCallRoom(data: { + room_id: string + call_type: 'audio' | 'video' + is_group_call?: boolean +}) { + return request.post('/call/room', data) +} + +/** 获取通话房间信息 */ +export function getCallRoom(roomId: string): Promise { + return request.get(`/call/room/${roomId}`) +} + +/** + * 加入通话房间 + * 后端会忽略 user_id 以 JWT 为准,但请求体仍需带上以通过 binding:"required" 校验 + */ +export function joinCallRoom(data: { + room_id: string + user_id: string + platform: 'h5' | 'web' | 'app' | 'miniprogram' | 'wxapp' +}): Promise { + return request.post('/call/join', data) +} + +/** 离开通话房间 */ +export function leaveCallRoom(data: { + room_id: string + user_id: string +}) { + return request.post('/call/leave', data) +} + +/** 获取 ICE 服务器配置(TURN 凭证按 JWT 身份签发) */ +export function getICEServers(): Promise<{ ice_servers: ICEServerConfig[] }> { + return request.get('/call/ice-servers') +} diff --git a/src/api/modules/message.ts b/src/api/modules/message.ts index 6ac426c..6bfcc1e 100644 --- a/src/api/modules/message.ts +++ b/src/api/modules/message.ts @@ -29,6 +29,11 @@ export function recallMessage(messageId: number) { return request.post('/messages/recall', { message_id: messageId }) } +// 删除消息("对我删除":服务端记录删除标记,历史拉取时过滤,仅影响自己) +export function deleteMessage(messageId: number) { + return request.post('/messages/delete', { message_id: messageId }) +} + // 标记消息已读 export function markMessagesRead(messageIds: number[]) { return request.post('/messages/read-receipts', { message_ids: messageIds }) diff --git a/src/api/modules/qrcode.ts b/src/api/modules/qrcode.ts new file mode 100644 index 0000000..5907ad4 --- /dev/null +++ b/src/api/modules/qrcode.ts @@ -0,0 +1,38 @@ +/** + * 扫码登录相关 API(PC 侧) + * PC 未登录时调用:生成二维码 → 轮询状态 → confirmed 后拿 token 直接登录 + */ +import request from '../request' +import type { User } from '@/types/api' + +/** 生成二维码响应 */ +export interface QRCodeGenerateResponse { + qr_id: string + /** 服务端生成的二维码 PNG dataURL,前端直接塞进 ,无需引二维码库 */ + qr_image: string + /** 有效秒数(超时后轮询会返回 expired) */ + expires_in: number +} + +/** 轮询状态响应:confirmed 时附带一次性 token 与用户信息 */ +export interface QRCodeStatusResponse { + status: 'pending' | 'scanned' | 'confirmed' | 'cancelled' | 'expired' + token?: string + user?: User +} + +// 生成扫码登录二维码 +export function generateLoginQRCode() { + return request.post('/qrcode/generate') +} + +// 长轮询二维码状态:携带 known(客户端已知状态)时服务端 hold 至状态变化,最长 25 秒。 +// timeout 放宽到 35 秒覆盖 hold 时长;signal 用于停止轮询时取消在途请求, +// 失败由调用方静默重试,不弹错误提示 +export function getQRCodeStatus(qrId: string, known: string, signal?: AbortSignal) { + return request.get('/qrcode/status', { + params: { qr_id: qrId, known }, + timeout: 35000, + signal, + } as any) +} diff --git a/src/api/request/index.ts b/src/api/request/index.ts index 7ed0b52..ffabc16 100644 --- a/src/api/request/index.ts +++ b/src/api/request/index.ts @@ -25,18 +25,13 @@ const axiosInstance: AxiosInstance = axios.create({ // 请求拦截器 axiosInstance.interceptors.request.use( (config) => { - // 从localStorage获取token + // 从localStorage获取token,统一注入 Authorization 头 + // 说明:后端已强制从 JWT 解析用户身份,X-User-ID 头不再被读取,故不再发送 const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } - // 从localStorage获取user_id,用于需要X-User-ID header的接口 - const userId = localStorage.getItem('user_id') - if (userId) { - config.headers['X-User-ID'] = userId - } - return config }, (error) => { @@ -44,6 +39,32 @@ axiosInstance.interceptors.request.use( } ) +// 401 兜底处理:会话过期时统一登出并跳登录页。 +// 用 debounce 标记 + 懒加载 store/router,避免并发 401 反复跳转、以及与 auth/router 形成循环依赖 +let handlingUnauthorized = false +async function handleUnauthorized() { + if (handlingUnauthorized) return + handlingUnauthorized = true + try { + const [authModule, routerModule] = await Promise.all([ + import('@/stores/auth'), + import('@/router'), + ]) + try { + authModule.useAuthStore().logout() + } catch (e) { + console.warn('handleUnauthorized: logout 失败', e) + } + const router = routerModule.default + if (router.currentRoute.value.path !== '/login') { + router.push('/login').catch(() => {}) + } + } finally { + // 留一点时间让路由跳转完成,避免这段时间内的并发 401 又触发一次 + setTimeout(() => { handlingUnauthorized = false }, 1500) + } +} + // 响应拦截器 axiosInstance.interceptors.response.use( (response: AxiosResponse) => { @@ -52,6 +73,11 @@ axiosInstance.interceptors.response.use( // 统一响应格式处理 // 后端所有接口返回HTTP 200,错误通过code字段标识 if (res.code !== 0) { + // 兼容部分接口在 200 体里返回 code=401 的情况 + if (res.code === 401) { + handleUnauthorized() + return Promise.reject(new Error(res.message || '登录已过期,请重新登录')) + } // 错误处理 console.error('API Error:', res.message) return Promise.reject(new Error(res.message || '请求失败')) @@ -61,6 +87,11 @@ axiosInstance.interceptors.response.use( return res.result as any }, (error) => { + // 鉴权中间件返回 HTTP 401(携带统一响应体):会走到这里,触发登出兜底。 + // 原来注释里说"由 401 拦截器兜底",但实际并没有实现,导致会话过期后所有请求静默失败、用户被困在界面里 + if (error?.response?.status === 401) { + handleUnauthorized() + } console.error('Request Error:', error) return Promise.reject(error) } diff --git a/src/api/websocket/index.ts b/src/api/websocket/index.ts index e10d73a..9ae1df3 100644 --- a/src/api/websocket/index.ts +++ b/src/api/websocket/index.ts @@ -1,5 +1,7 @@ import type { ChatMessage } from '@/types/api' import type { MomentNotifPayload } from '@/types/moment' +import { MessageType } from '@/types/message' +import { storage } from '@/utils/storage' export interface WebSocketMessage { request_type?: string @@ -12,6 +14,15 @@ export type SignalHandler = (message: ChatMessage) => void export type MomentNotifHandler = (payload: MomentNotifPayload) => void export type ReadReceiptHandler = (data: { message_ids?: number[]; room_id?: string }) => void +/** + * WebSocketManager + * 功能:全局 WebSocket 连接管理器(单例) + * 职责: + * 1. 建立/断开连接(握手用 JWT token 鉴权,服务端以 token 解析身份) + * 2. 消息分发(普通消息 / WebRTC 信令 / 朋友圈通知 / 已读回执) + * 3. 按消息指纹去重(防止服务端重发/集群双推产生重复气泡) + * 4. 异常断线按指数退避自动重连;主动断开(登出)不重连 + */ class WebSocketManager { private ws: WebSocket | null = null private clientId: string | null = null @@ -21,30 +32,48 @@ class WebSocketManager { private momentNotifHandlers: MomentNotifHandler[] = [] private readReceiptHandlers: ReadReceiptHandler[] = [] private reconnectAttempts = 0 - private maxReconnectAttempts = 5 - private reconnectDelay = 3000 + private maxReconnectAttempts = 10 + // 重连采用指数退避:baseDelay * 2^n,封顶 maxReconnectDelay, + // 避免服务端故障期间所有客户端以固定短间隔发起重连风暴 + private baseReconnectDelay = 1000 + private maxReconnectDelay = 30000 + // 是否主动断开(登出场景):主动断开后不允许自动重连 + private intentionalClose = false // 用于通知去重的缓存(存储最近处理过的通知ID) private recentMomentNotifs: Set = new Set() private notifCacheTimeout = 5000 // 5秒内相同通知视为重复 + // 最近处理过的消息指纹(id+类型+内容摘要),用于普通消息去重; + // 撤回会以同一 id 但不同内容重推,因此指纹不能只用 id + private recentMessageKeys: Set = new Set() + private readonly maxRecentMessageKeys = 500 /** * 获取 WebSocket URL(支持动态配置) + * 鉴权说明:握手必须携带 JWT token(?token=),服务端以 token 解析用户身份, + * 原先的 user_id 查询参数已不被服务端信任(可伪造冒充任意人),不再传递 */ - private getWebSocketUrl(userId: string): string { + private getWebSocketUrl(): string { + const token = storage.getToken() || '' + const query = `?token=${encodeURIComponent(token)}` + // 优先使用环境变量配置 const envWsUrl = import.meta.env.VITE_WS_URL if (envWsUrl) { - return `${envWsUrl}?user_id=${userId}` + return `${envWsUrl}${query}` } // 自动根据当前页面协议和 host 构建 WebSocket URL const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' const host = window.location.host - return `${protocol}//${host}/ws?user_id=${userId}` + return `${protocol}//${host}/ws${query}` } /** * 连接WebSocket + * 为什么等 clientId 到达才 resolve: + * 服务端连接成功后会立刻下发一条 {clientId} 消息,调用方(绑定客户端、 + * 发消息时的 sender_client_id)都依赖它;原实现 onopen 即 resolve, + * 调用方马上读 clientId 拿到 null,导致绑定被跳过、回声去重失效 */ connect(userId: string): Promise { return new Promise((resolve, reject) => { @@ -54,30 +83,47 @@ class WebSocketManager { } this.userId = userId - const wsUrl = this.getWebSocketUrl(userId) + this.intentionalClose = false + const wsUrl = this.getWebSocketUrl() try { this.ws = new WebSocket(wsUrl) + // resolve/reject 只允许触发一次 + let settled = false + let clientIdTimer: number | null = null + const settleResolve = () => { + if (clientIdTimer !== null) { + window.clearTimeout(clientIdTimer) + clientIdTimer = null + } + if (!settled) { + settled = true + resolve() + } + } + this.ws.onopen = () => { console.log('✅ WebSocket connected') this.reconnectAttempts = 0 - resolve() + // 兜底:连上后 3 秒内未收到 clientId 也放行,避免服务端异常时 Promise 永久挂起 + clientIdTimer = window.setTimeout(settleResolve, 3000) } this.ws.onmessage = (event) => { try { // 处理可能的多行JSON或消息分割 const data = event.data.toString().trim() - const lines = data.split('\n').filter(line => line.trim()) + const lines = data.split('\n').filter((line: string) => line.trim()) for (const line of lines) { try { const payload: WebSocketMessage = JSON.parse(line) - // 接收clientId + // 接收clientId:到达后才认为连接就绪 if (payload.clientId) { this.clientId = payload.clientId + settleResolve() } // 处理接收消息 @@ -94,8 +140,14 @@ class WebSocketManager { if (payload.request_type === 'moment_notification' && payload.data) { this.handleMomentNotification(payload.data as MomentNotifPayload) } + + // 处理服务端错误推送(消息被拒收/禁言/会话不合法等): + // 不提示的话发送方完全无感知,气泡显示已发送但对方永远收不到 + if (payload.request_type === 'error' && payload.data) { + this.handleServerError(payload.data as { message?: string; code?: string }) + } } catch (e) { - // Ignore single line parse error + // 单行解析失败忽略,不影响其他行 } } } catch (error) { @@ -105,13 +157,23 @@ class WebSocketManager { this.ws.onerror = (error) => { console.error('WebSocket error:', error) - reject(error) + if (clientIdTimer !== null) { + window.clearTimeout(clientIdTimer) + clientIdTimer = null + } + if (!settled) { + settled = true + reject(error) + } } this.ws.onclose = () => { console.log('WebSocket closed') this.ws = null - this.attemptReconnect() + // 主动断开(登出)不重连;异常断开按指数退避自动重连 + if (!this.intentionalClose) { + this.attemptReconnect() + } } } catch (error) { reject(error) @@ -120,18 +182,24 @@ class WebSocketManager { } /** - * 断开连接 + * 断开连接(登出等主动场景) + * 注意:不清空各业务 handler 数组—— + * 组件/Store 各自在挂载与卸载时对称地注册和移除监听(如 App.vue 只在应用 + * 启动时注册一次朋友圈通知监听),这里若强行清空,重新登录后(App 不会重新 + * 挂载)监听就永久丢失了,表现为"重登后收不到任何推送,刷新页面才恢复" */ disconnect() { + // 标记为主动断开,阻止 onclose 里的自动重连把连接又拉起来 + this.intentionalClose = true if (this.ws) { this.ws.close() this.ws = null } this.clientId = null this.userId = null - this.messageHandlers = [] - this.signalHandlers = [] - this.momentNotifHandlers = [] + // 清空消息去重缓存,避免换账号后误吞相同ID的消息 + this.recentMessageKeys.clear() + this.recentMomentNotifs.clear() } /** @@ -180,7 +248,7 @@ class WebSocketManager { onMessage(handler: MessageHandler) { // 防止重复注册 if (!this.messageHandlers.includes(handler)) { - this.messageHandlers.push(handler) + this.messageHandlers.push(handler) } } @@ -200,7 +268,7 @@ class WebSocketManager { onSignal(handler: SignalHandler) { // 防止重复注册 if (!this.signalHandlers.includes(handler)) { - this.signalHandlers.push(handler) + this.signalHandlers.push(handler) } } @@ -220,26 +288,10 @@ class WebSocketManager { onMomentNotification(handler: MomentNotifHandler) { // 防止重复注册 if (!this.momentNotifHandlers.includes(handler)) { - this.momentNotifHandlers.push(handler) + this.momentNotifHandlers.push(handler) } } - /** - * 添加已读回执处理器 - */ - onReadReceipt(handler: ReadReceiptHandler) { - if (!this.readReceiptHandlers.includes(handler)) { - this.readReceiptHandlers.push(handler) - } - } - - /** - * 内部处理已读回执 - */ - private handleReadReceipt(data: { message_ids?: number[]; room_id?: string }) { - this.readReceiptHandlers.forEach(handler => handler(data)) - } - /** * 移除朋友圈通知处理器 */ @@ -250,27 +302,67 @@ class WebSocketManager { } } + /** + * 添加已读回执处理器 + */ + onReadReceipt(handler: ReadReceiptHandler) { + if (!this.readReceiptHandlers.includes(handler)) { + this.readReceiptHandlers.push(handler) + } + } + + /** + * 移除已读回执处理器(与 onReadReceipt 对称,供组件卸载时清理) + */ + offReadReceipt(handler: ReadReceiptHandler) { + const index = this.readReceiptHandlers.indexOf(handler) + if (index > -1) { + this.readReceiptHandlers.splice(index, 1) + } + } + + /** + * 内部处理已读回执 + */ + private handleReadReceipt(data: { message_ids?: number[]; room_id?: string }) { + this.readReceiptHandlers.forEach(handler => handler(data)) + } + + /** + * 内部处理服务端错误推送(消息被拒收/禁言/会话不合法等) + * 用动态 import 取 toastStore:wsManager 是模块级单例,模块加载时 pinia + * 可能尚未装载,运行期再取才安全 + */ + private handleServerError(data: { message?: string; code?: string }) { + console.warn('⚠️ 服务端错误推送:', data) + import('@/stores/toast') + .then(({ useToastStore }) => { + useToastStore().error(data.message || '操作失败') + }) + .catch(() => {}) + } + /** * 内部处理朋友圈通知(带去重) */ private handleMomentNotification(payload: MomentNotifPayload) { // 生成通知唯一标识(基于 moment_id + type + from_user_id) const notifKey = `${payload.moment_id}_${payload.type}_${payload.from_user?.id || ''}_${payload.comment_id || ''}` - + // 检查是否为重复通知 if (this.recentMomentNotifs.has(notifKey)) { console.log('🔄 跳过重复朋友圈通知:', notifKey) return } - + // 添加到缓存 this.recentMomentNotifs.add(notifKey) - + // 设置定时器清除缓存 setTimeout(() => { this.recentMomentNotifs.delete(notifKey) }, this.notifCacheTimeout) - + // 分发给所有处理器 this.momentNotifHandlers.forEach(handler => handler(payload)) } @@ -279,21 +371,39 @@ class WebSocketManager { * 内部处理接收到的消息 */ private handleMessage(message: ChatMessage) { - // 信令消息(message_type = 6)- 路由到signalHandlers - if (message.message_type === 6) { + // 信令消息 - 路由到signalHandlers(信令无持久化ID,不参与去重) + if (message.message_type === MessageType.SIGNAL) { this.signalHandlers.forEach(handler => handler(message)) return } + // 普通消息按指纹去重:服务端多端回推/集群转发可能重复下发同一条消息。 + // 指纹 = id + 类型 + 内容前64字符:撤回是同一 id 但类型/内容变了,必须放行 + if (message.id) { + const key = `${message.id}:${message.message_type}:${(message.content || '').slice(0, 64)}` + if (this.recentMessageKeys.has(key)) { + return + } + this.recentMessageKeys.add(key) + // 控制缓存大小:淘汰最早加入的指纹,防止长时间挂机内存膨胀 + if (this.recentMessageKeys.size > this.maxRecentMessageKeys) { + const first = this.recentMessageKeys.values().next().value + if (first !== undefined) { + this.recentMessageKeys.delete(first) + } + } + } + // 普通消息 - 路由到messageHandlers this.messageHandlers.forEach(handler => handler(message)) } /** - * 尝试重连 + * 尝试重连(指数退避:1s、2s、4s、8s……封顶30s) */ private attemptReconnect() { if (this.reconnectAttempts >= this.maxReconnectAttempts) { + console.warn('⚠️ WebSocket 重连次数已达上限,停止自动重连') return } @@ -301,12 +411,18 @@ class WebSocketManager { return } + const delay = Math.min( + this.baseReconnectDelay * Math.pow(2, this.reconnectAttempts), + this.maxReconnectDelay + ) this.reconnectAttempts++ + setTimeout(() => { - if (this.userId) { + // 期间可能已登出(intentionalClose)或已重连成功,需再次判断 + if (this.userId && !this.intentionalClose && !this.isConnected()) { this.connect(this.userId).catch(console.error) } - }, this.reconnectDelay) + }, delay) } } diff --git a/src/components/call/CallWindow.vue b/src/components/call/CallWindow.vue index 67bc1f1..dbdbe02 100644 --- a/src/components/call/CallWindow.vue +++ b/src/components/call/CallWindow.vue @@ -224,13 +224,17 @@ const hasFlvStream = computed(() => { (props.call.miniprogramStreams && props.call.miniprogramStreams.length > 0) }) -// 计算显示的用户名(优先使用 target,fallback 到 call.callerName) +// 显示用户名:call.callerName 只在"我是被叫方"(收到 invite)时才被填充。 +// 一旦有值就优先用它,而不是 props.target——target 是我当前打开的聊天对象, +// 未必等于来电者,来电切窗又已不再改 currentTarget,用 target 会显示错人。 const displayName = computed(() => { - return props.target?.remark_name || props.target?.user?.name || props.call.callerName || '未知用户' + if (props.call.callerName) return props.call.callerName + return props.target?.remark_name || props.target?.user?.name || '未知用户' }) -// 计算显示的头像(优先使用 target,fallback 到 call.callerAvatar) +// 显示头像:同理,被叫方优先用来电信令里的头像 const displayAvatar = computed(() => { + if (props.call.callerName) return props.call.callerAvatar return props.target?.user?.avatar || props.call.callerAvatar }) @@ -259,6 +263,12 @@ const drag = reactive({ initialized: false }) +// 命名函数便于卸载时移除:原实现用匿名箭头函数注册且从不移除, +// 通话窗口每挂载一次就泄漏一个 resize 监听,多次通话后窗口缩放会触发一堆失效回调 +function handleWindowResize() { + if (drag.initialized && !props.isMobile) clampPosition() +} + onMounted(() => { if (!props.isMobile) { drag.left = (window.innerWidth - 900) / 2 @@ -267,10 +277,11 @@ onMounted(() => { if (drag.top < 0) drag.top = 0 drag.initialized = true } + window.addEventListener('resize', handleWindowResize) }) -window.addEventListener('resize', () => { - if (drag.initialized && !props.isMobile) clampPosition() +onUnmounted(() => { + window.removeEventListener('resize', handleWindowResize) }) const dragStyle = computed(() => { @@ -350,10 +361,11 @@ function toggleMinimize() { }) } +// 格式化通话时长(有小时位时分钟必须 %60,否则 3661 秒会显示成 01:61:01) const formatDuration = (seconds: number) => { - const m = Math.floor(seconds / 60).toString().padStart(2, '0') - const s = (seconds % 60).toString().padStart(2, '0') const h = Math.floor(seconds / 3600) + const m = (Math.floor(seconds / 60) % 60).toString().padStart(2, '0') + const s = (seconds % 60).toString().padStart(2, '0') return h > 0 ? `${h.toString().padStart(2, '0')}:${m}:${s}` : `${m}:${s}` } diff --git a/src/components/chat/GroupCallWindow.vue b/src/components/chat/GroupCallWindow.vue index 04c4bd8..763937e 100644 --- a/src/components/chat/GroupCallWindow.vue +++ b/src/components/chat/GroupCallWindow.vue @@ -81,15 +81,18 @@ :key="user.userId" class="relative bg-gray-800 rounded-xl overflow-hidden shadow-lg border border-white/5 transition-all min-h-0" > + -
+
() -const emit = defineEmits(['member-clicked', 'member-removed', 'group-updated', 'show-info', 'show-notice']) +const emit = defineEmits(['member-clicked', 'member-removed', 'group-updated', 'show-info', 'show-notice', 'open-chat']) const authStore = useAuthStore() const chatStore = useChatStore() @@ -514,7 +514,8 @@ function handleSendMessageFromCard() { } as Contact } - chatStore.setCurrentTarget(contact) + // 交给父组件走 selectChat(加载消息/成员/已读),直接 setCurrentTarget 会打开空白会话 + emit('open-chat', contact) showUserInfoCard.value = false selectedMemberUser.value = null } diff --git a/src/components/chat/MessageBubble.vue b/src/components/chat/MessageBubble.vue index 3c0ec9f..06ee73d 100644 --- a/src/components/chat/MessageBubble.vue +++ b/src/components/chat/MessageBubble.vue @@ -20,19 +20,19 @@ ]" > - + - + - + - + - +
@@ -97,6 +97,6 @@ const showIpLocation = computed(() => { /** 单聊已读回执展示(自己发送的消息) */ const showReadReceipt = computed(() => { - return !!props.message.isSelf && !!props.message.is_read && props.message.message_type === 0 + return !!props.message.isSelf && !!props.message.is_read && props.message.message_type === MessageType.TEXT }) diff --git a/src/components/chat/MessageInput.vue b/src/components/chat/MessageInput.vue index 2407616..1dee1b9 100644 --- a/src/components/chat/MessageInput.vue +++ b/src/components/chat/MessageInput.vue @@ -168,6 +168,18 @@ interface Props { const props = defineProps() +// emit 提前到这里声明:updateMuteCountdown 会在 immediate watch 里同步调用 emit('mute-expired'), +// 若 emit 仍声明在下方会命中 const 的暂时性死区(TDZ)报错 +const emit = defineEmits<{ + 'update:modelValue': [value: string] + send: [mentionUserIds?: string[]] + file: [type: number, file: File] + 'record-start': [] + 'record-stop': [blob: Blob, duration: number] + 'record-cancel': [] + 'mute-expired': [] // 禁言到期,通知父层清除禁言状态 +}>() + // 禁言倒计时 const muteCountdown = ref('') let muteTimer: ReturnType | null = null @@ -192,6 +204,9 @@ function updateMuteCountdown() { clearInterval(muteTimer) muteTimer = null } + // 禁言到期:父层的 isMuted 依赖 isMyMuted(),内部用 new Date() 比较不是响应式, + // 时间流逝不会触发重算,遮罩会一直挂着。这里主动通知父层清除禁言状态 + emit('mute-expired') return } @@ -234,19 +249,15 @@ onUnmounted(() => { } }) -const emit = defineEmits<{ - 'update:modelValue': [value: string] - send: [mentionUserIds?: string[]] - file: [type: number, file: File] - 'record-start': [] - 'record-stop': [blob: Blob, duration: number] - 'record-cancel': [] -}>() - const fileInput = ref() const textareaRef = ref() let mediaRecorder: MediaRecorder | null = null let recordStartTime = 0 +// 录音会话标记:getUserMedia 是异步的(权限弹窗/设备初始化可能耗时数秒), +// 若用户快速"按下即松开",松开事件先于流就绪触发,此时 mediaRecorder 还是 null, +// stop 无从执行;随后流才就绪并开始录音,麦克风将永远开着(隐私泄漏)。 +// 用该标记记录"用户是否仍按住",流就绪后若已松开则立即释放流 +let recordSessionActive = false // @功能相关状态 const showMentionPicker = ref(false) @@ -455,8 +466,14 @@ function handlePaste(e: ClipboardEvent) { async function handleRecordStart() { if (props.isRecording) return + recordSessionActive = true try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }) + // 等待期间用户已松开/取消:立即释放刚拿到的音频流,不进入录音 + if (!recordSessionActive) { + stream.getTracks().forEach((t) => t.stop()) + return + } mediaRecorder = new MediaRecorder(stream) const chunks: Blob[] = [] @@ -472,6 +489,10 @@ async function handleRecordStart() { if (duration < 1000) { toastStore.warning('说话时间太短') + // 必须补发 record-cancel:父组件的 isRecording 只在 record-stop/record-cancel + // 里才置 false。原来这里直接 return,isRecording 永远为 true, + // 下次按录音键被 handleRecordStart 开头的 if(isRecording) 拦死,直到刷新页面 + emit('record-cancel') return } @@ -486,21 +507,33 @@ async function handleRecordStart() { } catch (error: any) { console.error('Failed to start recording:', error) toastStore.error(error.message || '无法访问麦克风') + // 启动失败(拒绝授权/无设备)时结束会话标记,避免影响下一次录音 + recordSessionActive = false } } function handleRecordStop() { - if (!props.isRecording || !mediaRecorder) return - mediaRecorder.stop() + // 无论录音是否已真正开始都要结束会话: + // 若 getUserMedia 还在等待中,置 false 后 handleRecordStart 会自行释放流 + recordSessionActive = false + if (!mediaRecorder) return + if (mediaRecorder.state !== 'inactive') { + mediaRecorder.stop() + } + mediaRecorder = null } function handleRecordCancel() { - if (props.isRecording && mediaRecorder) { + recordSessionActive = false + if (mediaRecorder) { mediaRecorder.onstop = null - mediaRecorder.stop() + if (mediaRecorder.state !== 'inactive') { + mediaRecorder.stop() + } if (mediaRecorder.stream) { mediaRecorder.stream.getTracks().forEach((t) => t.stop()) } + mediaRecorder = null emit('record-cancel') } } @@ -522,7 +555,7 @@ onMounted(() => { if (node instanceof HTMLElement && node.classList.contains('absolute') && node.classList.contains('inset-0') && - node.classList.contains('bg-black\\/80')) { + node.classList.contains('bg-black/80')) { // 如果遮罩层被删除,刷新页面 if (props.isBlocked) { console.warn('检测到遮罩层被删除,刷新页面以恢复') @@ -564,7 +597,7 @@ watch(() => props.isBlocked, (newVal) => { if (node instanceof HTMLElement && node.classList.contains('absolute') && node.classList.contains('inset-0') && - node.classList.contains('bg-black\\/80')) { + node.classList.contains('bg-black/80')) { if (props.isBlocked) { console.warn('检测到遮罩层被删除,刷新页面以恢复') setTimeout(() => location.reload(), 100) diff --git a/src/components/chat/MessageList.vue b/src/components/chat/MessageList.vue index f38bdb9..0dbd507 100644 --- a/src/components/chat/MessageList.vue +++ b/src/components/chat/MessageList.vue @@ -34,9 +34,11 @@
+
+ +
@@ -111,7 +113,6 @@ const props = defineProps() const emit = defineEmits<{ 'contextmenu': [event: MouseEvent, message: ChatMessage] 'avatar-click': [user: User | Contact] - 'bubble-click': [event: MouseEvent, message: ChatMessage] 'load-more': [] }>() diff --git a/src/components/chat/SelectContactsModal.vue b/src/components/chat/SelectContactsModal.vue index f7978e7..7e7a632 100644 --- a/src/components/chat/SelectContactsModal.vue +++ b/src/components/chat/SelectContactsModal.vue @@ -8,7 +8,7 @@
-

{{ mode === 'invite' ? '邀请好友加入群聊' : '选择联系人创建群聊' }}

+

{{ modalTitle }}

-
+ +