音视频通话功能完成(P2P)读秒

已知问题:
文件无法发送
This commit is contained in:
2025-12-03 15:58:57 +08:00
parent 9db9b65ea3
commit 3a98cee2b7
3 changed files with 619 additions and 289 deletions

View File

@@ -1,31 +1,29 @@
<template>
<div
v-show="call.active"
class="fixed z-[999] transition-all duration-300 overflow-hidden bg-gray-900 flex flex-col shadow-2xl border border-gray-700 font-sans"
:class="[
call.minimized
? 'w-48 h-64 bottom-5 right-5 rounded-xl border-gray-600'
: isMobile
? 'inset-0 w-full h-full rounded-none'
: 'top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[900px] h-[600px] rounded-2xl',
]"
ref="windowRef"
class="fixed z-[999] transition-shadow duration-300 overflow-hidden bg-gray-900 flex flex-col shadow-2xl border border-gray-700 font-sans select-none"
:class="windowClass"
:style="dragStyle"
>
<!-- 顶部功能栏 (渐变遮罩) -->
<!-- 顶部功能栏 (作为拖拽手柄) -->
<div
class="absolute top-0 w-full p-4 z-30 flex justify-between items-start bg-gradient-to-b from-black/80 to-transparent transition-opacity hover:opacity-100"
:class="{ 'opacity-0': !call.minimized && !isMobile && call.status === 'connected' }"
class="absolute top-0 w-full p-4 z-30 flex justify-between items-start bg-gradient-to-b from-black/80 to-transparent cursor-move"
@mousedown="startDrag"
>
<div class="text-white text-sm font-bold drop-shadow-md px-2 flex items-center gap-2" v-if="!call.minimized">
<div class="text-white text-sm font-bold drop-shadow-md px-2 flex items-center gap-2 pointer-events-none" v-if="!call.minimized">
<i class="fas" :class="call.type === 'video' ? 'fa-video' : 'fa-phone'"></i>
<span>{{ call.type === 'video' ? '视频通话' : '语音通话' }}</span>
<span v-if="call.status === 'connected'" class="text-xs font-normal opacity-80 pl-2 border-l border-white/30">
<!-- 计时器 -->
<span v-if="call.status === 'connected'" class="text-xs font-normal opacity-80 pl-2 border-l border-white/30 font-mono tracking-wider">
{{ formatDuration(call.duration) }}
</span>
</div>
<!-- 最小化按钮 -->
<!-- 最小化按钮 (阻止冒泡以免触发拖拽) -->
<button
class="w-8 h-8 rounded-full bg-white/10 hover:bg-white/20 text-white flex items-center justify-center backdrop-blur transition ml-auto"
class="w-8 h-8 rounded-full bg-white/10 hover:bg-white/20 text-white flex items-center justify-center backdrop-blur transition ml-auto cursor-pointer"
@mousedown.stop
@click="emit('toggle-minimize')"
title="最小化/还原"
>
@@ -36,9 +34,9 @@
<!-- 主内容区域 -->
<div class="flex-1 relative bg-black overflow-hidden flex items-center justify-center">
<!-- 1. 语音/等待状态 UI -->
<!-- A. 头像背景层在语音模式等待连接或对方关闭摄像头时显示 -->
<div
v-if="call.type === 'audio' || call.status !== 'connected'"
v-if="call.type === 'audio' || call.status !== 'connected' || call.remoteCamOff"
class="absolute inset-0 flex flex-col items-center justify-center bg-gray-900 z-10"
>
<!-- 动态波纹头像 -->
@@ -57,30 +55,40 @@
<div class="text-2xl font-bold text-gray-100 tracking-wide mt-2">
{{ target?.remark_name || target?.user?.name || '未知用户' }}
</div>
<!-- 状态提示 -->
<div class="text-gray-400 mt-2 font-mono flex items-center gap-2 text-sm">
<span v-if="call.status === 'connected'" class="w-2 h-2 bg-green-500 rounded-full animate-pulse"></span>
{{ call.statusText }}
<span v-if="call.remoteCamOff && call.status === 'connected'">对方已关闭摄像头</span>
<span v-else>{{ call.statusText }}</span>
</div>
<!-- 纯语音模式下的计时器 -->
<div v-if="call.status === 'connected'" class="mt-4 text-3xl font-mono text-white/90 font-light">
{{ formatDuration(call.duration) }}
</div>
</div>
<!-- 2. 远程视频流 (全屏) -->
<!-- B. 远程视频流 (全屏) -->
<!-- 只有在视频模式已连接且对方开启摄像头时显示 -->
<video
ref="remoteVideoRef"
class="w-full h-full object-cover transition-opacity duration-500 absolute inset-0 z-0"
autoplay
playsinline
:class="{ 'opacity-0': call.type === 'audio' || call.status !== 'connected' }"
:class="{ 'opacity-0': call.type === 'audio' || call.status !== 'connected' || call.remoteCamOff }"
></video>
<!-- 3. 本地视频流 (画中画) -->
<!-- 优化移动端使用竖屏比例 (w-24 h-32 -> 3:4)PC端使用横屏比例 (w-48 h-36 -> 4:3) -->
<!-- C. 本地视频流 (画中画) -->
<!-- 需求发起视频通话时即使未接通也要看到自己 -->
<!-- 显示条件是视频通话 && 未最小化 && (状态是已连接 正在呼叫/拨号) -->
<div
v-show="call.type === 'video' && !call.minimized && call.status === 'connected'"
v-show="call.type === 'video' && !call.minimized && (call.status === 'connected' || call.status === 'outgoing')"
class="absolute z-20 overflow-hidden shadow-2xl transition hover:scale-105 border border-white/20 bg-gray-800"
:class="[
isMobile
? 'top-16 right-4 w-28 h-40 rounded-xl' /* 移动端:右上角,竖长方形 */
: 'bottom-28 right-8 w-56 h-40 rounded-lg' /* PC端右下角横长方形 */
? 'top-16 right-4 w-28 h-40 rounded-xl'
: 'bottom-28 right-8 w-56 h-40 rounded-lg'
]"
>
<video
@@ -102,7 +110,6 @@
v-if="!call.minimized"
class="h-24 bg-gray-900/80 backdrop-blur-md border-t border-white/10 flex items-center justify-center gap-6 md:gap-10 shrink-0 pb-4 z-30"
>
<!-- 挂断 -->
<button
class="w-14 h-14 rounded-full bg-red-500 hover:bg-red-600 text-white flex items-center justify-center text-xl transition transform active:scale-95 shadow-lg shadow-red-500/30"
@click="emit('end-call')"
@@ -111,7 +118,6 @@
<i class="fas fa-phone-slash"></i>
</button>
<!-- 接听 (仅来电时显示) -->
<button
v-if="call.status === 'incoming'"
class="w-14 h-14 rounded-full bg-green-500 hover:bg-green-600 text-white flex items-center justify-center text-xl transition transform active:scale-95 shadow-lg shadow-green-500/30 animate-pulse"
@@ -121,7 +127,6 @@
<i class="fas fa-phone"></i>
</button>
<!-- 通话中功能按钮 -->
<template v-if="call.status === 'connected'">
<button
class="w-12 h-12 rounded-full bg-gray-700/80 hover:bg-gray-600 text-white transition flex items-center justify-center backdrop-blur"
@@ -147,7 +152,7 @@
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import { ref, watch, computed, onMounted, onUnmounted, reactive } from 'vue'
import type { Contact } from '@/types/api'
interface Props {
@@ -159,11 +164,11 @@ interface Props {
statusText: string
muted: boolean
camOff: boolean
remoteCamOff: boolean
duration: number
}
target: Contact | null
isMobile: boolean
// 接收 MediaStream 对象,而不是 DOM 元素
localStream: MediaStream | null
remoteStream: MediaStream | null
}
@@ -180,34 +185,107 @@ const emit = defineEmits<{
const localVideoRef = ref<HTMLVideoElement | null>(null)
const remoteVideoRef = ref<HTMLVideoElement | null>(null)
const windowRef = ref<HTMLElement | null>(null)
// 格式化时间辅助函数
// --- 窗口类名逻辑 ---
const windowClass = computed(() => {
if (props.call.minimized) {
// 最小化时固定在右下角,不应用拖拽位置(或者您可以选择最小化也能拖拽,这里简化为固定)
return 'w-48 h-64 bottom-5 right-5 rounded-xl border-gray-600 fixed'
}
if (props.isMobile) {
return 'inset-0 w-full h-full rounded-none fixed'
}
// 正常模式,移除 top/left/transform 的 Tailwind 类,完全由 style 控制位置
return 'w-[900px] h-[600px] rounded-2xl'
})
// --- 拖拽逻辑 ---
const drag = reactive({
isDragging: false,
startX: 0,
startY: 0,
left: 0,
top: 0,
initialized: false
})
// 居中初始化
onMounted(() => {
if (!props.isMobile && !props.call.minimized) {
drag.left = window.innerWidth / 2 - 450
drag.top = window.innerHeight / 2 - 300
drag.initialized = true
}
})
// 监听窗口大小变化以修正边界
window.addEventListener('resize', () => {
if (drag.initialized) clampPosition()
})
const dragStyle = computed(() => {
if (props.isMobile || props.call.minimized || !drag.initialized) return {}
return {
left: `${drag.left}px`,
top: `${drag.top}px`,
position: 'fixed',
transform: 'none' // 覆盖掉 Tailwind 可能的 transform
}
})
function clampPosition() {
const w = 900
const h = 600
const maxLeft = window.innerWidth - w
const maxTop = window.innerHeight - h
drag.left = Math.max(0, Math.min(drag.left, maxLeft))
drag.top = Math.max(0, Math.min(drag.top, maxTop))
}
function startDrag(e: MouseEvent) {
if (props.isMobile || props.call.minimized) return
drag.isDragging = true
drag.startX = e.clientX - drag.left
drag.startY = e.clientY - drag.top
document.addEventListener('mousemove', onDrag)
document.addEventListener('mouseup', stopDrag)
}
function onDrag(e: MouseEvent) {
if (!drag.isDragging) return
drag.left = e.clientX - drag.startX
drag.top = e.clientY - drag.startY
clampPosition()
}
function stopDrag() {
drag.isDragging = false
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag)
}
// --- 格式化时间 ---
const formatDuration = (seconds: number) => {
const m = Math.floor(seconds / 60).toString().padStart(2, '0')
const s = (seconds % 60).toString().padStart(2, '0')
return `${m}:${s}`
const h = Math.floor(seconds / 3600)
return h > 0 ? `${h.toString().padStart(2, '0')}:${m}:${s}` : `${m}:${s}`
}
// 自动绑定流到 Video 元素 (解决本地视频异常的核心)
// --- 自动绑定流 ---
watch(() => props.localStream, (newStream) => {
if (localVideoRef.value && newStream) {
localVideoRef.value.srcObject = newStream
}
if (localVideoRef.value && newStream) localVideoRef.value.srcObject = newStream
}, { immediate: true, flush: 'post' })
watch(() => props.remoteStream, (newStream) => {
if (remoteVideoRef.value && newStream) {
remoteVideoRef.value.srcObject = newStream
}
if (remoteVideoRef.value && newStream) remoteVideoRef.value.srcObject = newStream
}, { immediate: true, flush: 'post' })
// 确保在组件挂载或 Video 元素出现时也能绑定
// 重新激活或状态变化时确保绑定
watch([localVideoRef, remoteVideoRef], () => {
if (localVideoRef.value && props.localStream) {
localVideoRef.value.srcObject = props.localStream
}
if (remoteVideoRef.value && props.remoteStream) {
remoteVideoRef.value.srcObject = props.remoteStream
}
if (localVideoRef.value && props.localStream) localVideoRef.value.srcObject = props.localStream
if (remoteVideoRef.value && props.remoteStream) remoteVideoRef.value.srcObject = props.remoteStream
})
</script>

