修复了一些问题

This commit is contained in:
李琦
2026-08-12 13:14:32 +08:00
parent f4d7493114
commit 0f53edc94c
48 changed files with 2336 additions and 681 deletions

View File

@@ -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)
})

65
src/api/modules/ai.ts Normal file
View File

@@ -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<AIConfig>('/ai/config')
}
// 保存全局 AI 配置(api_key 传空或脱敏值表示不修改密钥)
export function saveAIConfig(data: Partial<AIConfig>) {
return request.post<AIConfig>('/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<AIBot[]>('/ai/bots', all ? { params: { all: '1' } } : undefined)
}
// 创建机器人
export function createBot(data: { name: string; avatar?: string; role_prompt?: string }) {
return request.post<AIBot>('/ai/bots', data)
}
// 更新机器人(名称/头像/角色设定/启停)
export function updateBot(id: number, data: { name: string; avatar?: string; role_prompt?: string; enabled?: boolean }) {
return request.post<AIBot>(`/ai/bots/update/${id}`, data)
}
// 删除机器人(连带删除虚拟用户与群成员关系)
export function deleteBot(id: number) {
return request.post<void>(`/ai/bots/delete/${id}`)
}

View File

@@ -30,3 +30,12 @@ export function checkToken() {
return request.get<void>('/check-token')
}
// 登出(通知服务端使 token 失效/更新在线状态)
// 显式携带 token:登出流程随后会同步清空 localStorage,而 axios 拦截器在微任务中才读取
// localStorage,届时 token 已被清掉,请求会以未认证状态发出而 401
export function logout(token: string) {
return request.post<void>('/logout', undefined, {
headers: { Authorization: `Bearer ${token}` },
} as any)
}

View File

@@ -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<ChatMessage[]>('/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<RoomInfoResponse> {
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<JoinCallRoomResponse> {
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')
}

View File

@@ -29,6 +29,11 @@ export function recallMessage(messageId: number) {
return request.post<ChatMessage>('/messages/recall', { message_id: messageId })
}
// 删除消息("对我删除":服务端记录删除标记,历史拉取时过滤,仅影响自己)
export function deleteMessage(messageId: number) {
return request.post<void>('/messages/delete', { message_id: messageId })
}
// 标记消息已读
export function markMessagesRead(messageIds: number[]) {
return request.post<void>('/messages/read-receipts', { message_ids: messageIds })

38
src/api/modules/qrcode.ts Normal file
View File

@@ -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,前端直接塞进 <img>,无需引二维码库 */
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<QRCodeGenerateResponse>('/qrcode/generate')
}
// 长轮询二维码状态:携带 known(客户端已知状态)时服务端 hold 至状态变化,最长 25 秒。
// timeout 放宽到 35 秒覆盖 hold 时长;signal 用于停止轮询时取消在途请求,
// 失败由调用方静默重试,不弹错误提示
export function getQRCodeStatus(qrId: string, known: string, signal?: AbortSignal) {
return request.get<QRCodeStatusResponse>('/qrcode/status', {
params: { qr_id: qrId, known },
timeout: 35000,
signal,
} as any)
}

View File

@@ -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<ApiResponse>) => {
@@ -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)
}

View File

@@ -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<string> = new Set()
private notifCacheTimeout = 5000 // 5秒内相同通知视为重复
// 最近处理过的消息指纹(id+类型+内容摘要),用于普通消息去重;
// 撤回会以同一 id 但不同内容重推,因此指纹不能只用 id
private recentMessageKeys: Set<string> = 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<void> {
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)
}
}

View File

@@ -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}`
}

View File

@@ -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"
>
<!-- 只要有流就保持 video 挂载:它是该参与者音轨唯一的播放出口。
原来 isCamOff 时用 v-if 把整个 video 从 DOM 移除,导致对方一关摄像头声音也一起没了。
现在改为始终渲染(承载音频),关摄像头时用下方 absolute 头像层盖住画面即可。 -->
<video
v-if="user.stream && !user.isCamOff"
v-if="user.stream"
:srcObject="user.stream"
class="w-full h-full object-contain"
autoplay
playsinline
></video>
<div v-else class="absolute inset-0 bg-[#252525] flex flex-col items-center justify-center z-10">
<div v-if="!user.stream || user.isCamOff" class="absolute inset-0 bg-[#252525] flex flex-col items-center justify-center z-10">
<div class="relative">
<Avatar
:name="user.name"

View File

@@ -341,7 +341,7 @@ import type { GroupMember, Contact, User } from '@/types/api'
import type { MenuItem } from '@/stores/contextMenu'
const props = defineProps<{ roomId: string }>()
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
}

View File

@@ -20,19 +20,19 @@
]"
>
<!-- 文本消息 -->
<TextBubble v-if="message.message_type === 0" :message="message" class="px-4 py-3" />
<TextBubble v-if="message.message_type === MessageType.TEXT" :message="message" class="px-4 py-3" />
<!-- 图片消息 (无Padding) -->
<ImageBubble v-else-if="message.message_type === 1" :message="message" />
<ImageBubble v-else-if="message.message_type === MessageType.IMAGE" :message="message" />
<!-- 语音消息 -->
<AudioBubble v-else-if="message.message_type === 2" :message="message" class="px-4 py-3" />
<AudioBubble v-else-if="message.message_type === MessageType.AUDIO" :message="message" class="px-4 py-3" />
<!-- 视频消息 (无Padding) -->
<VideoBubble v-else-if="message.message_type === 3" :message="message" />
<VideoBubble v-else-if="message.message_type === MessageType.VIDEO" :message="message" />
<!-- 文件消息 -->
<FileBubble v-else-if="message.message_type === 8" :message="message" class="px-3 py-2" />
<FileBubble v-else-if="message.message_type === MessageType.FILE" :message="message" class="px-3 py-2" />
<!-- 未知类型消息 -->
<div v-else class="px-4 py-3">
@@ -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
})
</script>

View File

@@ -168,6 +168,18 @@ interface Props {
const props = defineProps<Props>()
// 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<typeof setInterval> | 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<HTMLInputElement>()
const textareaRef = ref<HTMLTextAreaElement>()
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)

View File

@@ -34,9 +34,11 @@
</div>
</div>
<!-- key 用消息唯一 id 而非 index:加载历史消息会在列表头部插入,
index 作 key 会让全列表的 key 错位,媒体气泡(图片/音频播放状态)被复用到错误消息上 -->
<div
v-for="(msg, index) in messages"
:key="index"
:key="msg.id || `local-${index}`"
class="flex gap-3 msg-row group"
:class="{
'flex-row-reverse': msg.isSelf && !isSystemMessage(msg),
@@ -82,8 +84,8 @@
</div>
</div>
<!-- 消息气泡 -->
<MessageBubble :message="msg" @click="$emit('bubble-click', $event, msg)" />
<!-- 消息气泡(点击行为由 MessageBubble 内部处理,此前转发的 bubble-click 无人监听,属死契约,已移除) -->
<MessageBubble :message="msg" />
</div>
</div>
</div>
@@ -111,7 +113,6 @@ const props = defineProps<Props>()
const emit = defineEmits<{
'contextmenu': [event: MouseEvent, message: ChatMessage]
'avatar-click': [user: User | Contact]
'bubble-click': [event: MouseEvent, message: ChatMessage]
'load-more': []
}>()

View File

@@ -8,7 +8,7 @@
<div class="bg-panel rounded-2xl w-full max-w-md shadow-2xl border border-gray-700 overflow-hidden animate-fade-in max-h-[80vh] flex flex-col">
<!-- 头部 -->
<div class="px-6 py-4 border-b border-gray-800 flex justify-between items-center">
<h2 class="text-xl font-bold text-white">{{ mode === 'invite' ? '邀请好友加入群聊' : '选择联系人创建群聊' }}</h2>
<h2 class="text-xl font-bold text-white">{{ modalTitle }}</h2>
<button
class="text-gray-400 hover:text-white transition text-xl"
@click="$emit('close')"
@@ -132,12 +132,17 @@ interface Props {
show: boolean
contacts: Contact[]
mode?: 'create' | 'invite' // 创建群聊或邀请好友
title?: string // 自定义标题:此前未声明,调用方传的 title 被静默忽略
}
const props = withDefaults(defineProps<Props>(), {
mode: 'create'
mode: 'create',
title: ''
})
// 标题:优先用调用方传入的 title,否则按模式给默认文案
const modalTitle = computed(() => props.title || (props.mode === 'invite' ? '邀请好友加入群聊' : '选择联系人创建群聊'))
const emit = defineEmits<{
'close': []
'create': [data: { member_ids: string[]; name?: string; avatar?: string }]

View File

@@ -27,6 +27,7 @@
<script setup lang="ts">
import { computed, ref, onUnmounted } from 'vue'
import { resolveImageUrl } from '@/utils/image'
import type { ChatMessage } from '@/types/api'
const props = defineProps<{
@@ -48,8 +49,9 @@ const duration = computed(() => {
return extra.value.duration || `00:${Math.ceil(10)}`
})
// 统一走 resolveImageUrl 解析(带空值守卫,服务器前缀来自环境配置而非硬编码 127.0.0.1)
const audioUrl = computed(() => {
return extra.value.url || props.message.content
return resolveImageUrl(extra.value.url || props.message.content)
})
function toggleAudio() {
@@ -63,10 +65,12 @@ function toggleAudio() {
}
function playAudio() {
// 空值守卫:没有可播放的地址时直接返回,避免 new Audio('') 抛出播放错误
if (!audioUrl.value) {
return
}
if (!audio) {
// 补充 Base URL 处理
const src = audioUrl.value.startsWith('http') ? audioUrl.value : `http://127.0.0.1:12080${audioUrl.value}`
audio = new Audio(src)
audio = new Audio(audioUrl.value)
audio.onended = () => {
isPlaying.value = false
}

View File

