1026 lines
31 KiB
Vue
1026 lines
31 KiB
Vue
<template>
|
||
<!-- 可拖拽、可调整大小的浮动窗口 -->
|
||
<div
|
||
v-if="visible"
|
||
class="floating-video-window"
|
||
:style="windowStyle"
|
||
ref="windowRef"
|
||
>
|
||
<!-- 窗口头部 - 可拖拽区域 -->
|
||
<div
|
||
class="window-header"
|
||
@mousedown="startDrag"
|
||
>
|
||
<div class="window-title">
|
||
<el-icon><VideoCamera /></el-icon>
|
||
<span>{{ callInfo.isGroup ? '群组视频通话' : '视频通话' }} - {{ callInfo.patientName }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 窗口内容 -->
|
||
<div class="window-content">
|
||
<div class="video-call-container">
|
||
<!-- 被拒绝状态 -->
|
||
<div v-if="callRejected" class="call-rejected">
|
||
<el-icon class="rejected-icon" :size="60" color="#F56C6C"><CircleClose /></el-icon>
|
||
<p class="mt-4 text-lg">{{ statusText }}</p>
|
||
<p class="text-gray-500">{{ callInfo.patientName }}</p>
|
||
</div>
|
||
|
||
<!-- 初始化前的等待状态 -->
|
||
<div v-else-if="!isInitialized" class="call-waiting">
|
||
<el-icon class="loading-icon" :size="60"><VideoCamera /></el-icon>
|
||
<p class="mt-4">{{ statusText }}</p>
|
||
<p class="text-gray-500">{{ callInfo.patientName }}</p>
|
||
</div>
|
||
|
||
<!-- TUICallKit 视频通话界面 -->
|
||
<div v-else class="video-wrapper">
|
||
<!-- 将 TUICallKit 放在我们的容器中,并隐藏其默认全屏 UI -->
|
||
<TUICallKit
|
||
class="tui-call-kit"
|
||
:allowedFullScreen="false"
|
||
:allowedMinimized="false"
|
||
/>
|
||
|
||
<!-- 等待接听时的浮层提示 -->
|
||
<div v-if="calling && !callConnected" class="waiting-overlay">
|
||
<div class="waiting-content">
|
||
<el-icon class="waiting-icon" :size="40"><Phone /></el-icon>
|
||
<p class="waiting-text">{{ statusText }}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 窗口底部操作栏 -->
|
||
<div class="window-footer">
|
||
<el-button
|
||
@click="handleClose"
|
||
:disabled="calling && !callRejected"
|
||
size="small"
|
||
>
|
||
{{ callRejected ? '关闭' : '取消' }}
|
||
</el-button>
|
||
<el-button
|
||
v-if="!isInitialized && !callRejected"
|
||
type="primary"
|
||
@click="startCall"
|
||
:loading="initializing"
|
||
size="small"
|
||
>
|
||
开始通话
|
||
</el-button>
|
||
<el-button
|
||
v-else-if="!callRejected"
|
||
type="danger"
|
||
@click="handleHangup"
|
||
size="small"
|
||
>
|
||
挂断
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 调整大小的拖拽点 -->
|
||
<div
|
||
class="resize-handle resize-handle-se"
|
||
@mousedown="startResize"
|
||
></div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, onUnmounted, nextTick, computed } from 'vue'
|
||
import { VideoCamera, CircleClose, Phone } from '@element-plus/icons-vue'
|
||
import { getCallSignature, endCall } from '@/api/tcm'
|
||
import feedback from '@/utils/feedback'
|
||
import { formatTUICallUserError } from '@/utils/tuicall-error'
|
||
import { TUICallKitAPI, TUICallKit, TUICallType, STATUS } from '@trtc/calls-uikit-vue'
|
||
|
||
interface CallInfo {
|
||
diagnosisId: number
|
||
patientId: number
|
||
patientName: string
|
||
userId?: string // 一对一通话时使用
|
||
assistantId?: number // 群组通话时的医助ID
|
||
userIds?: string[] // 群组通话时的用户ID列表
|
||
isGroup: boolean // 是否为群组通话
|
||
}
|
||
|
||
const visible = ref(false)
|
||
const isInitialized = ref(false)
|
||
const initializing = ref(false)
|
||
const calling = ref(false)
|
||
const callConnected = ref(false)
|
||
const callRejected = ref(false)
|
||
const shouldAutoClose = ref(false) // 标记是否应该自动关闭
|
||
const statusText = ref('准备通话...')
|
||
const callInfo = ref<CallInfo>({
|
||
diagnosisId: 0,
|
||
patientId: 0,
|
||
patientName: '',
|
||
userId: '',
|
||
isGroup: false
|
||
})
|
||
|
||
// 窗口位置和大小
|
||
const windowRef = ref<HTMLElement>()
|
||
const windowPosition = ref({ x: 100, y: 100 })
|
||
const windowSize = ref({ width: 300, height: 750 })
|
||
|
||
// 拖拽相关
|
||
const isDragging = ref(false)
|
||
const dragStart = ref({ x: 10, y: 0 })
|
||
|
||
// 调整大小相关
|
||
const isResizing = ref(false)
|
||
const resizeStart = ref({ x: 0, y: 0, width: 0, height: 0 })
|
||
|
||
// 计算窗口样式
|
||
const windowStyle = computed(() => ({
|
||
left: `${windowPosition.value.x}px`,
|
||
top: `${windowPosition.value.y}px`,
|
||
width: `${windowSize.value.width}px`,
|
||
height: `${windowSize.value.height}px`
|
||
}))
|
||
|
||
// 开始拖拽
|
||
const startDrag = (e: MouseEvent) => {
|
||
isDragging.value = true
|
||
dragStart.value = {
|
||
x: e.clientX - windowPosition.value.x,
|
||
y: e.clientY - windowPosition.value.y
|
||
}
|
||
|
||
document.addEventListener('mousemove', onDrag)
|
||
document.addEventListener('mouseup', stopDrag)
|
||
e.preventDefault()
|
||
}
|
||
|
||
const onDrag = (e: MouseEvent) => {
|
||
if (!isDragging.value) return
|
||
|
||
windowPosition.value = {
|
||
x: e.clientX - dragStart.value.x,
|
||
y: e.clientY - dragStart.value.y
|
||
}
|
||
}
|
||
|
||
const stopDrag = () => {
|
||
isDragging.value = false
|
||
document.removeEventListener('mousemove', onDrag)
|
||
document.removeEventListener('mouseup', stopDrag)
|
||
}
|
||
|
||
// 开始调整大小
|
||
const startResize = (e: MouseEvent) => {
|
||
isResizing.value = true
|
||
resizeStart.value = {
|
||
x: e.clientX,
|
||
y: e.clientY,
|
||
width: windowSize.value.width,
|
||
height: windowSize.value.height
|
||
}
|
||
|
||
document.addEventListener('mousemove', onResize)
|
||
document.addEventListener('mouseup', stopResize)
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
}
|
||
|
||
const onResize = (e: MouseEvent) => {
|
||
if (!isResizing.value) return
|
||
|
||
const deltaX = e.clientX - resizeStart.value.x
|
||
const deltaY = e.clientY - resizeStart.value.y
|
||
|
||
windowSize.value = {
|
||
width: Math.max(400, resizeStart.value.width + deltaX),
|
||
height: Math.max(300, resizeStart.value.height + deltaY)
|
||
}
|
||
}
|
||
|
||
const stopResize = () => {
|
||
isResizing.value = false
|
||
document.removeEventListener('mousemove', onResize)
|
||
document.removeEventListener('mouseup', stopResize)
|
||
}
|
||
|
||
// 监听通话状态变化
|
||
const handleStatusChange = ({ oldStatus, newStatus }: any) => {
|
||
console.log('========== statusChanged 回调触发 ==========')
|
||
console.log('状态变化:', {
|
||
oldStatus,
|
||
newStatus,
|
||
calling: calling.value,
|
||
callConnected: callConnected.value,
|
||
visible: visible.value
|
||
})
|
||
console.log('STATUS 常量:', {
|
||
IDLE: STATUS.IDLE,
|
||
DIALING_C2C: STATUS.DIALING_C2C,
|
||
CALLING_C2C_VIDEO: STATUS.CALLING_C2C_VIDEO
|
||
})
|
||
|
||
// 通话接通
|
||
if (newStatus === STATUS.CALLING_C2C_VIDEO || newStatus === STATUS.CALLING_GROUP_VIDEO ||
|
||
newStatus === STATUS.CALLING_C2C_AUDIO || newStatus === STATUS.CALLING_GROUP_AUDIO) {
|
||
console.log('>>> 通话已接通')
|
||
callRejected.value = false
|
||
callConnected.value = true
|
||
calling.value = true
|
||
shouldAutoClose.value = false // 通话接通时,不自动关闭
|
||
statusText.value = '通话中...'
|
||
feedback.msgSuccess('通话已接通')
|
||
|
||
// 如果是多人通话且还有其他用户需要邀请
|
||
if (callInfo.value.isGroup && callInfo.value.userIds && callInfo.value.userIds.length > 1) {
|
||
inviteOtherUsers()
|
||
}
|
||
}
|
||
|
||
// 正在呼叫
|
||
if (newStatus === STATUS.DIALING_C2C || newStatus === STATUS.DIALING_GROUP) {
|
||
console.log('>>> 正在呼叫')
|
||
callConnected.value = false
|
||
calling.value = true
|
||
shouldAutoClose.value = false
|
||
statusText.value = '等待对方接听...'
|
||
}
|
||
|
||
// 通话结束,回到空闲状态
|
||
if (newStatus === STATUS.IDLE) {
|
||
console.log('>>> 回到空闲状态')
|
||
|
||
// 从呼叫状态直接回到空闲 - 对方拒绝或未接听
|
||
if (oldStatus === STATUS.DIALING_C2C || oldStatus === STATUS.DIALING_GROUP) {
|
||
console.log('>>> 对方拒绝或未接听')
|
||
callRejected.value = true
|
||
callConnected.value = false
|
||
|
||
// 根据具体情况给出不同的提示
|
||
const rejectReasons = [
|
||
'对方已拒绝通话',
|
||
'对方未接听',
|
||
'对方设备不支持视频通话',
|
||
'对方网络连接失败',
|
||
'对方麦克风或摄像头未授权'
|
||
]
|
||
|
||
// 这里可以根据实际错误码判断具体原因
|
||
statusText.value = '对方未能接听'
|
||
|
||
feedback.msgWarning('通话未能接通,可能原因:对方拒绝、未接听、设备不支持或网络问题')
|
||
calling.value = false
|
||
shouldAutoClose.value = true
|
||
|
||
setTimeout(() => {
|
||
if (visible.value && shouldAutoClose.value) {
|
||
console.log('>>> 5秒后关闭窗口(拒绝)')
|
||
visible.value = false
|
||
isInitialized.value = false
|
||
statusText.value = '准备通话...'
|
||
}
|
||
}, 5000) // 延长到5秒,让用户看清提示
|
||
}
|
||
// 从通话中状态回到空闲 - 通话已挂断
|
||
else if (oldStatus === STATUS.CALLING_C2C_VIDEO || oldStatus === STATUS.CALLING_GROUP_VIDEO ||
|
||
oldStatus === STATUS.CALLING_C2C_AUDIO || oldStatus === STATUS.CALLING_GROUP_AUDIO) {
|
||
console.log('>>> 通话中挂断')
|
||
feedback.msg('通话已结束')
|
||
calling.value = false
|
||
callConnected.value = false
|
||
shouldAutoClose.value = true
|
||
|
||
setTimeout(() => {
|
||
if (visible.value && shouldAutoClose.value) {
|
||
console.log('>>> 1秒后关闭窗口(挂断)')
|
||
visible.value = false
|
||
isInitialized.value = false
|
||
statusText.value = '准备通话...'
|
||
}
|
||
}, 1000)
|
||
}
|
||
// 其他情况也关闭窗口
|
||
else if (calling.value || callConnected.value) {
|
||
console.log('>>> 其他情况结束')
|
||
calling.value = false
|
||
callConnected.value = false
|
||
shouldAutoClose.value = true
|
||
|
||
setTimeout(() => {
|
||
if (visible.value && shouldAutoClose.value) {
|
||
console.log('>>> 1秒后关闭窗口(其他)')
|
||
visible.value = false
|
||
isInitialized.value = false
|
||
statusText.value = '准备通话...'
|
||
}
|
||
}, 1000)
|
||
} else {
|
||
console.log('>>> 未匹配任何关闭条件')
|
||
}
|
||
}
|
||
}
|
||
|
||
// 监听通话错误
|
||
const handleCallError = (error: any) => {
|
||
console.error('========== 通话错误 ==========', error)
|
||
|
||
let errorMessage = '通话失败'
|
||
|
||
// 根据错误码给出具体提示
|
||
if (error.code) {
|
||
switch (error.code) {
|
||
case 60006:
|
||
errorMessage = '当前环境不支持 WebRTC,请使用 HTTPS 访问或在 localhost 测试'
|
||
break
|
||
case 60007:
|
||
errorMessage = '对方设备不支持视频通话'
|
||
break
|
||
case 60008:
|
||
errorMessage = '对方拒绝了通话'
|
||
break
|
||
case 60009:
|
||
errorMessage = '对方未接听'
|
||
break
|
||
case 60010:
|
||
errorMessage = '对方忙线中'
|
||
break
|
||
case 60011:
|
||
errorMessage = '对方不在线'
|
||
break
|
||
case 60012:
|
||
errorMessage = '网络连接失败'
|
||
break
|
||
case 60013:
|
||
errorMessage = '对方麦克风或摄像头未授权'
|
||
break
|
||
default:
|
||
errorMessage = error.message || '通话失败,请稍后重试'
|
||
}
|
||
}
|
||
|
||
feedback.msgError(errorMessage)
|
||
callRejected.value = true
|
||
statusText.value = errorMessage
|
||
calling.value = false
|
||
shouldAutoClose.value = true
|
||
|
||
setTimeout(() => {
|
||
if (visible.value && shouldAutoClose.value) {
|
||
visible.value = false
|
||
isInitialized.value = false
|
||
}
|
||
}, 5000)
|
||
}
|
||
|
||
// 邀请其他用户加入通话
|
||
const inviteOtherUsers = async () => {
|
||
if (!callInfo.value.userIds || callInfo.value.userIds.length <= 1) {
|
||
return
|
||
}
|
||
|
||
console.log('开始邀请其他用户加入通话')
|
||
|
||
// 从第二个用户开始邀请
|
||
for (let i = 1; i < callInfo.value.userIds.length; i++) {
|
||
try {
|
||
const userId = callInfo.value.userIds[i]
|
||
console.log(`正在邀请用户: ${userId}`)
|
||
|
||
// 使用 calls 方法邀请用户(在已有通话的情况下会自动转为邀请加入)
|
||
await TUICallKitAPI.calls({
|
||
userIDList: [userId],
|
||
type: TUICallType.VIDEO_CALL
|
||
})
|
||
|
||
console.log(`已成功邀请用户: ${userId}`)
|
||
feedback.msgSuccess(`已邀请 ${userId} 加入通话`)
|
||
|
||
// 等待一下再邀请下一个
|
||
await new Promise(resolve => setTimeout(resolve, 500))
|
||
} catch (error) {
|
||
console.error(`邀请用户失败: ${callInfo.value.userIds[i]}`, error)
|
||
feedback.msgWarning(`邀请 ${callInfo.value.userIds[i]} 失败`)
|
||
}
|
||
}
|
||
|
||
console.log('所有用户邀请完成')
|
||
}
|
||
|
||
// 通话结束后的回调(兜底机制)
|
||
const handleAfterCalling = () => {
|
||
console.log('========== afterCalling 回调触发 ==========')
|
||
console.log('当前状态:', {
|
||
visible: visible.value,
|
||
calling: calling.value,
|
||
callConnected: callConnected.value,
|
||
shouldAutoClose: shouldAutoClose.value,
|
||
isInitialized: isInitialized.value
|
||
})
|
||
|
||
// 只有在标记为应该自动关闭时才关闭
|
||
// 这样可以避免在通话接通时误触发
|
||
if (visible.value && shouldAutoClose.value) {
|
||
feedback.msg('通话已结束')
|
||
calling.value = false
|
||
callConnected.value = false
|
||
|
||
setTimeout(() => {
|
||
if (visible.value && shouldAutoClose.value) {
|
||
console.log('通过 afterCalling 关闭窗口')
|
||
visible.value = false
|
||
isInitialized.value = false
|
||
statusText.value = '准备通话...'
|
||
}
|
||
}, 1000)
|
||
} else {
|
||
console.log('afterCalling 触发但不关闭窗口(shouldAutoClose=false)')
|
||
}
|
||
}
|
||
|
||
const open = async (info: CallInfo) => {
|
||
// 检查 WebRTC 支持
|
||
if (!checkWebRTCSupport()) {
|
||
return
|
||
}
|
||
|
||
callInfo.value = info
|
||
visible.value = true
|
||
isInitialized.value = false
|
||
calling.value = false
|
||
callConnected.value = false
|
||
callRejected.value = false
|
||
shouldAutoClose.value = false // 重置自动关闭标志
|
||
statusText.value = '准备通话...'
|
||
|
||
// 居中显示窗口
|
||
const screenWidth = window.innerWidth
|
||
const screenHeight = window.innerHeight
|
||
windowPosition.value = {
|
||
x: (screenWidth - windowSize.value.width) / 2,
|
||
y: (screenHeight - windowSize.value.height) / 2
|
||
}
|
||
}
|
||
|
||
// 检查 WebRTC 支持
|
||
const checkWebRTCSupport = () => {
|
||
const isHttps = window.location.protocol === 'https:' ||
|
||
window.location.hostname === 'localhost' ||
|
||
window.location.hostname === '127.0.0.1'
|
||
|
||
const hasGetUserMedia = !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia)
|
||
const hasRTCPeerConnection = !!window.RTCPeerConnection
|
||
|
||
console.log('WebRTC 环境检测:', {
|
||
protocol: window.location.protocol,
|
||
hostname: window.location.hostname,
|
||
isHttps,
|
||
hasGetUserMedia,
|
||
hasRTCPeerConnection
|
||
})
|
||
|
||
if (!isHttps) {
|
||
feedback.msgError('视频通话需要 HTTPS 环境。请使用 HTTPS 协议访问,或在 localhost 下测试。')
|
||
return false
|
||
}
|
||
|
||
if (!hasGetUserMedia) {
|
||
feedback.msgError('当前浏览器不支持访问摄像头和麦克风,请使用 Chrome、Edge 或 Safari 浏览器。')
|
||
return false
|
||
}
|
||
|
||
if (!hasRTCPeerConnection) {
|
||
feedback.msgError('当前浏览器不支持 WebRTC,请升级浏览器版本。')
|
||
return false
|
||
}
|
||
|
||
return true
|
||
}
|
||
|
||
const startCall = async () => {
|
||
let res: any = null // 保存签名响应,供后续使用
|
||
|
||
try {
|
||
initializing.value = true
|
||
callRejected.value = false
|
||
statusText.value = '正在获取签名...'
|
||
|
||
// 获取腾讯云签名
|
||
try {
|
||
res = await getCallSignature({
|
||
diagnosis_id: callInfo.value.diagnosisId,
|
||
patient_id: callInfo.value.patientId
|
||
})
|
||
} catch (error: any) {
|
||
console.error('获取签名失败:', error)
|
||
throw new Error('获取通话签名失败,请检查网络连接或稍后重试')
|
||
}
|
||
|
||
if (!res || !res.userSig) {
|
||
throw new Error('获取签名失败,返回数据不完整')
|
||
}
|
||
|
||
console.log('签名获取成功:', {
|
||
doctorUserId: res.userId,
|
||
patientUserId: res.patientUserId,
|
||
sdkAppId: res.sdkAppId
|
||
})
|
||
statusText.value = '正在初始化通话组件...'
|
||
|
||
// 设置状态变化回调和通话结束回调
|
||
// 注意:4.x 版本移除了 onError 回调,错误处理通过 try-catch 和状态判断
|
||
TUICallKitAPI.setCallback({
|
||
statusChanged: handleStatusChange,
|
||
afterCalling: handleAfterCalling
|
||
})
|
||
|
||
// 禁用浮窗功能,防止 TUICallKit 创建自己的全屏层
|
||
TUICallKitAPI.enableFloatWindow(false)
|
||
|
||
// 初始化 TUICallKit
|
||
try {
|
||
await TUICallKitAPI.init({
|
||
userID: res.userId,
|
||
userSig: res.userSig,
|
||
SDKAppID: Number(res.sdkAppId)
|
||
})
|
||
console.log('TUICallKit init 方法调用完成')
|
||
} catch (error: any) {
|
||
console.error('TUICallKit 初始化失败:', error)
|
||
|
||
// 根据错误码给出具体提示
|
||
if (error.code === 60006) {
|
||
throw new Error('当前环境不支持 WebRTC,请使用 HTTPS 访问或在 localhost 测试')
|
||
} else if (error.message && error.message.includes('getUserMedia')) {
|
||
throw new Error('无法访问摄像头或麦克风,请检查设备权限设置')
|
||
} else if (error.message && error.message.includes('network')) {
|
||
throw new Error('网络连接失败,请检查网络设置')
|
||
} else {
|
||
throw new Error(`初始化失败: ${error.message || '未知错误'}`)
|
||
}
|
||
}
|
||
|
||
statusText.value = '等待初始化完成...'
|
||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||
|
||
// 生产包中 init 返回后引擎可能尚未挂载,短暂轮询等待 .calls 可用
|
||
for (let i = 0; i < 6; i++) {
|
||
if (typeof TUICallKitAPI.calls === 'function') break
|
||
await new Promise(resolve => setTimeout(resolve, 500))
|
||
}
|
||
if (typeof TUICallKitAPI.calls !== 'function') {
|
||
throw new Error('通话引擎未就绪,请关闭窗口后重试或刷新页面')
|
||
}
|
||
|
||
console.log('初始化完成,显示通话界面')
|
||
statusText.value = '准备发起通话...'
|
||
|
||
// 设置初始化完成标志,显示 TUICallKit 组件
|
||
isInitialized.value = true
|
||
calling.value = true
|
||
|
||
// 等待 DOM 更新和组件渲染
|
||
await nextTick()
|
||
await new Promise(resolve => setTimeout(resolve, 500))
|
||
|
||
// 发起通话
|
||
try {
|
||
if (callInfo.value.isGroup && callInfo.value.userIds && callInfo.value.userIds.length > 0) {
|
||
// 多人通话 - 依次邀请所有用户
|
||
console.log('准备发起多人通话,目标用户:', callInfo.value.userIds)
|
||
|
||
// 先邀请第一个用户建立通话
|
||
await TUICallKitAPI.calls({
|
||
userIDList: [callInfo.value.userIds[0]],
|
||
type: TUICallType.VIDEO_CALL
|
||
})
|
||
|
||
console.log(`已邀请第一个用户: ${callInfo.value.userIds[0]}`)
|
||
|
||
if (callInfo.value.userIds.length > 1) {
|
||
statusText.value = `等待 ${callInfo.value.userIds[0]} 接听后将邀请其他成员...`
|
||
feedback.msgSuccess(`已邀请第一位成员,接通后将邀请其他 ${callInfo.value.userIds.length - 1} 位成员`)
|
||
} else {
|
||
statusText.value = '等待成员接听...'
|
||
feedback.msgSuccess('多人通话已发起,等待成员接听...')
|
||
}
|
||
|
||
console.log('多人通话已发起')
|
||
} else {
|
||
// 一对一通话 - 使用后端返回的 patientUserId
|
||
const targetUserId = res.patientUserId || callInfo.value.userId || ''
|
||
console.log('=== 发起一对一通话 ===')
|
||
console.log('后端返回的 patientUserId:', res.patientUserId)
|
||
console.log('前端传入的 userId:', callInfo.value.userId)
|
||
console.log('最终使用的 targetUserId:', targetUserId)
|
||
console.log('完整的 res 对象:', res)
|
||
|
||
if (!targetUserId) {
|
||
throw new Error('无法获取患者通话ID')
|
||
}
|
||
|
||
await TUICallKitAPI.calls({
|
||
userIDList: [targetUserId],
|
||
type: TUICallType.VIDEO_CALL
|
||
})
|
||
|
||
console.log('通话已发起,目标用户:', targetUserId)
|
||
statusText.value = '等待对方接听...'
|
||
feedback.msgSuccess('通话已发起,等待对方接听...')
|
||
}
|
||
} catch (error: any) {
|
||
console.error('发起通话失败:', error)
|
||
|
||
// 根据错误类型给出具体提示
|
||
if (error.code === 60011) {
|
||
throw new Error('对方不在线,无法发起通话')
|
||
} else if (error.code === 60010) {
|
||
throw new Error('对方忙线中,请稍后再试')
|
||
} else if (error.message && error.message.includes('getUserMedia')) {
|
||
throw new Error('无法访问摄像头或麦克风,请在浏览器设置中允许访问')
|
||
} else {
|
||
throw new Error(`发起通话失败: ${error.message || '未知错误'}`)
|
||
}
|
||
}
|
||
|
||
} catch (error: any) {
|
||
console.error('启动通话失败:', error)
|
||
const errorMessage = formatTUICallUserError(error, '启动通话失败,请稍后重试')
|
||
feedback.msgError(errorMessage)
|
||
callRejected.value = true
|
||
statusText.value = errorMessage
|
||
isInitialized.value = false
|
||
calling.value = false
|
||
shouldAutoClose.value = true
|
||
|
||
// 5秒后自动关闭错误提示窗口
|
||
setTimeout(() => {
|
||
if (visible.value && shouldAutoClose.value && callRejected.value) {
|
||
visible.value = false
|
||
statusText.value = '准备通话...'
|
||
}
|
||
}, 5000)
|
||
} finally {
|
||
initializing.value = false
|
||
}
|
||
}
|
||
|
||
const handleHangup = async () => {
|
||
try {
|
||
await TUICallKitAPI.hangup()
|
||
feedback.msgSuccess('已挂断通话')
|
||
await handleCallEnd()
|
||
visible.value = false
|
||
isInitialized.value = false
|
||
calling.value = false
|
||
callConnected.value = false
|
||
} catch (error) {
|
||
console.error('挂断失败:', error)
|
||
}
|
||
}
|
||
|
||
const handleCallEnd = async () => {
|
||
try {
|
||
// 记录通话结束
|
||
await endCall({
|
||
diagnosis_id: callInfo.value.diagnosisId
|
||
})
|
||
} catch (error) {
|
||
console.error('记录通话结束失败:', error)
|
||
}
|
||
}
|
||
|
||
const handleClose = async () => {
|
||
if (calling.value && !callRejected.value) {
|
||
try {
|
||
await feedback.confirm('通话进行中,确定要结束吗?')
|
||
} catch {
|
||
return
|
||
}
|
||
|
||
// 挂断通话
|
||
try {
|
||
shouldAutoClose.value = true // 手动关闭时设置标志
|
||
await TUICallKitAPI.hangup()
|
||
} catch (error) {
|
||
console.error('挂断通话失败:', error)
|
||
}
|
||
}
|
||
|
||
// 记录通话结束
|
||
if (isInitialized.value) {
|
||
await handleCallEnd()
|
||
}
|
||
|
||
// 重置所有状态
|
||
visible.value = false
|
||
isInitialized.value = false
|
||
calling.value = false
|
||
callConnected.value = false
|
||
callRejected.value = false
|
||
shouldAutoClose.value = false
|
||
statusText.value = '准备通话...'
|
||
|
||
// 等待一下再销毁,确保 TUICallKit 完全清理
|
||
await nextTick()
|
||
}
|
||
|
||
onUnmounted(() => {
|
||
// 组件卸载时清理
|
||
if (calling.value) {
|
||
TUICallKitAPI.hangup().catch(console.error)
|
||
}
|
||
|
||
// 清理事件监听
|
||
document.removeEventListener('mousemove', onDrag)
|
||
document.removeEventListener('mouseup', stopDrag)
|
||
document.removeEventListener('mousemove', onResize)
|
||
document.removeEventListener('mouseup', stopResize)
|
||
})
|
||
|
||
defineExpose({
|
||
open
|
||
})
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.floating-video-window {
|
||
position: fixed;
|
||
z-index: 2000;
|
||
background: white;
|
||
border-radius: 8px;
|
||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
min-width: 400px;
|
||
min-height: 300px;
|
||
}
|
||
|
||
.window-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 12px 16px;
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
color: white;
|
||
cursor: move;
|
||
user-select: none;
|
||
position: relative;
|
||
z-index: 2001;
|
||
|
||
.window-title {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
font-weight: 500;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.window-actions {
|
||
display: flex;
|
||
gap: 4px;
|
||
position: relative;
|
||
z-index: 2002;
|
||
|
||
:deep(.el-button) {
|
||
color: white;
|
||
|
||
&:hover {
|
||
background: rgba(255, 255, 255, 0.2);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
.window-content {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
position: relative;
|
||
}
|
||
|
||
.video-call-container {
|
||
flex: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: #f5f5f5;
|
||
position: relative;
|
||
min-height: 0;
|
||
}
|
||
|
||
.video-wrapper {
|
||
width: 100%;
|
||
height: 100%;
|
||
position: relative;
|
||
}
|
||
|
||
.call-waiting {
|
||
text-align: center;
|
||
|
||
.loading-icon {
|
||
animation: pulse 2s ease-in-out infinite;
|
||
}
|
||
}
|
||
|
||
.call-rejected {
|
||
text-align: center;
|
||
|
||
.rejected-icon {
|
||
animation: shake 0.5s ease-in-out;
|
||
}
|
||
|
||
.text-lg {
|
||
font-size: 18px;
|
||
font-weight: 500;
|
||
color: #F56C6C;
|
||
}
|
||
|
||
.text-gray-500 {
|
||
color: #909399;
|
||
margin-top: 8px;
|
||
}
|
||
}
|
||
|
||
.waiting-overlay {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background: rgba(0, 0, 0, 0.3);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 10;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.waiting-content {
|
||
text-align: center;
|
||
color: white;
|
||
|
||
.waiting-icon {
|
||
animation: ring 1.5s ease-in-out infinite;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.waiting-text {
|
||
font-size: 16px;
|
||
font-weight: 500;
|
||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
|
||
}
|
||
}
|
||
|
||
.window-footer {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 8px;
|
||
padding: 12px 16px;
|
||
border-top: 1px solid #e4e7ed;
|
||
background: white;
|
||
position: relative;
|
||
z-index: 2001;
|
||
}
|
||
|
||
/* 调整大小拖拽点 */
|
||
.resize-handle {
|
||
position: absolute;
|
||
background: transparent;
|
||
z-index: 2001;
|
||
}
|
||
|
||
.resize-handle-se {
|
||
right: 0;
|
||
bottom: 0;
|
||
width: 16px;
|
||
height: 16px;
|
||
cursor: se-resize;
|
||
|
||
&::after {
|
||
content: '';
|
||
position: absolute;
|
||
right: 2px;
|
||
bottom: 2px;
|
||
width: 12px;
|
||
height: 12px;
|
||
border-right: 2px solid #dcdfe6;
|
||
border-bottom: 2px solid #dcdfe6;
|
||
}
|
||
}
|
||
|
||
@keyframes pulse {
|
||
0%, 100% {
|
||
opacity: 1;
|
||
}
|
||
50% {
|
||
opacity: 0.5;
|
||
}
|
||
}
|
||
|
||
@keyframes shake {
|
||
0%, 100% {
|
||
transform: translateX(0);
|
||
}
|
||
25% {
|
||
transform: translateX(-10px);
|
||
}
|
||
75% {
|
||
transform: translateX(10px);
|
||
}
|
||
}
|
||
|
||
@keyframes ring {
|
||
0%, 100% {
|
||
transform: rotate(-15deg);
|
||
}
|
||
50% {
|
||
transform: rotate(15deg);
|
||
}
|
||
}
|
||
|
||
.tui-call-kit {
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
|
||
.mt-4 {
|
||
margin-top: 16px;
|
||
}
|
||
|
||
/* 确保 TUICallKit 组件内的视频元素正确显示,但不超过我们的控制层 */
|
||
:deep(.tui-call-kit) {
|
||
width: 100%;
|
||
height: 100%;
|
||
position: relative;
|
||
z-index: 1;
|
||
|
||
video {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
}
|
||
|
||
/* 确保视频容器有正确的尺寸 */
|
||
.tui-call-kit-video-container,
|
||
.tui-video-container,
|
||
.video-container {
|
||
width: 100% !important;
|
||
height: 100% !important;
|
||
}
|
||
|
||
/* 确保本地和远程视频都正确显示 */
|
||
.local-video,
|
||
.remote-video {
|
||
width: 100% !important;
|
||
height: 100% !important;
|
||
}
|
||
|
||
/* 限制 TUICallKit 内部元素的 z-index,防止遮挡我们的控制按钮 */
|
||
* {
|
||
z-index: auto !important;
|
||
}
|
||
|
||
/* 但保持视频元素的层级 */
|
||
video,
|
||
.video-container,
|
||
.tui-video-container {
|
||
z-index: 1 !important;
|
||
}
|
||
}
|
||
|
||
/* 全局覆盖 TUICallKit 可能创建的全屏层 - 完全隐藏 */
|
||
:global(body > #tui-call-kit-id) {
|
||
display: none !important;
|
||
visibility: hidden !important;
|
||
opacity: 0 !important;
|
||
pointer-events: none !important;
|
||
}
|
||
|
||
/* 只在我们的窗口内显示 TUICallKit */
|
||
.floating-video-window :deep(.tui-call-kit) {
|
||
display: block !important;
|
||
visibility: visible !important;
|
||
opacity: 1 !important;
|
||
}
|
||
|
||
/* 只在我们的浮动窗口内显示 TUICallKit */
|
||
.floating-video-window .TUICallKit-desktop,
|
||
.floating-video-window .TUICallKit-mobile {
|
||
display: block !important;
|
||
position: static !important;
|
||
width: 100% !important;
|
||
height: 100% !important;
|
||
transform: none !important;
|
||
}
|
||
|
||
/* 确保 Element Plus 的遮罩层不会覆盖视频通话 */
|
||
:global(.el-overlay) {
|
||
z-index: 1000;
|
||
}
|
||
</style>
|