Files
zyt/admin/src/views/test/patient-call.vue
T
2026-03-23 18:02:16 +08:00

698 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>