修复各种场景音视频通话的bug
This commit is contained in:
@@ -6,7 +6,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>NL-IM 即时通讯系统</title>
|
<title>NL-IM 即时通讯系统</title>
|
||||||
<!-- FontAwesome -->
|
<!-- 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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons-vue": "^7.0.0",
|
"@ant-design/icons-vue": "^7.0.0",
|
||||||
|
"@fortawesome/fontawesome-free": "^7.1.0",
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
"pinia": "^2.1.0",
|
"pinia": "^2.1.0",
|
||||||
"vue": "^3.4.0",
|
"vue": "^3.4.0",
|
||||||
|
|||||||
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
@@ -11,6 +11,9 @@ importers:
|
|||||||
'@ant-design/icons-vue':
|
'@ant-design/icons-vue':
|
||||||
specifier: ^7.0.0
|
specifier: ^7.0.0
|
||||||
version: 7.0.1(vue@3.5.25(typescript@5.9.3))
|
version: 7.0.1(vue@3.5.25(typescript@5.9.3))
|
||||||
|
'@fortawesome/fontawesome-free':
|
||||||
|
specifier: ^7.1.0
|
||||||
|
version: 7.1.0
|
||||||
axios:
|
axios:
|
||||||
specifier: ^1.6.0
|
specifier: ^1.6.0
|
||||||
version: 1.13.2
|
version: 1.13.2
|
||||||
@@ -228,6 +231,10 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
|
'@fortawesome/fontawesome-free@7.1.0':
|
||||||
|
resolution: {integrity: sha512-+WxNld5ZCJHvPQCr/GnzCTVREyStrAJjisUPtUxG5ngDA8TMlPnKp6dddlTpai4+1GNmltAeuk1hJEkBohwZYA==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
'@jridgewell/gen-mapping@0.3.13':
|
'@jridgewell/gen-mapping@0.3.13':
|
||||||
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
||||||
|
|
||||||
@@ -1265,6 +1272,8 @@ snapshots:
|
|||||||
'@esbuild/win32-x64@0.21.5':
|
'@esbuild/win32-x64@0.21.5':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@fortawesome/fontawesome-free@7.1.0': {}
|
||||||
|
|
||||||
'@jridgewell/gen-mapping@0.3.13':
|
'@jridgewell/gen-mapping@0.3.13':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|||||||
49
src/App.vue
49
src/App.vue
@@ -1,9 +1,56 @@
|
|||||||
<template>
|
<template>
|
||||||
<router-view />
|
<router-view />
|
||||||
<Toast />
|
<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>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||||
import Toast from '@/components/common/Toast.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>
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ export async function getContactDetail(id: string): Promise<Contact> {
|
|||||||
id: response.contact.id?.toString() || response.contact.contact_id,
|
id: response.contact.id?.toString() || response.contact.contact_id,
|
||||||
user_id: response.contact.contact_id || response.contact.user_id,
|
user_id: response.contact.contact_id || response.contact.user_id,
|
||||||
contact_user_id: response.contact.contact_id,
|
contact_user_id: response.contact.contact_id,
|
||||||
|
room_id: response.contact.room_id, // 确保 room_id 被正确传递
|
||||||
user: response.user,
|
user: response.user,
|
||||||
} as Contact
|
} as Contact
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,25 +6,25 @@
|
|||||||
:class="windowClass"
|
:class="windowClass"
|
||||||
:style="dragStyle"
|
:style="dragStyle"
|
||||||
>
|
>
|
||||||
<!-- 顶部功能栏 (作为拖拽手柄) -->
|
<!-- 顶部功能栏 -->
|
||||||
<div
|
<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"
|
@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">
|
<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>
|
<i class="fas" :class="call.type === 'video' ? 'fa-video' : 'fa-phone'"></i>
|
||||||
<span>{{ call.type === 'video' ? '视频通话' : '语音通话' }}</span>
|
<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">
|
<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) }}
|
{{ formatDuration(call.duration) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 最小化按钮 (阻止冒泡以免触发拖拽) -->
|
|
||||||
<button
|
<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"
|
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
|
@mousedown.stop
|
||||||
@click="emit('toggle-minimize')"
|
@touchstart.stop
|
||||||
|
@click="toggleMinimize"
|
||||||
title="最小化/还原"
|
title="最小化/还原"
|
||||||
>
|
>
|
||||||
<i class="fas" :class="call.minimized ? 'fa-expand' : 'fa-compress'"></i>
|
<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">
|
<div class="flex-1 relative bg-black overflow-hidden flex items-center justify-center">
|
||||||
|
|
||||||
<!-- A. 头像背景层:在语音模式、等待连接、或对方关闭摄像头时显示 -->
|
<!-- A. 头像背景层 -->
|
||||||
<div
|
<div
|
||||||
v-if="call.type === 'audio' || call.status !== 'connected' || call.remoteCamOff"
|
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"
|
class="absolute inset-0 flex flex-col items-center justify-center bg-gray-900 z-10"
|
||||||
>
|
>
|
||||||
<!-- 动态波纹头像 -->
|
<div class="relative" :class="call.minimized ? 'scale-50' : ''">
|
||||||
<div class="relative">
|
|
||||||
<div
|
<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"
|
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' }"
|
:style="{ background: target?.color || '#6366f1' }"
|
||||||
>
|
>
|
||||||
{{ target?.user?.avatar || target?.remark_name?.charAt(0) || '?' }}
|
{{ target?.user?.avatar || target?.remark_name?.charAt(0) || '?' }}
|
||||||
</div>
|
</div>
|
||||||
<!-- 波纹动画 -->
|
|
||||||
<div v-if="call.status === 'outgoing' || call.status === 'connected'"
|
<div v-if="call.status === 'outgoing' || call.status === 'connected'"
|
||||||
class="absolute inset-0 bg-white/20 rounded-full animate-ping z-10"></div>
|
class="absolute inset-0 bg-white/20 rounded-full animate-ping z-10"></div>
|
||||||
</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 || '未知用户' }}
|
{{ target?.remark_name || target?.user?.name || '未知用户' }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 状态提示 -->
|
<div class="text-gray-400 mt-2 font-mono flex items-center gap-2 text-sm" :class="{'scale-75': call.minimized}">
|
||||||
<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>
|
<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-if="call.remoteCamOff && call.status === 'connected'">对方已关闭摄像头</span>
|
||||||
<span v-else>{{ call.statusText }}</span>
|
<span v-else>{{ call.statusText }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 纯语音模式下的计时器 -->
|
<div v-if="call.status === 'connected' && !call.minimized" class="mt-4 text-3xl font-mono text-white/90 font-light">
|
||||||
<div v-if="call.status === 'connected'" class="mt-4 text-3xl font-mono text-white/90 font-light">
|
|
||||||
{{ formatDuration(call.duration) }}
|
{{ formatDuration(call.duration) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- B. 远程视频流 (全屏) -->
|
<!-- B. 远程视频流 -->
|
||||||
<!-- 只有在视频模式、已连接、且对方开启摄像头时显示 -->
|
|
||||||
<video
|
<video
|
||||||
ref="remoteVideoRef"
|
ref="remoteVideoRef"
|
||||||
class="w-full h-full object-cover transition-opacity duration-500 absolute inset-0 z-0"
|
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 }"
|
:class="{ 'opacity-0': call.type === 'audio' || call.status !== 'connected' || call.remoteCamOff }"
|
||||||
></video>
|
></video>
|
||||||
|
|
||||||
<!-- C. 本地视频流 (画中画) -->
|
<!-- C. 本地视频流 -->
|
||||||
<!-- 需求:发起视频通话时即使未接通也要看到自己 -->
|
|
||||||
<!-- 显示条件:是视频通话 && 未最小化 && (状态是已连接 或 正在呼叫/拨号) -->
|
|
||||||
<div
|
<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="absolute z-20 overflow-hidden shadow-2xl transition hover:scale-105 border border-white/20 bg-gray-800"
|
||||||
:class="[
|
:class="[
|
||||||
isMobile
|
isMobile
|
||||||
@@ -98,7 +91,6 @@
|
|||||||
playsinline
|
playsinline
|
||||||
muted
|
muted
|
||||||
></video>
|
></video>
|
||||||
<!-- 摄像头关闭提示 -->
|
|
||||||
<div v-if="call.camOff" class="absolute inset-0 flex items-center justify-center bg-gray-800 text-white/50">
|
<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>
|
<i class="fas fa-video-slash"></i>
|
||||||
</div>
|
</div>
|
||||||
@@ -152,7 +144,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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'
|
import type { Contact } from '@/types/api'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -187,17 +179,22 @@ const localVideoRef = ref<HTMLVideoElement | null>(null)
|
|||||||
const remoteVideoRef = ref<HTMLVideoElement | null>(null)
|
const remoteVideoRef = ref<HTMLVideoElement | null>(null)
|
||||||
const windowRef = ref<HTMLElement | 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(() => {
|
const windowClass = computed(() => {
|
||||||
if (props.call.minimized) {
|
if (props.call.minimized) {
|
||||||
// 最小化时固定在右下角,不应用拖拽位置(或者您可以选择最小化也能拖拽,这里简化为固定)
|
return 'w-48 h-64 rounded-xl border-gray-600 fixed shadow-xl'
|
||||||
return 'w-48 h-64 bottom-5 right-5 rounded-xl border-gray-600 fixed'
|
|
||||||
}
|
}
|
||||||
if (props.isMobile) {
|
if (props.isMobile) {
|
||||||
return 'inset-0 w-full h-full rounded-none fixed'
|
return 'inset-0 w-full h-full rounded-none fixed'
|
||||||
}
|
}
|
||||||
// 正常模式,移除 top/left/transform 的 Tailwind 类,完全由 style 控制位置
|
return 'w-[900px] h-[600px] rounded-2xl fixed'
|
||||||
return 'w-[900px] h-[600px] rounded-2xl'
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// --- 拖拽逻辑 ---
|
// --- 拖拽逻辑 ---
|
||||||
@@ -210,33 +207,34 @@ const drag = reactive({
|
|||||||
initialized: false
|
initialized: false
|
||||||
})
|
})
|
||||||
|
|
||||||
// 居中初始化
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (!props.isMobile && !props.call.minimized) {
|
if (!props.isMobile) {
|
||||||
drag.left = window.innerWidth / 2 - 450
|
drag.left = (window.innerWidth - 900) / 2
|
||||||
drag.top = window.innerHeight / 2 - 300
|
drag.top = (window.innerHeight - 600) / 2
|
||||||
|
if (drag.left < 0) drag.left = 0
|
||||||
|
if (drag.top < 0) drag.top = 0
|
||||||
drag.initialized = true
|
drag.initialized = true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 监听窗口大小变化以修正边界
|
|
||||||
window.addEventListener('resize', () => {
|
window.addEventListener('resize', () => {
|
||||||
if (drag.initialized) clampPosition()
|
if (drag.initialized && !props.isMobile) clampPosition()
|
||||||
})
|
})
|
||||||
|
|
||||||
const dragStyle = computed(() => {
|
const dragStyle = computed(() => {
|
||||||
if (props.isMobile || props.call.minimized || !drag.initialized) return {}
|
if (props.isMobile) return {}
|
||||||
|
if (!drag.initialized) return {}
|
||||||
return {
|
return {
|
||||||
left: `${drag.left}px`,
|
left: `${drag.left}px`,
|
||||||
top: `${drag.top}px`,
|
top: `${drag.top}px`,
|
||||||
position: 'fixed',
|
transform: 'translate3d(0,0,0)',
|
||||||
transform: 'none' // 覆盖掉 Tailwind 可能的 transform
|
touchAction: 'none'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function clampPosition() {
|
function clampPosition() {
|
||||||
const w = 900
|
const w = props.call.minimized ? 192 : 900
|
||||||
const h = 600
|
const h = props.call.minimized ? 256 : 600
|
||||||
const maxLeft = window.innerWidth - w
|
const maxLeft = window.innerWidth - w
|
||||||
const maxTop = window.innerHeight - h
|
const maxTop = window.innerHeight - h
|
||||||
drag.left = Math.max(0, Math.min(drag.left, maxLeft))
|
drag.left = Math.max(0, Math.min(drag.left, maxLeft))
|
||||||
@@ -244,17 +242,18 @@ function clampPosition() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function startDrag(e: MouseEvent) {
|
function startDrag(e: MouseEvent) {
|
||||||
if (props.isMobile || props.call.minimized) return
|
if (props.isMobile) return
|
||||||
|
e.preventDefault()
|
||||||
drag.isDragging = true
|
drag.isDragging = true
|
||||||
drag.startX = e.clientX - drag.left
|
drag.startX = e.clientX - drag.left
|
||||||
drag.startY = e.clientY - drag.top
|
drag.startY = e.clientY - drag.top
|
||||||
|
|
||||||
document.addEventListener('mousemove', onDrag)
|
document.addEventListener('mousemove', onDrag)
|
||||||
document.addEventListener('mouseup', stopDrag)
|
document.addEventListener('mouseup', stopDrag)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onDrag(e: MouseEvent) {
|
function onDrag(e: MouseEvent) {
|
||||||
if (!drag.isDragging) return
|
if (!drag.isDragging) return
|
||||||
|
e.preventDefault()
|
||||||
drag.left = e.clientX - drag.startX
|
drag.left = e.clientX - drag.startX
|
||||||
drag.top = e.clientY - drag.startY
|
drag.top = e.clientY - drag.startY
|
||||||
clampPosition()
|
clampPosition()
|
||||||
@@ -266,7 +265,39 @@ function stopDrag() {
|
|||||||
document.removeEventListener('mouseup', 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 formatDuration = (seconds: number) => {
|
||||||
const m = Math.floor(seconds / 60).toString().padStart(2, '0')
|
const m = Math.floor(seconds / 60).toString().padStart(2, '0')
|
||||||
const s = (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}`
|
return h > 0 ? `${h.toString().padStart(2, '0')}:${m}:${s}` : `${m}:${s}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 自动绑定流 ---
|
// --- 增强版视频流绑定 ---
|
||||||
watch(() => props.localStream, (newStream) => {
|
// 监听流变化 + 视频容器显隐状态
|
||||||
if (localVideoRef.value && newStream) localVideoRef.value.srcObject = newStream
|
watchEffect(() => {
|
||||||
}, { immediate: true, flush: 'post' })
|
const stream = props.localStream
|
||||||
|
const isVisible = shouldShowLocalVideo.value // 依赖显隐状态
|
||||||
|
const videoEl = localVideoRef.value
|
||||||
|
|
||||||
watch(() => props.remoteStream, (newStream) => {
|
// 只有当元素存在、流存在,且(虽然v-show会自动控制display,但我们确保逻辑对齐)时才尝试绑定
|
||||||
if (remoteVideoRef.value && newStream) remoteVideoRef.value.srcObject = newStream
|
if (videoEl && stream) {
|
||||||
}, { immediate: true, flush: 'post' })
|
// 强制静音,防止浏览器策略阻止自动播放
|
||||||
|
videoEl.muted = true
|
||||||
|
|
||||||
// 重新激活或状态变化时确保绑定
|
if (videoEl.srcObject !== stream) {
|
||||||
watch([localVideoRef, remoteVideoRef], () => {
|
console.log('CallWindow: Binding local stream', stream.id)
|
||||||
if (localVideoRef.value && props.localStream) localVideoRef.value.srcObject = props.localStream
|
videoEl.srcObject = stream
|
||||||
if (remoteVideoRef.value && props.remoteStream) remoteVideoRef.value.srcObject = props.remoteStream
|
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>
|
</script>
|
||||||
|
|||||||
@@ -1,10 +1,27 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
id="msgListContainer"
|
id="msgListContainer"
|
||||||
|
ref="containerRef"
|
||||||
class="flex-1 overflow-y-auto p-4 space-y-5"
|
class="flex-1 overflow-y-auto p-4 space-y-5"
|
||||||
@dragover.prevent
|
@dragover.prevent
|
||||||
@drop.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
|
<div
|
||||||
v-for="(msg, index) in messages"
|
v-for="(msg, index) in messages"
|
||||||
:key="index"
|
:key="index"
|
||||||
@@ -40,6 +57,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
import { generateColor } from '@/utils/format'
|
import { generateColor } from '@/utils/format'
|
||||||
import Avatar from '@/components/common/Avatar.vue'
|
import Avatar from '@/components/common/Avatar.vue'
|
||||||
import MessageBubble from '@/components/chat/MessageBubble.vue'
|
import MessageBubble from '@/components/chat/MessageBubble.vue'
|
||||||
@@ -49,14 +67,45 @@ interface Props {
|
|||||||
messages: ChatMessage[]
|
messages: ChatMessage[]
|
||||||
currentUser: User
|
currentUser: User
|
||||||
target: Contact
|
target: Contact
|
||||||
|
loadingHistory?: boolean
|
||||||
|
hasMoreHistory?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
defineEmits<{
|
const emit = defineEmits<{
|
||||||
'contextmenu': [event: MouseEvent, message: ChatMessage]
|
'contextmenu': [event: MouseEvent, message: ChatMessage]
|
||||||
'avatar-click': [user: User | Contact]
|
'avatar-click': [user: User | Contact]
|
||||||
'bubble-click': [event: MouseEvent, message: ChatMessage]
|
'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>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -35,17 +35,20 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex border-t border-gray-700">
|
<div class="flex border-t border-gray-700">
|
||||||
<button
|
<button
|
||||||
|
v-if="cancelText"
|
||||||
class="flex-1 py-3 text-gray-400 hover:bg-gray-700 transition"
|
class="flex-1 py-3 text-gray-400 hover:bg-gray-700 transition"
|
||||||
@click="$emit('cancel')"
|
@click="$emit('cancel')"
|
||||||
>
|
>
|
||||||
{{ cancelText }}
|
{{ cancelText }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="flex-1 py-3 font-bold hover:bg-gray-700 transition border-l border-gray-700"
|
:class="[
|
||||||
: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',
|
'text-danger': type === 'danger',
|
||||||
}"
|
'text-primary': type !== 'danger',
|
||||||
|
}
|
||||||
|
]"
|
||||||
@click="$emit('confirm')"
|
@click="$emit('confirm')"
|
||||||
>
|
>
|
||||||
{{ confirmText }}
|
{{ confirmText }}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { reactive, shallowRef, ref } from 'vue'
|
import { reactive, shallowRef, ref } from 'vue'
|
||||||
import * as systemApi from '@/api/modules/system'
|
|
||||||
import * as messageApi from '@/api/modules/message'
|
import * as messageApi from '@/api/modules/message'
|
||||||
import { wsManager } from '@/api/websocket'
|
import { wsManager } from '@/api/websocket'
|
||||||
import { useToastStore } from '@/stores/toast'
|
import { useToastStore } from '@/stores/toast'
|
||||||
import { useChatStore } from '@/stores/chat'
|
import { useChatStore } from '@/stores/chat'
|
||||||
import type { ChatMessage } from '@/types/api'
|
import type { ChatMessage, Contact } from '@/types/api'
|
||||||
import type { CallStatus } from '@/types/message'
|
import type { CallStatus } from '@/types/message'
|
||||||
|
|
||||||
export interface CallState {
|
export interface CallState {
|
||||||
@@ -15,20 +14,20 @@ export interface CallState {
|
|||||||
statusText: string
|
statusText: string
|
||||||
id: string | null
|
id: string | null
|
||||||
muted: boolean
|
muted: boolean
|
||||||
remoteMuted: boolean // 新增:对方是否静音
|
remoteMuted: boolean
|
||||||
camOff: boolean
|
camOff: boolean
|
||||||
remoteCamOff: boolean
|
remoteCamOff: boolean
|
||||||
duration: number
|
duration: number
|
||||||
startTime: number | null
|
startTime: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用 Base64 数据 URI 避免 403 和跨域问题
|
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////////////////////////////////////////////////////////////////////////////////'
|
||||||
// 简短的电话铃声 (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'
|
|
||||||
|
|
||||||
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 toastStore = useToastStore()
|
||||||
const chatStore = useChatStore()
|
const chatStore = useChatStore()
|
||||||
|
|
||||||
@@ -40,7 +39,7 @@ export function useWebRTC(userId: string, onIncomingCall?: (senderUserId: string
|
|||||||
statusText: '',
|
statusText: '',
|
||||||
id: null,
|
id: null,
|
||||||
muted: false,
|
muted: false,
|
||||||
remoteMuted: false, // 初始化
|
remoteMuted: false,
|
||||||
camOff: false,
|
camOff: false,
|
||||||
remoteCamOff: false,
|
remoteCamOff: false,
|
||||||
duration: 0,
|
duration: 0,
|
||||||
@@ -54,39 +53,29 @@ export function useWebRTC(userId: string, onIncomingCall?: (senderUserId: string
|
|||||||
let pc: RTCPeerConnection | null = null
|
let pc: RTCPeerConnection | null = null
|
||||||
const pendingCandidates: RTCIceCandidate[] = []
|
const pendingCandidates: RTCIceCandidate[] = []
|
||||||
let currentReceiverUserId = ''
|
let currentReceiverUserId = ''
|
||||||
|
let currentRoomId = ''
|
||||||
|
|
||||||
// 音频对象初始化 (使用 Base64)
|
|
||||||
const audioIncoming = new Audio(RINGTONE_INCOMING_BASE64)
|
const audioIncoming = new Audio(RINGTONE_INCOMING_BASE64)
|
||||||
const audioDialing = new Audio(RINGTONE_DIALING_BASE64) // 使用 Base64 或简单的音频
|
|
||||||
|
|
||||||
// 由于 Base64 字符串较短,需要循环播放
|
|
||||||
audioIncoming.loop = true
|
audioIncoming.loop = true
|
||||||
audioDialing.loop = true
|
|
||||||
|
|
||||||
// 简单的嘟嘟声生成器 (如果 Base64 不工作作为备选,但通常 Base64 更可靠)
|
let oscCtx: AudioContext | null = null
|
||||||
// 这里直接使用 Audio Context 生成嘟嘟声可能更专业,但为了兼容性先用 Audio 标签
|
let oscillator: OscillatorNode | null = null
|
||||||
// 注意:上面的 Base64 只是示例片段,为了确保有声音,建议用真实的短音频 Base64
|
let gainNode: GainNode | null = null
|
||||||
|
|
||||||
// --- 铃声控制 ---
|
|
||||||
function playRingtone(type: 'incoming' | 'dialing') {
|
function playRingtone(type: 'incoming' | 'dialing') {
|
||||||
stopRingtone() // 先停止当前的
|
stopRingtone()
|
||||||
|
|
||||||
// 如果是拨号音,我们可以用 Web Audio API 生成一个标准的嘟嘟声,这样不需要加载文件
|
|
||||||
if (type === 'dialing') {
|
if (type === 'dialing') {
|
||||||
playOscillatorTone()
|
playOscillatorTone()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const playPromise = audioIncoming.play()
|
||||||
const audio = audioIncoming
|
if (playPromise !== undefined) {
|
||||||
audio.currentTime = 0
|
playPromise.catch(e => {
|
||||||
audio.play().catch(e => console.warn('Autoplay prevented:', 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() {
|
function playOscillatorTone() {
|
||||||
try {
|
try {
|
||||||
const AudioContext = window.AudioContext || (window as any).webkitAudioContext
|
const AudioContext = window.AudioContext || (window as any).webkitAudioContext
|
||||||
@@ -94,20 +83,15 @@ export function useWebRTC(userId: string, onIncomingCall?: (senderUserId: string
|
|||||||
oscCtx = new AudioContext()
|
oscCtx = new AudioContext()
|
||||||
oscillator = oscCtx.createOscillator()
|
oscillator = oscCtx.createOscillator()
|
||||||
gainNode = oscCtx.createGain()
|
gainNode = oscCtx.createGain()
|
||||||
|
|
||||||
oscillator.type = 'sine'
|
oscillator.type = 'sine'
|
||||||
oscillator.frequency.setValueAtTime(440, oscCtx.currentTime) // 440Hz 标准音
|
oscillator.frequency.setValueAtTime(440, oscCtx.currentTime)
|
||||||
|
|
||||||
// 模拟嘟-嘟-嘟的效果
|
|
||||||
gainNode.gain.setValueAtTime(0.1, oscCtx.currentTime)
|
gainNode.gain.setValueAtTime(0.1, oscCtx.currentTime)
|
||||||
|
|
||||||
oscillator.connect(gainNode)
|
oscillator.connect(gainNode)
|
||||||
gainNode.connect(oscCtx.destination)
|
gainNode.connect(oscCtx.destination)
|
||||||
oscillator.start()
|
oscillator.start()
|
||||||
|
|
||||||
// 简单的循环效果
|
|
||||||
const pulse = () => {
|
const pulse = () => {
|
||||||
if(!gainNode || !oscCtx) return
|
if(!gainNode || !oscCtx) return
|
||||||
|
if (oscCtx.state === 'suspended') oscCtx.resume()
|
||||||
gainNode.gain.setValueAtTime(0.1, oscCtx.currentTime)
|
gainNode.gain.setValueAtTime(0.1, oscCtx.currentTime)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if(gainNode && oscCtx) gainNode.gain.setValueAtTime(0, oscCtx.currentTime)
|
if(gainNode && oscCtx) gainNode.gain.setValueAtTime(0, oscCtx.currentTime)
|
||||||
@@ -115,15 +99,12 @@ export function useWebRTC(userId: string, onIncomingCall?: (senderUserId: string
|
|||||||
setTimeout(pulse, 2000)
|
setTimeout(pulse, 2000)
|
||||||
}
|
}
|
||||||
pulse()
|
pulse()
|
||||||
|
} catch(e) { console.error('AudioContext Error:', e) }
|
||||||
} catch(e) { console.error(e) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopRingtone() {
|
function stopRingtone() {
|
||||||
audioIncoming.pause()
|
audioIncoming.pause()
|
||||||
audioIncoming.currentTime = 0
|
audioIncoming.currentTime = 0
|
||||||
|
|
||||||
// 停止 Web Audio API 的声音
|
|
||||||
if (oscillator) {
|
if (oscillator) {
|
||||||
try { oscillator.stop(); oscillator.disconnect() } catch(e){}
|
try { oscillator.stop(); oscillator.disconnect() } catch(e){}
|
||||||
oscillator = null
|
oscillator = null
|
||||||
@@ -138,37 +119,16 @@ export function useWebRTC(userId: string, onIncomingCall?: (senderUserId: string
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 系统通知 ---
|
function getSafeRoomId(targetUserId?: string): string | null {
|
||||||
function sendSystemNotification(title: string, body: string) {
|
if (currentRoomId) return currentRoomId
|
||||||
if (!('Notification' in window)) return
|
if (targetUserId && getRoomId) {
|
||||||
|
const rid = getRoomId(targetUserId)
|
||||||
// 再次尝试请求权限 (如果是用户触发的操作中调用)
|
if (rid) return rid
|
||||||
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' })
|
|
||||||
}
|
}
|
||||||
}
|
if (targetUserId && userId) {
|
||||||
|
return [userId, targetUserId].sort().join('_')
|
||||||
// --- 辅助函数 ---
|
}
|
||||||
function getRoomId(uid1: string, uid2: string): string {
|
return null
|
||||||
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') {
|
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 durationStr = formatTextDuration(call.duration)
|
||||||
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
|
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
|
||||||
const deviceText = isMobile ? '移动端' : '电脑端'
|
const deviceText = isMobile ? '移动端' : '电脑端'
|
||||||
|
|
||||||
if (reason === 'connected') content = `通话结束,时长:${durationStr}`
|
if (reason === 'connected') content = `通话结束,时长:${durationStr}`
|
||||||
else if (reason === 'cancelled') content = '已取消呼叫'
|
else if (reason === 'cancelled') content = '已取消呼叫'
|
||||||
else if (reason === 'rejected') content = '对方拒绝接听'
|
else if (reason === 'rejected') content = '对方拒绝接听'
|
||||||
else if (reason === 'busy') content = '对方忙'
|
else if (reason === 'busy') content = '对方忙'
|
||||||
|
|
||||||
content += ` [${deviceText}]`
|
content += ` [${deviceText}]`
|
||||||
|
const roomId = getSafeRoomId(currentReceiverUserId)
|
||||||
const roomId = getRoomId(userId, currentReceiverUserId)
|
if (!roomId) return
|
||||||
const clientId = wsManager.getClientId()
|
const clientId = wsManager.getClientId()
|
||||||
const payload = {
|
const payload = {
|
||||||
sender_client_id: clientId || '',
|
sender_client_id: clientId || '',
|
||||||
@@ -196,7 +154,6 @@ export function useWebRTC(userId: string, onIncomingCall?: (senderUserId: string
|
|||||||
duration: 0,
|
duration: 0,
|
||||||
extra: JSON.stringify({ isSystem: true }),
|
extra: JSON.stringify({ isSystem: true }),
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await messageApi.sendMessage(payload)
|
await messageApi.sendMessage(payload)
|
||||||
const localMsg: any = {
|
const localMsg: any = {
|
||||||
@@ -204,39 +161,103 @@ export function useWebRTC(userId: string, onIncomingCall?: (senderUserId: string
|
|||||||
}
|
}
|
||||||
chatStore.addMessage(roomId, localMsg)
|
chatStore.addMessage(roomId, localMsg)
|
||||||
chatStore.updateContactLastMsg(currentReceiverUserId, content, Date.now())
|
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> {
|
async function initMedia(videoEnabled: boolean): Promise<void> {
|
||||||
try {
|
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 = {
|
const constraints: MediaStreamConstraints = {
|
||||||
video: videoEnabled ? { width: { ideal: 1280 }, height: { ideal: 720 }, facingMode: 'user' } : false,
|
video: videoEnabled ? { width: { ideal: 1280 }, height: { ideal: 720 }, facingMode: 'user' } : false,
|
||||||
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
|
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('[WebRTC] Requesting user media...', constraints)
|
||||||
const stream = await navigator.mediaDevices.getUserMedia(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
|
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> {
|
async function createPC(): Promise<void> {
|
||||||
const servers = [{ urls: 'stun:stun.l.google.com:19302' }]
|
const servers = [{ urls: 'stun:stun.l.google.com:19302' }]
|
||||||
if (pc) pc.close()
|
if (pc) {
|
||||||
|
pc.close()
|
||||||
|
pc = null
|
||||||
|
}
|
||||||
pc = new RTCPeerConnection({ iceServers: servers })
|
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.oniceconnectionstatechange = () => {
|
||||||
pc.onicecandidate = (e) => { if (e.candidate && call.id) sendSignal('candidate', e.candidate) }
|
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) {
|
function sendSignal(status: CallStatus, data?: any, receiverUserId?: string) {
|
||||||
if (!call.id) return
|
if (!call.id) return
|
||||||
const targetUserId = receiverUserId || currentReceiverUserId
|
const targetUserId = receiverUserId || currentReceiverUserId
|
||||||
if (!targetUserId) return
|
const roomId = getSafeRoomId(targetUserId)
|
||||||
|
if (!roomId) {
|
||||||
|
console.error('No room_id available for sendSignal')
|
||||||
|
return
|
||||||
|
}
|
||||||
const payload = {
|
const payload = {
|
||||||
sender_client_id: wsManager.getClientId() || '',
|
sender_client_id: wsManager.getClientId() || '',
|
||||||
receiver_user_id: targetUserId,
|
receiver_user_id: targetUserId,
|
||||||
room_id: '',
|
room_id: roomId,
|
||||||
message_type: 6,
|
message_type: 6,
|
||||||
content: JSON.stringify(data || {}),
|
content: JSON.stringify(data || {}),
|
||||||
call_id: call.id,
|
call_id: call.id,
|
||||||
@@ -246,13 +267,14 @@ export function useWebRTC(userId: string, onIncomingCall?: (senderUserId: string
|
|||||||
messageApi.sendMessage(payload).catch(console.error)
|
messageApi.sendMessage(payload).catch(console.error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 状态同步信令 (摄像头/麦克风) ---
|
|
||||||
function sendSyncState(type: 'cam-toggle' | 'mic-toggle', value: boolean) {
|
function sendSyncState(type: 'cam-toggle' | 'mic-toggle', value: boolean) {
|
||||||
if (call.status !== 'connected') return
|
if (call.status !== 'connected') return
|
||||||
|
const roomId = getSafeRoomId(currentReceiverUserId)
|
||||||
|
if (!roomId) return
|
||||||
const payload = {
|
const payload = {
|
||||||
sender_client_id: wsManager.getClientId() || '',
|
sender_client_id: wsManager.getClientId() || '',
|
||||||
receiver_user_id: currentReceiverUserId,
|
receiver_user_id: currentReceiverUserId,
|
||||||
room_id: '',
|
room_id: roomId,
|
||||||
message_type: 6,
|
message_type: 6,
|
||||||
content: JSON.stringify({ action: type, value }),
|
content: JSON.stringify({ action: type, value }),
|
||||||
call_id: call.id,
|
call_id: call.id,
|
||||||
@@ -262,11 +284,184 @@ export function useWebRTC(userId: string, onIncomingCall?: (senderUserId: string
|
|||||||
messageApi.sendMessage(payload).catch(console.error)
|
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() {
|
function startCallTimer() {
|
||||||
stopCallTimer()
|
stopCallTimer()
|
||||||
call.startTime = Date.now()
|
call.startTime = Date.now()
|
||||||
call.duration = 0
|
|
||||||
durationTimer = window.setInterval(() => {
|
durationTimer = window.setInterval(() => {
|
||||||
if (call.startTime) call.duration = Math.floor((Date.now() - call.startTime) / 1000)
|
if (call.startTime) call.duration = Math.floor((Date.now() - call.startTime) / 1000)
|
||||||
}, 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}`
|
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() {
|
function toggleMute() {
|
||||||
call.muted = !call.muted
|
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)
|
if (localStream.value) localStream.value.getAudioTracks().forEach(t => t.enabled = !call.muted)
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleCamera() {
|
function toggleCamera() {
|
||||||
call.camOff = !call.camOff
|
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)
|
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 {
|
return {
|
||||||
call, localStream, remoteStream, startCall, acceptCall, endCall, handleSignaling, toggleMute, toggleCamera, formatDuration, sendSystemNotification
|
call,
|
||||||
|
localStream,
|
||||||
|
remoteStream,
|
||||||
|
startCall,
|
||||||
|
acceptCall,
|
||||||
|
endCall,
|
||||||
|
handleSignaling,
|
||||||
|
toggleMute,
|
||||||
|
toggleCamera,
|
||||||
|
formatDuration,
|
||||||
|
sendSystemNotification
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { createPinia } from 'pinia'
|
|||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
import './assets/styles/main.scss'
|
import './assets/styles/main.scss'
|
||||||
|
// 引入 FontAwesome 主 CSS
|
||||||
|
import '@fortawesome/fontawesome-free/css/all.css'
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
|
||||||
app.use(createPinia())
|
app.use(createPinia())
|
||||||
|
|||||||
@@ -36,8 +36,9 @@ export const useConversationStore = defineStore('conversation', () => {
|
|||||||
/**
|
/**
|
||||||
* 处理新消息 (发送或接收)
|
* 处理新消息 (发送或接收)
|
||||||
* 实时更新前端列表:这里只做前端 UI 层的兜底,后端已更新会话表
|
* 实时更新前端列表:这里只做前端 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
|
const targetId = isSelf ? message.receiver_user_id : message.sender_user_id
|
||||||
if (!targetId) return
|
if (!targetId) return
|
||||||
|
|
||||||
@@ -48,7 +49,8 @@ export const useConversationStore = defineStore('conversation', () => {
|
|||||||
if (conv) {
|
if (conv) {
|
||||||
conv.last_message = summary
|
conv.last_message = summary
|
||||||
conv.last_time = now
|
conv.last_time = now
|
||||||
if (!isSelf && !conv.is_muted) {
|
// 如果不是自己发送的,且不是当前选中的聊天,且未设置免打扰,则增加未读数
|
||||||
|
if (!isSelf && !isCurrentChat && !conv.is_muted) {
|
||||||
conv.unread_count = (conv.unread_count || 0) + 1
|
conv.unread_count = (conv.unread_count || 0) + 1
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
64
src/stores/webrtc.ts
Normal file
64
src/stores/webrtc.ts
Normal 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,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -10,6 +10,7 @@ const CURRENT_TAB_KEY = 'current_tab'
|
|||||||
const CONTACT_LEFT_PANEL_MODE_KEY = 'contact_left_panel_mode'
|
const CONTACT_LEFT_PANEL_MODE_KEY = 'contact_left_panel_mode'
|
||||||
const CONTACT_LIST_TAB_KEY = 'contact_list_tab'
|
const CONTACT_LIST_TAB_KEY = 'contact_list_tab'
|
||||||
const SELECTED_CONVERSATION_KEY = 'selected_conversation'
|
const SELECTED_CONVERSATION_KEY = 'selected_conversation'
|
||||||
|
const SELECTED_ROOM_ID_KEY = 'selected_room_id'
|
||||||
|
|
||||||
export const storage = {
|
export const storage = {
|
||||||
// Token
|
// Token
|
||||||
@@ -102,6 +103,19 @@ export const storage = {
|
|||||||
getSelectedConversation(): string | null {
|
getSelectedConversation(): string | null {
|
||||||
return localStorage.getItem(SELECTED_CONVERSATION_KEY)
|
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 all
|
||||||
clear() {
|
clear() {
|
||||||
|
|||||||
@@ -140,7 +140,10 @@
|
|||||||
:messages="currentMessages"
|
:messages="currentMessages"
|
||||||
:current-user="authStore.user!"
|
:current-user="authStore.user!"
|
||||||
:target="chatStore.currentTarget"
|
:target="chatStore.currentTarget"
|
||||||
|
:loading-history="loadingHistory"
|
||||||
|
:has-more-history="chatStore.currentTarget ? (hasMoreHistory[getRoomId(chatStore.currentTarget)] !== false) : true"
|
||||||
@scroll="handleScroll"
|
@scroll="handleScroll"
|
||||||
|
@load-more="loadMoreMessages"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 新消息提示/回到底部按钮 -->
|
<!-- 新消息提示/回到底部按钮 -->
|
||||||
@@ -227,7 +230,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ContextMenu />
|
<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" />
|
<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) -->
|
<!-- Profile Modal (kept simple) -->
|
||||||
@@ -267,12 +269,11 @@ import { formatTime } from '@/utils/format'
|
|||||||
import { storage } from '@/utils/storage'
|
import { storage } from '@/utils/storage'
|
||||||
import type { Contact, ChatMessage } from '@/types/api'
|
import type { Contact, ChatMessage } from '@/types/api'
|
||||||
import type { Conversation } from '@/types/conversation'
|
import type { Conversation } from '@/types/conversation'
|
||||||
import { useWebRTC } from '@/composables/useWebRTC'
|
import { useWebRTCStore } from '@/stores/webrtc'
|
||||||
// Components...
|
// Components...
|
||||||
import Avatar from '@/components/common/Avatar.vue'
|
import Avatar from '@/components/common/Avatar.vue'
|
||||||
import ContextMenu from '@/components/common/ContextMenu.vue'
|
import ContextMenu from '@/components/common/ContextMenu.vue'
|
||||||
import FileConfirmModal from '@/components/common/FileConfirmModal.vue'
|
import FileConfirmModal from '@/components/common/FileConfirmModal.vue'
|
||||||
import CallWindow from '@/components/call/CallWindow.vue'
|
|
||||||
import MessageList from '@/components/chat/MessageList.vue'
|
import MessageList from '@/components/chat/MessageList.vue'
|
||||||
import MessageInput from '@/components/chat/MessageInput.vue'
|
import MessageInput from '@/components/chat/MessageInput.vue'
|
||||||
import ContactView from '@/views/contact/ContactView.vue'
|
import ContactView from '@/views/contact/ContactView.vue'
|
||||||
@@ -287,15 +288,8 @@ const conversationStore = useConversationStore()
|
|||||||
const toastStore = useToastStore()
|
const toastStore = useToastStore()
|
||||||
const contactStore = useContactStore()
|
const contactStore = useContactStore()
|
||||||
const { showContextMenu } = useContextMenu()
|
const { showContextMenu } = useContextMenu()
|
||||||
|
const webrtcStore = useWebRTCStore()
|
||||||
// WebRTC logic...
|
const webrtc = webrtcStore.webrtc
|
||||||
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)
|
|
||||||
|
|
||||||
// State - 从本地存储恢复
|
// State - 从本地存储恢复
|
||||||
const currentTab = ref<'chat' | 'contact'>(storage.getCurrentTab())
|
const currentTab = ref<'chat' | 'contact'>(storage.getCurrentTab())
|
||||||
@@ -305,13 +299,16 @@ const chatVisible = ref(false)
|
|||||||
const isMobile = ref(window.innerWidth < 768)
|
const isMobile = ref(window.innerWidth < 768)
|
||||||
const showProfileModal = ref(false)
|
const showProfileModal = ref(false)
|
||||||
const isRecording = 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 })
|
const fileModal = ref({ show: false, type: 0, preview: '', name: '', size: 0, file: null as File | null })
|
||||||
|
|
||||||
// Scroll Logic State
|
// Scroll Logic State
|
||||||
const showScrollBottomBtn = ref(false)
|
const showScrollBottomBtn = ref(false)
|
||||||
const unreadCount = ref(0)
|
const unreadCount = ref(0)
|
||||||
const isNearBottom = ref(true)
|
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 filteredConversations = computed(() => {
|
||||||
const query = searchQuery.value.toLowerCase()
|
const query = searchQuery.value.toLowerCase()
|
||||||
@@ -370,27 +367,127 @@ function scrollToBottom(smooth = true) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function selectChat(contact: Contact) {
|
async function selectChat(contact: Contact) {
|
||||||
// 保存选中的会话
|
// 保存选中的会话和房间ID
|
||||||
storage.setSelectedConversation(contact.user_id || contact.id)
|
storage.setSelectedConversation(contact.user_id || contact.id)
|
||||||
|
const roomId = getRoomId(contact)
|
||||||
|
storage.setSelectedRoomId(roomId)
|
||||||
chatStore.setCurrentTarget(contact)
|
chatStore.setCurrentTarget(contact)
|
||||||
chatVisible.value = true
|
chatVisible.value = true
|
||||||
unreadCount.value = 0 // 切换聊天时重置
|
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 {
|
try {
|
||||||
const response = await messageApi.getMessages(roomId, 1, 50)
|
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,
|
...msg,
|
||||||
isSelf: msg.sender_user_id === authStore.user!.id,
|
isSelf: msg.sender_user_id === authStore.user!.id,
|
||||||
extra: typeof msg.extra === 'string' ? JSON.parse(msg.extra || '{}') : msg.extra,
|
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) {
|
if (contact) {
|
||||||
chatStore.updateContactLastMsg(contact.id, getMsgSummary(message), Date.now())
|
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 (!message.isSelf) {
|
||||||
// 如果不是当前聊天窗口,或者是当前窗口但用户不在底部
|
// 如果不是当前聊天窗口,或者是当前窗口但用户不在底部
|
||||||
if (!chatStore.currentTarget || chatStore.currentTarget.id !== contact.id) {
|
if (!isCurrentChat) {
|
||||||
chatStore.incrementUnread(contact.id)
|
chatStore.incrementUnread(contact.id)
|
||||||
} else {
|
} else {
|
||||||
// 当前窗口
|
// 当前窗口
|
||||||
@@ -478,7 +580,17 @@ function handleWebSocketMessage(message: ChatMessage) {
|
|||||||
function backToList() { if (isMobile.value) chatVisible.value = false }
|
function backToList() { if (isMobile.value) chatVisible.value = false }
|
||||||
function handleLogout() { authStore.logout(); wsManager.disconnect(); router.push('/login') }
|
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 }
|
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 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)) }]) }
|
function showChatOptionsMenu(event: MouseEvent, contact: Contact) { showContextMenu(event, [{ label: '清空记录', icon: 'fas fa-eraser', danger: true, action: () => chatStore.clearRoomMessages(getRoomId(contact)) }]) }
|
||||||
// File handlers...
|
// File handlers...
|
||||||
@@ -507,6 +619,12 @@ async function loadContacts() { try { const contacts = await contactApi.getConta
|
|||||||
// 监听 currentTab 变化并保存
|
// 监听 currentTab 变化并保存
|
||||||
watch(currentTab, (newTab) => {
|
watch(currentTab, (newTab) => {
|
||||||
storage.setCurrentTab(newTab)
|
storage.setCurrentTab(newTab)
|
||||||
|
// 当切换到联系人模块时,清除选中的会话和房间ID
|
||||||
|
if (newTab === 'contact') {
|
||||||
|
chatStore.setCurrentTarget(null)
|
||||||
|
storage.setSelectedConversation('')
|
||||||
|
storage.setSelectedRoomId('')
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@@ -523,8 +641,23 @@ onMounted(async () => {
|
|||||||
await loadContacts()
|
await loadContacts()
|
||||||
await conversationStore.loadConversations()
|
await conversationStore.loadConversations()
|
||||||
|
|
||||||
// 恢复选中的会话
|
// 恢复选中的会话(优先使用 room_id 查找)
|
||||||
|
const savedRoomId = storage.getSelectedRoomId()
|
||||||
const savedTargetId = storage.getSelectedConversation()
|
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) {
|
if (savedTargetId) {
|
||||||
const contact = chatStore.contacts.find(c => c.user_id === savedTargetId || c.id === savedTargetId)
|
const contact = chatStore.contacts.find(c => c.user_id === savedTargetId || c.id === savedTargetId)
|
||||||
if (contact) {
|
if (contact) {
|
||||||
|
|||||||
@@ -205,6 +205,18 @@
|
|||||||
@confirm="handleDeleteContact"
|
@confirm="handleDeleteContact"
|
||||||
@cancel="showDeleteConfirm = false"
|
@cancel="showDeleteConfirm = false"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- 房间ID获取失败提示 -->
|
||||||
|
<ConfirmModal
|
||||||
|
:show="showRoomIdErrorModal"
|
||||||
|
title="通话失败"
|
||||||
|
message="获取房间ID失败,无法发起通话"
|
||||||
|
type="danger"
|
||||||
|
confirm-text="确定"
|
||||||
|
:cancel-text="''"
|
||||||
|
@confirm="showRoomIdErrorModal = false"
|
||||||
|
@cancel="showRoomIdErrorModal = false"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -213,7 +225,7 @@ import { ref, onMounted } from 'vue'
|
|||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useChatStore } from '@/stores/chat'
|
import { useChatStore } from '@/stores/chat'
|
||||||
import { useToastStore } from '@/stores/toast'
|
import { useToastStore } from '@/stores/toast'
|
||||||
import { useWebRTC } from '@/composables/useWebRTC'
|
import { useWebRTCStore } from '@/stores/webrtc'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import * as contactApi from '@/api/modules/contact'
|
import * as contactApi from '@/api/modules/contact'
|
||||||
import Avatar from '@/components/common/Avatar.vue'
|
import Avatar from '@/components/common/Avatar.vue'
|
||||||
@@ -225,7 +237,8 @@ const router = useRouter()
|
|||||||
const chatStore = useChatStore()
|
const chatStore = useChatStore()
|
||||||
const toastStore = useToastStore()
|
const toastStore = useToastStore()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const webrtc = useWebRTC(authStore.user?.id || '')
|
const webrtcStore = useWebRTCStore()
|
||||||
|
const webrtc = webrtcStore.webrtc
|
||||||
|
|
||||||
const contact = ref<Contact | null>(null)
|
const contact = ref<Contact | null>(null)
|
||||||
const groups = ref<ContactGroup[]>([])
|
const groups = ref<ContactGroup[]>([])
|
||||||
@@ -234,6 +247,7 @@ const showMoreOptions = ref(false)
|
|||||||
const showEditRemarkModal = ref(false)
|
const showEditRemarkModal = ref(false)
|
||||||
const showGroupSelectModal = ref(false)
|
const showGroupSelectModal = ref(false)
|
||||||
const showDeleteConfirm = ref(false)
|
const showDeleteConfirm = ref(false)
|
||||||
|
const showRoomIdErrorModal = ref(false)
|
||||||
const remarkName = ref('')
|
const remarkName = ref('')
|
||||||
|
|
||||||
async function loadContactDetail() {
|
async function loadContactDetail() {
|
||||||
@@ -245,8 +259,22 @@ async function loadContactDetail() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const detail = await contactApi.getContactDetail(contactId)
|
const detail = await contactApi.getContactDetail(contactId)
|
||||||
|
console.log('ContactDetail loaded detail:', detail)
|
||||||
contact.value = detail
|
contact.value = detail
|
||||||
remarkName.value = detail.remark_name || ''
|
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) {
|
} catch (error: any) {
|
||||||
console.error('Failed to load contact detail:', error)
|
console.error('Failed to load contact detail:', error)
|
||||||
toastStore.error(error.message || '加载失败')
|
toastStore.error(error.message || '加载失败')
|
||||||
@@ -272,21 +300,41 @@ function handleSendMessage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleAudioCall() {
|
async function handleAudioCall() {
|
||||||
if (contact.value) {
|
if (contact.value) {
|
||||||
// contact_id 或 contact_user_id 是联系人的用户ID(接收者)
|
// contact_id 或 contact_user_id 是联系人的用户ID(接收者)
|
||||||
const receiverUserId = contact.value.contact_user_id || contact.value.contact_id || contact.value.user_id || contact.value.id
|
const receiverUserId = contact.value.contact_user_id || contact.value.contact_id || contact.value.user_id || contact.value.id
|
||||||
// 直接发起通话,不跳转到会话模块
|
// 直接传入 room_id
|
||||||
webrtc.startCall('audio', receiverUserId)
|
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) {
|
if (contact.value) {
|
||||||
// contact_id 或 contact_user_id 是联系人的用户ID(接收者)
|
// contact_id 或 contact_user_id 是联系人的用户ID(接收者)
|
||||||
const receiverUserId = contact.value.contact_user_id || contact.value.contact_id || contact.value.user_id || contact.value.id
|
const receiverUserId = contact.value.contact_user_id || contact.value.contact_id || contact.value.user_id || contact.value.id
|
||||||
// 直接发起通话,不跳转到会话模块
|
// 直接传入 room_id
|
||||||
webrtc.startCall('video', receiverUserId)
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -208,6 +208,18 @@
|
|||||||
@confirm="handleDeleteContact"
|
@confirm="handleDeleteContact"
|
||||||
@cancel="showDeleteConfirm = false"
|
@cancel="showDeleteConfirm = false"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- 房间ID获取失败提示 -->
|
||||||
|
<ConfirmModal
|
||||||
|
:show="showRoomIdErrorModal"
|
||||||
|
title="通话失败"
|
||||||
|
message="获取房间ID失败,无法发起通话"
|
||||||
|
type="danger"
|
||||||
|
confirm-text="确定"
|
||||||
|
:cancel-text="''"
|
||||||
|
@confirm="showRoomIdErrorModal = false"
|
||||||
|
@cancel="showRoomIdErrorModal = false"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -217,7 +229,7 @@ import { useRouter } from 'vue-router'
|
|||||||
import { useChatStore } from '@/stores/chat'
|
import { useChatStore } from '@/stores/chat'
|
||||||
import { useContactStore } from '@/stores/contact'
|
import { useContactStore } from '@/stores/contact'
|
||||||
import { useToastStore } from '@/stores/toast'
|
import { useToastStore } from '@/stores/toast'
|
||||||
import { useWebRTC } from '@/composables/useWebRTC'
|
import { useWebRTCStore } from '@/stores/webrtc'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import * as contactApi from '@/api/modules/contact'
|
import * as contactApi from '@/api/modules/contact'
|
||||||
import Avatar from '@/components/common/Avatar.vue'
|
import Avatar from '@/components/common/Avatar.vue'
|
||||||
@@ -233,10 +245,12 @@ const chatStore = useChatStore()
|
|||||||
const contactStore = useContactStore()
|
const contactStore = useContactStore()
|
||||||
const toastStore = useToastStore()
|
const toastStore = useToastStore()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const webrtc = useWebRTC(authStore.user?.id || '', () => {})
|
const webrtcStore = useWebRTCStore()
|
||||||
|
const webrtc = webrtcStore.webrtc
|
||||||
|
|
||||||
const contact = ref<Contact | null>(null)
|
const contact = ref<Contact | null>(null)
|
||||||
const groups = ref<ContactGroup[]>([])
|
const groups = ref<ContactGroup[]>([])
|
||||||
|
const showRoomIdErrorModal = ref(false)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const showMoreOptions = ref(false)
|
const showMoreOptions = ref(false)
|
||||||
const showEditRemarkModal = ref(false)
|
const showEditRemarkModal = ref(false)
|
||||||
@@ -258,8 +272,27 @@ async function loadContactDetail() {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const detail = await contactApi.getContactDetail(contactStore.selectedContact.id)
|
const detail = await contactApi.getContactDetail(contactStore.selectedContact.id)
|
||||||
|
console.log('ContactDetailCard loaded detail:', detail)
|
||||||
contact.value = detail
|
contact.value = detail
|
||||||
remarkName.value = detail.remark_name || ''
|
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) {
|
} catch (error: any) {
|
||||||
console.error('Failed to load contact detail:', error)
|
console.error('Failed to load contact detail:', error)
|
||||||
toastStore.error(error.message || '加载失败')
|
toastStore.error(error.message || '加载失败')
|
||||||
@@ -285,21 +318,41 @@ function handleSendMessage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleAudioCall() {
|
async function handleAudioCall() {
|
||||||
if (contact.value) {
|
if (contact.value) {
|
||||||
// contact_id 或 contact_user_id 是联系人的用户ID
|
// contact_id 或 contact_user_id 是联系人的用户ID
|
||||||
const receiverUserId = contact.value.contact_user_id || contact.value.contact_id || contact.value.user_id || contact.value.id
|
const receiverUserId = contact.value.contact_user_id || contact.value.contact_id || contact.value.user_id || contact.value.id
|
||||||
// 直接发起通话,不跳转到会话模块
|
// 直接传入 room_id
|
||||||
webrtc.startCall('audio', receiverUserId)
|
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) {
|
if (contact.value) {
|
||||||
// contact_id 或 contact_user_id 是联系人的用户ID
|
// contact_id 或 contact_user_id 是联系人的用户ID
|
||||||
const receiverUserId = contact.value.contact_user_id || contact.value.contact_id || contact.value.user_id || contact.value.id
|
const receiverUserId = contact.value.contact_user_id || contact.value.contact_id || contact.value.user_id || contact.value.id
|
||||||
// 直接发起通话,不跳转到会话模块
|
// 直接传入 room_id
|
||||||
webrtc.startCall('video', receiverUserId)
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user