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

已知问题:
文件无法发送
This commit is contained in:
2025-12-03 15:33:48 +08:00
parent be7bd1279a
commit 9db9b65ea3
15 changed files with 1544 additions and 704 deletions

View File

@@ -1,8 +1,8 @@
<template>
<div
v-show="call.active"
class="fixed z-[999] transition-all duration-300 overflow-hidden bg-gray-900 flex flex-col shadow-2xl border border-gray-700"
:class="[
v-show="call.active"
class="fixed z-[999] transition-all duration-300 overflow-hidden bg-gray-900 flex flex-col shadow-2xl border border-gray-700 font-sans"
:class="[
call.minimized
? 'w-48 h-64 bottom-5 right-5 rounded-xl border-gray-600'
: isMobile
@@ -10,120 +10,134 @@
: 'top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[900px] h-[600px] rounded-2xl',
]"
>
<!-- 顶部功能栏 -->
<!-- 顶部功能栏 (渐变遮罩) -->
<div
class="absolute top-0 w-full p-4 z-20 flex justify-between items-start bg-gradient-to-b from-black/60 to-transparent transition-opacity hover:opacity-100"
:class="{ 'opacity-0': !call.minimized }"
class="absolute top-0 w-full p-4 z-30 flex justify-between items-start bg-gradient-to-b from-black/80 to-transparent transition-opacity hover:opacity-100"
:class="{ 'opacity-0': !call.minimized && !isMobile && call.status === 'connected' }"
>
<div class="text-white text-sm font-bold drop-shadow-md px-2" v-if="!call.minimized">
<div class="text-white text-sm font-bold drop-shadow-md px-2 flex items-center gap-2" v-if="!call.minimized">
<i class="fas" :class="call.type === 'video' ? 'fa-video' : 'fa-phone'"></i>
{{ call.type === 'video' ? '视频通话' : '语音通话' }}
<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">
{{ formatDuration(call.duration) }}
</span>
</div>
<div class="flex gap-3">
<button
class="w-8 h-8 rounded-full bg-black/40 hover:bg-white/20 text-white flex items-center justify-center backdrop-blur transition"
@click="call.minimized = !call.minimized"
<!-- 最小化按钮 -->
<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"
@click="emit('toggle-minimize')"
title="最小化/还原"
>
<i class="fas" :class="call.minimized ? 'fa-expand' : 'fa-compress'"></i>
</button>
</div>
>
<i class="fas" :class="call.minimized ? 'fa-expand' : 'fa-compress'"></i>
</button>
</div>
<!-- 内容区域 -->
<!-- 内容区域 -->
<div class="flex-1 relative bg-black overflow-hidden flex items-center justify-center">
<!-- 纯语音 UI 连接中状态显示大头像 -->
<!-- 1. 语音/等待状态 UI -->
<div
v-if="call.type === 'audio' || call.status !== 'connected'"
class="absolute inset-0 flex flex-col items-center justify-center bg-gray-900 z-10"
v-if="call.type === 'audio' || call.status !== 'connected'"
class="absolute inset-0 flex flex-col items-center justify-center bg-gray-900 z-10"
>
<!-- 呼叫中/语音通话时的波纹动画 -->
<div
class="w-32 h-32 rounded-full flex items-center justify-center text-5xl font-bold mb-6 shadow-xl transition-all duration-1000 relative"
:class="{
'animate-[ripple_2s_infinite]':
call.status === 'connected' || call.status === 'outgoing',
}"
:style="{ background: target?.color || '#6366f1' }"
>
{{ target?.user?.avatar || target?.remark_name?.charAt(0) || '?' }}
<!-- 动态波纹头像 -->
<div class="relative">
<div
class="w-32 h-32 rounded-full flex items-center justify-center text-5xl font-bold mb-6 shadow-2xl z-20 relative border-4 border-gray-800"
:style="{ background: target?.color || '#6366f1' }"
>
{{ target?.user?.avatar || target?.remark_name?.charAt(0) || '?' }}
</div>
<!-- 波纹动画 -->
<div v-if="call.status === 'outgoing' || call.status === 'connected'"
class="absolute inset-0 bg-white/20 rounded-full animate-ping z-10"></div>
</div>
<div class="text-2xl font-bold text-gray-100 tracking-wide">
<div class="text-2xl font-bold text-gray-100 tracking-wide mt-2">
{{ target?.remark_name || target?.user?.name || '未知用户' }}
</div>
<div class="text-gray-400 mt-2 font-mono flex items-center gap-2">
<span
v-if="call.status === 'connected'"
class="w-2 h-2 bg-green-500 rounded-full animate-pulse"
></span>
<div class="text-gray-400 mt-2 font-mono flex items-center gap-2 text-sm">
<span v-if="call.status === 'connected'" class="w-2 h-2 bg-green-500 rounded-full animate-pulse"></span>
{{ call.statusText }}
</div>
</div>
<!-- 视频通话 UI -->
<!-- 远程视频流 ( Video 模式且连接成功显示) -->
<!-- 2. 远程视频流 (全屏) -->
<video
ref="remoteVideo"
class="w-full h-full object-cover transition-opacity duration-500 absolute inset-0"
autoplay
playsinline
:class="{
'opacity-0': call.type === 'audio' || call.status !== 'connected',
}"
ref="remoteVideoRef"
class="w-full h-full object-cover transition-opacity duration-500 absolute inset-0 z-0"
autoplay
playsinline
:class="{ 'opacity-0': call.type === 'audio' || call.status !== 'connected' }"
></video>
<!-- 本地视频 (小窗) - Video 模式显示 -->
<video
v-show="call.type === 'video' && !call.minimized"
ref="localVideo"
class="absolute top-5 right-5 w-48 h-36 bg-gray-800 rounded-lg border border-gray-600 object-cover z-20 shadow-2xl transition hover:scale-105"
autoplay
playsinline
muted
></video>
<!-- 3. 本地视频 (画中画) -->
<!-- 优化移动端使用竖屏比例 (w-24 h-32 -> 3:4)PC端使用横屏比例 (w-48 h-36 -> 4:3) -->
<div
v-show="call.type === 'video' && !call.minimized && call.status === 'connected'"
class="absolute z-20 overflow-hidden shadow-2xl transition hover:scale-105 border border-white/20 bg-gray-800"
:class="[
isMobile
? 'top-16 right-4 w-28 h-40 rounded-xl' /* 移动端:右上角,竖长方形 */
: 'bottom-28 right-8 w-56 h-40 rounded-lg' /* PC端右下角横长方形 */
]"
>
<video
ref="localVideoRef"
class="w-full h-full object-cover"
autoplay
playsinline
muted
></video>
<!-- 摄像头关闭提示 -->
<div v-if="call.camOff" class="absolute inset-0 flex items-center justify-center bg-gray-800 text-white/50">
<i class="fas fa-video-slash"></i>
</div>
</div>
</div>
<!-- 底部控制栏 -->
<div
v-if="!call.minimized"
class="h-24 bg-gray-900/90 backdrop-blur border-t border-gray-800 flex items-center justify-center gap-8 shrink-0 pb-4 z-30"
v-if="!call.minimized"
class="h-24 bg-gray-900/80 backdrop-blur-md border-t border-white/10 flex items-center justify-center gap-6 md:gap-10 shrink-0 pb-4 z-30"
>
<!-- 挂断 -->
<button
class="w-14 h-14 rounded-full bg-red-500 hover:bg-red-600 text-white flex items-center justify-center text-xl transition transform hover:scale-110 shadow-lg shadow-red-500/30"
@click="endCall"
title="挂断"
class="w-14 h-14 rounded-full bg-red-500 hover:bg-red-600 text-white flex items-center justify-center text-xl transition transform active:scale-95 shadow-lg shadow-red-500/30"
@click="emit('end-call')"
title="挂断"
>
<i class="fas fa-phone-slash"></i>
</button>
<!-- 接听 -->
<!-- 接听 (仅来电时显示) -->
<button
v-if="call.status === 'incoming'"
class="w-14 h-14 rounded-full bg-green-500 hover:bg-green-600 text-white flex items-center justify-center text-xl transition transform hover:scale-110 shadow-lg shadow-green-500/30"
@click="acceptCall"
title="接听"
v-if="call.status === 'incoming'"
class="w-14 h-14 rounded-full bg-green-500 hover:bg-green-600 text-white flex items-center justify-center text-xl transition transform active:scale-95 shadow-lg shadow-green-500/30 animate-pulse"
@click="emit('accept-call')"
title="接听"
>
<i class="fas fa-phone"></i>
</button>
<!-- 通话中功能 -->
<!-- 通话中功能按钮 -->
<template v-if="call.status === 'connected'">
<button
class="w-12 h-12 rounded-full bg-gray-700 hover:bg-gray-600 text-white transition flex items-center justify-center"
@click="toggleMute"
:class="{ 'bg-white text-black': call.muted }"
title="静音"
class="w-12 h-12 rounded-full bg-gray-700/80 hover:bg-gray-600 text-white transition flex items-center justify-center backdrop-blur"
@click="emit('toggle-mute')"
:class="{ 'bg-white !text-black': call.muted }"
title="静音"
>
<i class="fas" :class="call.muted ? 'fa-microphone-slash' : 'fa-microphone'"></i>
</button>
<!-- 仅视频模式显示关闭摄像头 -->
<button
v-if="call.type === 'video'"
class="w-12 h-12 rounded-full bg-gray-700 hover:bg-gray-600 text-white transition flex items-center justify-center"
@click="toggleCamera"
:class="{ 'bg-white text-black': call.camOff }"
title="关摄像头"
v-if="call.type === 'video'"
class="w-12 h-12 rounded-full bg-gray-700/80 hover:bg-gray-600 text-white transition flex items-center justify-center backdrop-blur"
@click="emit('toggle-camera')"
:class="{ 'bg-white !text-black': call.camOff }"
title="关摄像头"
>
<i class="fas" :class="call.camOff ? 'fa-video-slash' : 'fa-video'"></i>
</button>
@@ -133,7 +147,7 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, watch, onMounted } from 'vue'
import type { Contact } from '@/types/api'
interface Props {
@@ -141,16 +155,20 @@ interface Props {
active: boolean
minimized: boolean
type: 'audio' | 'video'
status: 'idle' | 'outgoing' | 'incoming' | 'connected'
status: string
statusText: string
muted: boolean
camOff: boolean
duration: number
}
target: Contact | null
isMobile: boolean
// 接收 MediaStream 对象,而不是 DOM 元素
localStream: MediaStream | null
remoteStream: MediaStream | null
}
defineProps<Props>()
const props = defineProps<Props>()
const emit = defineEmits<{
'end-call': []
@@ -160,28 +178,36 @@ const emit = defineEmits<{
'toggle-minimize': []
}>()
const remoteVideo = ref<HTMLVideoElement>()
const localVideo = ref<HTMLVideoElement>()
const localVideoRef = ref<HTMLVideoElement | null>(null)
const remoteVideoRef = ref<HTMLVideoElement | null>(null)
function endCall() {
emit('end-call')
// 格式化时间辅助函数
const formatDuration = (seconds: number) => {
const m = Math.floor(seconds / 60).toString().padStart(2, '0')
const s = (seconds % 60).toString().padStart(2, '0')
return `${m}:${s}`
}
function acceptCall() {
emit('accept-call')
}
// 自动绑定流到 Video 元素 (解决本地视频异常的核心)
watch(() => props.localStream, (newStream) => {
if (localVideoRef.value && newStream) {
localVideoRef.value.srcObject = newStream
}
}, { immediate: true, flush: 'post' })
function toggleMute() {
emit('toggle-mute')
}
watch(() => props.remoteStream, (newStream) => {
if (remoteVideoRef.value && newStream) {
remoteVideoRef.value.srcObject = newStream
}
}, { immediate: true, flush: 'post' })
function toggleCamera() {
emit('toggle-camera')
}
defineExpose({
remoteVideo,
localVideo,
// 确保在组件挂载或 Video 元素出现时也能绑定
watch([localVideoRef, remoteVideoRef], () => {
if (localVideoRef.value && props.localStream) {
localVideoRef.value.srcObject = props.localStream
}
if (remoteVideoRef.value && props.remoteStream) {
remoteVideoRef.value.srcObject = props.remoteStream
}
})
</script>

View File

@@ -21,7 +21,7 @@
<div
class="relative flex items-center group cursor-pointer select-none"
@mousedown="handleRecordStart"
@touchstart.prevent="handleRecordStart"
@touchstart.passive="handleRecordStart"
@mouseup="handleRecordStop"
@touchend.prevent="handleRecordStop"
@mouseleave="handleRecordCancel"
@@ -69,6 +69,9 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useToastStore } from '@/stores/toast'
const toastStore = useToastStore()
interface Props {
modelValue: string
@@ -144,7 +147,7 @@ async function handleRecordStart() {
stream.getTracks().forEach((t) => t.stop())
if (duration < 1000) {
alert('说话时间太短')
toastStore.warning('说话时间太短')
return
}
@@ -156,9 +159,9 @@ async function handleRecordStart() {
mediaRecorder.start()
recordStartTime = Date.now()
emit('record-start')
} catch (error) {
} catch (error: any) {
console.error('Failed to start recording:', error)
alert('无法访问麦克风')
toastStore.error(error.message || '无法访问麦克风')
}
}

View File

@@ -1,6 +1,6 @@
<template>
<img
:src="imageUrl"
:src="`http://127.0.0.1:12080${imageUrl}`"
class="rounded-lg max-w-full cursor-zoom-in hover:brightness-90 transition border border-white/10"
@click="previewImage"
/>
@@ -22,7 +22,7 @@ const imageUrl = computed(() => {
function previewImage() {
const w = window.open('')
if (w) {
w.document.write(`<img src="${imageUrl.value}" style="max-width:100%">`)
w.document.write(`<img src="http://127.0.0.1:12080${imageUrl.value}" style="max-width:100%">`)
}
}
</script>

View File

@@ -0,0 +1,82 @@
<template>
<div
v-if="show"
class="fixed inset-0 bg-black/80 z-[100] flex items-center justify-center p-4 backdrop-blur-sm"
@click.self="$emit('cancel')"
>
<div class="bg-panel rounded-xl w-96 overflow-hidden shadow-2xl border border-gray-700 transform transition-all scale-100">
<div class="p-4 border-b border-gray-700 font-bold bg-gray-800/50 flex justify-between items-center">
<span>{{ title }}</span>
<i class="fas fa-times cursor-pointer hover:text-white text-gray-500" @click="$emit('cancel')"></i>
</div>
<div class="p-6">
<div class="flex items-start gap-4">
<div
class="flex-shrink-0 w-12 h-12 rounded-full flex items-center justify-center text-xl"
:class="{
'bg-danger/20 text-danger': type === 'danger',
'bg-warning/20 text-warning': type === 'warning',
'bg-primary/20 text-primary': type === 'info',
}"
>
<i
:class="{
'fas fa-exclamation-triangle': type === 'danger',
'fas fa-question-circle': type === 'warning',
'fas fa-info-circle': type === 'info',
}"
></i>
</div>
<div class="flex-1">
<p class="text-sm text-gray-300 leading-relaxed">{{ message }}</p>
<p v-if="description" class="text-xs text-gray-400 mt-2">{{ description }}</p>
</div>
</div>
</div>
<div class="flex border-t border-gray-700">
<button
class="flex-1 py-3 text-gray-400 hover:bg-gray-700 transition"
@click="$emit('cancel')"
>
{{ cancelText }}
</button>
<button
class="flex-1 py-3 font-bold hover:bg-gray-700 transition border-l border-gray-700"
:class="{
'text-danger': type === 'danger',
'text-primary': type !== 'danger',
}"
@click="$emit('confirm')"
>
{{ confirmText }}
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
interface Props {
show: boolean
title?: string
message: string
description?: string
type?: 'danger' | 'warning' | 'info'
confirmText?: string
cancelText?: string
}
withDefaults(defineProps<Props>(), {
title: '确认操作',
type: 'info',
confirmText: '确认',
cancelText: '取消',
})
defineEmits<{
confirm: []
cancel: []
}>()
</script>

View File

@@ -0,0 +1,102 @@
<template>
<TransitionGroup
name="toast"
tag="div"
class="fixed top-4 right-4 z-[10000] flex flex-col gap-2 pointer-events-none"
>
<div
v-for="toast in toasts"
:key="toast.id"
class="pointer-events-auto min-w-[300px] max-w-[500px] bg-panel border border-gray-700 rounded-lg shadow-2xl p-4 flex items-start gap-3 animate-slide-in"
:class="{
'border-success': toast.type === 'success',
'border-danger': toast.type === 'error',
'border-yellow-500': toast.type === 'warning',
'border-primary': toast.type === 'info',
}"
>
<!-- 图标 -->
<div
class="flex-shrink-0 w-6 h-6 rounded-full flex items-center justify-center text-sm"
:class="{
'bg-success/20 text-success': toast.type === 'success',
'bg-danger/20 text-danger': toast.type === 'error',
'bg-yellow-500/20 text-yellow-500': toast.type === 'warning',
'bg-primary/20 text-primary': toast.type === 'info',
}"
>
<i
:class="{
'fas fa-check-circle': toast.type === 'success',
'fas fa-exclamation-circle': toast.type === 'error',
'fas fa-exclamation-triangle': toast.type === 'warning',
'fas fa-info-circle': toast.type === 'info',
}"
></i>
</div>
<!-- 内容 -->
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-white">{{ toast.message }}</div>
<div v-if="toast.description" class="text-xs text-gray-400 mt-1">
{{ toast.description }}
</div>
</div>
<!-- 关闭按钮 -->
<button
class="flex-shrink-0 text-gray-400 hover:text-white transition"
@click="removeToast(toast.id)"
>
<i class="fas fa-times text-sm"></i>
</button>
</div>
</TransitionGroup>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useToastStore } from '@/stores/toast'
const toastStore = useToastStore()
const toasts = computed(() => toastStore.toasts)
function removeToast(id: string) {
toastStore.removeToast(id)
}
</script>
<style scoped>
.toast-enter-active,
.toast-leave-active {
transition: all 0.3s ease;
}
.toast-enter-from {
opacity: 0;
transform: translateX(100%);
}
.toast-leave-to {
opacity: 0;
transform: translateX(100%);
}
@keyframes slide-in {
from {
opacity: 0;
transform: translateX(100%);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.animate-slide-in {
animation: slide-in 0.3s ease;
}
</style>