修复各种场景音视频通话的bug

This commit is contained in:
2025-12-04 12:46:15 +08:00
parent f54a103de5
commit 1441312097
16 changed files with 887 additions and 358 deletions

View File

@@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NL-IM 即时通讯系统</title>
<!-- FontAwesome -->
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<!-- <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">-->
</head>
<body>
<div id="app"></div>

View File

@@ -9,6 +9,7 @@
},
"dependencies": {
"@ant-design/icons-vue": "^7.0.0",
"@fortawesome/fontawesome-free": "^7.1.0",
"axios": "^1.6.0",
"pinia": "^2.1.0",
"vue": "^3.4.0",

9
pnpm-lock.yaml generated
View File

@@ -11,6 +11,9 @@ importers:
'@ant-design/icons-vue':
specifier: ^7.0.0
version: 7.0.1(vue@3.5.25(typescript@5.9.3))
'@fortawesome/fontawesome-free':
specifier: ^7.1.0
version: 7.1.0
axios:
specifier: ^1.6.0
version: 1.13.2
@@ -228,6 +231,10 @@ packages:
cpu: [x64]
os: [win32]
'@fortawesome/fontawesome-free@7.1.0':
resolution: {integrity: sha512-+WxNld5ZCJHvPQCr/GnzCTVREyStrAJjisUPtUxG5ngDA8TMlPnKp6dddlTpai4+1GNmltAeuk1hJEkBohwZYA==}
engines: {node: '>=6'}
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@@ -1265,6 +1272,8 @@ snapshots:
'@esbuild/win32-x64@0.21.5':
optional: true
'@fortawesome/fontawesome-free@7.1.0': {}
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5

View File

@@ -1,9 +1,56 @@
<template>
<router-view />
<Toast />
<!-- 全局通话窗口 -->
<CallWindow
v-if="webrtcStore.webrtc?.call?.active"
:call="webrtcStore.webrtc.call"
:target="chatStore.currentTarget"
:is-mobile="isMobile"
:local-stream="localStream"
:remote-stream="remoteStream"
@end-call="webrtcStore.webrtc.endCall"
@accept-call="webrtcStore.webrtc.acceptCall"
@toggle-mute="webrtcStore.webrtc.toggleMute"
@toggle-camera="webrtcStore.webrtc.toggleCamera"
@toggle-minimize="webrtcStore.webrtc.call.minimized = !webrtcStore.webrtc.call.minimized"
/>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue'
import Toast from '@/components/common/Toast.vue'
</script>
import CallWindow from '@/components/call/CallWindow.vue'
import { useWebRTCStore } from '@/stores/webrtc'
import { useChatStore } from '@/stores/chat'
const webrtcStore = useWebRTCStore()
const chatStore = useChatStore()
const isMobile = ref(window.innerWidth < 768)
// --- 关键修复:去除 .value ---
// Pinia/Vue 的 reactive 系统会自动解包 Store 中的 refs。
// 因此 webrtcStore.webrtc.localStream 已经是 MediaStream 对象或 null而不是 Ref。
// 之前的报错是因为尝试访问 null.value。
const localStream = computed(() => {
if (!webrtcStore.webrtc) return null
return webrtcStore.webrtc.localStream ?? null
})
const remoteStream = computed(() => {
if (!webrtcStore.webrtc) return null
return webrtcStore.webrtc.remoteStream ?? null
})
const handleResize = () => {
isMobile.value = window.innerWidth < 768
}
onMounted(() => {
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
})
</script>

View File

@@ -66,6 +66,7 @@ export async function getContactDetail(id: string): Promise<Contact> {
id: response.contact.id?.toString() || response.contact.contact_id,
user_id: response.contact.contact_id || response.contact.user_id,
contact_user_id: response.contact.contact_id,
room_id: response.contact.room_id, // 确保 room_id 被正确传递
user: response.user,
} as Contact
}

View File