@@ -17,6 +17,7 @@
import { computed } from 'vue'
import type { ChatMessage } from '@/types/api'
import { formatSize } from '@/utils/format'
import { resolveImageUrl } from '@/utils/image'
const props = defineProps<{
message: ChatMessage
@@ -34,11 +35,16 @@ const fileSize = computed(() => {
return formatSize(extra.value.size || 0)
})
// 统一走 resolveImageUrl 解析(带空值守卫,相对路径自动补环境域名前缀)
const fileUrl = computed(() => {
return extra.value.url || props.message.content
return resolveImageUrl(extra.value.url || props.message.content)
})
function downloadFile() {
// 空值守卫:没有下载地址时不触发下载,避免下载到空白页
if (!fileUrl.value) {
return
}
const a = document.createElement('a')
a.href = fileUrl.value
a.download = fileName.value

View File

@@ -19,6 +19,7 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import ImagePreview from '@/components/common/ImagePreview.vue'
import { resolveImageUrl } from '@/utils/image'
import type { ChatMessage } from '@/types/api'
const props = defineProps<{
@@ -32,13 +33,7 @@ const imageUrl = computed(() => {
return extra.url || props.message.content
})
const fullImageUrl = computed(() => {
const url = imageUrl.value
// 如果已经是完整URL,直接返回
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:')) {
return url
}
// 否则添加服务器前缀
return `http://127.0.0.1:12080${url}`
})
// 统一走 resolveImageUrl:内部已做空值守卫(url 为 undefined 时原实现的
// startsWith 会直接抛错白屏),且服务器前缀来自环境配置而非硬编码 127.0.0.1
const fullImageUrl = computed(() => resolveImageUrl(imageUrl.value))
</script>

View File

@@ -40,8 +40,13 @@ const extra = computed(() => {
return typeof props.message.extra === 'object' ? props.message.extra : {}
})
/**
* 打开链接卡片的 URL
* 协议白名单:仅允许 http/https,防止 javascript: 等伪协议被点击执行(XSS)
*/
function openUrl(url: string) {
window.open(url, '_blank')
if (!/^https?:\/\//i.test(url)) return
window.open(url, '_blank', 'noopener,noreferrer')
}
</script>

View File

@@ -10,15 +10,17 @@
<script setup lang="ts">
import { computed } from 'vue'
import { resolveImageUrl } from '@/utils/image'
import type { ChatMessage } from '@/types/api'
const props = defineProps<{
message: ChatMessage
}>()
// 统一走 resolveImageUrl 解析(带空值守卫,相对路径 /uploads 自动补环境域名前缀)
const videoUrl = computed(() => {
const extra = typeof props.message.extra === 'object' ? props.message.extra : {}
return extra.url || props.message.content
return resolveImageUrl(extra.url || props.message.content)
})
</script>

View File

@@ -0,0 +1,402 @@
<template>
<teleport to="body">
<transition name="modal-fade">
<div v-if="show" class="fixed inset-0 z-[200] flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="$emit('close')">
<div class="w-[640px] max-w-[92vw] max-h-[86vh] bg-[#1e293b] border border-gray-700 rounded-2xl shadow-2xl flex flex-col overflow-hidden">
<!-- 标题栏 -->
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-700/60">
<h3 class="text-lg font-bold text-white flex items-center gap-2">
<i class="fas fa-robot text-primary"></i>
AI 与机器人管理
</h3>
<button class="w-8 h-8 rounded-lg text-gray-400 hover:text-white hover:bg-white/10 transition" @click="$emit('close')">
<i class="fas fa-times"></i>
</button>
</div>
<!-- Tab 切换 -->
<div class="flex gap-1 px-6 pt-4">
<button
v-for="tab in tabs"
:key="tab.key"
class="px-4 py-2 rounded-lg text-sm font-medium transition"
:class="activeTab === tab.key ? 'bg-primary text-white' : 'text-gray-400 hover:text-white hover:bg-white/5'"
@click="activeTab = tab.key"
>
{{ tab.label }}
</button>
</div>
<!-- 内容区 -->
<div class="flex-1 overflow-y-auto custom-scrollbar p-6">
<!-- AI 配置 -->
<div v-if="activeTab === 'config'" class="space-y-4">
<div class="flex items-center justify-between p-3 bg-input rounded-lg">
<span class="text-sm text-gray-300">启用 AI 服务</span>
<button
class="relative w-11 h-6 rounded-full transition-colors"
:class="config.enabled ? 'bg-primary' : 'bg-gray-600'"
@click="config.enabled = !config.enabled"
>
<span
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
:class="config.enabled ? 'translate-x-5' : ''"
></span>
</button>
</div>
<div>
<label class="block text-xs text-gray-400 mb-1.5">提供商</label>
<select
v-model="config.provider"
class="w-full px-3 py-2.5 bg-input border border-gray-700 rounded-lg text-sm text-white focus:border-primary focus:outline-none"
>
<option v-for="p in providers" :key="p.value" :value="p.value">{{ p.label }}</option>
</select>
</div>
<div>
<label class="block text-xs text-gray-400 mb-1.5">API 地址</label>
<input
v-model="config.base_url"
class="w-full px-3 py-2.5 bg-input border border-gray-700 rounded-lg text-sm text-white placeholder-gray-500 focus:border-primary focus:outline-none"
:placeholder="currentProviderPlaceholder"
/>
</div>
<div>
<label class="block text-xs text-gray-400 mb-1.5">API 密钥</label>
<input
v-model="config.api_key"
type="password"
class="w-full px-3 py-2.5 bg-input border border-gray-700 rounded-lg text-sm text-white placeholder-gray-500 focus:border-primary focus:outline-none"
:placeholder="config.has_key ? '已配置(留空则不修改)' : '请输入 API Key'"
/>
</div>
<div>
<label class="block text-xs text-gray-400 mb-1.5">模型名称</label>
<input
v-model="config.model"
class="w-full px-3 py-2.5 bg-input border border-gray-700 rounded-lg text-sm text-white placeholder-gray-500 focus:border-primary focus:outline-none"
placeholder="如 gpt-4o-mini / deepseek-chat / qwen-plus"
/>
</div>
<div class="flex gap-3 pt-2">
<button
class="flex-1 py-2.5 bg-input hover:bg-white/10 text-white rounded-lg transition text-sm disabled:opacity-50"
:disabled="testing"
@click="handleTest"
>
<i class="fas fa-plug mr-1.5"></i>{{ testing ? '测试中...' : '测试连通' }}
</button>
<button
class="flex-1 py-2.5 bg-primary hover:bg-indigo-500 text-white rounded-lg transition text-sm font-medium disabled:opacity-50"
:disabled="saving"
@click="handleSave"
>
<i class="fas fa-save mr-1.5"></i>{{ saving ? '保存中...' : '保存配置' }}
</button>
</div>
<p v-if="testReply" class="text-xs text-emerald-400 bg-emerald-500/10 rounded-lg p-3 leading-relaxed">
<i class="fas fa-check-circle mr-1"></i>{{ testReply }}
</p>
</div>
<!-- 机器人管理 -->
<div v-else class="space-y-3">
<!-- 编辑表单 -->
<div v-if="editorVisible" class="bg-input/50 border border-gray-700 rounded-xl p-4 space-y-3">
<h4 class="text-sm font-bold text-white">{{ editingBot ? '编辑机器人' : '新建机器人' }}</h4>
<div>
<label class="block text-xs text-gray-400 mb-1.5">名称(群聊中 @名称 触发应答)</label>
<input
v-model="botForm.name"
class="w-full px-3 py-2 bg-input border border-gray-700 rounded-lg text-sm text-white placeholder-gray-500 focus:border-primary focus:outline-none"
placeholder="如:小助手"
/>
</div>
<div>
<label class="block text-xs text-gray-400 mb-1.5">头像(URL 或 emoji,可留空默认 🤖)</label>
<input
v-model="botForm.avatar"
class="w-full px-3 py-2 bg-input border border-gray-700 rounded-lg text-sm text-white placeholder-gray-500 focus:border-primary focus:outline-none"
placeholder="https://... 或 🤖"
/>
</div>
<div>
<label class="block text-xs text-gray-400 mb-1.5">角色设定(system 提示词)</label>
<textarea
v-model="botForm.role_prompt"
rows="3"
maxlength="2000"
class="w-full px-3 py-2 bg-input border border-gray-700 rounded-lg text-sm text-white placeholder-gray-500 focus:border-primary focus:outline-none resize-none"
placeholder="如:你是一个温柔耐心的生活助手,说话简洁幽默"
></textarea>
</div>
<div v-if="editingBot" class="flex items-center justify-between">
<span class="text-xs text-gray-400">启用状态</span>
<button
class="relative w-11 h-6 rounded-full transition-colors"
:class="botForm.enabled ? 'bg-primary' : 'bg-gray-600'"
@click="botForm.enabled = !botForm.enabled"
>
<span
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
:class="botForm.enabled ? 'translate-x-5' : ''"
></span>
</button>
</div>
<div class="flex gap-2 pt-1">
<button
v-if="editingBot"
class="py-2 px-4 bg-red-500/10 hover:bg-red-500/20 text-red-400 rounded-lg transition text-sm"
@click="handleDeleteBot"
>
删除
</button>
<div class="flex-1"></div>
<button class="py-2 px-4 bg-input hover:bg-white/10 text-white rounded-lg transition text-sm" @click="editorVisible = false">
取消
</button>
<button
class="py-2 px-4 bg-primary hover:bg-indigo-500 text-white rounded-lg transition text-sm font-medium disabled:opacity-50"
:disabled="botSaving"
@click="handleSaveBot"
>
{{ botSaving ? '保存中...' : '保存' }}
</button>
</div>
</div>
<button
v-if="!editorVisible"
class="w-full py-2.5 border-2 border-dashed border-gray-600 hover:border-primary text-gray-400 hover:text-primary rounded-xl transition text-sm"
@click="openBotEditor()"
>
<i class="fas fa-plus mr-1.5"></i>新建机器人
</button>
<div v-if="bots.length === 0 && !editorVisible" class="text-center py-10 text-gray-500 text-sm">
还没有机器人,点击上方按钮创建一个吧
</div>
<div
v-for="bot in bots"
:key="bot.id"
class="flex items-center gap-3 p-3 bg-input/50 hover:bg-input border border-gray-700/50 rounded-xl transition cursor-pointer"
@click="openBotEditor(bot)"
>
<Avatar :name="bot.name" :avatar="bot.avatar" size="md" rounded="full" />
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-white">{{ bot.name }}</span>
<span
class="text-[10px] px-1.5 py-0.5 rounded"
:class="bot.enabled ? 'bg-emerald-500/15 text-emerald-400' : 'bg-gray-600/30 text-gray-500'"
>
{{ bot.enabled ? '启用中' : '已停用' }}
</span>
</div>
<p class="text-xs text-gray-500 truncate mt-0.5">{{ bot.role_prompt || '未设置角色' }}</p>
</div>
<i class="fas fa-chevron-right text-gray-600 text-xs"></i>
</div>
<p class="text-xs text-gray-500 leading-relaxed pt-2">
机器人会出现在所有用户的好友列表中且无法被删除;私聊可直接对话,拉进群后 @机器人名称 即可提问。机器人只根据聊天记录回答,不执行其他操作。
</p>
</div>
</div>
</div>
</div>
</transition>
</teleport>
</template>
<script setup lang="ts">
/**
* AI 与机器人管理弹窗(仅管理员 id=1 使用)
* 功能:全局 AI 提供商配置(工厂模式对应的 provider 选择)+机器人增删改
*/
import { ref, computed, watch } from 'vue'
import * as aiApi from '@/api/modules/ai'
import type { AIBot, AIConfig } from '@/api/modules/ai'
import { useToastStore } from '@/stores/toast'
import Avatar from '@/components/common/Avatar.vue'
const props = defineProps<{ show: boolean }>()
defineEmits<{ (e: 'close'): void }>()
const toastStore = useToastStore()
const tabs = [
{ key: 'config', label: 'AI 配置' },
{ key: 'bots', label: '机器人' },
]
const activeTab = ref('config')
// ---------- AI 配置 ----------
// 支持的提供商:与后端工厂函数 NewAIProvider 的 switch 分支一一对应
const providers = [
{ value: 'openai', label: 'OpenAI', placeholder: 'https://api.openai.com/v1' },
{ value: 'deepseek', label: 'DeepSeek', placeholder: 'https://api.deepseek.com/v1' },
{ value: 'dashscope', label: '通义千问(阿里)', placeholder: 'https://dashscope.aliyuncs.com/compatible-mode/v1' },
{ value: 'moonshot', label: 'Kimi(月之暗面)', placeholder: 'https://api.moonshot.cn/v1' },
{ value: 'ollama', label: 'Ollama(本地)', placeholder: 'http://127.0.0.1:11434/v1' },
{ value: 'custom', label: '自定义(OpenAI兼容)', placeholder: '必填,如 https://xxx/v1' },
]
const config = ref<AIConfig>({ provider: 'openai', base_url: '', api_key: '', model: '', enabled: false })
const saving = ref(false)
const testing = ref(false)
const testReply = ref('')
const currentProviderPlaceholder = computed(() => {
const p = providers.find(p => p.value === config.value.provider)
if (!p) return ''
return config.value.provider === 'custom' ? p.placeholder : `留空使用默认: ${p.placeholder}`
})
/** 打开弹窗时拉取配置与机器人列表 */
watch(() => props.show, (visible) => {
if (visible) {
loadConfig()
loadBots()
testReply.value = ''
}
})
async function loadConfig() {
try {
const cfg = await aiApi.getAIConfig()
config.value = { ...cfg, api_key: '' } // 脱敏值不回填,避免原样提交
if (!providers.some(p => p.value === cfg.provider)) {
config.value.provider = 'openai'
}
} catch {
// 首次未配置时后端返回空配置,忽略错误
}
}
async function handleSave() {
if (saving.value) return
if (!config.value.model) {
toastStore.error('请填写模型名称')
return
}
saving.value = true
try {
const saved = await aiApi.saveAIConfig({
provider: config.value.provider,
base_url: config.value.base_url,
api_key: config.value.api_key,
model: config.value.model,
enabled: config.value.enabled,
})
config.value.has_key = saved.has_key
config.value.api_key = ''
toastStore.success('已保存')
} catch (e: any) {
toastStore.error(e?.message || '保存失败')
} finally {
saving.value = false
}
}
/** 测试连通性:先保存再测试,保证测试的是最新填写的配置 */
async function handleTest() {
if (testing.value) return
testing.value = true
testReply.value = ''
try {
await aiApi.saveAIConfig({
provider: config.value.provider,
base_url: config.value.base_url,
api_key: config.value.api_key,
model: config.value.model,
enabled: config.value.enabled,
})
config.value.api_key = ''
const res = await aiApi.testAIConfig()
testReply.value = res.reply || 'AI 服务连通正常'
} catch (e: any) {
toastStore.error(e?.message || '测试失败')
} finally {
testing.value = false
}
}
// ---------- 机器人管理 ----------
const bots = ref<AIBot[]>([])
const editorVisible = ref(false)
const editingBot = ref<AIBot | null>(null)
const botSaving = ref(false)
const botForm = ref({ name: '', avatar: '', role_prompt: '', enabled: true })
async function loadBots() {
try {
// all=true:管理页需要看到停用中的机器人
bots.value = await aiApi.getBots(true)
} catch {
// 列表加载失败静默
}
}
function openBotEditor(bot?: AIBot) {
editingBot.value = bot || null
botForm.value = bot
? { name: bot.name, avatar: bot.avatar, role_prompt: bot.role_prompt, enabled: bot.enabled }
: { name: '', avatar: '', role_prompt: '', enabled: true }
editorVisible.value = true
}
async function handleSaveBot() {
if (botSaving.value) return
if (!botForm.value.name.trim()) {
toastStore.error('请填写机器人名称')
return
}
botSaving.value = true
try {
if (editingBot.value) {
await aiApi.updateBot(editingBot.value.id, botForm.value)
} else {
await aiApi.createBot(botForm.value)
}
toastStore.success('已保存')
editorVisible.value = false
await loadBots()
} catch (e: any) {
toastStore.error(e?.message || '保存失败')
} finally {
botSaving.value = false
}
}
async function handleDeleteBot() {
if (!editingBot.value) return
if (!window.confirm(`确定删除"${editingBot.value.name}"吗?它将从所有用户的好友列表和群聊中消失`)) return
try {
await aiApi.deleteBot(editingBot.value.id)
toastStore.success('已删除')
editorVisible.value = false
await loadBots()
} catch (e: any) {
toastStore.error(e?.message || '删除失败')
}
}
</script>
<style scoped>
.modal-fade-enter-active,
.modal-fade-leave-active {
transition: opacity 0.2s ease;
}
.modal-fade-enter-from,
.modal-fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -17,7 +17,7 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { generateColor } from '@/utils/format'
import { resolveImageUrl } from '@/utils/image'
@@ -36,6 +36,13 @@ const props = withDefaults(defineProps<Props>(), {
const imageError = ref(false)
// 头像地址变化时重置加载失败标记:
// 原实现 imageError 一旦置 true 就永远显示文字兜底,
// 用户更换头像后(或组件被复用渲染另一个人)新图片也不会再尝试加载
watch(() => props.avatar, () => {
imageError.value = false
})
/** 解析后的头像地址 */
const displayAvatar = computed(() => resolveImageUrl(props.avatar))

View File

@@ -23,16 +23,20 @@
</div>
<div class="flex border-t border-gray-700">
<button
class="flex-1 py-3 text-gray-400 hover:bg-gray-700 transition"
class="flex-1 py-3 text-gray-400 hover:bg-gray-700 transition disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="loading"
@click="$emit('close')"
>
取消
</button>
<!-- loading 时禁用按钮:上传/发送是异步过程,不加锁连点会把同一文件发多次 -->
<button
class="flex-1 py-3 text-primary font-bold hover:bg-gray-700 transition border-l border-gray-700"
class="flex-1 py-3 text-primary font-bold hover:bg-gray-700 transition border-l border-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="loading"
@click="$emit('confirm')"
>
确认发送
<i v-if="loading" class="fas fa-spinner fa-spin mr-1"></i>
{{ loading ? '发送中...' : '确认发送' }}
</button>
</div>
</div>
@@ -49,6 +53,8 @@ interface Props {
preview?: string
name?: string
size?: number
// 发送中标记:由父组件传入,用于禁用按钮防止重复发送
loading?: boolean
}
const props = defineProps<Props>()

View File

@@ -121,6 +121,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import Avatar from '@/components/common/Avatar.vue'
import { escapeHtml } from '@/utils/format'
import type { GlobalSearchResult, ContactSearchResult, GroupSearchResult, MessageSearchResult } from '@/api/modules/search'
interface Props {
@@ -160,10 +161,15 @@ const isEmpty = computed(() => {
})
// 高亮关键词
// 为什么这样写:结果会经 v-html 渲染,昵称/邮箱/消息内容都是用户可控数据,
// 必须先 HTML 转义再包裹高亮标签,否则恶意昵称或消息中的 HTML 会被执行(XSS)。
// 关键词也需做同样转义,才能与转义后的文本正确匹配(如搜索 "<a" 要匹配 "&lt;a")。
function highlightKeyword(text: string): string {
if (!text || !props.keyword) return text || ''
const regex = new RegExp(`(${escapeRegExp(props.keyword)})`, 'gi')
return text.replace(regex, '<span class="text-indigo-400 font-medium">$1</span>')
if (!text) return ''
const escaped = escapeHtml(text)
if (!props.keyword) return escaped
const regex = new RegExp(`(${escapeRegExp(escapeHtml(props.keyword))})`, 'gi')
return escaped.replace(regex, '<span class="text-indigo-400 font-medium">$1</span>')
}
// 转义正则特殊字符

View File

@@ -0,0 +1,12 @@
import { ref } from 'vue'
/**
* 1v1 通话占线标志(共享单例)
*
* 为什么单独一个文件:群通话(useGroupWebRTC)需要知道 1v1 是否在通话,
* 但 1v1 的 call 状态在 useWebRTC 实例内、只能经 stores/webrtc 访问,
* 若 useGroupWebRTC 直接 import stores/webrtc 会与 useWebRTC 形成
* useWebRTC → useGroupWebRTC → stores/webrtc → useWebRTC 的循环依赖。
* 用这个独立的轻量 ref 解耦:useWebRTC 负责写,useGroupWebRTC 只读。
*/
export const p2pBusy = ref(false)

View File

@@ -4,6 +4,8 @@ import { wsManager } from '@/api/websocket'
import { useToastStore } from '@/stores/toast'
import { useAuthStore } from '@/stores/auth'
import type { ChatMessage } from '@/types/api'
import { MessageType } from '@/types/message'
import { p2pBusy } from './callActivity'
// --- 类型定义 ---
export interface CallParticipant {
@@ -79,396 +81,416 @@ const iceServers = {
]
}
export function useGroupWebRTC() {
const toastStore = useToastStore()
const authStore = useAuthStore()
const isActive = computed(() => callState.joined || callState.incoming)
const isActive = computed(() => callState.joined || callState.incoming)
/** 群通话是否占线(供 1v1 侧互查,避免两边同时响铃/并发 getUserMedia) */
export function isGroupCallBusy(): boolean {
return callState.joined || callState.incoming
}
function initListener() {
wsManager.offSignal(handleSignalMessage)
wsManager.onSignal(handleSignalMessage)
/**
* 关键修复:以下所有函数(尤其是 handleSignalMessage)都提升到模块级,成为单例引用。
* 之前它们定义在 useGroupWebRTC() 函数体内,7 个调用点(App.vue、各面板、通话窗)
* 各自持有不同的闭包引用,initListener 的"先 off 再 on"只能防同实例重复、防不了跨实例,
* 导致同一条群信令被处理两遍(handleOffer 重复 → 协商竞态/重复 answer、reject toast 弹两次),
* 且面板卸载后其闭包无人注销,反复开关面板会持续累积监听器。
* 现在 wsManager.onSignal 永远只注册这一个模块级引用。
*
* store 是 Pinia 单例,模块级函数内直接 useXxxStore() 拿到的始终是同一实例,成本极低。
*/
function initListener() {
wsManager.offSignal(handleSignalMessage)
wsManager.onSignal(handleSignalMessage)
}
// --- 状态重置 ---
function resetState() {
stopTimer()
peerConnections.forEach(pc => pc.close())
peerConnections.clear()
makingOffer.clear()
ignoreOffer.clear()
pendingCandidates.clear()
if (localStream.value) {
localStream.value.getTracks().forEach(t => t.stop())
localStream.value = null
}
participants.value = []
// --- 状态重置 ---
function resetState() {
stopTimer()
peerConnections.forEach(pc => pc.close())
peerConnections.clear()
makingOffer.clear()
ignoreOffer.clear()
pendingCandidates.clear()
callState.incoming = false
callState.joined = false
callState.minimized = false
callState.roomId = ''
callState.groupId = ''
callState.duration = 0
callState.initiatorId = ''
callState.inviterName = ''
}
async function initLocalMedia(videoEnabled: boolean) {
const toastStore = useToastStore()
try {
if (localStream.value) {
localStream.value.getTracks().forEach(t => t.stop())
localStream.value = null
}
participants.value = []
callState.incoming = false
callState.joined = false
callState.minimized = false
callState.roomId = ''
callState.groupId = ''
callState.duration = 0
callState.initiatorId = ''
callState.inviterName = ''
const stream = await navigator.mediaDevices.getUserMedia({
video: videoEnabled ? { width: { ideal: 640 }, height: { ideal: 480 }, facingMode: 'user' } : false,
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }
})
localStream.value = stream
callState.isSelfMuted = false
callState.isSelfCamOff = false
return stream
} catch (error) {
console.error('获取媒体失败', error)
toastStore.error('无法获取摄像头或麦克风权限')
throw error
}
}
async function initLocalMedia(videoEnabled: boolean) {
try {
if (localStream.value) {
localStream.value.getTracks().forEach(t => t.stop())
}
const stream = await navigator.mediaDevices.getUserMedia({
video: videoEnabled ? { width: { ideal: 640 }, height: { ideal: 480 }, facingMode: 'user' } : false,
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }
})
localStream.value = stream
callState.isSelfMuted = false
callState.isSelfCamOff = false
return stream
} catch (error) {
console.error('获取媒体失败', error)
toastStore.error('无法获取摄像头或麦克风权限')
throw error
// --- 核心信令处理 ---
async function handleSignalMessage(msg: ChatMessage) {
const authStore = useAuthStore()
const toastStore = useToastStore()
const myId = authStore.user?.id
if (!myId) return
try {
const content: SignalPayload = JSON.parse(msg.content || '{}')
if (content.senderId === myId) return
// 1. 处理邀请 (Invite)
if (content.action === 'invite') {
if (!content.participantIds || !content.callRoomId) return
// 忙线自动忽略:已在群通话中,或正处于 1v1 通话中,都不弹群来电
if (callState.joined || p2pBusy.value) return
callState.incoming = true
callState.roomId = content.callRoomId
callState.groupId = msg.room_id || ''
callState.type = content.type || 'video'
callState.initiatorId = content.senderId
callState.inviterName = content.senderName || '群成员'
callState.inviterAvatar = content.senderAvatar
return
}
}
// --- 核心信令处理 ---
async function handleSignalMessage(msg: ChatMessage) {
const myId = authStore.user?.id
if (!myId) return
try {
const content: SignalPayload = JSON.parse(msg.content || '{}')
if (content.senderId === myId) return
// 1. 处理邀请 (Invite)
if (content.action === 'invite') {
if (!content.participantIds || !content.callRoomId) return
if (callState.joined) return // 忙线自动忽略
callState.incoming = true
// 2. Banner 状态更新
if (content.action === 'join' && msg.room_id) {
if (callState.roomId && content.callRoomId !== callState.roomId) return
if (!callState.joined && !callState.incoming) {
callState.roomId = content.callRoomId
callState.groupId = msg.room_id || ''
callState.type = content.type || 'video'
callState.initiatorId = content.senderId
callState.inviterName = content.senderName || '群成员'
callState.inviterAvatar = content.senderAvatar
return
callState.groupId = msg.room_id
}
}
// 2. Banner 状态更新
if (content.action === 'join' && msg.room_id) {
if (callState.roomId && content.callRoomId !== callState.roomId) return
if (!callState.joined && !callState.incoming) {
callState.roomId = content.callRoomId
callState.groupId = msg.room_id
if (content.callRoomId !== callState.roomId) return
switch (content.action) {
case 'join':
addParticipant(content.senderId, content.senderName || '成员', content.senderAvatar || '', 'connecting')
// 只有我已经加入了,才发起连接
if (callState.joined) {
getPeerConnection(content.senderId)
}
}
break
if (content.callRoomId !== callState.roomId) return
switch (content.action) {
case 'join':
case 'offer':
if (content.targetId === myId && callState.joined) {
addParticipant(content.senderId, content.senderName || '成员', content.senderAvatar || '', 'connecting')
// 只有我已经加入了,才发起连接
if (callState.joined) {
getPeerConnection(content.senderId)
}
break
await handleOffer(content.senderId, content.data)
}
break
case 'offer':
if (content.targetId === myId && callState.joined) {
addParticipant(content.senderId, content.senderName || '成员', content.senderAvatar || '', 'connecting')
await handleOffer(content.senderId, content.data)
}
break
case 'answer':
if (content.targetId === myId && callState.joined) {
await handleAnswer(content.senderId, content.data)
}
break
case 'answer':
if (content.targetId === myId && callState.joined) {
await handleAnswer(content.senderId, content.data)
}
break
case 'candidate':
if (content.targetId === myId && callState.joined) {
await handleCandidate(content.senderId, content.data)
}
break
case 'candidate':
if (content.targetId === myId && callState.joined) {
await handleCandidate(content.senderId, content.data)
}
break
case 'leave':
removeParticipant(content.senderId)
if (participants.value.length === 0 && !callState.joined) {
callState.roomId = ''
}
break
case 'leave':
removeParticipant(content.senderId)
if (participants.value.length === 0 && !callState.joined) {
callState.roomId = ''
}
break
case 'reject': {
// 【核心修复】 处理对方拒绝
// 找到对应的参与者,标记为已拒绝,或者直接移除
const pIndex = participants.value.findIndex(p => p.userId === content.senderId)
if (pIndex !== -1) {
// 标记状态 (UI显示灰色或"已拒绝")
participants.value[pIndex].status = 'rejected'
toastStore.info(`${content.senderName || '对方'} 拒绝了通话`)
case 'reject':
// 【核心修复】 处理对方拒绝
// 找到对应的参与者,标记为已拒绝,或者直接移除
const pIndex = participants.value.findIndex(p => p.userId === content.senderId)
if (pIndex !== -1) {
// 方案A: 移除
// participants.value.splice(pIndex, 1)
// 方案B: 标记状态 (UI显示灰色或"已拒绝")
participants.value[pIndex].status = 'rejected'
// 如果所有人都拒绝了,可以考虑提示用户
toastStore.info(`${content.senderName || '对方'} 拒绝了通话`)
// 关闭可能存在的连接
const pc = peerConnections.get(content.senderId)
if (pc) { pc.close(); peerConnections.delete(content.senderId) }
}
break
case 'sync_state':
updateParticipantState(content.senderId, content.state)
break
// 关闭可能存在的连接
const pc = peerConnections.get(content.senderId)
if (pc) { pc.close(); peerConnections.delete(content.senderId) }
}
break
}
} catch (e) { console.error('Signal error', e) }
case 'sync_state':
updateParticipantState(content.senderId, content.state)
break
}
} catch (e) { console.error('Signal error', e) }
}
// --- WebRTC 连接管理 (Perfect Negotiation) ---
function getPeerConnection(targetUserId: string): RTCPeerConnection {
if (peerConnections.has(targetUserId)) return peerConnections.get(targetUserId)!
const pc = new RTCPeerConnection(iceServers)
makingOffer.set(targetUserId, false)
ignoreOffer.set(targetUserId, false)
if (localStream.value) {
localStream.value.getTracks().forEach(t => pc.addTrack(t, localStream.value!))
}
// --- WebRTC 连接管理 (Perfect Negotiation) ---
function getPeerConnection(targetUserId: string): RTCPeerConnection {
if (peerConnections.has(targetUserId)) return peerConnections.get(targetUserId)!
const pc = new RTCPeerConnection(iceServers)
makingOffer.set(targetUserId, false)
ignoreOffer.set(targetUserId, false)
if (localStream.value) {
localStream.value.getTracks().forEach(t => pc.addTrack(t, localStream.value!))
}
pc.onnegotiationneeded = async () => {
try {
makingOffer.set(targetUserId, true)
await pc.setLocalDescription()
await sendSignal('offer', { targetId: targetUserId, data: pc.localDescription })
} catch (err) {
console.error('Negotiation failed:', err)
} finally {
makingOffer.set(targetUserId, false)
}
}
pc.onicecandidate = (event) => {
if (event.candidate) sendSignal('candidate', { targetId: targetUserId, data: event.candidate })
}
pc.ontrack = (event) => {
if (event.streams[0]) updateParticipantStream(targetUserId, event.streams[0])
}
pc.onconnectionstatechange = () => {
if (pc.connectionState === 'connected') updateParticipantStatus(targetUserId, 'connected')
else if (pc.connectionState === 'failed') updateParticipantStatus(targetUserId, 'failed')
}
peerConnections.set(targetUserId, pc)
return pc
}
// ... (handleOffer, handleAnswer, handleCandidate, flushPendingCandidates 保持不变,复用上一版代码) ...
// 为了节省篇幅,这里假设已包含上一版 Perfect Negotiation 的完整逻辑
// 请务必将上一版 handleOffer 等函数完整保留在这里
async function handleOffer(senderId: string, offerSdp: RTCSessionDescriptionInit) {
const pc = getPeerConnection(senderId)
const myId = authStore.user?.id || ''
const polite = myId < senderId
const offerCollision = (makingOffer.get(senderId) || pc.signalingState !== 'stable')
ignoreOffer.set(senderId, !polite && offerCollision)
if (ignoreOffer.get(senderId)) return
if (offerCollision) {
await pc.setLocalDescription({ type: 'rollback' })
}
pc.onnegotiationneeded = async () => {
try {
await pc.setRemoteDescription(offerSdp)
makingOffer.set(targetUserId, true)
await pc.setLocalDescription()
await sendSignal('answer', { targetId: senderId, data: pc.localDescription })
await sendSignal('offer', { targetId: targetUserId, data: pc.localDescription })
} catch (err) {
console.error('Negotiation failed:', err)
} finally {
makingOffer.set(targetUserId, false)
}
}
pc.onicecandidate = (event) => {
if (event.candidate) sendSignal('candidate', { targetId: targetUserId, data: event.candidate })
}
pc.ontrack = (event) => {
if (event.streams[0]) updateParticipantStream(targetUserId, event.streams[0])
}
pc.onconnectionstatechange = () => {
if (pc.connectionState === 'connected') updateParticipantStatus(targetUserId, 'connected')
else if (pc.connectionState === 'failed') updateParticipantStatus(targetUserId, 'failed')
}
peerConnections.set(targetUserId, pc)
return pc
}
async function handleOffer(senderId: string, offerSdp: RTCSessionDescriptionInit) {
const pc = getPeerConnection(senderId)
const myId = useAuthStore().user?.id || ''
const polite = myId < senderId
const offerCollision = (makingOffer.get(senderId) || pc.signalingState !== 'stable')
ignoreOffer.set(senderId, !polite && offerCollision)
if (ignoreOffer.get(senderId)) return
if (offerCollision) {
await pc.setLocalDescription({ type: 'rollback' })
}
try {
await pc.setRemoteDescription(offerSdp)
await pc.setLocalDescription()
await sendSignal('answer', { targetId: senderId, data: pc.localDescription })
await flushPendingCandidates(senderId, pc)
} catch (e) { console.error('Offer error', e) }
}
async function handleAnswer(senderId: string, answerSdp: RTCSessionDescriptionInit) {
const pc = getPeerConnection(senderId)
if (ignoreOffer.get(senderId)) return
try {
if (pc.signalingState === 'have-local-offer') {
await pc.setRemoteDescription(answerSdp)
await flushPendingCandidates(senderId, pc)
} catch (e) { console.error('Offer error', e) }
}
}
} catch (e) { console.error('Answer error', e) }
}
async function handleAnswer(senderId: string, answerSdp: RTCSessionDescriptionInit) {
const pc = getPeerConnection(senderId)
if (ignoreOffer.get(senderId)) return
try {
if (pc.signalingState === 'have-local-offer') {
await pc.setRemoteDescription(answerSdp)
await flushPendingCandidates(senderId, pc)
}
} catch (e) { console.error('Answer error', e) }
}
async function handleCandidate(senderId: string, candidate: RTCIceCandidateInit) {
const pc = getPeerConnection(senderId)
if (ignoreOffer.get(senderId)) return
try {
await pc.addIceCandidate(new RTCIceCandidate(candidate))
} catch (e) {
if (!pc.remoteDescription) {
if (!pendingCandidates.has(senderId)) pendingCandidates.set(senderId, [])
pendingCandidates.get(senderId)?.push(candidate)
}
async function handleCandidate(senderId: string, candidate: RTCIceCandidateInit) {
const pc = getPeerConnection(senderId)
if (ignoreOffer.get(senderId)) return
try {
await pc.addIceCandidate(new RTCIceCandidate(candidate))
} catch (e) {
if (!pc.remoteDescription) {
if (!pendingCandidates.has(senderId)) pendingCandidates.set(senderId, [])
pendingCandidates.get(senderId)?.push(candidate)
}
}
}
async function flushPendingCandidates(userId: string, pc: RTCPeerConnection) {
const candidates = pendingCandidates.get(userId) || []
for (const c of candidates) await pc.addIceCandidate(c).catch(e=>{})
pendingCandidates.delete(userId)
async function flushPendingCandidates(userId: string, pc: RTCPeerConnection) {
const candidates = pendingCandidates.get(userId) || []
for (const c of candidates) await pc.addIceCandidate(c).catch(() => {})
pendingCandidates.delete(userId)
}
async function sendSignal(action: SignalPayload['action'], payload: Partial<SignalPayload>) {
const authStore = useAuthStore()
const myId = authStore.user?.id
if (!myId || !callState.groupId) return
const fullPayload: SignalPayload = {
action,
callRoomId: callState.roomId,
senderId: myId,
senderName: authStore.user?.name || '我',
senderAvatar: authStore.user?.avatar || '',
...payload
}
async function sendSignal(action: SignalPayload['action'], payload: Partial<SignalPayload>) {
const myId = authStore.user?.id
if (!myId || !callState.groupId) return
let receiverId = ''
if (['offer', 'answer', 'candidate'].includes(action)) receiverId = payload.targetId || ''
const fullPayload: SignalPayload = {
action,
callRoomId: callState.roomId,
senderId: myId,
senderName: authStore.user?.name || '我',
senderAvatar: authStore.user?.avatar || '',
...payload
}
let receiverId = ''
if (['offer', 'answer', 'candidate'].includes(action)) receiverId = payload.targetId || ''
const message = {
room_id: callState.groupId,
receiver_user_id: receiverId,
message_type: 6,
content: JSON.stringify(fullPayload),
call_status: action as any,
}
await messageApi.sendMessage(message)
const message = {
room_id: callState.groupId,
receiver_user_id: receiverId,
message_type: MessageType.SIGNAL,
content: JSON.stringify(fullPayload),
call_status: action as any,
}
await messageApi.sendMessage(message)
}
// --- 操作方法 ---
// --- 操作方法 ---
async function startGroupCall(groupId: string, selectedUserIds: string[], type: 'audio' | 'video') {
const authStore = useAuthStore()
const toastStore = useToastStore()
resetState()
async function startGroupCall(groupId: string, selectedUserIds: string[], type: 'audio' | 'video') {
resetState()
callState.groupId = groupId
callState.type = type
callState.roomId = `group_call_${groupId}_${Date.now()}`
callState.initiatorId = authStore.user?.id || ''
callState.minimized = false
callState.joined = true
callState.groupId = groupId
callState.type = type
callState.roomId = `group_call_${groupId}_${Date.now()}`
callState.initiatorId = authStore.user?.id || ''
callState.minimized = false
callState.joined = true
try {
await initLocalMedia(type === 'video')
initListener()
startTimer()
try {
await initLocalMedia(type === 'video')
initListener()
startTimer()
await sendSignal('invite', { participantIds: selectedUserIds, type })
await sendSignal('invite', { participantIds: selectedUserIds, type })
selectedUserIds.forEach(uid => {
if (uid !== callState.initiatorId) addParticipant(uid, '呼叫中...', '', 'connecting')
})
} catch (e) {
console.error('Start call error', e)
if (e instanceof Error && (e.name === 'NotAllowedError' || e.name === 'NotFoundError')) {
toastStore.error('无法启动通话:请检查权限')
resetState()
}
}
}
async function acceptInvite() {
callState.incoming = false
callState.joined = true
try {
await initLocalMedia(callState.type === 'video')
startTimer()
await sendSignal('join', {})
} catch (e) {
console.error('Accept call error', e)
selectedUserIds.forEach(uid => {
if (uid !== callState.initiatorId) addParticipant(uid, '呼叫中...', '', 'connecting')
})
} catch (e) {
console.error('Start call error', e)
if (e instanceof Error && (e.name === 'NotAllowedError' || e.name === 'NotFoundError')) {
toastStore.error('无法启动通话:请检查权限')
resetState()
}
}
}
function rejectInvite() {
// 【核心修复】发送 reject 信号
sendSignal('reject', {})
async function acceptInvite() {
// 重入防护:已加入则忽略,防止连点接听时二次 initLocalMedia/startTimer
if (callState.joined) return
callState.incoming = false
callState.joined = true
try {
await initLocalMedia(callState.type === 'video')
startTimer()
await sendSignal('join', {})
} catch (e) {
console.error('Accept call error', e)
resetState()
}
}
async function joinCurrentCall() {
if (!callState.roomId) return
await acceptInvite()
}
function rejectInvite() {
// 【核心修复】发送 reject 信号
sendSignal('reject', {})
resetState()
}
function leaveCall() {
if (callState.joined) sendSignal('leave', {})
resetState()
}
async function joinCurrentCall() {
if (!callState.roomId) return
await acceptInvite()
}
// --- 辅助函数 ---
function addParticipant(userId: string, name: string, avatar: string, status: any) {
const idx = participants.value.findIndex(p => p.userId === userId)
if (idx === -1) participants.value.push({ userId, name, avatar, status, isMuted: false, isCamOff: false })
else participants.value[idx].status = status
}
function removeParticipant(userId: string) {
const idx = participants.value.findIndex(p => p.userId === userId)
if (idx > -1) participants.value.splice(idx, 1)
const pc = peerConnections.get(userId)
if (pc) { pc.close(); peerConnections.delete(userId) }
}
function updateParticipantStream(userId: string, stream: MediaStream) {
const p = participants.value.find(p => p.userId === userId)
if (p) { p.stream = stream; p.status = 'connected' }
}
function updateParticipantStatus(userId: string, status: any) {
const p = participants.value.find(p => p.userId === userId)
if (p) p.status = status
}
function updateParticipantState(userId: string, state: any) {
if (!state) return
const p = participants.value.find(p => p.userId === userId)
if (p) { p.isMuted = state.muted; p.isCamOff = state.camOff }
}
function leaveCall() {
if (callState.joined) sendSignal('leave', {})
resetState()
}
function toggleSelfMute() {
callState.isSelfMuted = !callState.isSelfMuted
if (localStream.value) localStream.value.getAudioTracks().forEach(t => t.enabled = !callState.isSelfMuted)
sendSignal('sync_state', { state: { muted: callState.isSelfMuted, camOff: callState.isSelfCamOff } })
}
// --- 辅助函数 ---
function addParticipant(userId: string, name: string, avatar: string, status: any) {
const idx = participants.value.findIndex(p => p.userId === userId)
if (idx === -1) participants.value.push({ userId, name, avatar, status, isMuted: false, isCamOff: false })
else participants.value[idx].status = status
}
function removeParticipant(userId: string) {
const idx = participants.value.findIndex(p => p.userId === userId)
if (idx > -1) participants.value.splice(idx, 1)
const pc = peerConnections.get(userId)
if (pc) { pc.close(); peerConnections.delete(userId) }
}
function updateParticipantStream(userId: string, stream: MediaStream) {
const p = participants.value.find(p => p.userId === userId)
if (p) { p.stream = stream; p.status = 'connected' }
}
function updateParticipantStatus(userId: string, status: any) {
const p = participants.value.find(p => p.userId === userId)
if (p) p.status = status
}
function updateParticipantState(userId: string, state: any) {
if (!state) return
const p = participants.value.find(p => p.userId === userId)
if (p) { p.isMuted = state.muted; p.isCamOff = state.camOff }
}
function toggleSelfCamera() {
callState.isSelfCamOff = !callState.isSelfCamOff
if (localStream.value) localStream.value.getVideoTracks().forEach(t => t.enabled = !callState.isSelfCamOff)
sendSignal('sync_state', { state: { muted: callState.isSelfMuted, camOff: callState.isSelfCamOff } })
}
function toggleSelfMute() {
callState.isSelfMuted = !callState.isSelfMuted
if (localStream.value) localStream.value.getAudioTracks().forEach(t => t.enabled = !callState.isSelfMuted)
sendSignal('sync_state', { state: { muted: callState.isSelfMuted, camOff: callState.isSelfCamOff } })
}
function startTimer() {
callState.startTime = Date.now()
durationTimer = window.setInterval(() => {
if (callState.startTime) callState.duration = Math.floor((Date.now() - callState.startTime) / 1000)
}, 1000)
}
function toggleSelfCamera() {
callState.isSelfCamOff = !callState.isSelfCamOff
if (localStream.value) localStream.value.getVideoTracks().forEach(t => t.enabled = !callState.isSelfCamOff)
sendSignal('sync_state', { state: { muted: callState.isSelfMuted, camOff: callState.isSelfCamOff } })
}
function stopTimer() {
if (durationTimer) clearInterval(durationTimer)
}
function startTimer() {
// 先清旧计时器:连点接听/重复进入会覆盖 durationTimer 引用,
// 旧 interval 永远清不掉(stopTimer 只能清最后一个),造成计时器泄漏
stopTimer()
callState.startTime = Date.now()
durationTimer = window.setInterval(() => {
if (callState.startTime) callState.duration = Math.floor((Date.now() - callState.startTime) / 1000)
}, 1000)
}
function formatDuration(seconds: number) {
const m = Math.floor(seconds / 60).toString().padStart(2, '0')
const s = (seconds % 60).toString().padStart(2, '0')
return `${m}:${s}`
}
function stopTimer() {
if (durationTimer) { clearInterval(durationTimer); durationTimer = null }
}
function formatDuration(seconds: number) {
const m = Math.floor(seconds / 60).toString().padStart(2, '0')
const s = (seconds % 60).toString().padStart(2, '0')
return `${m}:${s}`
}
/**
* 群通话组合式函数:状态与所有方法均为模块级单例,
* 多处调用 useGroupWebRTC() 拿到的是同一份状态和同一组函数引用。
*/
export function useGroupWebRTC() {
return {
callState,
localStream,

View File

@@ -9,7 +9,11 @@ import { useChatStore } from '@/stores/chat'
import { useAuthStore } from '@/stores/auth'
import { isRTMPMode, isAutoMode, shouldUseRTMP, parsePlatform, type Platform } from '@/config/call'
import type { ChatMessage, Contact, ICEServerConfig } from '@/types/api'
import type { CallStatus } from '@/types/message'
import { MessageType, type CallStatus } from '@/types/message'
import { createClientMsgId } from '@/utils/messageMerge'
import { parseMessageExtra } from '@/utils/messageTypes'
import { p2pBusy } from './callActivity'
import { isGroupCallBusy } from './useGroupWebRTC'
export interface CallState {
active: boolean
@@ -50,6 +54,15 @@ export function useWebRTC(
const chatStore = useChatStore()
const authStore = useAuthStore()
/**
* 动态读取当前登录用户ID
* 为什么这样写:userId 参数是在 store 创建时一次性传入的快照,
* 若实例化发生在登录完成前(如登录页阶段就触碰了 webrtc store),
* 这个快照永远是空字符串,整个通话模块会以空身份发信令导致全部失败。
* 因此优先实时读 authStore,参数仅作兜底。
*/
const getUserId = () => authStore.user?.id || userId
const call = reactive<CallState>({
active: false,
minimized: false,
@@ -74,6 +87,9 @@ export function useWebRTC(
const localStream = shallowRef<MediaStream | null>(null)
const remoteStream = shallowRef<MediaStream | null>(null)
let durationTimer: number | null = null
// 呼出超时:对方长时间不接(含离线)时自动结束,避免"正在呼叫"永远挂着
let outgoingTimeout: number | null = null
const OUTGOING_TIMEOUT_MS = 60000
let pc: RTCPeerConnection | null = null
const pendingCandidates: RTCIceCandidate[] = []
let currentReceiverUserId = ''
@@ -116,6 +132,15 @@ export function useWebRTC(
if (extra.type) call.type = extra.type
// invite 是唯一允许在空闲态处理的信令(它负责把 call.active 置 true)。
// 其余信令若当前无活跃通话一律忽略:否则空闲时收到一条孤立 offer 就会走
// initMedia→createPC 静默打开摄像头/麦克风(隐私问题 + 媒体流泄漏),
// 收到 accepted 还会把状态改成 connected 并启动计时器。
if (signal !== 'invite' && !call.active) {
console.warn('[WebRTC] 无活跃通话,忽略信令:', signal)
return
}
if (signal === 'sync_state') {
if (content.action === 'cam-toggle') call.remoteCamOff = content.value
else if (content.action === 'mic-toggle') call.remoteMuted = content.value
@@ -138,8 +163,23 @@ export function useWebRTC(
senderName: extra.senderName,
senderAvatar: extra.senderAvatar
})
if (call.active) {
console.log('[WebRTC] ⚠️ 当前有通话,忽略新来电')
if (call.active || isGroupCallBusy()) {
console.log('[WebRTC] ⚠️ 当前有通话(1v1或群),向来电者回复忙线')
// 忙线回执:用来电信令自带的 call_id/room_id 定向回复对方,
// 不能用 sendSignal(它绑定的是我当前通话的 call.id/接收者),
// 否则主叫会一直响铃、也不落"对方忙"记录
if (message.call_id && message.room_id && message.sender_user_id) {
messageApi.sendMessage({
sender_client_id: wsManager.getClientId() || '',
receiver_user_id: message.sender_user_id,
room_id: message.room_id,
message_type: MessageType.SIGNAL,
content: '{}',
call_id: message.call_id,
call_status: 'busy' as any,
extra: JSON.stringify({ type: extra.type, platform: 'web' })
}).catch(console.error)
}
return
}
isCaller.value = false
@@ -147,6 +187,7 @@ export function useWebRTC(
if (message.room_id) currentRoomId = message.room_id
call.id = message.call_id
call.active = true
p2pBusy.value = true // 收到来电即标记占线,避免同时被群通话邀请打断
call.minimized = false
call.status = 'incoming'
call.statusText = `邀请你通话`
@@ -175,6 +216,13 @@ export function useWebRTC(
if (onIncomingCall) onIncomingCall(message.sender_user_id)
} else if (signal === 'accepted') {
// 只有"我是主叫且处于呼出中"时才处理 accepted:
// 防止重复 accepted 对已协商完成的连接再次 createOffer(stable 态 InvalidStateError),
// 也防止非主叫端误处理
if (!isCaller.value || call.status !== 'outgoing') {
console.warn('[WebRTC] 忽略 accepted:非主叫或状态非 outgoing', { isCaller: isCaller.value, status: call.status })
return
}
// Auto 模式:从 accepted 信令中检测对方平台,决定最终使用的模式
const previousMode = useRTMP.value
if (extra.platform) {
@@ -192,6 +240,7 @@ export function useWebRTC(
console.log('[WebRTC] ✅ 对方已接听, 最终模式:', useRTMP.value ? 'RTMP' : 'WebRTC')
stopRingtone()
clearOutgoingTimeout() // 对方已接听,取消呼出超时
call.status = 'connected'
call.statusText = '通话中'
startCallTimer()
@@ -299,13 +348,29 @@ export function useWebRTC(
refreshRoomStreams()
}
} else if (['hangup', 'ended', 'answered_elsewhere'].includes(signal)) {
} else if (['hangup', 'ended', 'answered_elsewhere', 'busy'].includes(signal)) {
// call_id 校验:迟到/跨通话的终结信令不能误杀当前通话。
// 典型场景(多端):被叫的忙线设备回 busy 后,主叫会回发 hangup 让被叫其他
// 振铃设备停铃——该 hangup 带的是本次呼叫的 call_id,忙线设备正在进行的
// 另一通通话 call_id 不同,靠这里忽略,不会被误挂断
if (message.call_id && call.id && message.call_id !== call.id) {
console.log('[WebRTC] ⏭️ 忽略非当前通话的终结信令:', signal, message.call_id, '!=', call.id)
return
}
if (isCaller.value) {
if (call.status === 'outgoing') sendSummaryMessage('rejected')
if (signal === 'busy') sendSummaryMessage('busy')
else if (call.status === 'outgoing') sendSummaryMessage('rejected')
else if (call.status === 'connected') sendSummaryMessage('connected')
}
// 多端修复:被叫忙线设备自动回 busy 后,其空闲设备仍在振铃且无人通知,
// 若用户在空闲设备接听会因主叫已结束而卡在"正在连接"。
// 主叫收到 busy 后回发一条 hangup(带本次 call_id),让被叫所有振铃设备停铃收场
if (signal === 'busy' && isCaller.value) {
sendSignal('hangup')
}
closeCall()
if (signal === 'answered_elsewhere') toastStore.info('已在其他设备接听')
else if (signal === 'busy') toastStore.info('对方正在通话中')
}
} catch (error) {
console.error('Error handling signaling:', error)
@@ -331,8 +396,9 @@ export function useWebRTC(
const rid = getRoomId(targetUserId)
if (rid) return rid
}
if (targetUserId && userId) {
return [userId, targetUserId].sort().join('_')
if (targetUserId && getUserId()) {
// 与后端 GenerateP2PRoomID 规则一致:两个用户ID按字典序排序后用下划线连接
return [getUserId(), targetUserId].sort().join('_')
}
return null
}
@@ -362,36 +428,44 @@ export function useWebRTC(
}
// 构造通话结果的 extra 数据
// client_msg_id:后端会把消息回推给发送者所有设备做多端同步,
// 本地乐观插入的副本必须能与 WS 回声按 client_msg_id 合并,
// 否则主叫每次通话结束摘要气泡会显示两条(本地一条 + 回声一条)
const extraData = {
call_type: call.type,
call_reason: reason,
call_duration: call.duration,
call_id: call.id
call_id: call.id,
client_msg_id: createClientMsgId()
}
try {
// 发送系统消息 (message_type = 4)
// 发送系统消息(通话结束摘要,落库后双方可见)
const payload = {
sender_client_id: wsManager.getClientId() || '',
receiver_user_id: currentReceiverUserId,
room_id: roomId,
message_type: 4, // 系统消息
message_type: MessageType.SYSTEM,
content: content,
extra: JSON.stringify(extraData)
}
await messageApi.sendMessage(payload)
const saved = await messageApi.sendMessage(payload)
// 同时添加到本地消息列表
const systemMessage: ChatMessage = {
id: Date.now(),
room_id: roomId,
sender_user_id: userId,
message_type: 4,
content: content,
extra: extraData,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
}
// 优先用服务端返回的消息(带真实数据库 ID)插入本地:
// WS 回声携带同一 id,会被 mergeMessageIntoList 按 id 去重;
// HTTP 未返回有效消息时退回本地临时消息,回声靠 extra.client_msg_id 合并
const systemMessage: ChatMessage = saved?.id
? { ...saved, extra: parseMessageExtra(saved.extra) }
: {
id: Date.now(),
room_id: roomId,
sender_user_id: getUserId(),
message_type: MessageType.SYSTEM,
content: content,
extra: extraData,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
}
chatStore.addMessage(roomId, systemMessage)
} catch (error) {
console.error('发送通话结束消息失败:', error)
@@ -411,7 +485,7 @@ export function useWebRTC(
}
try {
const servers = await systemApi.getIceServers(userId)
const servers = await systemApi.getIceServers(getUserId())
if (servers && servers.length > 0) {
// 转换为 RTCIceServer 格式
cachedIceServers = servers.map((s: ICEServerConfig) => ({
@@ -501,7 +575,7 @@ export function useWebRTC(
sender_client_id: wsManager.getClientId() || '',
receiver_user_id: targetUserId,
room_id: roomId,
message_type: 6,
message_type: MessageType.SIGNAL,
content: JSON.stringify(data || {}),
call_id: call.id,
call_status: status,
@@ -526,9 +600,9 @@ export function useWebRTC(
sender_client_id: wsManager.getClientId() || '',
receiver_user_id: currentReceiverUserId,
room_id: roomId,
message_type: 6,
message_type: MessageType.SIGNAL,
content: JSON.stringify({ action: type, value }),
call_id: call.id,
call_id: call.id || undefined,
call_status: 'sync_state' as any,
extra: JSON.stringify({ type: call.type })
}
@@ -570,6 +644,7 @@ export function useWebRTC(
call.type = type
call.id = Date.now().toString()
call.active = true
p2pBusy.value = true // 标记 1v1 占线,供群通话侧互查
call.minimized = false
call.status = 'outgoing'
call.statusText = '正在呼叫...'
@@ -596,6 +671,8 @@ export function useWebRTC(
playRingtone('dialing')
console.log('[WebRTC] 📤 发送 invite 信令给:', receiverUserId)
sendSignal('invite', undefined, receiverUserId)
// 呼出超时:60s 内对方没接(含离线),主叫侧自动挂断并落"对方未接听"
startOutgoingTimeout()
return true
} catch (error: any) {
console.error('[WebRTC] ❌ 发起通话失败:', error)
@@ -606,6 +683,12 @@ export function useWebRTC(
}
async function acceptCall(senderUserId?: string) {
// 重入防护:只有处于来电振铃态才允许接听。
// 连点接听会二次 initMedia/createPC 并发出两个 accepted,导致协商竞态
if (call.status !== 'incoming') {
console.warn('[WebRTC] 忽略重复接听,当前状态:', call.status)
return
}
// 接听时 useRTMP 已经在 invite 处理时设置好了
console.log('[WebRTC] 📞 接听来电:', { senderUserId, currentRoomId, mode: useRTMP.value ? 'RTMP' : 'WebRTC' })
@@ -660,13 +743,24 @@ export function useWebRTC(
}
// 被叫方不发送消息,由发起方统一发送
sendSignal('hangup')
// 离开通话房间
leaveCallRoom()
// 离开房间统一由 closeCall 处理,这里不再单独调用,避免重复请求
closeCall()
}
function closeCall() {
stopRingtone()
clearOutgoingTimeout()
p2pBusy.value = false // 通话结束,解除 1v1 占线标记
// 静音/摄像头标志必须在这里统一清零:否则上一通静音状态下挂断,
// 下一通 UI 会显示"已静音"但 initMedia 新建的音轨其实是开着的(显示与实际相反)
call.muted = false
call.remoteMuted = false
call.camOff = false
call.remoteCamOff = false
// 先通知服务端离开通话房间,再清 currentRoomId。
// 原实现只有 endCall 主动挂断才 leaveCallRoom,对方挂断/拒接/呼叫失败等
// 路径直接走 closeCall,服务端房间里会残留成员,影响下次通话的房间状态
leaveCallRoom()
currentRoomId = ''
call.active = false
call.status = 'idle'
@@ -685,6 +779,9 @@ export function useWebRTC(
pc.close()
pc = null
}
// 清空未消费的 ICE 候选:不清的话下次通话 createPC 后会把上一通的旧候选
// addIceCandidate 进新连接,导致连接失败或串流
pendingCandidates.length = 0
if (localStream.value) {
localStream.value.getTracks().forEach(t => t.stop())
localStream.value = null
@@ -699,7 +796,7 @@ export function useWebRTC(
* 加入通话房间并获取小程序用户的 FLV 流地址
*/
async function joinCallRoomAndGetFlvUrls(): Promise<void> {
console.log('[WebRTC] 🚪 准备加入房间:', { roomId: currentRoomId, userId })
console.log('[WebRTC] 🚪 准备加入房间:', { roomId: currentRoomId, userId: getUserId() })
if (!currentRoomId) {
console.error('[WebRTC] ❌ joinCallRoomAndGetFlvUrls: currentRoomId 为空,跳过')
@@ -710,7 +807,7 @@ export function useWebRTC(
console.log('[WebRTC] 📤 发送加入房间请求...')
const response = await callApi.joinCallRoom({
room_id: currentRoomId,
user_id: userId,
user_id: getUserId(),
platform: 'web'
})
@@ -774,7 +871,7 @@ export function useWebRTC(
// 构建参与者信息,包含自己的 FLV 地址供小程序拉流
const participantInfo = {
user_id: userId,
user_id: getUserId(),
platform: 'web',
// Web 端的 FLV 地址(供小程序拉流)
pull_url: response.ws_push_url || '', // WebSocket 推流地址(仅供参考)
@@ -787,7 +884,7 @@ export function useWebRTC(
sender_client_id: wsManager.getClientId() || '',
receiver_user_id: currentReceiverUserId,
room_id: currentRoomId,
message_type: 6,
message_type: MessageType.SIGNAL,
content: JSON.stringify(participantInfo),
call_id: call.id,
call_status: 'participant_joined' as any,
@@ -809,7 +906,7 @@ export function useWebRTC(
try {
const response = await callApi.joinCallRoom({
room_id: currentRoomId,
user_id: userId,
user_id: getUserId(),
platform: 'web'
})
@@ -1011,7 +1108,7 @@ export function useWebRTC(
try {
await callApi.leaveCallRoom({
room_id: currentRoomId,
user_id: userId
user_id: getUserId()
})
} catch (error) {
console.error('[WebRTC] 离开房间失败:', error)
@@ -1154,10 +1251,30 @@ export function useWebRTC(
call.duration = 0
}
// 呼出超时计时器:主叫发出 invite 后启动,接通/挂断时清除
function startOutgoingTimeout() {
clearOutgoingTimeout()
outgoingTimeout = window.setTimeout(() => {
// 仍处于呼出中说明对方一直没接(或离线),主动结束
if (call.active && call.status === 'outgoing') {
console.warn('[WebRTC] ⏰ 呼出超时,对方无应答')
toastStore.info('对方无应答')
endCall()
}
}, OUTGOING_TIMEOUT_MS)
}
function clearOutgoingTimeout() {
if (outgoingTimeout) { clearTimeout(outgoingTimeout); outgoingTimeout = null }
}
/**
* 格式化通话时长(有小时位时分钟必须 %60,否则 3661 秒会显示成 01:61:01)
*/
function formatDuration(seconds: number): string {
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}`
}

View File

@@ -1,5 +1,6 @@
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const routes: RouteRecordRaw[] = [
{
@@ -61,13 +62,34 @@ const router = createRouter({
routes,
})
// 是否已完成首次服务端 Token 校验。
// 每次页面加载只远程校验一次(避免每次路由跳转都请求服务端),后续导航由 401 拦截器兜底。
let hasValidatedToken = false
// 路由守卫
router.beforeEach((to, from, next) => {
const token = localStorage.getItem('token')
if (to.meta.requiresAuth && !token) {
next('/login')
} else if (to.path === '/login' && token) {
// 为什么这样写:原实现只检查 localStorage 里是否存在 token 字符串,不校验有效性,
// 失效/伪造的 token 也能进入应用,随后到处 401。改为首次导航时调用 checkAuth 向服务端校验。
router.beforeEach(async (to, _from, next) => {
const authStore = useAuthStore()
if (to.meta.requiresAuth) {
// 1. 本地无 token,直接去登录页
if (!authStore.token) {
next('/login')
return
}
// 2. 首次进入受保护页面时,向服务端校验 token 有效性(校验失败 checkAuth 内部会登出)
if (!hasValidatedToken) {
const valid = await authStore.checkAuth()
hasValidatedToken = true
if (!valid) {
next('/login')
return
}
}
next()
} else if (to.path === '/login' && authStore.token) {
// 已登录访问登录页,重定向到聊天页
next('/chat')
} else {
next()

View File

@@ -3,7 +3,14 @@ import { ref } from 'vue'
import * as authApi from '@/api/modules/auth'
import * as userApi from '@/api/modules/user'
import { storage } from '@/utils/storage'
import type { User, LoginRequest } from '@/types/api'
import { wsManager } from '@/api/websocket'
import { useChatStore } from './chat'
import { useConversationStore } from './conversation'
import { useContactStore } from './contact'
import { useMomentStore } from './moment'
import { useWebRTCStore } from './webrtc'
import { useGroupWebRTC } from '@/composables/useGroupWebRTC'
import type { User, LoginRequest, RegisterRequest } from '@/types/api'
export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(storage.getToken())
@@ -35,10 +42,24 @@ export const useAuthStore = defineStore('auth', () => {
}
}
/**
* 使用已签发的凭证直接登录(扫码登录场景:
* token 由后端在 App 确认后签发,PC 轮询取到后走这里写入登录态,
* 与账号密码登录共用同一套存储逻辑,保证后续请求/刷新行为一致)
*/
function loginWithToken(newToken: string, userInfo: User) {
token.value = newToken
user.value = userInfo
isAuthenticated.value = true
storage.setToken(newToken)
storage.setUserId(userInfo.id)
storage.setUserInfo(userInfo)
}
/**
* 注册
*/
async function register(data: any) {
async function register(data: RegisterRequest) {
try {
await authApi.register(data)
} catch (error) {
@@ -49,16 +70,53 @@ export const useAuthStore = defineStore('auth', () => {
/**
* 登出
* 为什么要做这么多清理:原实现只清 token/user,
* 聊天消息、会话、好友、朋友圈等仍留在内存里且 WebSocket 不断开,
* 换账号登录后会看到上一个账号的数据(串号)、并继续收到旧账号的推送
*/
function logout() {
// 先结束进行中的通话并释放摄像头/麦克风:
// 否则通话中登出,App.vue 的全局 CallWindow 会继续渲染在登录页上,设备也不释放。
// 放在清 token 之前,保证挂断信令还能带着有效身份发出去
try {
const wr = useWebRTCStore().webrtc
if (wr?.call?.active) wr.endCall()
} catch (e) {
console.warn('logout: 结束 1v1 通话失败', e)
}
try {
const g = useGroupWebRTC()
if (g.callState.joined || g.callState.incoming) g.leaveCall()
} catch (e) {
console.warn('logout: 结束群通话失败', e)
}
// 通知服务端登出(更新在线状态/使 token 失效)。
// fire-and-forget:失败不阻塞本地登出;显式传 token 避免与下方清空 storage 竞争
if (token.value) {
authApi.logout(token.value).catch(() => {})
}
token.value = null
user.value = null
isAuthenticated.value = false
// 停止 Token 定时校验,避免登出后定时器仍在跑
stopTokenAutoCheck()
// 主动断开 WebSocket(内部会标记为主动断开,不触发自动重连)
wsManager.disconnect()
// 重置各业务 Store,防止换账号串数据
useChatStore().reset()
useConversationStore().reset()
useContactStore().reset()
useMomentStore().reset()
storage.clear()
}
/**
* 检查Token并获取用户信息
* 为什么这样写:需要区分"网络错误"与"Token 无效"两种失败:
* - 网络错误(axios 请求发不出去,无响应):可能只是服务暂时不可达,不应误登出用户;
* - Token 无效(服务端明确返回认证失败):清除本地会话并登出。
*/
async function checkAuth() {
if (!token.value) {
@@ -72,8 +130,13 @@ export const useAuthStore = defineStore('auth', () => {
user.value = userInfo
storage.setUserInfo(userInfo)
return true
} catch (error) {
// Token无效,清除
} catch (error: any) {
// 网络层错误(AxiosError 且无 response):保留会话,避免服务重启期间把用户踢下线
if (error?.isAxiosError && !error.response) {
console.warn('checkAuth: 网络异常,暂不登出')
return true
}
// Token确认无效,清除本地会话
logout()
return false
}
@@ -143,6 +206,7 @@ export const useAuthStore = defineStore('auth', () => {
user,
isAuthenticated,
login,
loginWithToken,
register,
logout,
checkAuth,

View File

@@ -132,15 +132,24 @@ export const useChatStore = defineStore('chat', () => {
}
}
// 群通知负载结构:data 为通知附带的业务数据(成员变更/禁言等,结构随 type 而异),
// _timestamp 用于让 watch 侦测到"同类型通知连续到达"(内容相同但需要再次触发刷新)
interface GroupNotification {
room_id: string
type: string
data: Record<string, unknown>
_timestamp?: number
}
// 群通知相关状态
const lastGroupNotification = ref<{ room_id: string; type: string; data: any } | null>(null)
const lastGroupNotification = ref<GroupNotification | null>(null)
const myMuteStatus = ref<Record<string, string | null>>({}) // room_id -> muted_until
/**
* 设置最后的群通知(用于触发群面板刷新)
*/
function setLastGroupNotification(notification: { room_id: string; type: string; data: any }) {
lastGroupNotification.value = { ...notification, _timestamp: Date.now() } as any
function setLastGroupNotification(notification: Omit<GroupNotification, '_timestamp'>) {
lastGroupNotification.value = { ...notification, _timestamp: Date.now() }
}
/**
@@ -166,6 +175,19 @@ export const useChatStore = defineStore('chat', () => {
return myMuteStatus.value[roomId] || null
}
/**
* 重置聊天状态(登出时调用)
* 为什么需要:登出只清了 token/user,聊天消息、联系人等仍留在内存里,
* 换账号登录后会把上一个账号的消息/联系人渲染出来(串号)
*/
function reset() {
currentTarget.value = null
messages.value = {}
contacts.value = []
lastGroupNotification.value = null
myMuteStatus.value = {}
}
return {
currentTarget,
messages,
@@ -188,6 +210,7 @@ export const useChatStore = defineStore('chat', () => {
setMyMuteStatus,
isMyMuted,
getMyMutedUntil,
reset,
}
})

View File

@@ -98,6 +98,16 @@ export const useContactStore = defineStore('contact', () => {
storage.setContactListTab(newTab)
})
/**
* 重置联系人状态(登出时调用,防止换账号后看到上一个账号的好友申请/分组/选中项)
*/
function reset() {
friendRequests.value = []
groups.value = []
selectedContact.value = null
leftPanelMode.value = 'default'
}
return {
friendRequests,
groups,
@@ -113,6 +123,7 @@ export const useContactStore = defineStore('contact', () => {
removeGroup,
setLeftPanelMode,
setSelectedContact,
reset,
}
})

View File

@@ -207,6 +207,14 @@ export const useConversationStore = defineStore('conversation', () => {
return getMessageSummary(msg)
}
/**
* 重置会话状态(登出时调用,防止换账号后看到上一个账号的会话列表)
*/
function reset() {
conversations.value = []
loading.value = false
}
return {
conversations,
loading,
@@ -215,6 +223,7 @@ export const useConversationStore = defineStore('conversation', () => {
handleMessageUpdate,
incrementUnread,
clearUnread,
sortConversations
sortConversations,
reset
}
})

View File

@@ -179,28 +179,34 @@ export const useMomentStore = defineStore('moment', () => {
// ==========================================
/**
* 切换点赞状态
* 切换点赞状态(乐观更新 + 失败回滚)
* 为什么这样写:先本地更新 is_liked/like_count 让 UI 即时响应,
* 请求失败时必须把两个字段一起回滚——原实现只回滚 is_liked 不回滚 like_count,
* 失败后会出现"没点亮但数字加了1"的不一致状态
*/
async function toggleLike(momentId: number) {
const moment = moments.value.find((m) => m.id === momentId) || currentMoment.value
if (!moment) return
// 快照回滚点:is_liked 与 like_count 必须成对保存、成对恢复
const wasLiked = moment.is_liked
const prevCount = moment.like_count
// 乐观更新(取消赞时数字下限为0,防止脏数据出现负数)
moment.is_liked = !wasLiked
moment.like_count = wasLiked ? Math.max(0, prevCount - 1) : prevCount + 1
try {
if (wasLiked) {
await momentApi.unlikeMoment(momentId)
moment.is_liked = false
moment.like_count = Math.max(0, moment.like_count - 1)
} else {
await momentApi.likeMoment(momentId)
moment.is_liked = true
moment.like_count += 1
}
} catch (error) {
console.error('点赞操作失败:', error)
// 恢复状态
// 请求失败:完整回滚点赞状态与数量
moment.is_liked = wasLiked
moment.like_count = prevCount
throw error
}
}

View File

@@ -11,26 +11,22 @@ export const useWebRTCStore = defineStore('webrtc', () => {
const chatStore = useChatStore()
// --- 回调:处理收到来电 ---
function handleIncomingCall(senderUserId: string) {
// 查找来电者的联系人信息
const contact = chatStore.contacts.find((c) => c.user_id === senderUserId || c.id === senderUserId)
// 如果找到了联系人,且当前聊天窗口不是他,则切换过去 (可选,视需求而定)
if (contact && (!chatStore.currentTarget || chatStore.currentTarget.id !== contact.id)) {
chatStore.setCurrentTarget(contact)
}
// 注意:这里不再切换 currentTarget。
// 原实现来电时把 currentTarget 硬切成来电者,但绕过了 selectChat(不加载消息/成员/已读),
// 会把用户正在看的聊天窗切成空白;而且来电者信息应由通话窗自己用信令携带的
// callerName/callerAvatar 显示,不依赖 currentTarget。
function handleIncomingCall(_senderUserId: string) {
// 保留回调入口以便未来扩展(如系统通知),当前无需改动会话状态
}
// --- 回调:获取房间ID策略 ---
// 这个函数非常关键,它定义了如何找到两个人聊天的房间ID
// 必须在 useWebRTC 之前定义,因为它被作为参数传入
// 优先级说明:显式接收者对应的房间 > 当前聊天窗口 > 本地缓存。
// 原实现把 storage 里的 selected_room_id 缓存放在第一位,
// 该缓存可能是上次会话遗留的过期值,导致通话被建到错误的房间里
function getRoomId(receiverUserId?: string): string {
// 策略 1: 优先从本地存储获取当前选中的房间ID (最准确)
const cachedRoomId = storage.getSelectedRoomId()
if (cachedRoomId) {
return cachedRoomId
}
// 策略 2: 如果提供了接收者ID,尝试从联系人列表中查找对应的 room_id
// 策略 1: 如果提供了接收者ID,从联系人列表中查找对应的 room_id(最准确)
if (receiverUserId) {
const contact = chatStore.contacts.find((c) =>
c.user_id === receiverUserId ||
@@ -43,12 +39,18 @@ export const useWebRTCStore = defineStore('webrtc', () => {
}
}
// 策略 3: 使用当前打开的聊天窗口的 room_id
// 策略 2: 使用当前打开的聊天窗口的 room_id
const currentTarget = chatStore.currentTarget
if (currentTarget && currentTarget.room_id) {
return currentTarget.room_id
}
// 策略 3: 本地存储的选中房间ID仅作最后兜底(可能过期,不可优先信任)
const cachedRoomId = storage.getSelectedRoomId()
if (cachedRoomId) {
return cachedRoomId
}
// 如果都找不到,返回空字符串,后续逻辑会报错提示
return ''
}

View File

@@ -40,6 +40,8 @@ export interface User {
avatar: string
desc: string
region: string
moment_cover?: string // 朋友圈顶部封面图URL(空则前端展示默认封面)
is_bot?: boolean // 是否为AI机器人虚拟用户(详情页隐藏通话/删除等入口)
is_online?: boolean // 用户在线状态
last_online?: string // 最后在线时间
created_at: string
@@ -67,6 +69,7 @@ export interface Contact {
last_time?: number
unread?: number
color?: string
is_bot?: boolean // 是否为AI机器人(列表接口动态拼接的虚拟联系人,不可删除)
}
// 好友申请

View File

@@ -2,12 +2,26 @@
* 格式化工具函数
*/
/**
* 时间戳统一转 Date
* 为什么需要:后端部分接口返回的是"秒级"Unix时间戳(10位数字),
* 直接 new Date(秒级数字) 会被 JS 当成毫秒解析,得到 1970 年的错误时间。
* 这里对小于 1e12 的数字视为秒级并 *1000 转毫秒(1e12 毫秒 ≈ 2001年,
* 正常业务毫秒时间戳都大于该值,秒级时间戳到 33658 年前都小于该值,可安全区分)
*/
function toDate(timestamp: number | string): Date {
if (typeof timestamp === 'number' && timestamp < 1e12) {
return new Date(timestamp * 1000)
}
return new Date(timestamp)
}
/**
* 格式化时间(相对时间)
* 显示:刚刚、X分钟前、X小时前、昨天、X天前、具体日期
*/
export function formatTime(timestamp: number | string): string {
const date = new Date(typeof timestamp === 'string' ? timestamp : timestamp)
const date = toDate(timestamp)
const now = new Date()
const diff = now.getTime() - date.getTime()
const seconds = Math.floor(diff / 1000)
@@ -60,13 +74,12 @@ export function formatTime(timestamp: number | string): string {
* 显示:刚刚、X分钟前、X小时前、昨天 HH:mm、MM-DD HH:mm、YYYY-MM-DD HH:mm
*/
export function formatMessageTime(timestamp: number | string): string {
const date = new Date(typeof timestamp === 'string' ? timestamp : timestamp)
const date = toDate(timestamp)
const now = new Date()
const diff = now.getTime() - date.getTime()
const seconds = Math.floor(diff / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
// 刚刚(1分钟内)
if (seconds < 60) {
@@ -128,21 +141,46 @@ export function formatSize(bytes: number): string {
}
/**
* 格式化时长(秒转MM:SS)
* 格式化时长(秒转 MM:SS,超过1小时转 HH:MM:SS)
* 注意:有小时位时分钟必须 %60,否则 3661 秒会显示成 01:61:01
*/
export function formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
const h = Math.floor(seconds / 3600)
const mins = Math.floor(seconds / 60) % 60
const secs = Math.floor(seconds % 60)
const mm = mins.toString().padStart(2, '0')
const ss = secs.toString().padStart(2, '0')
return h > 0 ? `${h.toString().padStart(2, '0')}:${mm}:${ss}` : `${mm}:${ss}`
}
/**
* HTML 转义(防 XSS)
* 任何要插入 v-html 的用户输入文本,必须先调用本函数转义 & < > " ' 等特殊字符
*/
export function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
/**
* 解析文本中的URL
* 为什么这样写:消息内容会通过 v-html 渲染,必须先整体 HTML 转义,
* 防止 <script>/<img onerror> 等恶意标签被执行(存储型 XSS);转义后再把 URL 替换为可点击链接。
*/
export function parseText(text: string): string {
if (!text) return '&nbsp;'
// 步骤1: 先整体转义,杜绝任何 HTML 注入
const escaped = escapeHtml(text)
// 步骤2: 再匹配 URL 替换为链接(URL 中的特殊字符已转义为实体,无法破坏属性结构)
const urlRegex = /(https?:\/\/[^\s]+)/g
return text.replace(urlRegex, '<a href="$1" target="_blank" class="text-blue-400 hover:underline pointer-events-auto">$1</a>')
return escaped.replace(
urlRegex,
'<a href="$1" target="_blank" rel="noopener noreferrer" class="text-blue-400 hover:underline pointer-events-auto">$1</a>'
)
}
/**

View File

@@ -26,8 +26,19 @@ export function createClientMsgId(): string {
* 合并消息到列表:按服务端 id 去重,或按 client_msg_id 替换 pending 项
*/
export function mergeMessageIntoList(list: ChatMessage[], message: ChatMessage): boolean {
if (message.id && list.some((m) => m.id === message.id)) {
return false
if (message.id) {
const existingIdx = list.findIndex((m) => m.id === message.id)
if (existingIdx > -1) {
const existing = list[existingIdx]
// 撤回场景:服务端用同一条 id 重推,但 message_type/content 已变(变成"消息已撤回")。
// 原实现命中 id 直接 return false 丢弃,导致接收方开着会话时对方撤回的气泡永远不变。
// 内容有变化则原地更新,保留本地已有的 isSelf 标记。
if (existing.message_type !== message.message_type || existing.content !== message.content) {
list[existingIdx] = { ...existing, ...message, isSelf: existing.isSelf ?? message.isSelf, pending: false }
return true
}
return false
}
}
const clientMsgId = getClientMsgId(message)

View File

@@ -12,6 +12,26 @@ export interface MessageTypeConfig {
summaryFn: (msg: ChatMessage) => string
}
/**
* 安全解析消息 extra 字段(统一 JSON.parse 防护)
* 为什么需要:extra 来自服务端/历史数据,可能是对象、JSON 字符串或损坏的字符串。
* 裸 JSON.parse 遇到损坏数据会抛异常,渲染链路上一条脏消息就能让整个列表崩溃,
* 这里统一 try/catch 兜底返回空对象。
*/
export function parseMessageExtra(extra: unknown): Record<string, any> {
if (!extra) return {}
if (typeof extra === 'object') return extra as Record<string, any>
if (typeof extra === 'string') {
try {
const parsed = JSON.parse(extra)
return typeof parsed === 'object' && parsed !== null ? parsed : {}
} catch {
return {}
}
}
return {}
}
/**
* 消息类型配置映射表
*/
@@ -40,7 +60,7 @@ export const messageTypeConfigs: Record<number, MessageTypeConfig> = {
label: '系统消息',
icon: 'fas fa-info-circle',
summaryFn: (msg) => {
const extra = typeof msg.extra === 'string' ? JSON.parse(msg.extra || '{}') : (msg.extra || {})
const extra = parseMessageExtra(msg.extra)
return extra.title || msg.content || '[系统消息]'
}
},
@@ -48,7 +68,7 @@ export const messageTypeConfigs: Record<number, MessageTypeConfig> = {
label: '好友通知',
icon: 'fas fa-user-plus',
summaryFn: (msg) => {
const extra = typeof msg.extra === 'string' ? JSON.parse(msg.extra || '{}') : (msg.extra || {})
const extra = parseMessageExtra(msg.extra)
return extra.title || msg.content || '[好友通知]'
}
},
@@ -66,7 +86,7 @@ export const messageTypeConfigs: Record<number, MessageTypeConfig> = {
return msg.content
}
const extra = typeof msg.extra === 'string' ? JSON.parse(msg.extra || '{}') : (msg.extra || {})
const extra = parseMessageExtra(msg.extra)
// 根据事件类型生成更具体的文案(仅在无 content 时使用)
if (extra.type === 'member_invite' || extra.event === 'member_invite') {
const operatorName = extra.operator_name || '未知用户'
@@ -97,7 +117,7 @@ export const messageTypeConfigs: Record<number, MessageTypeConfig> = {
label: '文件',
icon: 'fas fa-file',
summaryFn: (msg) => {
const extra = typeof msg.extra === 'string' ? JSON.parse(msg.extra || '{}') : (msg.extra || {})
const extra = parseMessageExtra(msg.extra)
return extra.name ? `[文件] ${extra.name}` : '[文件]'
}
},
@@ -105,7 +125,7 @@ export const messageTypeConfigs: Record<number, MessageTypeConfig> = {
label: '朋友圈通知',
icon: 'fas fa-heart',
summaryFn: (msg) => {
const extra = typeof msg.extra === 'string' ? JSON.parse(msg.extra || '{}') : (msg.extra || {})
const extra = parseMessageExtra(msg.extra)
return extra.title || msg.content || '[朋友圈通知]'
}
}

View File

@@ -125,7 +125,11 @@ export const storage = {
this.removeUserId()
this.removeUserInfo()
this.setRemember(false)
// 不清除 UI 状态,让用户刷新后保持界面状态
// 账号相关的选中状态必须一并清除:selected_conversation / selected_room_id
// 属于上一个账号,残留会让下一个账号登录后自动打开不属于自己的会话(甚至发错消息)
this.setSelectedConversation(null)
this.setSelectedRoomId(null)
// 纯 UI 偏好(当前 tab、面板模式等)保留,刷新后保持界面状态
},
}

29
src/utils/validate.ts Normal file
View File

@@ -0,0 +1,29 @@
/**
* 表单校验工具
* 集中管理格式校验规则,登录/注册等表单复用,避免各处散落正则
*/
/** 校验邮箱格式 */
export function isValidEmail(email: string): boolean {
return /^[\w.%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(email)
}
/** 校验中国大陆手机号(11位,1开头,第二位3-9) */
export function isValidPhone(phone: string): boolean {
return /^1[3-9]\d{9}$/.test(phone)
}
/**
* 校验密码强度
* 规则:至少8位,且同时包含字母和数字。
* 返回 message 便于直接 toast 提示,避免调用方各自拼错误文案
*/
export function checkPasswordStrength(password: string): { valid: boolean; message: string } {
if (password.length < 8) {
return { valid: false, message: '密码至少需要8位' }
}
if (!/[a-zA-Z]/.test(password) || !/\d/.test(password)) {
return { valid: false, message: '密码需同时包含字母和数字' }
}
return { valid: true, message: '' }
}

View File

@@ -87,6 +87,16 @@
<i class="fas fa-phone-alt text-xl"></i>
</div>
<!-- AI 与机器人管理:仅管理员(id=1)可见 -->
<div
v-if="isAdminUser"
class="mb-2 text-gray-500 hover:text-primary cursor-pointer transition transform hover:scale-110 w-10 h-10 flex items-center justify-center rounded-xl hover:bg-white/5"
@click="showAIManageModal = true"
title="AI 与机器人"
>
<i class="fas fa-robot text-xl"></i>
</div>
<div
class="mt-auto mb-2 text-gray-500 hover:text-red-500 cursor-pointer transition transform hover:scale-110 w-10 h-10 flex items-center justify-center rounded-xl hover:bg-white/5"
@click="handleLogout"
@@ -264,8 +274,8 @@
</div>
<div class="flex gap-2 text-gray-400">
<!-- 单聊才显示通话按钮 -->
<template v-if="!isGroupChat">
<!-- 单聊才显示通话按钮;AI 机器人只支持文字聊天,同样隐藏 -->
<template v-if="!isGroupChat && !isCurrentTargetBot">
<button class="action-btn" title="语音通话" @click="startCall('audio')"><i class="fas fa-phone"></i></button>
<button class="action-btn" title="视频通话" @click="startCall('video')"><i class="fas fa-video"></i></button>
</template>
@@ -323,6 +333,7 @@
@record-start="handleRecordStart"
@record-stop="handleRecordStop"
@record-cancel="handleRecordCancel"
@mute-expired="handleMuteExpired"
/>
</div>
@@ -333,6 +344,7 @@
@member-removed="handleGroupMemberRemoved"
@member-clicked="handleGroupMemberClicked"
@group-updated="handleGroupUpdated"
@open-chat="selectChat"
@show-info="showGroupInfoPanel = true"
/>
</div>
@@ -391,7 +403,7 @@
<MomentPanel />
</div>
<ContextMenu />
<FileConfirmModal :show="fileModal.show" :type="fileModal.type" :preview="fileModal.preview" :name="fileModal.name" :size="fileModal.size" @close="fileModal.show = false" @confirm="confirmSendFile" />
<FileConfirmModal :show="fileModal.show" :type="fileModal.type" :preview="fileModal.preview" :name="fileModal.name" :size="fileModal.size" :loading="sendingFile" @close="fileModal.show = false" @confirm="confirmSendFile" />
<ConfirmModal
:show="showDeleteConversationConfirm"
title="删除会话"
@@ -712,6 +724,9 @@
@audio-call="handleAudioCallFromInfoCard"
@video-call="handleVideoCallFromInfoCard"
/>
<!-- AI 与机器人管理弹窗(仅管理员 id=1 可打开) -->
<AIBotManageModal :show="showAIManageModal" @close="showAIManageModal = false" />
</div>
</template>
@@ -733,11 +748,12 @@ import * as attachmentApi from '@/api/modules/attachment'
import * as conversationApi from '@/api/modules/conversation'
import * as userApi from '@/api/modules/user'
import { formatTime, generateColor } from '@/utils/format'
import { getMessageSummary } from '@/utils/messageTypes'
import { getMessageSummary, parseMessageExtra } from '@/utils/messageTypes'
import { createClientMsgId } from '@/utils/messageMerge'
import { storage } from '@/utils/storage'
import type { Contact, ChatMessage, User } from '@/types/api'
import type { Conversation } from '@/types/conversation'
import { MessageType } from '@/types/message'
import { useWebRTCStore } from '@/stores/webrtc'
// Components...
import Avatar from '@/components/common/Avatar.vue'
@@ -754,6 +770,7 @@ import GroupNotifyView from '@/views/contact/GroupNotifyView.vue'
import SelectContactsModal from '@/components/chat/SelectContactsModal.vue'
import GroupInfoPanel from '@/components/chat/GroupInfoPanel.vue'
import GroupChatPanel from '@/components/chat/GroupChatPanel.vue'
import AIBotManageModal from '@/components/common/AIBotManageModal.vue'
import GroupCallBanner from '@/components/chat/GroupCallBanner.vue'
import UserInfoCard from '@/components/common/UserInfoCard.vue'
import MomentPanel from '@/components/moment/MomentPanel.vue'
@@ -795,11 +812,16 @@ const chatVisible = ref(false)
const isMobile = ref(window.innerWidth < 768)
const showProfileModal = ref(false)
const showCallHistoryModal = ref(false)
// AI 与机器人管理弹窗:仅管理员(id=1)可打开
const showAIManageModal = ref(false)
const isAdminUser = computed(() => authStore.user?.id === '1')
const callHistory = ref<ChatMessage[]>([])
const loadingCallHistory = ref(false)
const selectedUser = ref<Contact | User | null>(null)
const isRecording = ref(false)
const fileModal = ref({ show: false, type: 0, preview: '', name: '', size: 0, file: null as File | null })
// 文件发送锁:防止确认弹窗连点导致同一文件重复上传发送
const sendingFile = ref(false)
const showCreateGroupModal = ref(false)
const showDeleteConversationConfirm = ref(false)
const pendingDeleteConversation = ref<Conversation | null>(null)
@@ -817,6 +839,10 @@ const selectedAvatarFile = ref<File | null>(null)
const showUserInfoCard = ref(false)
const selectedUserForInfo = ref<User | Contact | null>(null)
const roomMembers = ref<Record<string, Record<string, { name: string; avatar?: string }>>>({})
// 群成员加载失败标记:网络失败 ≠ 被移出群。
// 原实现加载失败时把成员表置空 {},isBlockedFromGroup 据此误判"我被移出群",
// 网络抖动一次输入框就被锁死。用该标记区分"真的不在群里"和"没加载成功"
const roomMembersLoadFailed = ref<Record<string, boolean>>({})
// Scroll Logic State
const showScrollBottomBtn = ref(false)
@@ -879,6 +905,11 @@ function getRoomId(contact: Contact): string {
// 判断是否为群聊
const isGroupChat = computed(() => chatStore.currentTarget?.is_group || chatStore.currentTarget?.room_type === 'group')
// 当前会话对象是否为 AI 机器人:机器人只支持文字聊天,聊天头部隐藏通话按钮
const isCurrentTargetBot = computed(() => {
const t = chatStore.currentTarget
return !!(t?.is_bot || t?.user?.is_bot || String(t?.contact_user_id || t?.user_id || '').startsWith('bot_'))
})
// 获取当前房间的成员映射(用于 MessageList)
const currentRoomMembers = computed(() => {
@@ -893,9 +924,14 @@ const isBlockedFromGroup = computed(() => {
if (!isGroupChat.value || !chatStore.currentTarget?.room_id || !authStore.user) {
return false
}
const currentMember = roomMembers.value[chatStore.currentTarget.room_id]?.[authStore.user.id]
// 如果当前用户不在群成员列表中,则认为被移除
return !currentMember
const roomId = chatStore.currentTarget.room_id
const members = roomMembers.value[roomId]
// 成员列表未加载完成或上次加载失败:不能断定被移出,放行避免误锁输入框
if (!members || roomMembersLoadFailed.value[roomId]) {
return false
}
// 成员列表加载成功且不包含自己,才认为被移出群
return !members[authStore.user.id]
})
// 判断当前用户是否在当前群中被禁言
@@ -914,6 +950,13 @@ const myMutedUntilInCurrentGroup = computed(() => {
return chatStore.getMyMutedUntil(chatStore.currentTarget.room_id)
})
// 禁言到期:MessageInput 倒计时归零时通知过来,清掉本群禁言状态。
// myMuteStatus 是响应式的,清空后 isMyMutedInCurrentGroup 立即重算为 false,输入框自动解锁
function handleMuteExpired() {
const roomId = chatStore.currentTarget?.room_id
if (roomId) chatStore.setMyMuteStatus(roomId, null)
}
// 获取当前目标名称
function getCurrentTargetName(): string {
if (!chatStore.currentTarget) return ''
@@ -953,7 +996,12 @@ function scrollToBottom(smooth = true) {
})
}
// 会话切换请求序号:快速连续切换会话时,只允许"最后一次选择"的异步结果生效,
// 否则先发出但后返回的旧请求会把消息/滚动位置写到当前正在查看的会话上(竞态错乱)
let selectChatSeq = 0
async function selectChat(contact: Contact) {
const seq = ++selectChatSeq
storage.setSelectedConversation(contact.user_id || contact.id)
const roomId = getRoomId(contact)
storage.setSelectedRoomId(roomId)
@@ -964,6 +1012,8 @@ async function selectChat(contact: Contact) {
// 如果是群聊,加载群成员信息
if (contact.is_group || contact.room_type === 'group') {
await loadGroupMembers(roomId)
// 加载期间用户可能已切到别的会话,后续逻辑不再执行
if (seq !== selectChatSeq) return
}
hasMoreHistory.value[roomId] = true
@@ -973,12 +1023,15 @@ async function selectChat(contact: Contact) {
currentPage.value[roomId] = 1
try {
const response = await messageApi.getMessages(roomId, 1, 50)
// 竞态守卫:期间已切换会话则丢弃本次结果(消息按 roomId 落库仍安全,但
// 已读上报/滚动等副作用只应属于当前会话)
if (seq !== selectChatSeq) return
if (response && response.data && Array.isArray(response.data)) {
const msgs = response.data.map((msg: ChatMessage) => ({
...msg,
isSelf: msg.sender_user_id === authStore.user!.id,
extra: typeof msg.extra === 'string' ? JSON.parse(msg.extra || '{}') : msg.extra,
extra: parseMessageExtra(msg.extra),
}))
const sortedMsgs = msgs.reverse()
@@ -994,17 +1047,20 @@ async function selectChat(contact: Contact) {
}
await nextTick()
if (seq !== selectChatSeq) return
scrollToBottom(false)
} else {
hasMoreHistory.value[roomId] = false
}
} catch (error) {
if (seq !== selectChatSeq) return
console.error('Failed to load messages:', error)
toastStore.error('加载消息失败')
hasMoreHistory.value[roomId] = false
}
} else {
await nextTick()
if (seq !== selectChatSeq) return
scrollToBottom(false)
}
}
@@ -1022,10 +1078,12 @@ async function loadGroupMembers(roomId: string) {
}
})
roomMembers.value[roomId] = membersMap
roomMembersLoadFailed.value[roomId] = false
} catch (error) {
console.error('Failed to load group members:', error)
// 如果加载失败,清空成员列表(表示用户可能已被移除)
roomMembers.value[roomId] = {} as Record<string, { name: string; avatar?: string }>
// 加载失败只打标记,保留旧成员数据:
// 网络失败不等于被移出群,置空会让 isBlockedFromGroup 误锁输入框
roomMembersLoadFailed.value[roomId] = true
}
}
@@ -1053,7 +1111,7 @@ async function loadMoreMessages() {
const newMsgs = response.data.map((msg: ChatMessage) => ({
...msg,
isSelf: msg.sender_user_id === authStore.user!.id,
extra: typeof msg.extra === 'string' ? JSON.parse(msg.extra || '{}') : msg.extra,
extra: parseMessageExtra(msg.extra),
}))
const existingMessages = [...currentMessages]
@@ -1215,6 +1273,9 @@ async function sendMessage(type: number, content: string, extra: any = {}, durat
}
chatStore.addMessage(roomId, message)
chatStore.updateContactLastMsg(chatStore.currentTarget.id, getMsgSummary(message), Date.now())
// 同步会话列表:本地发送也要刷新侧边栏的 last_message/last_time 并重排序,
// 原实现只更新 chatStore(联系人列表),会话列表要等下次拉取才变化
conversationStore.handleMessageUpdate(message, true, true)
scrollToBottom(true)
@@ -1224,7 +1285,7 @@ async function sendMessage(type: number, content: string, extra: any = {}, durat
const normalized: ChatMessage = {
...saved,
isSelf: true,
extra: typeof saved.extra === 'string' ? JSON.parse(saved.extra || '{}') : saved.extra,
extra: parseMessageExtra(saved.extra),
pending: false
}
chatStore.reconcileMessage(roomId, normalized)
@@ -1266,13 +1327,14 @@ function handleReadReceipt(data: { message_ids?: number[]; room_id?: string }) {
}
function handleWebSocketMessage(message: ChatMessage) {
if (message.message_type === 6) return
// 信令消息由 useWebRTC 的专用通道处理,这里直接跳过
if (message.message_type === MessageType.SIGNAL) return
const roomId = message.room_id
message.isSelf = message.sender_user_id === authStore.user!.id
message.extra = typeof message.extra === 'string' ? JSON.parse(message.extra || '{}') : message.extra
message.extra = parseMessageExtra(message.extra)
// 处理好友通知
if (message.message_type === 5) {
if (message.message_type === MessageType.FRIEND_NOTIFY) {
const extra = message.extra as any
// 处理好友资料更新通知
@@ -1316,8 +1378,8 @@ function handleWebSocketMessage(message: ChatMessage) {
}
// 处理群聊通知
if (message.message_type === 7) {
const extra = typeof message.extra === 'string' ? JSON.parse(message.extra || '{}') : (message.extra || {})
if (message.message_type === MessageType.GROUP_NOTIFY) {
const extra = parseMessageExtra(message.extra)
const notifRoomId = extra.room_id || message.room_id
// 根据通知类型进行不同处理
@@ -1491,62 +1553,60 @@ function processMessageAfterConversation(message: ChatMessage, roomId: string) {
const isCurrentChat = currentRoomId === roomId
// 会话列表按 room_id 更新,不依赖"发送者是否为好友":
// 群消息的发送者很可能不是我的好友,原实现把 handleMessageUpdate 放在
// if (contact) 里,导致非好友的群消息不刷新侧边栏(last_message/未读数全部不动)
conversationStore.handleMessageUpdate(message, message.isSelf === true, isCurrentChat)
// 联系人列表的最后消息(仅发送者是好友时才有对应条目)
if (contact) {
chatStore.updateContactLastMsg(contact.id, getMsgSummary(message), Date.now())
}
conversationStore.handleMessageUpdate(message, message.isSelf === true, isCurrentChat)
if (!message.isSelf) {
if (!isCurrentChat) {
if (!message.isSelf) {
if (!isCurrentChat) {
// 联系人列表未读角标(好友才有条目);会话列表未读已由 handleMessageUpdate 处理
if (contact) {
chatStore.incrementUnread(contact.id)
} else {
if (isNearBottom.value) {
scrollToBottom(true)
}
} else {
// 正盯着当前会话收到对方消息,立即回执已读,
// 对方端气泡实时显示"已读"(原先只在加载历史时上报,此场景漏掉了)。
// 标签页在后台时不能立即上报——人根本没在看,误报"已读"是回执语义错误,
// 先积攒起来,等 visibilitychange 恢复可见时统一补报
if (message.id) {
if (document.hidden) {
pendingReadMessageIds.add(message.id)
} else {
unreadCount.value++
showScrollBottomBtn.value = true
messageApi.markMessagesRead([message.id]).catch(() => {})
}
}
// 浏览器原生通知(仅在标签页隐藏时)
if ('Notification' in window && Notification.permission === 'granted' && document.hidden) {
new Notification(contact.remark_name || contact.user?.name || '新消息', {
body: getMsgSummary(message),
icon: '/favicon.ico'
})
}
// 未读提示音(非当前会话且未免打扰)
if (!isCurrentChat && !contact.is_muted) {
playNotificationSound()
}
// 不在会话tab时显示toast通知
if (currentTab.value !== 'chat') {
const senderName = contact.remark_name || contact.user?.name || message.sender_user_id || '未知用户'
toastStore.showToast(
'收到新消息',
'info',
5000,
`来自 ${senderName} 的新消息`,
{
label: '去查看',
handler: async () => {
currentTab.value = 'chat'
// 尝试查找或创建会话
await conversationStore.loadConversations()
const conv = conversationStore.conversations.find(c => c.room_id === roomId || c.target_id === message.sender_user_id)
if (conv) {
await selectChatByConversation(conv)
}
}
}
)
if (isNearBottom.value) {
scrollToBottom(true)
} else {
unreadCount.value++
showScrollBottomBtn.value = true
}
}
} else {
// 不在会话列表时收到消息,显示提示
if (!message.isSelf && currentTab.value !== 'chat') {
const senderName = message.sender_user_id || '未知用户'
// 发送者显示名:好友取备注/昵称,非好友回退用户ID
const senderName = contact?.remark_name || contact?.user?.name || message.sender_user_id || '未知用户'
// 浏览器原生通知(仅在标签页隐藏时)
if ('Notification' in window && Notification.permission === 'granted' && document.hidden) {
new Notification(senderName, {
body: getMsgSummary(message),
icon: '/favicon.ico'
})
}
// 未读提示音(非当前会话且未免打扰)
if (!isCurrentChat && !contact?.is_muted) {
playNotificationSound()
}
// 不在会话tab时显示toast通知
if (currentTab.value !== 'chat') {
toastStore.showToast(
'收到新消息',
'info',
@@ -1913,7 +1973,7 @@ function showMessageContextMenu(event: MouseEvent, message: ChatMessage) {
const roomId = getRoomId(chatStore.currentTarget)
const menuItems: import('@/stores/contextMenu').MenuItem[] = []
if (message.message_type === 0 && message.content) {
if (message.message_type === MessageType.TEXT && message.content) {
menuItems.push({
label: '复制',
icon: 'fas fa-copy',
@@ -1960,11 +2020,20 @@ function showMessageContextMenu(event: MouseEvent, message: ChatMessage) {
label: '删除',
icon: 'fas fa-trash-alt',
danger: true,
action: () => {
if (message.id) {
chatStore.removeMessage(roomId, message.id)
action: async () => {
try {
// pending 的是本地乐观消息(发送失败/未确认),服务端没有记录,只做本地移除;
// 已入库的调"对我删除"接口落删除标记,否则刷新/重进会话后消息又会回来
if (message.id && !message.pending) {
await messageApi.deleteMessage(message.id)
}
if (message.id) {
chatStore.removeMessage(roomId, message.id)
}
toastStore.success('已删除')
} catch (e: any) {
toastStore.error(e?.message || '删除失败')
}
toastStore.success('已从本地删除')
},
})
@@ -2009,16 +2078,26 @@ function handleFileSelect(type: number, file: File) {
fileModal.value = { show: true, type, preview: '', name: file.name, size: file.size, file }
if (type === 1) { const reader = new FileReader(); reader.onload = (e) => { fileModal.value.preview = e.target?.result as string }; reader.readAsDataURL(file) }
}
/**
* 确认发送文件
* sendingFile 发送锁:上传+发送是长耗时异步操作,不加锁时连点"确认发送"
* 会把同一文件上传发送多次
*/
async function confirmSendFile() {
if (!fileModal.value.file || !chatStore.currentTarget) return
if (sendingFile.value) return
sendingFile.value = true
try {
const uploadType = fileModal.value.type === 1 ? 'image' : (fileModal.value.type === 3 ? 'video' : 'file')
toastStore.info('正在上传...')
const attachment = await attachmentApi.uploadAttachment(fileModal.value.file, uploadType)
const extra = { name: fileModal.value.name, size: fileModal.value.size, url: attachment.file_url, attachment_id: attachment.id }
await sendMessage(fileModal.value.type, attachment.file_url, extra, 0)
fileModal.value.show = false; toastStore.success('发送成功')
} catch (e: any) { toastStore.error('发送失败') }
} catch (e: any) {
toastStore.error('发送失败')
} finally {
sendingFile.value = false
}
}
function handleRecordStart() { isRecording.value = true }
async function handleRecordStop(blob: Blob, duration: number) { isRecording.value = false; if (!chatStore.currentTarget) return; try { const audioFile = new File([blob], `audio.webm`, { type: 'audio/webm' }); toastStore.info('正在上传语音...'); const attachment = await attachmentApi.uploadAttachment(audioFile, 'file'); await sendMessage(2, attachment.file_url, { duration: `00:${duration}`, url: attachment.file_url }, duration) } catch (e) { toastStore.error('发送失败') } }
@@ -2170,7 +2249,9 @@ function handleSearchContactSelect(contact: ContactSearchResult) {
is_muted: false,
user: contact.user
}
chatStore.setCurrentTarget(targetContact)
// 走 selectChat 而非直接 setCurrentTarget:后者不加载历史消息/成员、不初始化 hasMoreHistory、
// 不上报已读,会打开一个空白会话
selectChat(targetContact)
}
// 选择搜索结果中的群聊
@@ -2192,7 +2273,7 @@ function handleSearchGroupSelect(group: GroupSearchResult) {
is_top: false,
is_muted: false
}
chatStore.setCurrentTarget(targetGroup)
selectChat(targetGroup)
}
// 选择搜索结果中的消息
@@ -2214,7 +2295,7 @@ function handleSearchMessageSelect(message: MessageSearchResult) {
is_muted: false,
user: message.sender
}
chatStore.setCurrentTarget(targetContact)
selectChat(targetContact)
}
// 刷新会话列表
@@ -2233,28 +2314,10 @@ async function refreshConversations() {
}
// 群成员点击处理
function handleGroupMemberClicked(member: any) {
// 显示成员的用户详细信息
if (member.user) {
// 如果有完整的用户信息,直接使用
selectedUser.value = member.user as User
} else if (member.user_id) {
// 如果没有完整信息,尝试从联系人列表中查找
const contact = chatStore.contacts.find(c => c.user_id === member.user_id || c.id === member.user_id)
if (contact) {
selectedUser.value = contact
} else {
// 如果找不到,创建一个临时的 Contact 对象
selectedUser.value = {
id: member.user_id,
user_id: member.user_id,
contact_user_id: member.user_id,
is_top: false,
is_muted: false,
user: member.user || undefined
} as Contact
}
}
// 成员资料卡已由 GroupChatPanel 自己的 UserInfoCard(带发消息/通话按钮)呈现,
// 这里不再打开 ChatView 的旧版 selectedUser 模态,避免点一次成员弹出两个叠加的资料卡
function handleGroupMemberClicked(_member: any) {
/* no-op:见上方说明 */
}
// 处理头像点击
@@ -2297,19 +2360,13 @@ function handleAvatarClick(userOrContact: User | Contact) {
showUserInfoCard.value = true
}
// 从用户信息卡片发送消息
function handleSendMessageFromInfoCard() {
if (!selectedUserForInfo.value) return
// 确保是 User 类型
// 从信息卡片里的 User 解析出 Contact(在联系人列表里找,找不到就临时构造)
function resolveInfoCardContact(): Contact | null {
if (!selectedUserForInfo.value) return null
const user = selectedUserForInfo.value as User
// 查找或创建对应的联系人
const userId = user.id
let contact = chatStore.contacts.find(c => c.user_id === userId || c.id === userId)
let contact = chatStore.contacts.find(c => c.user_id === userId || c.id === userId || c.contact_user_id === userId)
if (!contact) {
// 如果不在联系人列表中,创建一个临时的 Contact 对象
contact = {
id: userId,
user_id: userId,
@@ -2323,26 +2380,45 @@ function handleSendMessageFromInfoCard() {
user: user,
} as Contact
}
chatStore.setCurrentTarget(contact)
// 无 room_id(非好友群成员)时按后端规则合成 P2P 房间号(双方ID排序拼接)。
// 否则 startCall 会回落到"当前聊天窗口"策略拿到群 room_id:
// 通话摘要会作为系统消息广播并落库到整个群、媒体房间也会与群内其他私聊通话互串。
// 用拷贝赋值,避免直接修改 store 里的联系人对象
if (!contact.room_id && authStore.user) {
contact = { ...contact, room_id: [authStore.user.id, userId].sort().join('_') }
}
return contact
}
// 从用户信息卡片发送消息
function handleSendMessageFromInfoCard() {
const contact = resolveInfoCardContact()
if (!contact) return
// 走 selectChat 加载会话,避免打开空白窗
selectChat(contact)
showUserInfoCard.value = false
selectedUserForInfo.value = null
}
// 从用户信息卡片发起语音通话
// 原来是空实现(只关卡片、不发起通话),补上 webrtc.startCall
function handleAudioCallFromInfoCard() {
if (!selectedUserForInfo.value) return
// 这里可以调用 WebRTC 相关功能
const contact = resolveInfoCardContact()
showUserInfoCard.value = false
selectedUserForInfo.value = null
if (!contact) return
const receiverId = contact.contact_user_id || contact.user_id || contact.id
webrtc.startCall('audio', receiverId, contact.room_id || undefined, contact)
}
// 从用户信息卡片发起视频通话
function handleVideoCallFromInfoCard() {
if (!selectedUserForInfo.value) return
// 这里可以调用 WebRTC 相关功能
const contact = resolveInfoCardContact()
showUserInfoCard.value = false
selectedUserForInfo.value = null
if (!contact) return
const receiverId = contact.contact_user_id || contact.user_id || contact.id
webrtc.startCall('video', receiverId, contact.room_id || undefined, contact)
}
// 判断是否为好友
@@ -2472,7 +2548,7 @@ async function prefetchUnreadMessages() {
const msgs = response.data.map((msg: ChatMessage) => ({
...msg,
isSelf: msg.sender_user_id === authStore.user!.id,
extra: typeof msg.extra === 'string' ? JSON.parse(msg.extra || '{}') : msg.extra,
extra: parseMessageExtra(msg.extra),
}))
chatStore.setRoomMessages(roomId, msgs.reverse())
}
@@ -2491,6 +2567,15 @@ watch(currentTab, (newTab) => {
}
})
// 群通知(成员增减/被移出群等)到达时,刷新当前群的成员表。
// 否则 roomMembers 不更新:被移出群后 isBlockedFromGroup 仍为 false(输入框不锁,发送时才报错),
// 成员增减也不生效,直到用户重新选中会话
watch(() => chatStore.lastGroupNotification, (notif) => {
if (notif?.room_id && chatStore.currentTarget?.room_id === notif.room_id) {
loadGroupMembers(notif.room_id)
}
})
onMounted(async () => {
// 每次页面加载/刷新时都验证 token 并获取最新用户信息
const isValid = await authStore.checkAuth()
@@ -2512,7 +2597,7 @@ onMounted(async () => {
wsManager.onMessage(handleWebSocketMessage)
wsManager.onReadReceipt(handleReadReceipt)
wsManager.onSignal(webrtc.handleSignaling)
// 1v1 通话信令改由 App.vue 全局注册(离开聊天页也要能收来电),此处不再注册
// 朋友圈通知已在 App.vue 中统一注册,避免重复
}
await loadContacts()
@@ -2544,12 +2629,33 @@ onMounted(async () => {
}
}
window.addEventListener('resize', () => isMobile.value = window.innerWidth < 768)
// 命名函数注册,卸载时可移除(匿名箭头函数无法 removeEventListener,会泄漏)
window.addEventListener('resize', handleViewportResize)
// 监听 tab 切换事件(用于从其他地方触发 tab 切换,如朋友圈通知)
window.addEventListener('app-tab-change', handleTabChangeEvent)
// 标签页恢复可见时补报后台期间积攒的已读回执
document.addEventListener('visibilitychange', flushPendingReadReceipts)
})
// 后台标签页期间收到的当前会话消息ID,等页面恢复可见时统一补报已读。
// 切换会话只可能发生在可见状态(而可见瞬间就会 flush),集合里不会残留跨会话的ID
const pendingReadMessageIds = new Set<number>()
/** 页面恢复可见时补报积攒的已读回执 */
function flushPendingReadReceipts() {
if (document.hidden || pendingReadMessageIds.size === 0) return
const ids = Array.from(pendingReadMessageIds)
pendingReadMessageIds.clear()
messageApi.markMessagesRead(ids).catch(() => {})
}
// 视口尺寸变化:更新移动端布局标记
function handleViewportResize() {
isMobile.value = window.innerWidth < 768
}
// Tab 切换事件处理
function handleTabChangeEvent(e: Event) {
const customEvent = e as CustomEvent<{ tab: 'chat' | 'contact' | 'moment' }>
@@ -2559,10 +2665,20 @@ function handleTabChangeEvent(e: Event) {
}
onUnmounted(() => {
// 清掉搜索防抖定时器:否则卸载后 500ms 内还会多发一次全局搜索请求
if (searchDebounceTimer) {
clearTimeout(searchDebounceTimer)
searchDebounceTimer = null
}
wsManager.offMessage(handleWebSocketMessage)
wsManager.offSignal(webrtc.handleSignaling)
wsManager.offMomentNotification(momentStore.handleWsNotification)
// 已读回执处理器此前只注册不注销,ChatView 每次重挂载都会泄漏一个闭包,补上对称清理
wsManager.offReadReceipt(handleReadReceipt)
// 注意:1v1 信令已移到 App.vue 全局,这里不再 offSignal(否则会把全局监听误删)
// offMomentNotification 之前传的是 momentStore.handleWsNotification,但 App.vue 注册的是自己的
// wrapper 函数,引用不一致 → 是个删不掉任何东西的 no-op 死代码,已移除
window.removeEventListener('resize', handleViewportResize)
window.removeEventListener('app-tab-change', handleTabChangeEvent)
document.removeEventListener('visibilitychange', flushPendingReadReceipts)
stopConnectionCheck()
})
</script>

View File

@@ -775,10 +775,9 @@ function toggleGroupCollapse(gid: number) {
}
function handleContactClick(contact: Contact) {
console.log('点击联系人:', contact)
contactStore.setSelectedContact(contact)
// 保持路由同步,防止刷新丢失状态
// router.push(`/contacts/detail/${contact.id}`).catch(() => {})
// 详情用本页内联面板展示(不跳路由);独立详情页 /contact/:id 仅作为直链入口保留。
// 注意:此前注释掉的 push 路径 /contacts/detail/:id 与实际注册的路由也不一致,勿直接恢复
}
// --- Context Menus ---
@@ -1149,6 +1148,11 @@ onMounted(() => {
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
// 清掉搜索防抖定时器:否则卸载后 500ms 内还会多发一次全局搜索请求
if (searchDebounceTimer) {
clearTimeout(searchDebounceTimer)
searchDebounceTimer = null
}
})
</script>

View File

@@ -31,7 +31,12 @@
</h3>
<p class="text-sm text-gray-400 mb-2">{{ contact.user?.desc || '暂无签名' }}</p>
<div class="flex items-center justify-center gap-2 text-xs text-gray-500">
<span v-if="checkingOnline" class="flex items-center gap-1 animate-pulse">
<!-- 机器人恒定可用,不做真人的在线状态探测 -->
<span v-if="isBot" class="flex items-center gap-1 text-primary">
<i class="fas fa-robot"></i>
AI机器人 · 随时在线
</span>
<span v-else-if="checkingOnline" class="flex items-center gap-1 animate-pulse">
<span class="w-2 h-2 bg-gray-400 rounded-full"></span>
检查中...
</span>
@@ -44,7 +49,7 @@
离线
</span>
<button
v-if="!checkingOnline"
v-if="!isBot && !checkingOnline"
@click="checkOnlineStatus"
class="text-gray-500 hover:text-primary transition ml-1"
title="刷新在线状态"
@@ -82,7 +87,8 @@
>
<i class="fas fa-comment mr-2"></i>发送消息
</button>
<div class="flex gap-3">
<!-- 机器人只支持文字聊天:隐藏音视频通话;备注/分组/黑名单/删除对虚拟联系人无效,一并隐藏 -->
<div v-if="!isBot" class="flex gap-3">
<button
class="flex-1 py-3 bg-input hover:bg-white/10 text-white rounded-lg transition"
@click="handleAudioCall"
@@ -97,6 +103,7 @@
</button>
</div>
<button
v-if="!isBot"
class="w-full py-3 bg-input hover:bg-white/10 text-white rounded-lg transition"
@click="showMoreOptions = !showMoreOptions"
>
@@ -239,7 +246,7 @@
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useChatStore } from '@/stores/chat'
import { useToastStore } from '@/stores/toast'
@@ -272,6 +279,9 @@ const isOnline = ref(false)
const checkingOnline = ref(false)
let onlineCheckInterval: number | null = null
// 是否为 AI 机器人:隐藏通话/备注/分组/黑名单/删除等入口(机器人只支持文字聊天且不可删除)
const isBot = computed(() => !!contact.value?.user?.is_bot)
async function loadContactDetail() {
const contactId = route.params.id as string
if (!contactId) {
@@ -298,10 +308,12 @@ async function loadContactDetail() {
}
}
// 检查在线状态
await checkOnlineStatus()
// 开启定时检查
startOnlineCheck()
// 检查在线状态(机器人恒定"随时在线",UI 不展示真人在线状态,
// 跳过首查与每30秒的定时探测,省掉无意义的轮询请求)
if (!isBot.value) {
await checkOnlineStatus()
startOnlineCheck()
}
} catch (error: any) {
console.error('Failed to load contact detail:', error)
toastStore.error(error.message || '加载失败')

View File

@@ -3,8 +3,8 @@
<!-- 背景装饰 -->
<div class="absolute top-0 left-0 right-0 h-48 bg-gradient-to-b from-primary/20 to-transparent pointer-events-none"></div>
<!-- 右上角:更多设置 -->
<div class="absolute top-5 right-5 z-50">
<!-- 右上角:更多设置(机器人无可用操作,整体隐藏) -->
<div v-if="!isBot" class="absolute top-5 right-5 z-50">
<div class="relative">
<button
class="w-9 h-9 rounded-full bg-black/20 hover:bg-white/10 text-gray-300 hover:text-white flex items-center justify-center transition backdrop-blur-md border border-white/5"
@@ -299,8 +299,8 @@
<!-- 底部快捷操作栏 -->
<div class="mt-auto p-6 bg-gradient-to-t from-[#0f172a] via-[#0f172a]/80 to-transparent">
<div v-if="isGroupChat" class="max-w-lg mx-auto w-full">
<!-- 群聊:只显示发消息按钮 -->
<div v-if="isGroupChat || isBot" class="max-w-lg mx-auto w-full">
<!-- 群聊/机器人:只显示发消息按钮(机器人不支持音视频通话) -->
<button class="w-full flex flex-col items-center justify-center gap-1.5 py-3.5 px-2 rounded-2xl transition-all duration-200 active:scale-95 group bg-primary text-white shadow-lg shadow-primary/25 hover:bg-primary-hover hover:-translate-y-0.5"
@click="handleSendMessage"
>
@@ -441,6 +441,8 @@ const momentPreviewIndex = ref(0)
const currentGroup = computed(() => groups.value.find(g => g.id === contact.value?.group_id))
const allGroups = computed(() => [{id: 0, group_name: '未分组'}, ...groups.value])
const isGroupChat = computed(() => contact.value?.is_group || contact.value?.room_type === 'group')
// 是否为 AI 机器人:隐藏更多操作菜单与音视频通话(机器人只支持文字聊天且不可删除/备注/拉黑)
const isBot = computed(() => !!(contact.value?.is_bot || contact.value?.user?.is_bot))
// --- 核心逻辑:即时响应 ---
watch(() => contactStore.selectedContact, async (newVal) => {

View File

@@ -79,7 +79,7 @@ import { useToastStore } from '@/stores/toast'
import { useChatStore } from '@/stores/chat'
import * as groupApi from '@/api/modules/room'
import { formatTime } from '@/utils/format'
import { getMessageSummary } from '@/utils/messageTypes'
import { getMessageSummary, parseMessageExtra } from '@/utils/messageTypes'
import type { ChatMessage } from '@/types/api'
const toastStore = useToastStore()
@@ -110,9 +110,8 @@ function getGroupName(roomId: string): string {
// 获取通知图标
function getNotificationIcon(notification: GroupNotification): string {
const extra = typeof notification.extra === 'string'
? JSON.parse(notification.extra || '{}')
: (notification.extra || {})
// 统一走 parseMessageExtra 安全解析,损坏的 extra 字符串不会抛异常导致整页崩溃
const extra = parseMessageExtra(notification.extra)
const event = extra.event
switch (event) {
@@ -151,9 +150,7 @@ function getNotificationContent(notification: GroupNotification): string {
// 获取事件详情
function getEventDetails(notification: GroupNotification): string | null {
const extra = typeof notification.extra === 'string'
? JSON.parse(notification.extra || '{}')
: (notification.extra || {})
const extra = parseMessageExtra(notification.extra)
const event = extra.event
if (event === 'member_join' || event === 'member_leave' || event === 'member_remove') {

View File

@@ -7,8 +7,61 @@
<p class="text-gray-400 text-center mb-10 tracking-widest uppercase text-xs">沉浸式即时通讯系统</p>
<div class="space-y-5">
<!-- 登录方式切换(注册页不显示) -->
<div v-if="!showRegister" class="flex justify-center gap-2 mb-2">
<button
class="px-6 py-2 rounded-xl text-sm transition border"
:class="loginMode === 'password' ? 'bg-primary text-white border-primary' : 'bg-gray-700/50 text-gray-300 border-gray-600 hover:bg-gray-600'"
@click="loginMode = 'password'"
>
<i class="fas fa-keyboard mr-2"></i>账号登录
</button>
<button
class="px-6 py-2 rounded-xl text-sm transition border"
:class="loginMode === 'qrcode' ? 'bg-primary text-white border-primary' : 'bg-gray-700/50 text-gray-300 border-gray-600 hover:bg-gray-600'"
@click="loginMode = 'qrcode'"
>
<i class="fas fa-qrcode mr-2"></i>扫码登录
</button>
</div>
<!-- 扫码登录面板 -->
<div v-if="!showRegister && loginMode === 'qrcode'" class="animate-fade-in flex flex-col items-center py-4">
<div class="relative w-60 h-60 bg-white rounded-2xl p-3 shadow-lg">
<img v-if="qrImage" :src="qrImage" alt="扫码登录二维码" class="w-full h-full" />
<div v-else class="w-full h-full flex items-center justify-center">
<i class="fas fa-circle-notch fa-spin text-3xl text-gray-400"></i>
</div>
<!-- 状态遮罩:已扫描/已过期/已取消 -->
<div
v-if="qrStatus === 'scanned' || qrStatus === 'expired' || qrStatus === 'cancelled'"
class="absolute inset-0 rounded-2xl bg-black/80 flex flex-col items-center justify-center gap-3 text-white"
>
<template v-if="qrStatus === 'scanned'">
<i class="fas fa-check-circle text-4xl text-green-400"></i>
<p class="text-sm">扫描成功</p>
<p class="text-xs text-gray-300">请在手机上确认登录</p>
</template>
<template v-else>
<i class="fas fa-exclamation-circle text-4xl text-yellow-400"></i>
<p class="text-sm">{{ qrStatus === 'expired' ? '二维码已过期' : '已取消登录' }}</p>
<button
class="px-4 py-2 bg-primary hover:bg-indigo-500 rounded-xl text-sm transition active:scale-95"
@click="startQRLogin"
>
<i class="fas fa-sync-alt mr-1"></i>刷新二维码
</button>
</template>
</div>
</div>
<p class="text-gray-400 text-sm mt-5">
打开 <span class="text-primary font-medium">NL-IM App</span> 扫一扫登录
</p>
</div>
<!-- 登录表单 -->
<div v-if="!showRegister" class="space-y-5 animate-fade-in">
<div v-if="!showRegister && loginMode === 'password'" class="space-y-5 animate-fade-in">
<div class="group">
<input
v-model="loginForm.account"
@@ -52,8 +105,9 @@
</button>
</div>
<!-- 注册表单 (UI优化) -->
<div v-else class="space-y-4 animate-fade-in">
<!-- 注册表单 (UI优化):显式条件而非 v-else——
登录块拆成"账号/扫码"两个面板后,v-else 会在扫码模式下误显示注册表单 -->
<div v-if="showRegister" class="space-y-4 animate-fade-in">
<div class="grid grid-cols-2 gap-4">
<input v-model="registerForm.email" type="email" class="bg-input rounded-2xl py-3 px-4 text-white focus:ring-2 ring-primary outline-none transition hover:bg-white/5" placeholder="邮箱" />
<input v-model="registerForm.phone" type="tel" class="bg-input rounded-2xl py-3 px-4 text-white focus:ring-2 ring-primary outline-none transition hover:bg-white/5" placeholder="手机号" />
@@ -116,11 +170,13 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, watch, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useToastStore } from '@/stores/toast'
import * as authApi from '@/api/modules/auth'
import * as qrcodeApi from '@/api/modules/qrcode'
import { isValidEmail, isValidPhone, checkPasswordStrength } from '@/utils/validate'
const router = useRouter()
const authStore = useAuthStore()
@@ -133,6 +189,132 @@ const verifyType = ref<'email' | 'sms'>('email')
const cooldown = ref(0)
let cooldownTimer: number | null = null
// ---------------- 扫码登录 ----------------
// 登录方式:账号密码 / App 扫码
const loginMode = ref<'password' | 'qrcode'>('password')
// 当前二维码会话与图片(图片由服务端生成的 dataURL)
const qrId = ref('')
const qrImage = ref('')
// 二维码状态:pending 等待扫码 / scanned 待手机确认 / expired 过期 / cancelled 手机取消
const qrStatus = ref<'pending' | 'scanned' | 'confirmed' | 'cancelled' | 'expired'>('pending')
// 长轮询世代计数:刷新二维码/停止轮询时递增,旧循环据此自杀,
// 防止"刷新二维码后旧请求的迟到响应把新会话状态覆盖"
let pollGeneration = 0
// 在途长轮询请求的取消器:停止轮询时 abort,不让请求在后台白挂 25 秒
let pollAbort: AbortController | null = null
/**
* 启动扫码登录:生成新二维码并开启长轮询循环
* 过期/取消后点击"刷新二维码"也走这里(会话是一次性的,必须重新生成)
*/
async function startQRLogin() {
stopQRPolling()
// 记录本次启动对应的世代:生成请求在途期间用户可能切走登录方式/再次刷新
//(stopQRPolling 会递增世代),await 返回后必须复查,否则 runQRPollLoop
// 里的 ++pollGeneration 会把旧循环"复活"成当前世代,停止指令被覆盖,
// 轮询在账号登录面板背后持续空转
const gen = pollGeneration
qrImage.value = ''
qrStatus.value = 'pending'
try {
const res = await qrcodeApi.generateLoginQRCode()
if (gen !== pollGeneration) return // 已被停止/刷新,丢弃迟到的生成结果
qrId.value = res.qr_id
qrImage.value = res.qr_image
runQRPollLoop()
} catch (e: any) {
if (gen !== pollGeneration) return
toastStore.error(e?.message || '二维码生成失败,请重试')
// 置为过期态露出"刷新二维码"按钮:否则界面永远转圈且没有重试入口
qrStatus.value = 'expired'
}
}
/**
* 长轮询循环:服务端 hold 至状态变化(最长 25s),响应后立即发起下一轮。
* 相比 2 秒短轮询:请求数降一个量级,扫码/确认的感知还更即时。
* confirmed → 取到一次性 token 写登录态进主界面;
* expired/cancelled → 结束循环显示遮罩;网络抖动 → 等 2 秒重试
*/
async function runQRPollLoop() {
const gen = ++pollGeneration
// 连续失败计数:若后端对已失效的 qr_id 返回错误(而非 expired 状态),
// 无上限重试会让循环每 2 秒空打一次接口且过期遮罩永不出现
let failCount = 0
while (gen === pollGeneration && qrId.value) {
try {
pollAbort = new AbortController()
// known 传当前已知状态(pending/scanned),状态不变时服务端一直 hold
const res = await qrcodeApi.getQRCodeStatus(qrId.value, qrStatus.value, pollAbort.signal)
if (gen !== pollGeneration) return // 已被停止/刷新,丢弃迟到响应
failCount = 0
if (res.status === 'confirmed' && res.token && res.user) {
pollGeneration++
qrStatus.value = 'confirmed'
authStore.loginWithToken(res.token, res.user)
// 与账号密码登录保持一致:请求浏览器通知权限
if ('Notification' in window && Notification.permission === 'default') {
await Notification.requestPermission()
}
toastStore.success('扫码登录成功,欢迎回来')
router.push('/chat')
return
}
if (res.status === 'expired' || res.status === 'cancelled') {
pollGeneration++
qrStatus.value = res.status
return
}
// pending/scanned:更新状态后立即进入下一轮 hold
qrStatus.value = res.status
} catch {
if (gen !== pollGeneration) return
// 单次失败(网络抖动/服务重启)等 2 秒重试;连续失败达上限转为过期态,
// 露出"刷新二维码"按钮而不是永远静默重试
failCount++
if (failCount >= 5) {
pollGeneration++
qrStatus.value = 'expired'
return
}
await new Promise(resolve => setTimeout(resolve, 2000))
}
}
}
/** 停止轮询(切换登录方式、页面卸载、拿到终态时调用):世代递增 + 取消在途请求 */
function stopQRPolling() {
pollGeneration++
if (pollAbort) {
pollAbort.abort()
pollAbort = null
}
}
// 切到扫码模式自动生成二维码;切走停止轮询,避免后台空转
watch(loginMode, (mode) => {
if (mode === 'qrcode') {
startQRLogin()
} else {
stopQRPolling()
}
})
// 进入注册页时若在扫码模式,同样停止轮询
watch(showRegister, (val) => {
if (val) stopQRPolling()
else if (loginMode.value === 'qrcode') startQRLogin()
})
// 组件卸载(登录成功跳转)时清理验证码倒计时与扫码轮询定时器,避免定时器在页面销毁后继续执行
onUnmounted(() => {
if (cooldownTimer) {
clearInterval(cooldownTimer)
cooldownTimer = null
}
stopQRPolling()
})
const loginForm = ref({
account: '',
password: '',
@@ -172,20 +354,47 @@ async function handleLogin() {
}
}
// ... (注册和发送验证码逻辑保持原有逻辑,仅模板做了 UI 美化)
/**
* 注册
* 提交前完整校验:必填、邮箱/手机号格式、密码强度、两次密码一致、
* 验证码必填、同意条款。此前只做了必填与两次密码校验,
* 格式错误的数据会打到后端才报错,体验差且浪费请求
*/
async function handleRegister() {
if (!registerForm.value.email || !registerForm.value.phone || !registerForm.value.password) {
toastStore.warning('请填写完整信息'); return
}
if (!isValidEmail(registerForm.value.email)) {
toastStore.warning('邮箱格式不正确'); return
}
if (!isValidPhone(registerForm.value.phone)) {
toastStore.warning('手机号格式不正确'); return
}
const pwdCheck = checkPasswordStrength(registerForm.value.password)
if (!pwdCheck.valid) {
toastStore.warning(pwdCheck.message); return
}
if (registerForm.value.password !== registerForm.value.confirmPassword) {
toastStore.warning('两次密码不一致'); return
}
if (!registerForm.value.code.trim()) {
toastStore.warning('请填写验证码'); return
}
if (!registerForm.value.agreeTerms) {
toastStore.warning('请同意服务条款'); return
}
loading.value = true
try {
await authStore.register(registerForm.value)
// 显式映射为后端的 snake_case 字段:后端 binding 校验 confirm_password/agree_terms 必填,
// 原实现直接把 camelCase 表单对象发出去,这两个字段永远缺失导致注册必然 400
await authStore.register({
email: registerForm.value.email,
phone: registerForm.value.phone,
password: registerForm.value.password,
confirm_password: registerForm.value.confirmPassword,
code: registerForm.value.code,
agree_terms: registerForm.value.agreeTerms,
})
toastStore.success('注册成功,请登录')
showRegister.value = false
loginForm.value.account = registerForm.value.email
@@ -204,6 +413,15 @@ async function handleSendCode() {
toastStore.warning(verifyType.value === 'email' ? '请先填写邮箱' : '请先填写手机号')
return
}
// 发送前先做格式校验,避免格式错误的目标白白消耗一次验证码发送
if (verifyType.value === 'email' && !isValidEmail(target)) {
toastStore.warning('邮箱格式不正确')
return
}
if (verifyType.value === 'sms' && !isValidPhone(target)) {
toastStore.warning('手机号格式不正确')
return
}
codeLoading.value = true
try {

View File

@@ -36,6 +36,24 @@
<!-- 动态列表 -->
<main class="flex-1 overflow-y-auto" @scroll="handleScroll">
<!-- 朋友圈封面(微信同款:点击更换自己的封面) -->
<div class="relative h-56 bg-gray-200 group cursor-pointer overflow-hidden" @click="triggerCoverPick">
<img :src="coverUrl" class="w-full h-full object-cover" alt="朋友圈封面" />
<div class="absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-black/30 to-transparent pointer-events-none"></div>
<!-- 右下角个人信息 -->
<div class="absolute right-4 bottom-3 flex items-center gap-3">
<span class="text-white font-semibold drop-shadow">{{ authStore.user?.name || '我' }}</span>
<Avatar :avatar="authStore.user?.avatar" :name="authStore.user?.name" :size="48" rounded="xl" />
</div>
<!-- hover 提示更换封面 -->
<div class="absolute left-4 top-3 px-3 py-1.5 rounded-full bg-black/40 text-white text-xs opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none">
{{ uploadingCover ? '上传中...' : '点击更换封面' }}
</div>
<!-- 隐藏的文件选择框。@click.stop 阻断冒泡:程序触发的 input.click() 会冒泡回
父容器的 @click 再次调用 triggerCoverPick,形成无限循环 -->
<input ref="coverInputRef" type="file" accept="image/*" class="hidden" @click.stop @change="onCoverFileChange" />
</div>
<!-- 加载中 -->
<div v-if="momentStore.loading && momentStore.moments.length === 0" class="flex justify-center py-8">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
@@ -93,9 +111,15 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useMomentStore } from '@/stores/moment'
import { useAuthStore } from '@/stores/auth'
import { useToastStore } from '@/stores/toast'
import { resolveImageUrl } from '@/utils/image'
import * as userApi from '@/api/modules/user'
import { uploadAttachment } from '@/api/modules/attachment'
import Avatar from '@/components/common/Avatar.vue'
import MomentCard from '@/components/moment/MomentCard.vue'
import MomentPublisher from '@/components/moment/MomentPublisher.vue'
import MomentCommentInput from '@/components/moment/MomentCommentInput.vue'
@@ -103,12 +127,50 @@ import type { Moment, MomentComment } from '@/types/moment'
const router = useRouter()
const momentStore = useMomentStore()
const authStore = useAuthStore()
const toastStore = useToastStore()
// 状态
const showPublisher = ref(false)
const commentingMoment = ref<Moment | null>(null)
const replyToComment = ref<MomentComment | null>(null)
// --- 朋友圈封面(users.moment_cover,空则回退默认图) ---
const DEFAULT_COVER = 'https://images.unsplash.com/photo-1470252649378-9c29740c9fa8?w=800&q=80'
const coverInputRef = ref<HTMLInputElement | null>(null)
const uploadingCover = ref(false)
const coverUrl = computed(() => {
const raw = authStore.user?.moment_cover || ''
return raw ? resolveImageUrl(raw) : DEFAULT_COVER
})
/** 点击封面拉起文件选择(上传中防重入) */
function triggerCoverPick() {
if (uploadingCover.value) return
coverInputRef.value?.click()
}
/** 选中图片后上传并更新用户资料,成功后同步本地缓存使封面立即生效 */
async function onCoverFileChange(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
// 重置 value:同一文件再次选择时也能触发 change
input.value = ''
if (!file || !authStore.user) return
uploadingCover.value = true
try {
const attachment = await uploadAttachment(file, 'image')
if (!attachment?.file_url) throw new Error('上传失败')
await userApi.updateUser({ id: authStore.user.id, updates: { moment_cover: attachment.file_url } })
authStore.updateUserInfo({ ...authStore.user, moment_cover: attachment.file_url })
toastStore.success('封面已更新')
} catch {
toastStore.error('封面更新失败')
} finally {
uploadingCover.value = false
}
}
// 初始化
onMounted(() => {
momentStore.fetchMoments()

View File

@@ -3,13 +3,19 @@ import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
// https://vitejs.dev/config/
export default defineConfig({
export default defineConfig(({ mode }) => ({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
// 生产构建剔除 console.log/debugger:
// 开发期的调试日志(WebRTC/WS 链路较多)不应带到线上,
// 用 esbuild drop 在构建期统一剥离,保留 console.error/warn 便于线上排障
esbuild: mode === 'production'
? { pure: ['console.log', 'console.debug', 'console.info'], drop: ['debugger'] }
: {},
server: {
port: 3000,
proxy: {
@@ -32,5 +38,5 @@ export default defineConfig({
},
},
},
})
}))