View File

@@ -1,8 +1,9 @@
import { reactive, shallowRef } from 'vue'
import { reactive, shallowRef, ref } from 'vue'
import * as systemApi from '@/api/modules/system'
import * as messageApi from '@/api/modules/message'
import { wsManager } from '@/api/websocket'
import { useToastStore } from '@/stores/toast'
import { useChatStore } from '@/stores/chat'
import type { ChatMessage } from '@/types/api'
import type { CallStatus } from '@/types/message'
@@ -15,12 +16,18 @@ export interface CallState {
id: string | null
muted: boolean
camOff: boolean
remoteCamOff: boolean // 新增:对方是否关闭摄像头
duration: number
startTime: number | null
}
// 铃声资源 (使用在线资源作为示例)
const RINGTONE_INCOMING = 'https://assets.mixkit.co/active_storage/sfx/2860/2860-64-bit.mp3' // 电话铃声
const RINGTONE_DIALING = 'https://assets.mixkit.co/active_storage/sfx/2864/2864-64-bit.mp3' // 嘟嘟声
export function useWebRTC(userId: string, onIncomingCall?: (senderUserId: string) => void) {
const toastStore = useToastStore()
const chatStore = useChatStore()
const call = reactive<CallState>({
active: false,
@@ -31,144 +38,164 @@ export function useWebRTC(userId: string, onIncomingCall?: (senderUserId: string
id: null,
muted: false,
camOff: false,
remoteCamOff: false,
duration: 0,
startTime: null,
})
// 使用 shallowRef 存储 MediaStream避免 Vue 进行深层代理导致的性能问题
const isCaller = ref(false)
const localStream = shallowRef<MediaStream | null>(null)
const remoteStream = shallowRef<MediaStream | null>(null)
let durationTimer: number | null = null
let pc: RTCPeerConnection | null = null
const pendingCandidates: RTCIceCandidate[] = []
let currentReceiverUserId = ''
/**
* 初始化媒体流
*/
// 音频对象
const audioIncoming = new Audio(RINGTONE_INCOMING)
const audioDialing = new Audio(RINGTONE_DIALING)
audioIncoming.loop = true
audioDialing.loop = true
// --- 铃声控制 ---
function playRingtone(type: 'incoming' | 'dialing') {
stopRingtone() // 先停止当前的
const audio = type === 'incoming' ? audioIncoming : audioDialing
audio.currentTime = 0
audio.play().catch(e => console.warn('Autoplay prevented:', e))
}
function stopRingtone() {
audioIncoming.pause()
audioDialing.pause()
audioIncoming.currentTime = 0
audioDialing.currentTime = 0
}
// --- 系统通知 ---
function sendSystemNotification(title: string, body: string) {
if (!('Notification' in window)) return
if (Notification.permission === 'granted') {
new Notification(title, { body, icon: '/favicon.ico' })
} else if (Notification.permission !== 'denied') {
Notification.requestPermission().then(permission => {
if (permission === 'granted') {
new Notification(title, { body, icon: '/favicon.ico' })
}
})
}
}
// --- 辅助函数 ---
function getRoomId(uid1: string, uid2: string): string {
return [uid1, uid2].sort().join('_')
}
function formatTextDuration(seconds: number): string {
if (seconds <= 0) return '0秒'
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = seconds % 60
let str = ''
if (h > 0) str += `${h}小时`
if (m > 0) str += `${m}分钟`
if (s > 0) str += `${s}`
return str
}
async function sendSummaryMessage(reason: 'connected' | 'cancelled' | 'rejected' | 'busy') {
if (!isCaller.value || !currentReceiverUserId) return
let content = ''
const durationStr = formatTextDuration(call.duration)
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
const deviceText = isMobile ? '移动端' : '电脑端'
if (reason === 'connected') content = `通话结束,时长:${durationStr}`
else if (reason === 'cancelled') content = '已取消呼叫'
else if (reason === 'rejected') content = '对方拒绝接听'
else if (reason === 'busy') content = '对方忙'
content += ` [${deviceText}]`
const roomId = getRoomId(userId, currentReceiverUserId)
const clientId = wsManager.getClientId()
const payload = {
sender_client_id: clientId || '',
receiver_user_id: currentReceiverUserId,
room_id: roomId,
message_type: 0,
content: content,
duration: 0,
extra: JSON.stringify({ isSystem: true }),
}
try {
await messageApi.sendMessage(payload)
const localMsg: any = {
...payload, id: Date.now(), sender_user_id: userId, created_at: new Date().toISOString(), isSelf: true, extra: { isSystem: true }
}
chatStore.addMessage(roomId, localMsg)
chatStore.updateContactLastMsg(currentReceiverUserId, content, Date.now())
} catch (e) { console.error(e) }
}
// --- WebRTC 基础 ---
async function initMedia(videoEnabled: boolean): Promise<void> {
try {
// 停止之前的流
if (localStream.value) {
localStream.value.getTracks().forEach(t => t.stop())
}
if (localStream.value) localStream.value.getTracks().forEach(t => t.stop())
const constraints: MediaStreamConstraints = {
video: videoEnabled ? {
width: { ideal: 1280 },
height: { ideal: 720 },
facingMode: 'user', // 优先使用前置摄像头
} : false,
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
video: videoEnabled ? { width: { ideal: 1280 }, height: { ideal: 720 }, facingMode: 'user' } : false,
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
}
const stream = await navigator.mediaDevices.getUserMedia(constraints)
localStream.value = stream // 赋值给响应式对象,视图会自动更新
console.log('[WebRTC] Local media stream obtained')
} catch (error) {
console.error('Failed to get media:', error)
throw new Error('无法获取摄像头或麦克风权限')
}
localStream.value = stream
} catch (error) { throw new Error('无法获取设备权限') }
}
/**
* 获取并增强 ICE Servers 配置
*/
async function getIceServers() {
const servers: RTCIceServer[] = []
try {
const apiServers = await systemApi.getIceServers(userId)
if (apiServers && apiServers.length > 0) {
servers.push(...apiServers.map((s: any) => ({
urls: s.urls,
username: s.username,
credential: s.credential,
})))
}
} catch (error) {
console.warn('Failed to fetch ICE servers from API')
}
// 添加公共 STUN 服务器作为兜底
servers.push({ urls: 'stun:stun.l.google.com:19302' })
servers.push({ urls: 'stun:global.stun.twilio.com:3478' })
return servers
}
/**
* 创建 PeerConnection
*/
async function createPC(): Promise<void> {
const iceServers = await getIceServers()
// 关闭旧连接
const servers = [{ urls: 'stun:stun.l.google.com:19302' }]
if (pc) pc.close()
pc = new RTCPeerConnection({
iceServers,
iceCandidatePoolSize: 10
})
// 添加本地轨道
if (localStream.value) {
localStream.value.getTracks().forEach((track) => {
pc!.addTrack(track, localStream.value!)
})
} else {
console.warn('[WebRTC] No local stream to add to PC')
}
// 监听远程轨道
pc.ontrack = (e) => {
console.log('[WebRTC] Received remote track', e.streams)
if (e.streams && e.streams[0]) {
remoteStream.value = e.streams[0] // 赋值给响应式对象
}
}
// 监听 ICE 候选
pc.onicecandidate = (e) => {
if (e.candidate && call.id) {
sendSignal('candidate', e.candidate)
}
}
// 监听连接状态
pc.onconnectionstatechange = () => {
console.log('[WebRTC] Connection state:', pc?.connectionState)
if (pc?.connectionState === 'disconnected' || pc?.connectionState === 'failed') {
toastStore.error('通话连接中断')
}
}
pc = new RTCPeerConnection({ iceServers: servers })
if (localStream.value) localStream.value.getTracks().forEach((track) => pc!.addTrack(track, localStream.value!))
pc.ontrack = (e) => { if (e.streams && e.streams[0]) remoteStream.value = e.streams[0] }
pc.onicecandidate = (e) => { if (e.candidate && call.id) sendSignal('candidate', e.candidate) }
}
/**
* 发送信令 (统一封装)
*/
function sendSignal(status: CallStatus, data?: any, receiverUserId?: string) {
if (!call.id) return
const targetUserId = receiverUserId || currentReceiverUserId
if (!targetUserId) return
const payload = {
sender_client_id: wsManager.getClientId() || '',
receiver_user_id: targetUserId,
room_id: '',
message_type: 6, // Signaling Message
message_type: 6,
content: JSON.stringify(data || {}),
call_id: call.id,
call_status: status,
extra: JSON.stringify({ type: call.type }),
}
messageApi.sendMessage(payload).catch(console.error)
}
// --- 计时器逻辑 ---
// --- 摄像头开关同步信令 ---
function sendSyncState(type: 'cam-toggle', value: boolean) {
if (call.status !== 'connected') return
const payload = {
sender_client_id: wsManager.getClientId() || '',
receiver_user_id: currentReceiverUserId,
room_id: '',
message_type: 6, // 借用信令通道
content: JSON.stringify({ action: type, value }),
call_id: call.id,
call_status: 'sync_state' as any, // 自定义状态
extra: JSON.stringify({ type: call.type })
}
messageApi.sendMessage(payload).catch(console.error)
}
// --- 计时器 ---
function startCallTimer() {
stopCallTimer()
call.startTime = Date.now()
@@ -179,10 +206,7 @@ export function useWebRTC(userId: string, onIncomingCall?: (senderUserId: string
}
function stopCallTimer() {
if (durationTimer) {
clearInterval(durationTimer)
durationTimer = null
}
if (durationTimer) { clearInterval(durationTimer); durationTimer = null }
call.startTime = null
call.duration = 0
}
@@ -190,12 +214,13 @@ export function useWebRTC(userId: string, onIncomingCall?: (senderUserId: string
function formatDuration(seconds: number): string {
const m = Math.floor(seconds / 60).toString().padStart(2, '0')
const s = (seconds % 60).toString().padStart(2, '0')
return `${m}:${s}`
const h = Math.floor(seconds / 3600)
return h > 0 ? `${h.toString().padStart(2, '0')}:${m}:${s}` : `${m}:${s}`
}
// --- 通话控制 ---
// --- 操作 ---
async function startCall(type: 'audio' | 'video', receiverUserId: string) {
isCaller.value = true
currentReceiverUserId = receiverUserId
call.type = type
call.id = Date.now().toString()
@@ -204,27 +229,33 @@ export function useWebRTC(userId: string, onIncomingCall?: (senderUserId: string
call.status = 'outgoing'
call.statusText = '正在呼叫...'
call.duration = 0
remoteStream.value = null // 重置远程流
call.camOff = false
call.remoteCamOff = false
remoteStream.value = null
try {
await initMedia(type === 'video')
await createPC()
playRingtone('dialing') // 播放拨号音
sendSignal('invite', undefined, receiverUserId)
} catch (error: any) {
toastStore.error(error.message || '无法启动通话')
endCall()
closeCall()
}
}
async function acceptCall(senderUserId?: string) {
isCaller.value = false
stopRingtone() // 停止来电铃声
if (senderUserId) currentReceiverUserId = senderUserId
call.status = 'connected'
call.statusText = '正在连接...'
call.camOff = false
call.remoteCamOff = false
try {
await initMedia(call.type === 'video')
await createPC()
// 发送 accepted等待发起方创建 Offer
sendSignal('accepted', undefined, currentReceiverUserId)
} catch (error) {
endCall()
@@ -232,90 +263,94 @@ export function useWebRTC(userId: string, onIncomingCall?: (senderUserId: string
}
function endCall() {
stopRingtone()
if (isCaller.value) {
if (call.status === 'connected') sendSummaryMessage('connected')
else if (call.status === 'outgoing') sendSummaryMessage('cancelled')
}
sendSignal('hangup')
closeCall()
}
function closeCall() {
stopRingtone()
call.active = false
call.status = 'idle'
call.statusText = ''
call.id = null
stopCallTimer()
if (pc) {
pc.close()
pc = null
}
// 停止本地流
if (localStream.value) {
localStream.value.getTracks().forEach(t => t.stop())
localStream.value = null
}
if (pc) { pc.close(); pc = null }
if (localStream.value) { localStream.value.getTracks().forEach(t => t.stop()); localStream.value = null }
remoteStream.value = null
}
// --- 信令处理 ---
async function handleSignaling(message: ChatMessage) {
const signal = message.call_status as CallStatus
const signal = message.call_status as any
const content = message.content ? JSON.parse(message.content) : {}
const extra = message.extra ? (typeof message.extra === 'string' ? JSON.parse(message.extra) : message.extra) : {}
// 更新通话类型
if (extra.type) call.type = extra.type
// 处理自定义状态同步信令
if (signal === 'sync_state') {
if (content.action === 'cam-toggle') {
call.remoteCamOff = content.value
}
return
}
if (signal === 'invite') {
isCaller.value = false
currentReceiverUserId = message.sender_user_id
call.id = message.call_id
call.active = true
call.minimized = false
call.status = 'incoming'
call.statusText = `邀请你进行${call.type === 'video' ? '视频' : '语音'}通话`
call.remoteCamOff = false
playRingtone('incoming') // 播放来电铃声
sendSystemNotification('新来电', `收到来自 ${message.sender_user_id}${call.type === 'video' ? '视频' : '语音'}通话邀请`) // 触发系统通知
if (onIncomingCall) onIncomingCall(message.sender_user_id)
} else if (signal === 'accepted') {
// 对方已接受,作为发起方,创建 Offer
stopRingtone() // 对方接听,停止拨号音
call.status = 'connected'
call.statusText = '通话中'
startCallTimer()
const offer = await pc!.createOffer()
await pc!.setLocalDescription(offer)
sendSignal('offer', offer)
} else if (signal === 'offer') {
// 接收 Offer创建 Answer
stopRingtone() // 建立连接,停止可能存在的铃声
if (!pc) {
await initMedia(call.type === 'video')
await createPC()
}
await pc!.setRemoteDescription(content)
// 处理缓冲的 Candidates
processPendingCandidates()
const answer = await pc!.createAnswer()
await pc!.setLocalDescription(answer)
sendSignal('answer', answer, message.sender_user_id)
call.status = 'connected'
call.statusText = '通话中'
startCallTimer()
} else if (signal === 'answer') {
// 接收 Answer
await pc!.setRemoteDescription(content)
processPendingCandidates()
} else if (signal === 'candidate') {
if (pc && pc.remoteDescription) {
await pc.addIceCandidate(content)
} else {
pendingCandidates.push(content)
}
if (pc && pc.remoteDescription) await pc.addIceCandidate(content)
else pendingCandidates.push(content)
} else if (['hangup', 'ended', 'answered_elsewhere'].includes(signal)) {
if (isCaller.value) {
if (call.status === 'outgoing') sendSummaryMessage('rejected')
else if (call.status === 'connected') sendSummaryMessage('connected')
}
closeCall()
if (signal === 'answered_elsewhere') toastStore.info('已在其他设备接听')
}
@@ -323,39 +358,24 @@ export function useWebRTC(userId: string, onIncomingCall?: (senderUserId: string
async function processPendingCandidates() {
while (pendingCandidates.length > 0) {
const candidate = pendingCandidates.shift()
if (candidate && pc) {
await pc.addIceCandidate(candidate)
}
const c = pendingCandidates.shift()
if (c && pc) await pc.addIceCandidate(c)
}
}
// --- 媒体控制 ---
function toggleMute() {
call.muted = !call.muted
if (localStream.value) {
localStream.value.getAudioTracks().forEach(t => t.enabled = !call.muted)
}
if (localStream.value) localStream.value.getAudioTracks().forEach(t => t.enabled = !call.muted)
}
function toggleCamera() {
call.camOff = !call.camOff
if (localStream.value) {
localStream.value.getVideoTracks().forEach(t => t.enabled = !call.camOff)
}
// 同步状态给对方
sendSyncState('cam-toggle', call.camOff)
if (localStream.value) localStream.value.getVideoTracks().forEach(t => t.enabled = !call.camOff)
}
return {
call,
localStream, // 暴露流
remoteStream, // 暴露流
startCall,
acceptCall,
endCall,
handleSignaling,
toggleMute,
toggleCamera,
formatDuration,
call, localStream, remoteStream, startCall, acceptCall, endCall, handleSignaling, toggleMute, toggleCamera, formatDuration, sendSystemNotification
}
}

View File

@@ -1,4 +1,5 @@
<template>
<!-- 保持原有模板代码不变 -->
<div class="h-screen w-screen flex text-sm sm:text-base bg-dark" @contextmenu.prevent>
<!-- 左侧功能导航 (保持不变) -->
<div class="w-16 bg-dark border-r border-gray-800 flex flex-col items-center py-6 gap-6 z-20 hidden md:flex shrink-0">
@@ -9,35 +10,84 @@
class="cursor-pointer hover:ring-2 ring-white transition shadow-lg"
@click="showProfileModal = true"
/>
<div class="text-gray-400 hover:text-primary cursor-pointer transition relative" :class="{ 'text-primary': currentTab === 'chat' }" @click="currentTab = 'chat'">
<div
class="text-gray-400 hover:text-primary cursor-pointer transition relative"
:class="{ 'text-primary': currentTab === 'chat' }"
@click="currentTab = 'chat'"
>
<i class="fas fa-comment-dots text-xl"></i>
<div v-if="chatStore.totalUnread > 0" class="absolute -top-1 -right-1 w-2 h-2 bg-red-500 rounded-full"></div>
</div>
<div class="text-gray-400 hover:text-primary cursor-pointer transition" :class="{ 'text-primary': currentTab === 'contact' }" @click="currentTab = 'contact'">
<div
class="text-gray-400 hover:text-primary cursor-pointer transition"
:class="{ 'text-primary': currentTab === 'contact' }"
@click="currentTab = 'contact'"
>
<i class="fas fa-address-book text-xl"></i>
</div>
<div class="mt-auto mb-2 text-gray-500 hover:text-red-500 cursor-pointer transition" @click="handleLogout" title="退出登录">
<div
class="mt-auto mb-2 text-gray-500 hover:text-red-500 cursor-pointer transition"
@click="handleLogout"
title="退出登录"
>
<i class="fas fa-sign-out-alt text-xl"></i>
</div>
</div>
<!-- 中间侧边栏 (保持不变) -->
<div v-show="currentTab === 'chat' && (!isMobile || !chatVisible)" class="w-full md:w-72 bg-panel border-r border-gray-800 flex flex-col z-10 h-full shrink-0">
<div
v-show="currentTab === 'chat' && (!isMobile || !chatVisible)"
class="w-full md:w-72 bg-panel border-r border-gray-800 flex flex-col z-10 h-full shrink-0"
>
<div class="p-4 border-b border-gray-800">
<div class="relative group">
<i class="fas fa-search absolute left-3 top-2.5 text-gray-500 group-focus-within:text-primary transition"></i>
<input v-model="searchQuery" type="text" class="w-full bg-input rounded-lg py-2 pl-10 pr-4 text-sm text-white focus:ring-2 ring-primary outline-none transition placeholder-gray-500" placeholder="搜索联系人..." />
<input
v-model="searchQuery"
type="text"
class="w-full bg-input rounded-lg py-2 pl-10 pr-4 text-sm text-white focus:ring-2 ring-primary outline-none transition placeholder-gray-500"
placeholder="搜索联系人..."
/>
</div>
</div>
<div class="flex-1 overflow-y-auto">
<div v-for="contact in filteredContacts" :key="contact.id" class="flex items-center p-3 cursor-pointer hover:bg-white/5 transition-colors relative group border-l-4 border-transparent" :class="{ 'bg-white/10 border-primary': chatStore.currentTarget?.id === contact.id, 'bg-black/20': contact.is_top }" @click="selectChat(contact)" @contextmenu.stop="showContactMenu($event, contact)">
<div
v-for="contact in filteredContacts"
:key="contact.id"
class="flex items-center p-3 cursor-pointer hover:bg-white/5 transition-colors relative group border-l-4 border-transparent"
:class="{
'bg-white/10 border-primary': chatStore.currentTarget?.id === contact.id,
'bg-black/20': contact.is_top,
}"
@click="selectChat(contact)"
@contextmenu.stop="showContactMenu($event, contact)"
>
<div class="relative shrink-0">
<Avatar :name="contact.user?.name || contact.remark_name" :avatar="contact.user?.avatar || contact.remark_name?.charAt(0)" :color="contact.color" size="contact" rounded="xl" class="transition transform group-hover:scale-105" />
<div v-if="(contact.unread || 0) > 0" class="absolute -top-1.5 -right-1.5 bg-red-500 text-white text-[10px] min-w-[18px] h-[18px] flex items-center justify-center rounded-full border-2 border-panel font-bold shadow-sm">{{ contact.unread }}</div>
<Avatar
:name="contact.user?.name || contact.remark_name"
:avatar="contact.user?.avatar || contact.remark_name?.charAt(0)"
:color="contact.color"
size="contact"
rounded="xl"
class="transition transform group-hover:scale-105"
/>
<div
v-if="(contact.unread || 0) > 0"
class="absolute -top-1.5 -right-1.5 bg-red-500 text-white text-[10px] min-w-[18px] h-[18px] flex items-center justify-center rounded-full border-2 border-panel font-bold shadow-sm"
>
{{ contact.unread }}
</div>
</div>
<div class="flex-1 min-w-0 ml-3">
<div class="flex justify-between items-center mb-0.5">
<span class="font-semibold truncate text-gray-200 text-sm">{{ contact.remark_name || contact.user?.name || '未知' }}</span>
<span class="font-semibold truncate text-gray-200 text-sm">
{{ contact.remark_name || contact.user?.name || '未知' }}
</span>
<span class="text-xs text-gray-500">{{ formatTime(contact.last_time || Date.now()) }}</span>
</div>
<div class="text-xs text-gray-400 truncate flex items-center h-4">
@@ -49,36 +99,92 @@
</div>
</div>
<!-- 右侧主聊天区 (保持不变省略部分代码以聚焦核心更改) -->
<div v-if="chatStore.currentTarget && (currentTab === 'chat' && (!isMobile || chatVisible))" class="flex-1 flex flex-col bg-dark relative w-full h-full overflow-hidden">
<!-- 头部 -->
<!-- 右侧主聊天区 (保持不变) -->
<div
v-if="chatStore.currentTarget && (currentTab === 'chat' && (!isMobile || chatVisible))"
class="flex-1 flex flex-col bg-dark relative w-full h-full overflow-hidden"
>
<!-- 聊天头部 -->
<div class="h-16 border-b border-gray-800 flex justify-between items-center px-4 bg-panel/80 backdrop-blur shrink-0 z-20 shadow-sm">
<div class="flex items-center gap-3 cursor-pointer" @click="backToList">
<i class="fas fa-chevron-left md:hidden text-gray-400 text-lg p-2 -ml-2"></i>
<Avatar :name="chatStore.currentTarget.user?.name || chatStore.currentTarget.remark_name" :avatar="chatStore.currentTarget.user?.avatar || chatStore.currentTarget.remark_name?.charAt(0)" :color="chatStore.currentTarget.color" size="sm" rounded="full" />
<Avatar
:name="chatStore.currentTarget.user?.name || chatStore.currentTarget.remark_name"
:avatar="chatStore.currentTarget.user?.avatar || chatStore.currentTarget.remark_name?.charAt(0)"
:color="chatStore.currentTarget.color"
size="sm"
rounded="full"
/>
<div>
<h3 class="font-bold text-sm md:text-base text-gray-100 flex items-center gap-2">{{ chatStore.currentTarget.remark_name || chatStore.currentTarget.user?.name }} <span class="w-2 h-2 bg-success rounded-full animate-pulse"></span></h3>
<h3 class="font-bold text-sm md:text-base text-gray-100 flex items-center gap-2">
{{ chatStore.currentTarget.remark_name || chatStore.currentTarget.user?.name }}
<span class="w-2 h-2 bg-success rounded-full animate-pulse"></span>
</h3>
</div>
</div>
<div class="flex gap-2 text-gray-400">
<!-- 绑定通话事件 -->
<button class="w-9 h-9 rounded-lg hover:bg-white/10 hover:text-white transition flex items-center justify-center" @click="startCall('audio')"><i class="fas fa-phone"></i></button>
<button class="w-9 h-9 rounded-lg hover:bg-white/10 hover:text-white transition flex items-center justify-center" @click="startCall('video')"><i class="fas fa-video"></i></button>
<button class="w-9 h-9 rounded-lg hover:bg-white/10 hover:text-white transition flex items-center justify-center" @click.stop="showChatOptionsMenu($event, chatStore.currentTarget)"><i class="fas fa-ellipsis-v"></i></button>
<button
class="w-9 h-9 rounded-lg hover:bg-white/10 hover:text-white transition flex items-center justify-center"
title="语音通话"
@click="startCall('audio')"
>
<i class="fas fa-phone"></i>
</button>
<button
class="w-9 h-9 rounded-lg hover:bg-white/10 hover:text-white transition flex items-center justify-center"
title="视频通话"
@click="startCall('video')"
>
<i class="fas fa-video"></i>
</button>
<button
class="w-9 h-9 rounded-lg hover:bg-white/10 hover:text-white transition flex items-center justify-center"
@click.stop="showChatOptionsMenu($event, chatStore.currentTarget)"
>
<i class="fas fa-ellipsis-v"></i>
</button>
</div>
</div>
<!-- 消息列表与输入框 (保持原样) -->
<MessageList :messages="currentMessages" :current-user="authStore.user!" :target="chatStore.currentTarget" />
<MessageInput v-model="inputText" :is-recording="isRecording" @send="handleSendText" @file="handleFileSelect" @record-start="handleRecordStart" @record-stop="handleRecordStop" @record-cancel="handleRecordCancel" />
<!-- 消息列表 -->
<MessageList
:messages="currentMessages"
:current-user="authStore.user!"
:target="chatStore.currentTarget"
/>
<!-- 输入区 -->
<MessageInput
v-model="inputText"
:is-recording="isRecording"
@send="handleSendText"
@file="handleFileSelect"
@record-start="handleRecordStart"
@record-stop="handleRecordStop"
@record-cancel="handleRecordCancel"
/>
</div>
<!-- 空状态 -->
<div v-else-if="currentTab === 'chat'" class="flex-1 flex flex-col items-center justify-center bg-dark text-gray-500 hidden md:flex">
<!-- ... -->
<div
v-else-if="currentTab === 'chat'"
class="flex-1 flex flex-col items-center justify-center bg-dark text-gray-500 hidden md:flex"
>
<div class="w-24 h-24 bg-panel rounded-full flex items-center justify-center mb-6 shadow-xl border border-gray-800">
<i class="fas fa-comments text-4xl text-primary opacity-80"></i>
</div>
<p class="text-xl font-medium text-gray-300">NL-IM</p>
<p class="text-sm mt-2 opacity-60">安全 · 极速 · 沉浸式体验</p>
</div>
<!-- 关键修复 Stream 传递给 CallWindow -->
<!-- 联系人页面 -->
<ContactView v-if="currentTab === 'contact'" />
<!-- 右键菜单 -->
<ContextMenu />
<!-- 通话窗口 -->
<CallWindow
ref="callWindowRef"
:call="webrtc.call"
@@ -93,15 +199,43 @@
@toggle-minimize="webrtc.call.minimized = !webrtc.call.minimized"
/>
<!-- 其他模态框 (保持不变) -->
<ContactView v-if="currentTab === 'contact'" />
<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"
@close="fileModal.show = false"
@confirm="confirmSendFile"
/>
<!-- 用户资料模态框 -->
<div
v-if="showProfileModal && authStore.user"
class="fixed inset-0 bg-black/80 z-[60] flex items-center justify-center p-4 backdrop-blur-sm"
@click.self="showProfileModal = false"
>
<div class="bg-panel rounded-2xl w-80 shadow-2xl border border-gray-700 overflow-hidden">
<div class="h-24 bg-gradient-to-r from-indigo-600 to-purple-600"></div>
<div class="px-6 pb-6 text-center -mt-12">
<Avatar
:name="authStore.user.name"
:avatar="authStore.user.avatar"
size="xl"
rounded="full"
class="mx-auto border-4 border-panel shadow-xl"
/>
<h2 class="text-xl font-bold mt-3 text-white">{{ authStore.user.name }}</h2>
<p class="text-sm text-gray-400 mt-1">{{ authStore.user.desc || '暂无签名' }}</p>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useChatStore } from '@/stores/chat'
@@ -128,18 +262,18 @@ const chatStore = useChatStore()
const toastStore = useToastStore()
const { showContextMenu } = useContextMenu()
// 处理来电回调
function handleIncomingCall(senderUserId: string) {
const contact = chatStore.contacts.find(c => c.user_id === senderUserId || c.id === senderUserId)
const contact = chatStore.contacts.find(
(c) => c.user_id === senderUserId || c.id === senderUserId
)
if (contact && (!chatStore.currentTarget || chatStore.currentTarget.id !== contact.id)) {
selectChat(contact)
}
}
// 初始化 WebRTC
const webrtc = useWebRTC(authStore.user?.id || '', handleIncomingCall)
// 状态定义
// ... (其他原有变量保持不变)
const currentTab = ref<'chat' | 'contact'>('chat')
const searchQuery = ref('')
const inputText = ref('')
@@ -147,36 +281,41 @@ const chatVisible = ref(false)
const isMobile = ref(window.innerWidth < 768)
const showProfileModal = ref(false)
const isRecording = ref(false)
const callWindowRef = ref(null)
const fileModal = ref({ show: false, type: 0, preview: '', name: '', size: 0, file: null as File | null })
const callWindowRef = ref<InstanceType<typeof CallWindow> | null>(null)
const fileModal = ref({
show: false,
type: 0,
preview: '',
name: '',
size: 0,
file: null as File | null,
})
// 计算属性
const filteredContacts = computed(() => {
const query = searchQuery.value.toLowerCase()
return chatStore.contacts.filter(contact => (contact.remark_name || contact.user?.name || '').toLowerCase().includes(query))
return chatStore.contacts.filter(contact => {
const name = (contact.remark_name || contact.user?.name || '').toLowerCase()
return name.includes(query)
})
})
const currentMessages = computed(() => {
if (!chatStore.currentTarget) return []
return chatStore.getRoomMessages(getRoomId(chatStore.currentTarget))
const roomId = getRoomId(chatStore.currentTarget)
return chatStore.getRoomMessages(roomId)
})
// 辅助函数
function getRoomId(contact: Contact): string {
const userIds = [authStore.user!.id, contact.user_id || contact.id].sort()
return userIds.join('_')
}
function getMsgSummary(msg: ChatMessage): string {
const types: Record<number, string> = { 1: '[图片]', 2: '[语音]', 3: '[视频]', 8: '[文件]' }
return types[msg.message_type] || msg.content
}
// 核心业务逻辑
async function selectChat(contact: Contact) {
chatStore.setCurrentTarget(contact)
chatVisible.value = true
const roomId = getRoomId(contact)
if (chatStore.getRoomMessages(roomId).length === 0) {
try {
const response = await messageApi.getMessages(roomId, 1, 50)
@@ -186,36 +325,70 @@ async function selectChat(contact: Contact) {
extra: typeof msg.extra === 'string' ? JSON.parse(msg.extra || '{}') : msg.extra,
}))
chatStore.setRoomMessages(roomId, msgs.reverse())
} catch (e) { console.error(e) }
} catch (error) {
console.error('Failed to load messages:', error)
}
}
setTimeout(() => { const el = document.getElementById('msgListContainer'); if (el) el.scrollTop = el.scrollHeight }, 100)
setTimeout(() => {
const container = document.getElementById('msgListContainer')
if (container) {
container.scrollTop = container.scrollHeight
}
}, 100)
}
function backToList() { if (isMobile.value) chatVisible.value = false }
function handleLogout() { authStore.logout(); wsManager.disconnect(); router.push('/login') }
function backToList() {
if (isMobile.value) {
chatVisible.value = false
}
}
function handleLogout() {
authStore.logout()
wsManager.disconnect()
router.push('/login')
}
// ... (发送消息相关逻辑保持不变)
function handleSendText() {
if (!inputText.value.trim() || !chatStore.currentTarget) return
sendMessage(0, inputText.value)
inputText.value = ''
}
// 消息发送相关
function handleSendText() { if (inputText.value.trim()) { sendMessage(0, inputText.value); inputText.value = '' } }
function handleFileSelect(type: number, file: File) {
fileModal.value = { show: true, type, preview: '', name: file.name, size: file.size, file }
if (type === 1) { const r = new FileReader(); r.onload = e => fileModal.value.preview = e.target?.result as string; r.readAsDataURL(file) }
}
async function confirmSendFile() {
if (!fileModal.value.file) return
try {
const attachment = await attachmentApi.uploadAttachment(fileModal.value.file, fileModal.value.type === 1 ? 'image' : 'file')
await sendMessage(fileModal.value.type, attachment.file_url, { name: fileModal.value.name, size: fileModal.value.size, url: attachment.file_url }, 0)
fileModal.value.show = false
} catch (e) { toastStore.error('发送失败') }
if (type === 1) {
const reader = new FileReader()
reader.onload = (e) => { fileModal.value.preview = e.target?.result as string }
reader.readAsDataURL(file)
}
}
async function confirmSendFile() {
if (!fileModal.value.file || !chatStore.currentTarget) return
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('发送失败')
}
}
// 录音相关
function handleRecordStart() { isRecording.value = true }
async function handleRecordStop(blob: Blob, duration: number) {
isRecording.value = false
if (!chatStore.currentTarget) return
try {
const file = new File([blob], `audio.webm`, { type: 'audio/webm' })
const attachment = await attachmentApi.uploadAttachment(file, 'file')
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('发送失败') }
}
@@ -230,41 +403,100 @@ async function sendMessage(type: number, content: string, extra: any = {}, durat
room_id: roomId, message_type: type, content, duration, extra: JSON.stringify(extra)
}
await messageApi.sendMessage(payload)
const msg = { ...payload, id: Date.now(), created_at: new Date().toISOString(), isSelf: true, extra }
chatStore.addMessage(roomId, msg as any)
chatStore.updateContactLastMsg(chatStore.currentTarget.id, getMsgSummary(msg as any), Date.now())
const message: ChatMessage = {
id: Date.now(), room_id: roomId, sender_user_id: authStore.user!.id,
receiver_user_id: chatStore.currentTarget.user_id || chatStore.currentTarget.id,
message_type: type, content, duration, extra, created_at: new Date().toISOString(), isSelf: true
}
chatStore.addMessage(roomId, message)
chatStore.updateContactLastMsg(chatStore.currentTarget.id, getMsgSummary(message), Date.now())
}
function getMsgSummary(msg: ChatMessage): string {
const types: Record<number, string> = { 1: '[图片]', 2: '[语音]', 3: '[视频]', 8: '[文件]' }
return types[msg.message_type] || msg.content
}
// 通话相关
async function startCall(type: 'audio' | 'video') {
if (!chatStore.currentTarget) return
await webrtc.startCall(type, chatStore.currentTarget.user_id || chatStore.currentTarget.id)
}
// 菜单与上下文
function showContactMenu(e: MouseEvent, c: Contact) { showContextMenu(e, [{ label: '删除', danger: true, action: () => {} }]) }
function showChatOptionsMenu(e: MouseEvent, c: Contact) { showContextMenu(e, [{ label: '清空', danger: true, action: () => chatStore.clearRoomMessages(getRoomId(c)) }]) }
function showContactMenu(event: MouseEvent, contact: Contact) {
showContextMenu(event, [
{ label: '删除会话', icon: 'fas fa-trash', danger: true, action: () => {} }
])
}
function showChatOptionsMenu(event: MouseEvent, contact: Contact) {
showContextMenu(event, [
{ label: '清空记录', icon: 'fas fa-eraser', danger: true, action: () => chatStore.clearRoomMessages(getRoomId(contact)) }
])
}
// WebSocket 消息处理 & 系统通知触发
function handleWebSocketMessage(message: ChatMessage) {
if (message.message_type === 6) 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
chatStore.addMessage(roomId, message)
// 更新最后消息
const contact = chatStore.contacts.find(c => c.user_id === message.sender_user_id || c.id === message.sender_user_id)
if (contact) {
chatStore.updateContactLastMsg(contact.id, getMsgSummary(message), Date.now())
if (!message.isSelf) {
chatStore.incrementUnread(contact.id)
// 触发聊天消息系统通知
if ('Notification' in window && Notification.permission === 'granted' && document.hidden) {
new Notification(contact.remark_name || contact.user?.name || '新消息', {
body: getMsgSummary(message),
icon: '/favicon.ico'
})
}
}
}
if (chatStore.currentTarget && roomId === getRoomId(chatStore.currentTarget)) {
setTimeout(() => {
const container = document.getElementById('msgListContainer')
if (container) container.scrollTop = container.scrollHeight
}, 100)
}
}
async function loadContacts() {
try {
const contacts = await contactApi.getContacts()
chatStore.setContacts(contacts)
} catch (e) { console.error(e) }
}
// 生命周期
onMounted(async () => {
if (!authStore.isAuthenticated && !(await authStore.checkAuth())) { router.push('/login'); return }
if (!authStore.isAuthenticated) {
const isValid = await authStore.checkAuth()
if (!isValid) { router.push('/login'); return }
}
// 请求通知权限
if ('Notification' in window && Notification.permission !== 'granted' && Notification.permission !== 'denied') {
// toastStore.info('请允许浏览器通知权限')
Notification.requestPermission()
}
if (authStore.user) {
await wsManager.connect(authStore.user.id)
wsManager.onMessage(msg => {
if (msg.message_type === 6) return // 信令单独处理
const roomId = msg.room_id
msg.isSelf = msg.sender_user_id === authStore.user!.id
msg.extra = typeof msg.extra === 'string' ? JSON.parse(msg.extra || '{}') : msg.extra
chatStore.addMessage(roomId, msg)
if (chatStore.currentTarget && roomId === getRoomId(chatStore.currentTarget)) setTimeout(() => { const el = document.getElementById('msgListContainer'); if(el) el.scrollTop = el.scrollHeight }, 100)
})
// 绑定信令
wsManager.onMessage(handleWebSocketMessage)
wsManager.onSignal(webrtc.handleSignaling)
}
await contactApi.getContacts().then(chatStore.setContacts)
await loadContacts()
window.addEventListener('resize', () => isMobile.value = window.innerWidth < 768)
})
onUnmounted(() => {
wsManager.disconnect()
wsManager.offMessage(handleWebSocketMessage)
wsManager.offSignal(webrtc.handleSignaling)
})
</script>