@@ -6,25 +6,25 @@
: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 cursor-move"
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 touch-none"
@mousedown="startDrag"
@touchstart="startDragTouch"
>
<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 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 cursor-pointer"
@mousedown.stop
@click="emit('toggle-minimize')"
@touchstart.stop
@click="toggleMinimize"
title="最小化/还原"
>
<i class="fas" :class="call.minimized ? 'fa-expand' : 'fa-compress'"></i>
@@ -34,43 +34,38 @@
<!-- 主内容区域 -->
<div class="flex-1 relative bg-black overflow-hidden flex items-center justify-center">
<!-- A. 头像背景层在语音模式等待连接或对方关闭摄像头时显示 -->
<!-- A. 头像背景层 -->
<div
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"
>
<!-- 动态波纹头像 -->
<div class="relative">
<div class="relative" :class="call.minimized ? 'scale-50' : ''">
<div
class="w-32 h-32 rounded-full flex items-center justify-center text-5xl font-bold mb-6 shadow-2xl z-20 relative border-4 border-gray-800"
:style="{ background: target?.color || '#6366f1' }"
>
{{ target?.user?.avatar || target?.remark_name?.charAt(0) || '?' }}
</div>
<!-- 波纹动画 -->
<div v-if="call.status === 'outgoing' || call.status === 'connected'"
class="absolute inset-0 bg-white/20 rounded-full animate-ping z-10"></div>
</div>
<div class="text-2xl font-bold text-gray-100 tracking-wide mt-2">
<div v-if="!call.minimized" 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">
<div class="text-gray-400 mt-2 font-mono flex items-center gap-2 text-sm" :class="{'scale-75': call.minimized}">
<span v-if="call.status === 'connected'" class="w-2 h-2 bg-green-500 rounded-full animate-pulse"></span>
<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">
<div v-if="call.status === 'connected' && !call.minimized" class="mt-4 text-3xl font-mono text-white/90 font-light">
{{ formatDuration(call.duration) }}
</div>
</div>
<!-- B. 远程视频流 (全屏) -->
<!-- 只有在视频模式已连接且对方开启摄像头时显示 -->
<!-- B. 远程视频流 -->
<video
ref="remoteVideoRef"
class="w-full h-full object-cover transition-opacity duration-500 absolute inset-0 z-0"
@@ -79,11 +74,9 @@
:class="{ 'opacity-0': call.type === 'audio' || call.status !== 'connected' || call.remoteCamOff }"
></video>
<!-- C. 本地视频流 (画中画) -->
<!-- 需求发起视频通话时即使未接通也要看到自己 -->
<!-- 显示条件是视频通话 && 未最小化 && (状态是已连接 正在呼叫/拨号) -->
<!-- C. 本地视频流 -->
<div
v-show="call.type === 'video' && !call.minimized && (call.status === 'connected' || call.status === 'outgoing')"
v-show="shouldShowLocalVideo"
class="absolute z-20 overflow-hidden shadow-2xl transition hover:scale-105 border border-white/20 bg-gray-800"
:class="[
isMobile
@@ -98,7 +91,6 @@
playsinline
muted
></video>
<!-- 摄像头关闭提示 -->
<div v-if="call.camOff" class="absolute inset-0 flex items-center justify-center bg-gray-800 text-white/50">
<i class="fas fa-video-slash"></i>
</div>
@@ -152,7 +144,7 @@
</template>
<script setup lang="ts">
import { ref, watch, computed, onMounted, onUnmounted, reactive } from 'vue'
import { ref, watchEffect, computed, onMounted, reactive, nextTick } from 'vue'
import type { Contact } from '@/types/api'
interface Props {
@@ -187,17 +179,22 @@ const localVideoRef = ref<HTMLVideoElement | null>(null)
const remoteVideoRef = ref<HTMLVideoElement | null>(null)
const windowRef = ref<HTMLElement | null>(null)
// 显式本地视频显示逻辑
const shouldShowLocalVideo = computed(() => {
return props.call.type === 'video' &&
!props.call.minimized &&
(props.call.status === 'connected' || props.call.status === 'outgoing')
})
// --- 窗口类名逻辑 ---
const windowClass = computed(() => {
if (props.call.minimized) {
// 最小化时固定在右下角,不应用拖拽位置(或者您可以选择最小化也能拖拽,这里简化为固定)
return 'w-48 h-64 bottom-5 right-5 rounded-xl border-gray-600 fixed'
return 'w-48 h-64 rounded-xl border-gray-600 fixed shadow-xl'
}
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'
return 'w-[900px] h-[600px] rounded-2xl fixed'
})
// --- 拖拽逻辑 ---
@@ -210,33 +207,34 @@ const drag = reactive({
initialized: false
})
// 居中初始化
onMounted(() => {
if (!props.isMobile && !props.call.minimized) {
drag.left = window.innerWidth / 2 - 450
drag.top = window.innerHeight / 2 - 300
if (!props.isMobile) {
drag.left = (window.innerWidth - 900) / 2
drag.top = (window.innerHeight - 600) / 2
if (drag.left < 0) drag.left = 0
if (drag.top < 0) drag.top = 0
drag.initialized = true
}
})
// 监听窗口大小变化以修正边界
window.addEventListener('resize', () => {
if (drag.initialized) clampPosition()
if (drag.initialized && !props.isMobile) clampPosition()
})
const dragStyle = computed(() => {
if (props.isMobile || props.call.minimized || !drag.initialized) return {}
if (props.isMobile) return {}
if (!drag.initialized) return {}
return {
left: `${drag.left}px`,
top: `${drag.top}px`,
position: 'fixed',
transform: 'none' // 覆盖掉 Tailwind 可能的 transform
transform: 'translate3d(0,0,0)',
touchAction: 'none'
}
})
function clampPosition() {
const w = 900
const h = 600
const w = props.call.minimized ? 192 : 900
const h = props.call.minimized ? 256 : 600
const maxLeft = window.innerWidth - w
const maxTop = window.innerHeight - h
drag.left = Math.max(0, Math.min(drag.left, maxLeft))
@@ -244,17 +242,18 @@ function clampPosition() {
}
function startDrag(e: MouseEvent) {
if (props.isMobile || props.call.minimized) return
if (props.isMobile) return
e.preventDefault()
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
e.preventDefault()
drag.left = e.clientX - drag.startX
drag.top = e.clientY - drag.startY
clampPosition()
@@ -266,7 +265,39 @@ function stopDrag() {
document.removeEventListener('mouseup', stopDrag)
}
// --- 格式化时间 ---
function startDragTouch(e: TouchEvent) {
if (props.isMobile) return
if (e.touches.length !== 1) return
drag.isDragging = true
const touch = e.touches[0]
drag.startX = touch.clientX - drag.left
drag.startY = touch.clientY - drag.top
document.addEventListener('touchmove', onDragTouch, { passive: false })
document.addEventListener('touchend', stopDragTouch)
}
function onDragTouch(e: TouchEvent) {
if (!drag.isDragging) return
e.preventDefault()
const touch = e.touches[0]
drag.left = touch.clientX - drag.startX
drag.top = touch.clientY - drag.startY
clampPosition()
}
function stopDragTouch() {
drag.isDragging = false
document.removeEventListener('touchmove', onDragTouch)
document.removeEventListener('touchend', stopDragTouch)
}
function toggleMinimize() {
emit('toggle-minimize')
nextTick(() => {
clampPosition()
})
}
const formatDuration = (seconds: number) => {
const m = Math.floor(seconds / 60).toString().padStart(2, '0')
const s = (seconds % 60).toString().padStart(2, '0')
@@ -274,18 +305,40 @@ const formatDuration = (seconds: number) => {
return h > 0 ? `${h.toString().padStart(2, '0')}:${m}:${s}` : `${m}:${s}`
}
// --- 自动绑定 ---
watch(() => props.localStream, (newStream) => {
if (localVideoRef.value && newStream) localVideoRef.value.srcObject = newStream
}, { immediate: true, flush: 'post' })
// --- 增强版视频流绑定 ---
// 监听流变化 + 视频容器显隐状态
watchEffect(() => {
const stream = props.localStream
const isVisible = shouldShowLocalVideo.value // 依赖显隐状态
const videoEl = localVideoRef.value
watch(() => props.remoteStream, (newStream) => {
if (remoteVideoRef.value && newStream) remoteVideoRef.value.srcObject = newStream
}, { immediate: true, flush: 'post' })
// 只有当元素存在、流存在虽然v-show会自动控制display但我们确保逻辑对齐时才尝试绑定
if (videoEl && stream) {
// 强制静音,防止浏览器策略阻止自动播放
videoEl.muted = true
// 重新激活或状态变化时确保绑定
watch([localVideoRef, remoteVideoRef], () => {
if (localVideoRef.value && props.localStream) localVideoRef.value.srcObject = props.localStream
if (remoteVideoRef.value && props.remoteStream) remoteVideoRef.value.srcObject = props.remoteStream
if (videoEl.srcObject !== stream) {
console.log('CallWindow: Binding local stream', stream.id)
videoEl.srcObject = stream
videoEl.play().catch(e => console.error('Local video play error:', e))
}
} else if (videoEl && !stream) {
videoEl.srcObject = null
}
})
watchEffect(() => {
const stream = props.remoteStream
const videoEl = remoteVideoRef.value
if (videoEl && stream) {
if (videoEl.srcObject !== stream) {
console.log('CallWindow: Binding remote stream', stream.id)
videoEl.srcObject = stream
videoEl.play().catch(e => console.error('Remote video play error:', e))
}
} else if (videoEl && !stream) {
videoEl.srcObject = null
}
})
</script>

View File

@@ -1,10 +1,27 @@
<template>
<div
id="msgListContainer"
ref="containerRef"
class="flex-1 overflow-y-auto p-4 space-y-5"
@dragover.prevent
@drop.prevent
@scroll="handleScroll"
>
<!-- 加载历史消息提示 -->
<div v-if="loadingHistory" class="flex justify-center items-center py-4">
<div class="flex items-center gap-2 text-gray-400 text-sm">
<i class="fas fa-spinner fa-spin"></i>
<span>正在加载历史消息...</span>
</div>
</div>
<!-- 已经到顶提示 -->
<div v-else-if="!hasMoreHistory && messages.length > 0" class="flex justify-center items-center py-4">
<div class="text-gray-500 text-sm">
<span>已经到顶啦~</span>
</div>
</div>
<div
v-for="(msg, index) in messages"
:key="index"
@@ -40,6 +57,7 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { generateColor } from '@/utils/format'
import Avatar from '@/components/common/Avatar.vue'
import MessageBubble from '@/components/chat/MessageBubble.vue'
@@ -49,14 +67,45 @@ interface Props {
messages: ChatMessage[]
currentUser: User
target: Contact
loadingHistory?: boolean
hasMoreHistory?: boolean
}
const props = defineProps<Props>()
defineEmits<{
const emit = defineEmits<{
'contextmenu': [event: MouseEvent, message: ChatMessage]
'avatar-click': [user: User | Contact]
'bubble-click': [event: MouseEvent, message: ChatMessage]
'load-more': []
}>()
const containerRef = ref<HTMLElement | null>(null)
// 节流函数
function throttle<T extends (...args: any[]) => any>(func: T, delay: number): T {
let lastCall = 0
return ((...args: any[]) => {
const now = Date.now()
if (now - lastCall >= delay) {
lastCall = now
return func(...args)
}
}) as T
}
// 滚动处理(节流优化)
const handleScroll = throttle((e: Event) => {
const target = e.target as HTMLElement
// 检测是否滚动到顶部(阈值设为 50px提前加载
if (target.scrollTop < 50 && !props.loadingHistory && props.hasMoreHistory) {
emit('load-more')
}
}, 200)
// 暴露容器引用,供父组件使用
defineExpose({
containerRef,
})
</script>

View File

@@ -35,17 +35,20 @@
</div>
<div class="flex border-t border-gray-700">
<button
v-if="cancelText"
class="flex-1 py-3 text-gray-400 hover:bg-gray-700 transition"
@click="$emit('cancel')"
>
{{ cancelText }}
</button>
<button
class="flex-1 py-3 font-bold hover:bg-gray-700 transition border-l border-gray-700"
:class="{
'text-danger': type === 'danger',
'text-primary': type !== 'danger',
}"
:class="[
cancelText ? 'flex-1 py-3 font-bold hover:bg-gray-700 transition border-l border-gray-700' : 'w-full py-3 font-bold hover:bg-gray-700 transition',
{
'text-danger': type === 'danger',
'text-primary': type !== 'danger',
}
]"
@click="$emit('confirm')"
>
{{ confirmText }}

View File

@@ -1,10 +1,9 @@
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 { ChatMessage, Contact } from '@/types/api'
import type { CallStatus } from '@/types/message'
export interface CallState {
@@ -15,20 +14,20 @@ export interface CallState {
statusText: string
id: string | null
muted: boolean
remoteMuted: boolean // 新增:对方是否静音
remoteMuted: boolean
camOff: boolean
remoteCamOff: boolean
duration: number
startTime: number | null
}
// 使用 Base64 数据 URI 避免 403 和跨域问题
// 简短的电话铃声 (Incoming)
const RINGTONE_INCOMING_BASE64 = 'data:audio/mp3;base64,//uQxAAAAAAAAAAAAEluZm8AAAAPAAAAHgAABOYADQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NAAAAAAJDTEFtZTMuMTAwBK8AAAAAAAAAABQJAAHIAAAAHgAABObK82LdAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//uQxAAAAAAABAAAAAAAAAAASbXAzLm9yZwAAAP8AAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//uQxAEAMzI2hAAAAANIAAAAQAAAEAQGBAIAQBAEAQCBwAAAAe3/8w9///3v//+57//3///8w9///3v//+57//3///8AAKAAX/4EFwR//uQxBEAM8I2fAAAAANIAAAAQAAAEAAAAAAB///+BACAAAAAAH/9R//qD//6g////+oAAAD/4IHgAAAAAA//6gAAAAAT//uQxBIAOQI2fAAAAANIAAAAQAAAE//////////////////////////////////////////////////////////////////uQxBsAOYI2fAAAAANIAAAAQAAAE//////////////////////////////////////////////////////////////////uQxCsAOYI2fAAAAANIAAAAQAAAE//////////////////////////////////////////////////////////////////uQxDQAOYI2fAAAAANIAAAAQAAAE//////////////////////////////////////////////////////////////////uQxE4AOYI2fAAAAANIAAAAQAAAE//////////////////////////////////////////////////////////////////uQxF4AOYI2fAAAAANIAAAAQAAAE//////////////////////////////////////////////////////////////////uQxGwAOYI2fAAAAANIAAAAQAAAE//////////////////////////////////////////////////////////////////uQxHgAOYI2fAAAAANIAAAAQAAAE//////////////////////////////////////////////////////////////////uQxIUAAANIAAAAAQAIAAAB//////////////////////////////////////////////////////////////////////////////////uQxIkAAANIAAAAAQAIAAAB////////////////////////////////////////////////////////////////////////////////'
// 简短的嘟嘟拨号音 (Dialing)
const RINGTONE_DIALING_BASE64 = 'data:audio/wav;base64,UklGRl9vT1BXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YU'
const RINGTONE_INCOMING_BASE64 = 'data:audio/mp3;base64,//uQxAAAAAAAAAAAAEluZm8AAAAPAAAAHgAABOYADQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NAAAAAAJDTEFtZTMuMTAwBK8AAAAAAAAAABQJAAHIAAAAHgAABObK82LdAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//uQxAAAAAAABAAAAAAAAAAASbXAzLm9yZwAAAP8AAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//uQxAEAMzI2hAAAAANIAAAAQAAAEAQGBAIAQBAEAQCBwAAAAe3/8w9///3v//+57//3///8w9///3v//+57//3///8AAKAAX/4EFwR//uQxBEAM8I2fAAAAANIAAAAQAAAEAAAAAAB///+BACAAAAAAH/9R//qD//6g////+oAAAD/4IHgAAAAAA//6gAAAAAT//uQxBIAOQI2fAAAAANIAAAAQAAAE//////////////////////////////////////////////////////////////////uQxBsAOYI2fAAAAANIAAAAQAAAE//////////////////////////////////////////////////////////////////uQxCsAOYI2fAAAAANIAAAAQAAAE//////////////////////////////////////////////////////////////////uQxDQAOYI2fAAAAANIAAAAQAAAE//////////////////////////////////////////////////////////////////uQxE4AOYI2fAAAAANIAAAAQAAAE//////////////////////////////////////////////////////////////////uQxE4AOYI2fAAAAANIAAAAQAAAE//////////////////////////////////////////////////////////////////uQxF4AOYI2fAAAAANIAAAAQAAAE//////////////////////////////////////////////////////////////////uQxGwAOYI2fAAAAANIAAAAQAAAE//////////////////////////////////////////////////////////////////uQxHgAOYI2fAAAAANIAAAAQAAAE//////////////////////////////////////////////////////////////////uQxIUAAANIAAAAAQAIAAAB//////////////////////////////////////////////////////////////////////////////////uQxIkAAANIAAAAAQAIAAAB////////////////////////////////////////////////////////////////////////////////'
export function useWebRTC(userId: string, onIncomingCall?: (senderUserId: string) => void) {
export function useWebRTC(
userId: string,
onIncomingCall?: (senderUserId: string) => void,
getRoomId?: (receiverUserId?: string) => string
) {
const toastStore = useToastStore()
const chatStore = useChatStore()
@@ -40,7 +39,7 @@ export function useWebRTC(userId: string, onIncomingCall?: (senderUserId: string
statusText: '',
id: null,
muted: false,
remoteMuted: false, // 初始化
remoteMuted: false,
camOff: false,
remoteCamOff: false,
duration: 0,
@@ -54,39 +53,29 @@ export function useWebRTC(userId: string, onIncomingCall?: (senderUserId: string
let pc: RTCPeerConnection | null = null
const pendingCandidates: RTCIceCandidate[] = []
let currentReceiverUserId = ''
let currentRoomId = ''
// 音频对象初始化 (使用 Base64)
const audioIncoming = new Audio(RINGTONE_INCOMING_BASE64)
const audioDialing = new Audio(RINGTONE_DIALING_BASE64) // 使用 Base64 或简单的音频
// 由于 Base64 字符串较短,需要循环播放
audioIncoming.loop = true
audioDialing.loop = true
// 简单的嘟嘟声生成器 (如果 Base64 不工作作为备选,但通常 Base64 更可靠)
// 这里直接使用 Audio Context 生成嘟嘟声可能更专业,但为了兼容性先用 Audio 标签
// 注意:上面的 Base64 只是示例片段,为了确保有声音,建议用真实的短音频 Base64
let oscCtx: AudioContext | null = null
let oscillator: OscillatorNode | null = null
let gainNode: GainNode | null = null
// --- 铃声控制 ---
function playRingtone(type: 'incoming' | 'dialing') {
stopRingtone() // 先停止当前的
// 如果是拨号音,我们可以用 Web Audio API 生成一个标准的嘟嘟声,这样不需要加载文件
stopRingtone()
if (type === 'dialing') {
playOscillatorTone()
return
}
const audio = audioIncoming
audio.currentTime = 0
audio.play().catch(e => console.warn('Autoplay prevented:', e))
const playPromise = audioIncoming.play()
if (playPromise !== undefined) {
playPromise.catch(e => {
console.warn('Autoplay prevented:', e)
})
}
}
// 使用 Web Audio API 生成拨号音 (避免资源 403 问题)
let oscCtx: AudioContext | null = null
let oscillator: OscillatorNode | null = null
let gainNode: GainNode | null = null
function playOscillatorTone() {
try {
const AudioContext = window.AudioContext || (window as any).webkitAudioContext
@@ -94,20 +83,15 @@ export function useWebRTC(userId: string, onIncomingCall?: (senderUserId: string
oscCtx = new AudioContext()
oscillator = oscCtx.createOscillator()
gainNode = oscCtx.createGain()
oscillator.type = 'sine'
oscillator.frequency.setValueAtTime(440, oscCtx.currentTime) // 440Hz 标准音
// 模拟嘟-嘟-嘟的效果
oscillator.frequency.setValueAtTime(440, oscCtx.currentTime)
gainNode.gain.setValueAtTime(0.1, oscCtx.currentTime)
oscillator.connect(gainNode)
gainNode.connect(oscCtx.destination)
oscillator.start()
// 简单的循环效果
const pulse = () => {
if(!gainNode || !oscCtx) return
if (oscCtx.state === 'suspended') oscCtx.resume()
gainNode.gain.setValueAtTime(0.1, oscCtx.currentTime)
setTimeout(() => {
if(gainNode && oscCtx) gainNode.gain.setValueAtTime(0, oscCtx.currentTime)
@@ -115,15 +99,12 @@ export function useWebRTC(userId: string, onIncomingCall?: (senderUserId: string
setTimeout(pulse, 2000)
}
pulse()
} catch(e) { console.error(e) }
} catch(e) { console.error('AudioContext Error:', e) }
}
function stopRingtone() {
audioIncoming.pause()
audioIncoming.currentTime = 0
// 停止 Web Audio API 的声音
if (oscillator) {
try { oscillator.stop(); oscillator.disconnect() } catch(e){}
oscillator = null
@@ -138,37 +119,16 @@ export function useWebRTC(userId: string, onIncomingCall?: (senderUserId: string
}
}
// --- 系统通知 ---
function sendSystemNotification(title: string, body: string) {
if (!('Notification' in window)) return
// 再次尝试请求权限 (如果是用户触发的操作中调用)
if (Notification.permission !== 'granted' && Notification.permission !== 'denied') {
Notification.requestPermission().then(permission => {
if (permission === 'granted') {
new Notification(title, { body, icon: '/favicon.ico' })
}
})
} else if (Notification.permission === 'granted') {
new Notification(title, { body, icon: '/favicon.ico' })
function getSafeRoomId(targetUserId?: string): string | null {
if (currentRoomId) return currentRoomId
if (targetUserId && getRoomId) {
const rid = getRoomId(targetUserId)
if (rid) return rid
}
}
// --- 辅助函数 ---
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
if (targetUserId && userId) {
return [userId, targetUserId].sort().join('_')
}
return null
}
async function sendSummaryMessage(reason: 'connected' | 'cancelled' | 'rejected' | 'busy') {
@@ -177,15 +137,13 @@ export function useWebRTC(userId: string, onIncomingCall?: (senderUserId: string
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 roomId = getSafeRoomId(currentReceiverUserId)
if (!roomId) return
const clientId = wsManager.getClientId()
const payload = {
sender_client_id: clientId || '',
@@ -196,7 +154,6 @@ export function useWebRTC(userId: string, onIncomingCall?: (senderUserId: string
duration: 0,
extra: JSON.stringify({ isSystem: true }),
}
try {
await messageApi.sendMessage(payload)
const localMsg: any = {
@@ -204,39 +161,103 @@ export function useWebRTC(userId: string, onIncomingCall?: (senderUserId: string
}
chatStore.addMessage(roomId, localMsg)
chatStore.updateContactLastMsg(currentReceiverUserId, content, Date.now())
} catch (e) { console.error(e) }
} catch (e) { console.error('Failed to send summary:', e) }
}
// --- WebRTC 基础 ---
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
return [h > 0 ? `${h}小时` : '', m > 0 ? `${m}分钟` : '', s > 0 ? `${s}` : ''].join('')
}
// --- 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())
localStream.value = null
}
const constraints: MediaStreamConstraints = {
video: videoEnabled ? { width: { ideal: 1280 }, height: { ideal: 720 }, facingMode: 'user' } : false,
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
}
console.log('[WebRTC] Requesting user media...', constraints)
const stream = await navigator.mediaDevices.getUserMedia(constraints)
// 检查流的有效性
if (stream.active) {
console.log('[WebRTC] User media obtained successfully.', {
id: stream.id,
videoTracks: stream.getVideoTracks().length,
audioTracks: stream.getAudioTracks().length
})
} else {
console.warn('[WebRTC] Obtained stream is inactive!')
}
localStream.value = stream
} catch (error) { throw new Error('无法获取设备权限') }
} catch (error: any) {
console.error('[WebRTC] Failed to get user media:', error)
let errorMessage = '无法获取设备权限'
if (error.name === 'NotAllowedError') errorMessage = '请允许访问摄像头/麦克风'
else if (error.name === 'NotFoundError') errorMessage = '未找到媒体设备'
else if (error.name === 'NotReadableError') errorMessage = '设备被占用,请关闭其他应用'
throw new Error(errorMessage)
}
}
async function createPC(): Promise<void> {
const servers = [{ urls: 'stun:stun.l.google.com:19302' }]
if (pc) pc.close()
if (pc) {
pc.close()
pc = null
}
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) }
pc.oniceconnectionstatechange = () => {
console.log('ICE Connection State:', pc?.iceConnectionState)
if (pc?.iceConnectionState === 'disconnected') {
call.statusText = '网络不稳定...'
} else if (pc?.iceConnectionState === 'failed') {
call.statusText = '连接失败'
toastStore.error('连接失败,请检查网络')
endCall()
} else if (pc?.iceConnectionState === 'connected') {
call.statusText = '通话中'
}
}
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 roomId = getSafeRoomId(targetUserId)
if (!roomId) {
console.error('No room_id available for sendSignal')
return
}
const payload = {
sender_client_id: wsManager.getClientId() || '',
receiver_user_id: targetUserId,
room_id: '',
room_id: roomId,
message_type: 6,
content: JSON.stringify(data || {}),
call_id: call.id,
@@ -246,13 +267,14 @@ export function useWebRTC(userId: string, onIncomingCall?: (senderUserId: string
messageApi.sendMessage(payload).catch(console.error)
}
// --- 状态同步信令 (摄像头/麦克风) ---
function sendSyncState(type: 'cam-toggle' | 'mic-toggle', value: boolean) {
if (call.status !== 'connected') return
const roomId = getSafeRoomId(currentReceiverUserId)
if (!roomId) return
const payload = {
sender_client_id: wsManager.getClientId() || '',
receiver_user_id: currentReceiverUserId,
room_id: '',
room_id: roomId,
message_type: 6,
content: JSON.stringify({ action: type, value }),
call_id: call.id,
@@ -262,11 +284,184 @@ export function useWebRTC(userId: string, onIncomingCall?: (senderUserId: string
messageApi.sendMessage(payload).catch(console.error)
}
// --- 计时器 ---
async function startCall(type: 'audio' | 'video', receiverUserId: string, roomId?: string, contact?: Contact) {
if (roomId) currentRoomId = roomId
else if (contact?.room_id) currentRoomId = contact.room_id
else if (getRoomId) {
const found = getRoomId(receiverUserId)
if (found) currentRoomId = found
}
if (!currentRoomId) {
toastStore.error('无法建立通话连接:缺少房间信息')
return false
}
isCaller.value = true
currentReceiverUserId = receiverUserId
call.type = type
call.id = Date.now().toString()
call.active = true
call.minimized = false
call.status = 'outgoing'
call.statusText = '正在呼叫...'
call.duration = 0
call.camOff = false
call.remoteCamOff = false
call.remoteMuted = false
remoteStream.value = null
try {
await initMedia(type === 'video')
await createPC()
playRingtone('dialing')
sendSignal('invite', undefined, receiverUserId)
return true
} catch (error: any) {
toastStore.error(error.message || '无法启动通话')
closeCall()
return false
}
}
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()
sendSignal('accepted', undefined, currentReceiverUserId)
} catch (error) {
console.error('Accept call failed:', error)
endCall()
}
}
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()
currentRoomId = ''
call.active = false
call.status = 'idle'
call.statusText = ''
call.id = null
stopCallTimer()
if (pc) {
pc.onicecandidate = null
pc.ontrack = null
pc.oniceconnectionstatechange = null
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) {
try {
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
else if (content.action === 'mic-toggle') call.remoteMuted = content.value
return
}
if (signal === 'invite') {
if (call.active) return
isCaller.value = false
currentReceiverUserId = message.sender_user_id
if (message.room_id) currentRoomId = message.room_id
call.id = message.call_id
call.active = true
call.minimized = false
call.status = 'incoming'
call.statusText = `邀请你通话`
playRingtone('incoming')
if (onIncomingCall) onIncomingCall(message.sender_user_id)
} else if (signal === 'accepted') {
stopRingtone()
call.status = 'connected'
call.statusText = '通话中'
startCallTimer()
if (!pc) await createPC()
const offer = await pc!.createOffer()
await pc!.setLocalDescription(offer)
sendSignal('offer', offer)
} else if (signal === 'offer') {
stopRingtone()
if (!pc) {
await initMedia(call.type === 'video')
await createPC()
}
await pc!.setRemoteDescription(content)
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') {
if (pc) {
await pc.setRemoteDescription(content)
processPendingCandidates()
}
} else if (signal === 'candidate') {
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('已在其他设备接听')
}
} catch (error) {
console.error('Error handling signaling:', error)
}
}
async function processPendingCandidates() {
while (pendingCandidates.length > 0) {
const c = pendingCandidates.shift()
if (c && pc) await pc.addIceCandidate(c).catch(e => console.error('Add candidate failed', e))
}
}
function startCallTimer() {
stopCallTimer()
call.startTime = Date.now()
call.duration = 0
durationTimer = window.setInterval(() => {
if (call.startTime) call.duration = Math.floor((Date.now() - call.startTime) / 1000)
}, 1000)
@@ -285,180 +480,36 @@ export function useWebRTC(userId: string, onIncomingCall?: (senderUserId: string
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()
call.active = true
call.minimized = false
call.status = 'outgoing'
call.statusText = '正在呼叫...'
call.duration = 0
call.camOff = false
call.remoteCamOff = false
call.remoteMuted = false // 重置
remoteStream.value = null
try {
// 立即获取本地媒体流,确保自己能看到自己
await initMedia(type === 'video')
await createPC()
playRingtone('dialing')
sendSignal('invite', undefined, receiverUserId)
} catch (error: any) {
toastStore.error(error.message || '无法启动通话')
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
call.remoteMuted = false
try {
await initMedia(call.type === 'video')
await createPC()
sendSignal('accepted', undefined, currentReceiverUserId)
} catch (error) {
endCall()
}
}
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 }
remoteStream.value = null
}
async function handleSignaling(message: ChatMessage) {
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
} else if (content.action === 'mic-toggle') {
call.remoteMuted = 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
call.remoteMuted = false
playRingtone('incoming')
sendSystemNotification('新来电', `收到来自 ${message.sender_user_id}${call.type === 'video' ? '视频' : '语音'}通话邀请`)
if (onIncomingCall) onIncomingCall(message.sender_user_id)
} else if (signal === 'accepted') {
stopRingtone()
call.status = 'connected'
call.statusText = '通话中'
startCallTimer()
// 确保 pc 已创建
if (!pc) {
await initMedia(call.type === 'video')
await createPC()
}
const offer = await pc!.createOffer()
await pc!.setLocalDescription(offer)
sendSignal('offer', offer)
} else if (signal === 'offer') {
stopRingtone()
if (!pc) {
await initMedia(call.type === 'video')
await createPC()
}
await pc!.setRemoteDescription(content)
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') {
// 确保 pc 已创建
if (!pc) {
await initMedia(call.type === 'video')
await createPC()
}
await pc!.setRemoteDescription(content)
processPendingCandidates()
} else if (signal === 'candidate') {
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('已在其他设备接听')
}
}
async function processPendingCandidates() {
while (pendingCandidates.length > 0) {
const c = pendingCandidates.shift()
if (c && pc) await pc.addIceCandidate(c)
}
}
function toggleMute() {
call.muted = !call.muted
sendSyncState('mic-toggle', call.muted) // 同步
sendSyncState('mic-toggle', call.muted)
if (localStream.value) localStream.value.getAudioTracks().forEach(t => t.enabled = !call.muted)
}
function toggleCamera() {
call.camOff = !call.camOff
sendSyncState('cam-toggle', call.camOff) // 同步
sendSyncState('cam-toggle', call.camOff)
if (localStream.value) localStream.value.getVideoTracks().forEach(t => t.enabled = !call.camOff)
}
function sendSystemNotification(title: string, body: string) {
if (!('Notification' in window)) return
if (Notification.permission === 'granted') {
new Notification(title, { body, icon: '/favicon.ico' })
}
}
return {
call, localStream, remoteStream, startCall, acceptCall, endCall, handleSignaling, toggleMute, toggleCamera, formatDuration, sendSystemNotification
call,
localStream,
remoteStream,
startCall,
acceptCall,
endCall,
handleSignaling,
toggleMute,
toggleCamera,
formatDuration,
sendSystemNotification
}
}

View File

@@ -3,7 +3,8 @@ import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import './assets/styles/main.scss'
// 引入 FontAwesome 主 CSS
import '@fortawesome/fontawesome-free/css/all.css'
const app = createApp(App)
app.use(createPinia())

View File

@@ -36,8 +36,9 @@ export const useConversationStore = defineStore('conversation', () => {
/**
* 处理新消息 (发送或接收)
* 实时更新前端列表:这里只做前端 UI 层的兜底,后端已更新会话表
* @param isCurrentChat 是否是当前选中的聊天,如果是则不增加未读数
*/
function handleMessageUpdate(message: ChatMessage, isSelf: boolean) {
function handleMessageUpdate(message: ChatMessage, isSelf: boolean, isCurrentChat: boolean = false) {
const targetId = isSelf ? message.receiver_user_id : message.sender_user_id
if (!targetId) return
@@ -48,7 +49,8 @@ export const useConversationStore = defineStore('conversation', () => {
if (conv) {
conv.last_message = summary
conv.last_time = now
if (!isSelf && !conv.is_muted) {
// 如果不是自己发送的,且不是当前选中的聊天,且未设置免打扰,则增加未读数
if (!isSelf && !isCurrentChat && !conv.is_muted) {
conv.unread_count = (conv.unread_count || 0) + 1
}
} else {

64
src/stores/webrtc.ts Normal file
View File

@@ -0,0 +1,64 @@
import { defineStore } from 'pinia'
import { useWebRTC } from '@/composables/useWebRTC'
import { useAuthStore } from '@/stores/auth'
import { useChatStore } from '@/stores/chat'
import { storage } from '@/utils/storage'
import type { Contact } from '@/types/api'
// 定义 Pinia Store
export const useWebRTCStore = defineStore('webrtc', () => {
const authStore = useAuthStore()
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)
}
}
// --- 回调获取房间ID策略 ---
// 这个函数非常关键它定义了如何找到两个人聊天的房间ID
// 必须在 useWebRTC 之前定义,因为它被作为参数传入
function getRoomId(receiverUserId?: string): string {
// 策略 1: 优先从本地存储获取当前选中的房间ID (最准确)
const cachedRoomId = storage.getSelectedRoomId()
if (cachedRoomId) {
return cachedRoomId
}
// 策略 2: 如果提供了接收者ID尝试从联系人列表中查找对应的 room_id
if (receiverUserId) {
const contact = chatStore.contacts.find((c) =>
c.user_id === receiverUserId ||
c.id === receiverUserId ||
c.contact_user_id === receiverUserId ||
c.contact_id === receiverUserId
)
if (contact && contact.room_id) {
return contact.room_id
}
}
// 策略 3: 使用当前打开的聊天窗口的 room_id
const currentTarget = chatStore.currentTarget
if (currentTarget && currentTarget.room_id) {
return currentTarget.room_id
}
// 如果都找不到,返回空字符串,后续逻辑会报错提示
return ''
}
// --- 创建 WebRTC 实例 ---
// 传入当前用户ID以及上述定义的两个回调
const webrtc = useWebRTC(authStore.user?.id || '', handleIncomingCall, getRoomId)
return {
webrtc,
getRoomId,
}
})

View File

@@ -10,6 +10,7 @@ const CURRENT_TAB_KEY = 'current_tab'
const CONTACT_LEFT_PANEL_MODE_KEY = 'contact_left_panel_mode'
const CONTACT_LIST_TAB_KEY = 'contact_list_tab'
const SELECTED_CONVERSATION_KEY = 'selected_conversation'
const SELECTED_ROOM_ID_KEY = 'selected_room_id'
export const storage = {
// Token
@@ -102,6 +103,19 @@ export const storage = {
getSelectedConversation(): string | null {
return localStorage.getItem(SELECTED_CONVERSATION_KEY)
},
// Selected Room ID
setSelectedRoomId(roomId: string | null) {
if (roomId) {
localStorage.setItem(SELECTED_ROOM_ID_KEY, roomId)
} else {
localStorage.removeItem(SELECTED_ROOM_ID_KEY)
}
},
getSelectedRoomId(): string | null {
return localStorage.getItem(SELECTED_ROOM_ID_KEY)
},
// Clear all
clear() {

View File

@@ -140,7 +140,10 @@
:messages="currentMessages"
:current-user="authStore.user!"
:target="chatStore.currentTarget"
:loading-history="loadingHistory"
:has-more-history="chatStore.currentTarget ? (hasMoreHistory[getRoomId(chatStore.currentTarget)] !== false) : true"
@scroll="handleScroll"
@load-more="loadMoreMessages"
/>
<!-- 新消息提示/回到底部按钮 -->
@@ -227,7 +230,6 @@
</div>
</div>
<ContextMenu />
<CallWindow ref="callWindowRef" :call="webrtc.call" :target="chatStore.currentTarget" :is-mobile="isMobile" :local-stream="webrtc.localStream.value" :remote-stream="webrtc.remoteStream.value" @end-call="webrtc.endCall" @accept-call="webrtc.acceptCall" @toggle-mute="webrtc.toggleMute" @toggle-camera="webrtc.toggleCamera" @toggle-minimize="webrtc.call.minimized = !webrtc.call.minimized" />
<FileConfirmModal :show="fileModal.show" :type="fileModal.type" :preview="fileModal.preview" :name="fileModal.name" :size="fileModal.size" @close="fileModal.show = false" @confirm="confirmSendFile" />
<!-- Profile Modal (kept simple) -->
@@ -267,12 +269,11 @@ import { formatTime } from '@/utils/format'
import { storage } from '@/utils/storage'
import type { Contact, ChatMessage } from '@/types/api'
import type { Conversation } from '@/types/conversation'
import { useWebRTC } from '@/composables/useWebRTC'
import { useWebRTCStore } from '@/stores/webrtc'
// Components...
import Avatar from '@/components/common/Avatar.vue'
import ContextMenu from '@/components/common/ContextMenu.vue'
import FileConfirmModal from '@/components/common/FileConfirmModal.vue'
import CallWindow from '@/components/call/CallWindow.vue'
import MessageList from '@/components/chat/MessageList.vue'
import MessageInput from '@/components/chat/MessageInput.vue'
import ContactView from '@/views/contact/ContactView.vue'
@@ -287,15 +288,8 @@ const conversationStore = useConversationStore()
const toastStore = useToastStore()
const contactStore = useContactStore()
const { showContextMenu } = useContextMenu()
// WebRTC logic...
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)) {
selectChat(contact)
}
}
const webrtc = useWebRTC(authStore.user?.id || '', handleIncomingCall)
const webrtcStore = useWebRTCStore()
const webrtc = webrtcStore.webrtc
// State - 从本地存储恢复
const currentTab = ref<'chat' | 'contact'>(storage.getCurrentTab())
@@ -305,13 +299,16 @@ const chatVisible = ref(false)
const isMobile = ref(window.innerWidth < 768)
const showProfileModal = ref(false)
const isRecording = ref(false)
const callWindowRef = ref<InstanceType<typeof CallWindow> | null>(null)
const fileModal = ref({ show: false, type: 0, preview: '', name: '', size: 0, file: null as File | null })
// Scroll Logic State
const showScrollBottomBtn = ref(false)
const unreadCount = ref(0)
const isNearBottom = ref(true)
const loadingHistory = ref(false)
const currentPage = ref<Record<string, number>>({}) // 记录每个房间已加载的页码
const hasMoreHistory = ref<Record<string, boolean>>({}) // 记录每个房间是否还有更多历史消息
const messageListRef = ref<InstanceType<typeof MessageList> | null>(null)
const filteredConversations = computed(() => {
const query = searchQuery.value.toLowerCase()
@@ -370,27 +367,127 @@ function scrollToBottom(smooth = true) {
}
async function selectChat(contact: Contact) {
// 保存选中的会话
// 保存选中的会话和房间ID
storage.setSelectedConversation(contact.user_id || contact.id)
const roomId = getRoomId(contact)
storage.setSelectedRoomId(roomId)
chatStore.setCurrentTarget(contact)
chatVisible.value = true
unreadCount.value = 0 // 切换聊天时重置
const roomId = getRoomId(contact)
// 重置 hasMoreHistory 状态
hasMoreHistory.value[roomId] = true
if (chatStore.getRoomMessages(roomId).length === 0) {
// 如果还没有加载过消息,初始化加载第一页
const existingMessages = chatStore.getRoomMessages(roomId)
if (existingMessages.length === 0) {
currentPage.value[roomId] = 1
try {
const response = await messageApi.getMessages(roomId, 1, 50)
const msgs = response.data.map((msg: ChatMessage) => ({
console.log('Messages API response:', response)
// response 已经是 PaginatedResponse直接访问 data
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,
}))
console.log('Processed messages:', msgs)
// 确保消息按时间顺序排列reverse 后最新的在最后)
const sortedMsgs = msgs.reverse()
chatStore.setRoomMessages(roomId, sortedMsgs)
console.log('Messages set to store, roomId:', roomId, 'count:', sortedMsgs.length)
// 如果返回的消息数量少于 pageSize说明没有更多历史消息了
if (response.data.length < 50) {
hasMoreHistory.value[roomId] = false
}
// 等待 DOM 更新后再滚动
await nextTick()
scrollToBottom(false) // 初始进入直接跳到底部
} else {
console.warn('No messages in response:', response)
hasMoreHistory.value[roomId] = false
}
} catch (error) {
console.error('Failed to load messages:', error)
toastStore.error('加载消息失败')
hasMoreHistory.value[roomId] = false
}
} else {
// 如果已有消息,直接滚动到底部
console.log('Using existing messages, count:', existingMessages.length)
await nextTick()
scrollToBottom(false)
}
}
// 加载更多历史消息(触顶加载)
async function loadMoreMessages() {
if (!chatStore.currentTarget || loadingHistory.value) return
const roomId = getRoomId(chatStore.currentTarget)
const currentMessages = chatStore.getRoomMessages(roomId)
// 如果当前没有消息,不加载
if (currentMessages.length === 0) return
// 获取当前页码如果没有则初始化为1
const page = (currentPage.value[roomId] || 1) + 1
loadingHistory.value = true
try {
// 记录加载前的滚动位置和第一条消息的高度
const container = document.getElementById('msgListContainer')
const oldScrollTop = container?.scrollTop || 0
const oldScrollHeight = container?.scrollHeight || 0
// 加载历史消息
const pageSize = 50
const response = await messageApi.getMessages(roomId, page, pageSize)
if (response.data && response.data.length > 0) {
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,
}))
chatStore.setRoomMessages(roomId, msgs.reverse())
} catch (error) { console.error('Failed to load messages:', error) }
// 将新消息添加到现有消息数组的前面(历史消息)
const existingMessages = [...currentMessages]
const allMessages = [...newMsgs.reverse(), ...existingMessages]
chatStore.setRoomMessages(roomId, allMessages)
// 更新页码
currentPage.value[roomId] = page
// 如果返回的消息数量少于 pageSize说明没有更多历史消息了
if (response.data.length < pageSize) {
hasMoreHistory.value[roomId] = false
}
// 等待DOM更新后恢复滚动位置
await nextTick()
if (container) {
const newScrollHeight = container.scrollHeight
const heightDiff = newScrollHeight - oldScrollHeight
container.scrollTop = oldScrollTop + heightDiff
}
} else {
// 没有更多消息了
hasMoreHistory.value[roomId] = false
console.log('没有更多历史消息')
}
} catch (error) {
console.error('Failed to load more messages:', error)
toastStore.error('加载历史消息失败')
} finally {
loadingHistory.value = false
}
scrollToBottom(false) // 初始进入直接跳到底部
}
// 通过会话点击打开聊天
@@ -448,10 +545,15 @@ function handleWebSocketMessage(message: ChatMessage) {
if (contact) {
chatStore.updateContactLastMsg(contact.id, getMsgSummary(message), Date.now())
conversationStore.handleMessageUpdate(message, message.isSelf === true)
// 检查是否是当前选中的聊天(通过 room_id 匹配)
const currentRoomId = chatStore.currentTarget ? getRoomId(chatStore.currentTarget) : null
const isCurrentChat = currentRoomId === roomId
conversationStore.handleMessageUpdate(message, message.isSelf === true, isCurrentChat)
if (!message.isSelf) {
// 如果不是当前聊天窗口,或者是当前窗口但用户不在底部
if (!chatStore.currentTarget || chatStore.currentTarget.id !== contact.id) {
if (!isCurrentChat) {
chatStore.incrementUnread(contact.id)
} else {
// 当前窗口
@@ -478,7 +580,17 @@ function handleWebSocketMessage(message: ChatMessage) {
function backToList() { if (isMobile.value) chatVisible.value = false }
function handleLogout() { authStore.logout(); wsManager.disconnect(); router.push('/login') }
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) }
async function startCall(type: 'audio' | 'video') {
if (!chatStore.currentTarget) return
const receiverUserId = chatStore.currentTarget.user_id || chatStore.currentTarget.id
// 直接传入 room_id
const roomId = getRoomId(chatStore.currentTarget)
const result = await webrtc.startCall(type, receiverUserId, roomId, chatStore.currentTarget)
if (!result) {
// 显示错误提示
toastStore.error('获取房间ID失败无法发起通话')
}
}
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)) }]) }
// File handlers...
@@ -507,6 +619,12 @@ async function loadContacts() { try { const contacts = await contactApi.getConta
// 监听 currentTab 变化并保存
watch(currentTab, (newTab) => {
storage.setCurrentTab(newTab)
// 当切换到联系人模块时清除选中的会话和房间ID
if (newTab === 'contact') {
chatStore.setCurrentTarget(null)
storage.setSelectedConversation('')
storage.setSelectedRoomId('')
}
})
onMounted(async () => {
@@ -523,8 +641,23 @@ onMounted(async () => {
await loadContacts()
await conversationStore.loadConversations()
// 恢复选中的会话
// 恢复选中的会话(优先使用 room_id 查找)
const savedRoomId = storage.getSelectedRoomId()
const savedTargetId = storage.getSelectedConversation()
if (savedRoomId) {
// 优先使用 room_id 查找联系人
const contact = chatStore.contacts.find(c => {
const contactRoomId = getRoomId(c)
return contactRoomId === savedRoomId
})
if (contact) {
await selectChat(contact)
return
}
}
// 如果没有 room_id 或找不到,使用 targetId 查找
if (savedTargetId) {
const contact = chatStore.contacts.find(c => c.user_id === savedTargetId || c.id === savedTargetId)
if (contact) {

View File

@@ -205,6 +205,18 @@
@confirm="handleDeleteContact"
@cancel="showDeleteConfirm = false"
/>
<!-- 房间ID获取失败提示 -->
<ConfirmModal
:show="showRoomIdErrorModal"
title="通话失败"
message="获取房间ID失败无法发起通话"
type="danger"
confirm-text="确定"
:cancel-text="''"
@confirm="showRoomIdErrorModal = false"
@cancel="showRoomIdErrorModal = false"
/>
</div>
</template>
@@ -213,7 +225,7 @@ import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useChatStore } from '@/stores/chat'
import { useToastStore } from '@/stores/toast'
import { useWebRTC } from '@/composables/useWebRTC'
import { useWebRTCStore } from '@/stores/webrtc'
import { useAuthStore } from '@/stores/auth'
import * as contactApi from '@/api/modules/contact'
import Avatar from '@/components/common/Avatar.vue'
@@ -225,7 +237,8 @@ const router = useRouter()
const chatStore = useChatStore()
const toastStore = useToastStore()
const authStore = useAuthStore()
const webrtc = useWebRTC(authStore.user?.id || '')
const webrtcStore = useWebRTCStore()
const webrtc = webrtcStore.webrtc
const contact = ref<Contact | null>(null)
const groups = ref<ContactGroup[]>([])
@@ -234,6 +247,7 @@ const showMoreOptions = ref(false)
const showEditRemarkModal = ref(false)
const showGroupSelectModal = ref(false)
const showDeleteConfirm = ref(false)
const showRoomIdErrorModal = ref(false)
const remarkName = ref('')
async function loadContactDetail() {
@@ -245,8 +259,22 @@ async function loadContactDetail() {
try {
const detail = await contactApi.getContactDetail(contactId)
console.log('ContactDetail loaded detail:', detail)
contact.value = detail
remarkName.value = detail.remark_name || ''
// 如果 detail 没有 room_id尝试从 chatStore.contacts 中查找
if (!contact.value.room_id) {
const existingContact = chatStore.contacts.find(c =>
c.id === contactId ||
c.user_id === contactId ||
c.contact_id === contactId ||
c.contact_user_id === contactId
)
if (existingContact?.room_id) {
contact.value.room_id = existingContact.room_id
console.log('Using room_id from chatStore.contacts:', existingContact.room_id)
}
}
} catch (error: any) {
console.error('Failed to load contact detail:', error)
toastStore.error(error.message || '加载失败')
@@ -272,21 +300,41 @@ function handleSendMessage() {
}
}
function handleAudioCall() {
async function handleAudioCall() {
if (contact.value) {
// contact_id 或 contact_user_id 是联系人的用户ID接收者
const receiverUserId = contact.value.contact_user_id || contact.value.contact_id || contact.value.user_id || contact.value.id
// 直接发起通话,不跳转到会话模块
webrtc.startCall('audio', receiverUserId)
// 直接传入 room_id
const roomId = contact.value.room_id
console.log('ContactDetail handleAudioCall:', {
contact: contact.value,
room_id: roomId,
receiverUserId
})
const result = await webrtc.startCall('audio', receiverUserId, roomId, contact.value)
if (!result) {
// 显示模态框提示
showRoomIdErrorModal.value = true
}
}
}
function handleVideoCall() {
async function handleVideoCall() {
if (contact.value) {
// contact_id 或 contact_user_id 是联系人的用户ID接收者
const receiverUserId = contact.value.contact_user_id || contact.value.contact_id || contact.value.user_id || contact.value.id
// 直接发起通话,不跳转到会话模块
webrtc.startCall('video', receiverUserId)
// 直接传入 room_id
const roomId = contact.value.room_id
console.log('ContactDetail handleVideoCall:', {
contact: contact.value,
room_id: roomId,
receiverUserId
})
const result = await webrtc.startCall('video', receiverUserId, roomId, contact.value)
if (!result) {
// 显示模态框提示
showRoomIdErrorModal.value = true
}
}
}

View File

@@ -208,6 +208,18 @@
@confirm="handleDeleteContact"
@cancel="showDeleteConfirm = false"
/>
<!-- 房间ID获取失败提示 -->
<ConfirmModal
:show="showRoomIdErrorModal"
title="通话失败"
message="获取房间ID失败无法发起通话"
type="danger"
confirm-text="确定"
:cancel-text="''"
@confirm="showRoomIdErrorModal = false"
@cancel="showRoomIdErrorModal = false"
/>
</div>
</template>
@@ -217,7 +229,7 @@ import { useRouter } from 'vue-router'
import { useChatStore } from '@/stores/chat'
import { useContactStore } from '@/stores/contact'
import { useToastStore } from '@/stores/toast'
import { useWebRTC } from '@/composables/useWebRTC'
import { useWebRTCStore } from '@/stores/webrtc'
import { useAuthStore } from '@/stores/auth'
import * as contactApi from '@/api/modules/contact'
import Avatar from '@/components/common/Avatar.vue'
@@ -233,10 +245,12 @@ const chatStore = useChatStore()
const contactStore = useContactStore()
const toastStore = useToastStore()
const authStore = useAuthStore()
const webrtc = useWebRTC(authStore.user?.id || '', () => {})
const webrtcStore = useWebRTCStore()
const webrtc = webrtcStore.webrtc
const contact = ref<Contact | null>(null)
const groups = ref<ContactGroup[]>([])
const showRoomIdErrorModal = ref(false)
const loading = ref(false)
const showMoreOptions = ref(false)
const showEditRemarkModal = ref(false)
@@ -258,8 +272,27 @@ async function loadContactDetail() {
loading.value = true
try {
const detail = await contactApi.getContactDetail(contactStore.selectedContact.id)
console.log('ContactDetailCard loaded detail:', detail)
contact.value = detail
remarkName.value = detail.remark_name || ''
// 确保 room_id 被正确设置(如果 detail 没有,尝试从 selectedContact 或 chatStore.contacts 获取)
if (!contact.value.room_id) {
if (contactStore.selectedContact.room_id) {
contact.value.room_id = contactStore.selectedContact.room_id
} else {
// 从 chatStore.contacts 中查找
const existingContact = chatStore.contacts.find(c =>
c.id === contactStore.selectedContact.id ||
c.user_id === contactStore.selectedContact.id ||
c.contact_id === contactStore.selectedContact.id ||
c.contact_user_id === contactStore.selectedContact.id
)
if (existingContact?.room_id) {
contact.value.room_id = existingContact.room_id
console.log('Using room_id from chatStore.contacts:', existingContact.room_id)
}
}
}
} catch (error: any) {
console.error('Failed to load contact detail:', error)
toastStore.error(error.message || '加载失败')
@@ -285,21 +318,41 @@ function handleSendMessage() {
}
}
function handleAudioCall() {
async function handleAudioCall() {
if (contact.value) {
// contact_id 或 contact_user_id 是联系人的用户ID
const receiverUserId = contact.value.contact_user_id || contact.value.contact_id || contact.value.user_id || contact.value.id
// 直接发起通话,不跳转到会话模块
webrtc.startCall('audio', receiverUserId)
// 直接传入 room_id
const roomId = contact.value.room_id
console.log('ContactDetailCard handleAudioCall:', {
contact: contact.value,
room_id: roomId,
receiverUserId
})
const result = await webrtc.startCall('audio', receiverUserId, roomId, contact.value)
if (!result) {
// 显示模态框提示
showRoomIdErrorModal.value = true
}
}
}
function handleVideoCall() {
async function handleVideoCall() {
if (contact.value) {
// contact_id 或 contact_user_id 是联系人的用户ID
const receiverUserId = contact.value.contact_user_id || contact.value.contact_id || contact.value.user_id || contact.value.id
// 直接发起通话,不跳转到会话模块
webrtc.startCall('video', receiverUserId)
// 直接传入 room_id
const roomId = contact.value.room_id
console.log('ContactDetailCard handleVideoCall:', {
contact: contact.value,
room_id: roomId,
receiverUserId
})
const result = await webrtc.startCall('video', receiverUserId, roomId, contact.value)
if (!result) {
// 显示模态框提示
showRoomIdErrorModal.value = true
}
}
}