698 lines
20 KiB
Vue
698 lines
20 KiB
Vue
<template>
|
||
<div class="patient-test">
|
||
<el-card>
|
||
<template #header>
|
||
<div class="card-header">
|
||
<span>医疗助理端 - 接听来电</span>
|
||
</div>
|
||
</template>
|
||
|
||
|
||
<div v-if="!initialized" class="init-section">
|
||
<el-alert
|
||
type="info"
|
||
:closable="false"
|
||
class="mb-4"
|
||
>
|
||
<p><strong>当前登录用户:</strong> {{ currentUser.nickname || currentUser.name || '未知' }}</p>
|
||
<p><strong>用户ID:</strong> {{ currentUser.id || '未获取' }}</p>
|
||
<p><strong>状态:</strong> 正在初始化...</p>
|
||
</el-alert>
|
||
|
||
<el-button
|
||
type="primary"
|
||
@click="initPatient"
|
||
:loading="loading"
|
||
size="large"
|
||
v-if="!loading"
|
||
>
|
||
重新初始化
|
||
</el-button>
|
||
</div>
|
||
|
||
<div v-if="initialized" class="status">
|
||
<el-alert
|
||
:type="hasIncomingCall ? 'warning' : 'success'"
|
||
:closable="false"
|
||
>
|
||
<template #title>
|
||
<div class="flex items-center">
|
||
<el-icon class="mr-2" :class="{ 'animate-pulse': hasIncomingCall }">
|
||
<Phone />
|
||
</el-icon>
|
||
<span>{{ hasIncomingCall ? '收到来电!' : '医疗助理端已初始化,等待来电...' }}</span>
|
||
</div>
|
||
</template>
|
||
<div class="mt-2">
|
||
<p><strong>医疗助理ID:</strong> {{ currentUserId }}</p>
|
||
<p><strong>状态:</strong> {{ hasIncomingCall ? '来电中' : '在线' }}</p>
|
||
<p v-if="hasIncomingCall && incomingCallInfo">
|
||
<strong>通话类型:</strong> {{ incomingCallInfo.isGroup ? '群组视频通话' : '视频通话' }}
|
||
</p>
|
||
</div>
|
||
</el-alert>
|
||
|
||
<!-- 调试信息 -->
|
||
|
||
|
||
<!-- 来电操作按钮 -->
|
||
<div v-if="hasIncomingCall" class="call-actions mt-4">
|
||
<el-button
|
||
type="success"
|
||
size="large"
|
||
@click="acceptCall"
|
||
:icon="Phone"
|
||
>
|
||
接听
|
||
</el-button>
|
||
<el-button
|
||
type="danger"
|
||
size="large"
|
||
@click="rejectCall"
|
||
>
|
||
拒绝
|
||
</el-button>
|
||
</div>
|
||
|
||
<el-button
|
||
v-else
|
||
type="danger"
|
||
@click="reset"
|
||
class="mt-4"
|
||
>
|
||
重置
|
||
</el-button>
|
||
</div>
|
||
|
||
<div v-if="hasIncomingCall || callConnected" id="call-container" class="call-container phone-call-shell">
|
||
<TUICallKit class="TUICallKit-mobile" />
|
||
</div>
|
||
|
||
<div v-else-if="initialized" class="waiting-container">
|
||
<el-empty description="等待来电中...">
|
||
<template #image>
|
||
<el-icon :size="80" color="#909399">
|
||
<Phone />
|
||
</el-icon>
|
||
</template>
|
||
</el-empty>
|
||
</div>
|
||
|
||
<!-- 离屏挂载:手机视口尺寸,避免 display:none 宽高为 0;与小程序端比例接近 -->
|
||
<div
|
||
v-if="initialized && !hasIncomingCall && !callConnected"
|
||
class="phone-call-kit-host phone-call-kit-host--offscreen"
|
||
aria-hidden="true"
|
||
>
|
||
<TUICallKit class="TUICallKit-mobile" />
|
||
</div>
|
||
</el-card>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, onUnmounted, computed, onMounted, nextTick } from 'vue'
|
||
import { Phone } from '@element-plus/icons-vue'
|
||
import {
|
||
TUICallKitServer,
|
||
TUICallKit,
|
||
STATUS,
|
||
TUIStore,
|
||
StoreName,
|
||
NAME,
|
||
CallRole
|
||
} from '@tencentcloud/call-uikit-vue'
|
||
import { ElMessage, ElNotification } from 'element-plus'
|
||
import request from '@/utils/request'
|
||
import useUserStore from '@/stores/modules/user'
|
||
|
||
const userStore = useUserStore()
|
||
const currentUser = computed(() => userStore.userInfo)
|
||
|
||
const form = ref({
|
||
patientId: computed(() => currentUser.value.id?.toString() || '1')
|
||
})
|
||
|
||
const loading = ref(false)
|
||
const initialized = ref(false)
|
||
const currentUserId = ref('')
|
||
const hasIncomingCall = ref(false)
|
||
const callConnected = ref(false) // 添加通话连接状态
|
||
const incomingCallInfo = ref<any>(null)
|
||
const statusCheckTimer = ref<any>(null)
|
||
/** 从 TUIStore 同步:解决「未打开本页时来电,进入后 statusChanged 不触发」的问题 */
|
||
let storeUnwatch: (() => void) | null = null
|
||
|
||
// 页面加载时自动初始化
|
||
onMounted(() => {
|
||
initPatient()
|
||
})
|
||
|
||
/**
|
||
* 与 SDK 内 CallStatus 一致:idle / calling / connected
|
||
* statusChanged 只在状态「变化」时回调;晚进页面时可能已在 calling,需读 Store。
|
||
*/
|
||
function syncIncomingFromTUIStore(from: string) {
|
||
try {
|
||
const callStatus = TUIStore.getData(StoreName.CALL, NAME.CALL_STATUS) as string
|
||
const callRole = TUIStore.getData(StoreName.CALL, NAME.CALL_ROLE) as string
|
||
const isGroup = !!TUIStore.getData(StoreName.CALL, NAME.IS_GROUP)
|
||
|
||
const wasIncoming = hasIncomingCall.value
|
||
|
||
// 被叫振铃:calling + callee
|
||
if (callStatus === 'calling' && callRole === CallRole.CALLEE) {
|
||
hasIncomingCall.value = true
|
||
incomingCallInfo.value = { isGroup, from }
|
||
if (!wasIncoming) {
|
||
ElMessage.info('检测到待接听的来电,请点击接听')
|
||
ElNotification({
|
||
title: '来电提醒',
|
||
message: '有待接听的视频通话',
|
||
type: 'warning',
|
||
duration: 0,
|
||
position: 'top-right'
|
||
})
|
||
}
|
||
callConnected.value = false
|
||
return
|
||
}
|
||
|
||
// 已接通
|
||
if (callStatus === 'connected') {
|
||
hasIncomingCall.value = false
|
||
incomingCallInfo.value = null
|
||
callConnected.value = true
|
||
return
|
||
}
|
||
|
||
// 空闲
|
||
if (callStatus === 'idle') {
|
||
if (hasIncomingCall.value || callConnected.value) {
|
||
hasIncomingCall.value = false
|
||
callConnected.value = false
|
||
incomingCallInfo.value = null
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.warn('syncIncomingFromTUIStore:', from, e)
|
||
}
|
||
}
|
||
|
||
function onStoreCallField() {
|
||
syncIncomingFromTUIStore('watch')
|
||
}
|
||
|
||
function bindTUIStoreWatch() {
|
||
if (storeUnwatch) {
|
||
storeUnwatch()
|
||
storeUnwatch = null
|
||
}
|
||
const opts = {
|
||
[NAME.CALL_STATUS]: onStoreCallField,
|
||
[NAME.CALL_ROLE]: onStoreCallField
|
||
}
|
||
try {
|
||
TUIStore.watch(StoreName.CALL, opts)
|
||
storeUnwatch = () => {
|
||
try {
|
||
TUIStore.unwatch(StoreName.CALL, opts)
|
||
} catch {
|
||
//
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.warn('TUIStore.watch 失败:', e)
|
||
}
|
||
}
|
||
|
||
// 定时从 Store 拉状态(TIM 同步可能有延迟,前 45 秒加密轮询)
|
||
const startStatusCheck = () => {
|
||
if (statusCheckTimer.value) {
|
||
clearInterval(statusCheckTimer.value)
|
||
}
|
||
let ticks = 0
|
||
const maxTicks = 90
|
||
statusCheckTimer.value = setInterval(() => {
|
||
ticks += 1
|
||
syncIncomingFromTUIStore('poll')
|
||
if (ticks >= maxTicks) {
|
||
if (statusCheckTimer.value) {
|
||
clearInterval(statusCheckTimer.value)
|
||
statusCheckTimer.value = null
|
||
}
|
||
}
|
||
}, 500)
|
||
}
|
||
|
||
// 监听状态变化
|
||
const handleStatusChange = ({ oldStatus, newStatus }: any) => {
|
||
console.log('========================================')
|
||
console.log('=== 通话状态变化回调触发 ===')
|
||
console.log('oldStatus:', oldStatus)
|
||
console.log('newStatus:', newStatus)
|
||
console.log('STATUS 常量:', STATUS)
|
||
console.log('========================================')
|
||
|
||
// 收到来电邀请(与聊天组件同一套 SDK:@tencentcloud/call-uikit-vue)
|
||
if (newStatus === STATUS.BE_INVITED) {
|
||
console.log('>>> 收到来电邀请')
|
||
hasIncomingCall.value = true
|
||
incomingCallInfo.value = { oldStatus, newStatus }
|
||
|
||
ElNotification({
|
||
title: '来电提醒',
|
||
message: '收到视频通话请求',
|
||
type: 'info',
|
||
duration: 0,
|
||
position: 'top-right'
|
||
})
|
||
ElMessage.success('收到来电,请点击接听按钮')
|
||
}
|
||
|
||
// 通话接通
|
||
if (newStatus === STATUS.CALLING_C2C_VIDEO || newStatus === STATUS.CALLING_GROUP_VIDEO) {
|
||
console.log('>>> 通话已接通')
|
||
ElMessage.success('通话已接通')
|
||
hasIncomingCall.value = false
|
||
callConnected.value = true // 设置通话连接状态
|
||
}
|
||
|
||
// 通话结束,回到空闲状态(STATUS.IDLE 与聊天组件一致)
|
||
if (newStatus === STATUS.IDLE) {
|
||
if (hasIncomingCall.value || callConnected.value) {
|
||
console.log('>>> 通话已结束')
|
||
hasIncomingCall.value = false
|
||
callConnected.value = false
|
||
incomingCallInfo.value = null
|
||
ElMessage.info('通话已结束')
|
||
}
|
||
}
|
||
}
|
||
|
||
const initPatient = async () => {
|
||
try {
|
||
loading.value = true
|
||
|
||
const patientId = currentUser.value.id
|
||
if (!patientId) {
|
||
throw new Error('未获取到当前用户ID,请重新登录')
|
||
}
|
||
|
||
console.log('=== 开始初始化医疗助理端 ===')
|
||
console.log('当前用户ID:', patientId)
|
||
ElMessage.info('正在获取签名...')
|
||
|
||
// 获取医疗助理签名
|
||
const result = await request.get({
|
||
url: '/tcm.diagnosis/getDoctorSignature',
|
||
params: {
|
||
patient_id: patientId
|
||
}
|
||
})
|
||
|
||
if (!result) {
|
||
throw new Error('获取签名失败')
|
||
}
|
||
|
||
const { sdkAppId, userId, userSig } = result
|
||
currentUserId.value = userId
|
||
|
||
console.log('医疗助理签名获取成功:', { userId, sdkAppId, userSigLength: userSig.length })
|
||
console.log('完整签名信息:', result)
|
||
ElMessage.info('正在初始化通话组件...')
|
||
|
||
// 与聊天组件一致:使用 @tencentcloud/call-uikit-vue 的 TUICallKitServer(正式打包可收视频)
|
||
console.log('=== 开始调用 TUICallKitServer.init ===')
|
||
|
||
if (!TUICallKitServer || typeof TUICallKitServer.init !== 'function') {
|
||
throw new Error('TUICallKitServer 未正确加载,请刷新页面重试')
|
||
}
|
||
|
||
try {
|
||
await TUICallKitServer.init({
|
||
userID: userId,
|
||
userSig: userSig,
|
||
SDKAppID: Number(sdkAppId)
|
||
})
|
||
} catch (initError: any) {
|
||
console.warn('TUICallKitServer.init 出现错误,但继续执行:', initError)
|
||
}
|
||
|
||
console.log('=== TUICallKitServer.init 完成 ===')
|
||
|
||
const callbacks = {
|
||
statusChanged: handleStatusChange,
|
||
onError: (error: any) => {
|
||
console.error('=== TUICallKit 错误回调 ===', error)
|
||
ElMessage.error(`通话错误: ${error?.message || '未知错误'}`)
|
||
},
|
||
onInvited: (inviteData: any) => {
|
||
console.log('=== 收到邀请回调 (onInvited) ===', inviteData)
|
||
ElMessage.info('收到通话邀请 (onInvited)')
|
||
},
|
||
afterCalling: () => {
|
||
console.log('=== afterCalling 回调 ===')
|
||
}
|
||
}
|
||
|
||
try {
|
||
TUICallKitServer.setCallback(callbacks)
|
||
} catch (callbackError: any) {
|
||
console.warn('设置回调时出现错误,但继续执行:', callbackError)
|
||
}
|
||
|
||
console.log('回调设置完成')
|
||
|
||
ElMessage.info('等待初始化完成...')
|
||
|
||
// 等待 TIM/引擎就绪(未在本页时收到的邀请会在登录后由 SDK 同步)
|
||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||
|
||
initialized.value = true
|
||
await nextTick()
|
||
await new Promise((r) => setTimeout(r, 300))
|
||
|
||
// 需在 TUICallKit 挂载后订阅 Store(见模板里 v-if="initialized && ..." 的隐藏组件)
|
||
bindTUIStoreWatch()
|
||
syncIncomingFromTUIStore('post-init')
|
||
startStatusCheck()
|
||
|
||
ElMessage.success('医疗助理端初始化成功,等待来电...')
|
||
|
||
console.log('=== 医疗助理端初始化完成 ===')
|
||
console.log('userId:', userId)
|
||
console.log('等待来电中...')
|
||
console.log('请在医生端发起群组通话,目标用户ID应该是:', userId)
|
||
|
||
} catch (error: any) {
|
||
console.error('=== 初始化失败 ===', error)
|
||
ElMessage.error(error.message || '初始化失败')
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
/** 接听后自动关闭本地麦克风(助理端只听不说) */
|
||
async function muteLocalMicAfterAccept(): Promise<boolean> {
|
||
await nextTick()
|
||
await new Promise((r) => setTimeout(r, 400))
|
||
try {
|
||
const server = TUICallKitServer as typeof TUICallKitServer & {
|
||
closeMicrophone?: () => Promise<void>
|
||
}
|
||
if (typeof server.closeMicrophone === 'function') {
|
||
await server.closeMicrophone()
|
||
return true
|
||
}
|
||
const engine = server.getTUICallEngineInstance?.()
|
||
if (engine && typeof engine.closeMicrophone === 'function') {
|
||
await engine.closeMicrophone()
|
||
return true
|
||
}
|
||
} catch (e) {
|
||
console.warn('接听后关闭麦克风失败(可在通话条手动关麦):', e)
|
||
}
|
||
return false
|
||
}
|
||
|
||
// 接听来电
|
||
const acceptCall = async () => {
|
||
try {
|
||
console.log('接听来电')
|
||
await TUICallKitServer.accept()
|
||
ElMessage.success('已接听通话')
|
||
hasIncomingCall.value = false
|
||
callConnected.value = true // 设置通话连接状态
|
||
const muted = await muteLocalMicAfterAccept()
|
||
if (muted) {
|
||
ElMessage.info('已关闭本地麦克风')
|
||
}
|
||
} catch (error: any) {
|
||
console.error('接听失败:', error)
|
||
ElMessage.error(error.message || '接听失败')
|
||
}
|
||
}
|
||
|
||
// 拒绝来电
|
||
const rejectCall = async () => {
|
||
try {
|
||
console.log('拒绝来电')
|
||
await TUICallKitServer.reject()
|
||
ElMessage.info('已拒绝通话')
|
||
hasIncomingCall.value = false
|
||
incomingCallInfo.value = null
|
||
} catch (error: any) {
|
||
console.error('拒绝失败:', error)
|
||
ElMessage.error(error.message || '拒绝失败')
|
||
}
|
||
}
|
||
|
||
const reset = () => {
|
||
initialized.value = false
|
||
currentUserId.value = ''
|
||
hasIncomingCall.value = false
|
||
callConnected.value = false
|
||
incomingCallInfo.value = null
|
||
}
|
||
|
||
// 组件卸载时清理
|
||
onUnmounted(() => {
|
||
if (statusCheckTimer.value) {
|
||
clearInterval(statusCheckTimer.value)
|
||
statusCheckTimer.value = null
|
||
}
|
||
if (storeUnwatch) {
|
||
storeUnwatch()
|
||
storeUnwatch = null
|
||
}
|
||
|
||
if (initialized.value) {
|
||
// 清理资源
|
||
hasIncomingCall.value = false
|
||
callConnected.value = false
|
||
incomingCallInfo.value = null
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.patient-test {
|
||
padding: 20px;
|
||
max-width: 1200px;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
.card-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
font-size: 18px;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.init-section {
|
||
text-align: center;
|
||
padding: 20px;
|
||
}
|
||
|
||
.status {
|
||
margin: 20px 0;
|
||
}
|
||
|
||
.call-actions {
|
||
display: flex;
|
||
gap: 16px;
|
||
justify-content: center;
|
||
}
|
||
|
||
/* 手机视口:375×667(逻辑像素),圆角边框模拟机身 */
|
||
.phone-call-shell {
|
||
width: 375px;
|
||
height: 667px;
|
||
max-width: 100%;
|
||
margin: 20px auto 0;
|
||
padding: 0;
|
||
border-radius: 36px;
|
||
border: 12px solid #2c2c2e;
|
||
box-shadow:
|
||
0 0 0 2px #3a3a3c inset,
|
||
0 16px 48px rgba(0, 0, 0, 0.22);
|
||
background: #000;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.call-container {
|
||
display: flex;
|
||
align-items: stretch;
|
||
justify-content: stretch;
|
||
overflow: hidden;
|
||
position: relative;
|
||
}
|
||
|
||
.call-container > * {
|
||
width: 100%;
|
||
height: 100%;
|
||
flex: 1;
|
||
}
|
||
|
||
.waiting-container {
|
||
width: 100%;
|
||
min-height: 360px;
|
||
margin-top: 20px;
|
||
background: #f5f5f5;
|
||
border-radius: 8px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
/* 离屏监听:保持真实手机宽高,利于 SDK 内部布局 */
|
||
.phone-call-kit-host {
|
||
width: 375px;
|
||
height: 667px;
|
||
overflow: hidden;
|
||
border-radius: 28px;
|
||
background: #000;
|
||
}
|
||
|
||
.phone-call-kit-host--offscreen {
|
||
position: fixed;
|
||
left: -9999px;
|
||
top: 0;
|
||
z-index: -1;
|
||
pointer-events: none;
|
||
opacity: 0;
|
||
}
|
||
|
||
/* 确保 TUICallKit 组件正确显示 */
|
||
:deep(.call-container) {
|
||
.tui-call-kit {
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
|
||
video {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
}
|
||
}
|
||
|
||
/* 强制 TUICallKit 组件撑满容器 */
|
||
:deep(.TUICallKit) {
|
||
width: 100% !important;
|
||
height: 100% !important;
|
||
position: relative !important;
|
||
}
|
||
|
||
:deep(.tui-call-kit) {
|
||
width: 100% !important;
|
||
height: 100% !important;
|
||
}
|
||
|
||
:deep(.tui-call-kit-mini) {
|
||
display: none !important;
|
||
}
|
||
|
||
/* 隐藏 TUICallKit 的默认最小化窗口 */
|
||
:deep(.tui-call-kit-window) {
|
||
width: 100% !important;
|
||
height: 100% !important;
|
||
min-height: 0 !important;
|
||
}
|
||
|
||
/* 确保视频容器撑满 */
|
||
:deep(.tui-video-container) {
|
||
width: 100% !important;
|
||
height: 100% !important;
|
||
}
|
||
|
||
:deep(.tui-call-kit-video) {
|
||
width: 100% !important;
|
||
height: 100% !important;
|
||
}
|
||
|
||
/* 隐藏所有可能的最小化或浮窗样式 */
|
||
:deep([class*="mini"]) {
|
||
display: none !important;
|
||
}
|
||
|
||
:deep([class*="float"]) {
|
||
width: 100% !important;
|
||
height: 100% !important;
|
||
}
|
||
|
||
.mb-4 {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.mt-2 {
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.mt-4 {
|
||
margin-top: 16px;
|
||
}
|
||
|
||
.mr-2 {
|
||
margin-right: 8px;
|
||
}
|
||
|
||
.flex {
|
||
display: flex;
|
||
}
|
||
|
||
.items-center {
|
||
align-items: center;
|
||
}
|
||
|
||
.animate-pulse {
|
||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||
}
|
||
|
||
@keyframes pulse {
|
||
0%, 100% {
|
||
opacity: 1;
|
||
}
|
||
50% {
|
||
opacity: 0.5;
|
||
}
|
||
}
|
||
|
||
:deep(.el-alert__description) {
|
||
margin-top: 8px;
|
||
}
|
||
|
||
:deep(.el-alert__description ol) {
|
||
margin: 8px 0;
|
||
padding-left: 20px;
|
||
}
|
||
|
||
:deep(.el-alert__description li) {
|
||
margin: 4px 0;
|
||
}
|
||
|
||
.debug-info {
|
||
font-size: 14px;
|
||
line-height: 1.8;
|
||
}
|
||
|
||
.debug-info code {
|
||
background: #f5f5f5;
|
||
padding: 2px 6px;
|
||
border-radius: 3px;
|
||
color: #e6a23c;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.debug-info ul {
|
||
margin: 8px 0;
|
||
padding-left: 20px;
|
||
}
|
||
|
||
.debug-info li {
|
||
margin: 4px 0;
|
||
}
|
||
</style>
|