This commit is contained in:
Your Name
2026-08-10 17:29:05 +08:00
parent 2199887c07
commit 9add23e019
129 changed files with 34157 additions and 59 deletions
+33
View File
@@ -0,0 +1,33 @@
<script setup lang="ts">
import { TUICallKit } from '@trtc/calls-uikit-vue'
import type { Ref } from 'vue'
defineProps<{
phase: Readonly<Ref<string>>
statusText: Readonly<Ref<string>>
}>()
</script>
<template>
<main class="call-stage">
<TUICallKit
class="call-kit"
:allowed-minimized="false"
:allowed-full-screen="true"
/>
<section v-if="phase.value === 'ready' || phase.value === 'starting' || phase.value === 'error'" class="status-card">
<span class="status-dot" :class="`status-dot--${phase.value}`" aria-hidden="true" />
<div>
<p class="eyebrow">中医视频面诊</p>
<h1>{{ statusText.value }}</h1>
<p v-if="phase.value === 'ready'" class="status-hint">通话凭证仅由业务后端签发</p>
</div>
</section>
<div v-else class="live-status" role="status">
<span class="status-dot status-dot--live" aria-hidden="true" />
{{ statusText.value }}
</div>
</main>
</template>
+31
View File
@@ -0,0 +1,31 @@
/// <reference types="vite/client" />
interface DoctorCallConfig {
SDKAppID?: number | string
sdkAppId?: number | string
userID?: string
userId?: string
userSig: string
targetUserId?: string
patientUserId?: string
diagnosisId: number | string
}
interface DoctorCallApi {
start(config: DoctorCallConfig): Promise<void>
hangup(): Promise<void>
}
interface QtVideoBridge {
notify?: (payload: string) => void
}
interface Window {
doctorCall: DoctorCallApi
qtVideoBridge?: QtVideoBridge
qt?: { webChannelTransport?: unknown }
QWebChannel?: new (
transport: unknown,
callback: (channel: { objects: { qtVideoBridge?: QtVideoBridge } }) => void,
) => unknown
}
+289
View File
@@ -0,0 +1,289 @@
import { createApp, nextTick, readonly, ref } from 'vue'
import {
NAME,
StoreName,
TUIStore,
TUICallKitAPI,
TUICallType,
} from '@trtc/calls-uikit-vue'
import App from './App.vue'
import './style.css'
type CallPhase = 'ready' | 'starting' | 'dialing' | 'connected' | 'ended' | 'error'
interface NormalizedCallConfig {
SDKAppID: number
userID: string
userSig: string
targetUserId: string
diagnosisId: number | string
}
interface BridgeMessage {
source: 'doctor-call'
event: 'ready' | 'status' | 'room' | 'hangup' | 'error'
diagnosisId?: number | string
status?: string
roomId?: string
message?: string
}
const phase = ref<CallPhase>('ready')
const statusText = ref('等待桌面端发起视频面诊')
let activeConfig: NormalizedCallConfig | null = null
let endNotified = false
let starting = false
let emittedRoomId = ''
function initializeQtWebChannel(): void {
const transport = window.qt?.webChannelTransport
const QWebChannel = window.QWebChannel
if (!transport || typeof QWebChannel !== 'function') return
try {
new QWebChannel(transport, (channel) => {
const bridge = channel.objects.qtVideoBridge
if (bridge) window.qtVideoBridge = bridge
emit({ source: 'doctor-call', event: 'ready' })
})
} catch {
console.warn('[doctor-call] Qt WebChannel 初始化失败,将使用 postMessage 通知')
}
}
function postToHost(message: BridgeMessage): boolean {
let delivered = false
try {
if (window.parent && window.parent !== window) {
window.parent.postMessage(message, '*')
delivered = true
}
} catch {
// Cross-origin host may reject access; opener remains available as a fallback.
}
try {
if (window.opener && !window.opener.closed) {
window.opener.postMessage(message, '*')
delivered = true
}
} catch {
// The console fallback below is intentionally non-sensitive.
}
return delivered
}
function emit(message: BridgeMessage): void {
const bridge = window.qtVideoBridge
if (bridge && typeof bridge.notify === 'function') {
try {
bridge.notify(JSON.stringify(message))
return
} catch {
// A detached WebChannel object is equivalent to an unavailable bridge.
}
}
if (!postToHost(message)) {
console.info('[doctor-call]', message.event, message.status ?? message.message ?? '')
}
}
function cleanString(value: unknown, field: string): string {
if (typeof value !== 'string' || value.trim() === '') {
throw new Error(`${field} 不能为空`)
}
return value.trim()
}
function normalizeConfig(config: DoctorCallConfig): NormalizedCallConfig {
if (!config || typeof config !== 'object') throw new Error('通话配置无效')
const rawSdkAppId = config.SDKAppID ?? config.sdkAppId
const SDKAppID = Number(rawSdkAppId)
if (!Number.isSafeInteger(SDKAppID) || SDKAppID <= 0) {
throw new Error('SDKAppID 必须是正整数')
}
const diagnosisId = config.diagnosisId
if (
diagnosisId === undefined
|| diagnosisId === null
|| (typeof diagnosisId === 'string' && diagnosisId.trim() === '')
) {
throw new Error('diagnosisId 不能为空')
}
return {
SDKAppID,
userID: cleanString(config.userID ?? config.userId, 'userID'),
userSig: cleanString(config.userSig, 'userSig'),
targetUserId: cleanString(config.targetUserId ?? config.patientUserId, 'targetUserId'),
diagnosisId: typeof diagnosisId === 'string' ? diagnosisId.trim() : diagnosisId,
}
}
function safeErrorMessage(error: unknown): string {
let message = error instanceof Error ? error.message : '视频通话发生未知错误'
if (activeConfig?.userSig) message = message.split(activeConfig.userSig).join('[REDACTED]')
return message
.replace(/(user\s*sig\s*[:=]\s*)[^\s,;&]+/gi, '$1[REDACTED]')
.slice(0, 400)
}
function notifyHangup(status = 'ended'): void {
if (endNotified) return
endNotified = true
phase.value = 'ended'
statusText.value = '视频面诊已结束'
emit({
source: 'doctor-call',
event: 'hangup',
diagnosisId: activeConfig?.diagnosisId,
status,
})
}
function readRoomId(): string {
const raw = TUIStore.getData(StoreName.CALL, NAME.ROOM_ID)
if (raw === undefined || raw === null) return ''
const value = String(raw).trim()
return value && value !== '0' ? value : ''
}
function emitRoomId(): boolean {
const roomId = readRoomId()
if (!roomId || roomId === emittedRoomId) return Boolean(roomId)
emittedRoomId = roomId
emit({
source: 'doctor-call',
event: 'room',
diagnosisId: activeConfig?.diagnosisId,
roomId,
})
return true
}
async function pollRoomId(): Promise<void> {
for (let attempt = 0; attempt < 40 && activeConfig && !endNotified; attempt += 1) {
if (emitRoomId()) return
await new Promise((resolve) => window.setTimeout(resolve, 50))
}
}
function handleStatusChanged(payload: unknown): void {
const value = payload && typeof payload === 'object'
? (payload as { newStatus?: unknown }).newStatus
: payload
const status = typeof value === 'string' ? value : 'unknown'
if (status === 'connected' || status.startsWith('calling-')) {
phase.value = 'connected'
statusText.value = '视频面诊进行中'
void pollRoomId()
} else if (status === 'calling' || status.startsWith('dialing')) {
phase.value = 'dialing'
statusText.value = '正在等待患者接听'
} else if (status === 'idle' && activeConfig && !starting) {
notifyHangup(status)
}
emit({
source: 'doctor-call',
event: 'status',
diagnosisId: activeConfig?.diagnosisId,
status,
})
}
TUICallKitAPI.setCallback({
statusChanged: handleStatusChanged,
afterCalling: () => notifyHangup('after-calling'),
})
TUICallKitAPI.enableFloatWindow(false)
async function start(config: DoctorCallConfig): Promise<void> {
if (starting || (activeConfig && !endNotified)) {
throw new Error('已有视频通话正在进行')
}
starting = true
endNotified = false
phase.value = 'starting'
statusText.value = '正在初始化安全通话'
try {
const normalized = normalizeConfig(config)
activeConfig = normalized
emittedRoomId = ''
await TUICallKitAPI.init({
SDKAppID: normalized.SDKAppID,
userID: normalized.userID,
userSig: normalized.userSig,
})
await nextTick()
phase.value = 'dialing'
statusText.value = '正在呼叫患者'
await TUICallKitAPI.calls({
userIDList: [normalized.targetUserId],
type: TUICallType.VIDEO_CALL,
})
void pollRoomId()
emit({
source: 'doctor-call',
event: 'status',
diagnosisId: normalized.diagnosisId,
status: 'dialing',
})
} catch (error) {
const message = safeErrorMessage(error)
phase.value = 'error'
statusText.value = message
emit({
source: 'doctor-call',
event: 'error',
diagnosisId: activeConfig?.diagnosisId,
message,
})
activeConfig = null
endNotified = true
throw new Error(message)
} finally {
starting = false
}
}
async function hangup(): Promise<void> {
if (!activeConfig || endNotified) return
try {
await TUICallKitAPI.hangup()
notifyHangup('local-hangup')
} catch (error) {
const message = safeErrorMessage(error)
emit({
source: 'doctor-call',
event: 'error',
diagnosisId: activeConfig.diagnosisId,
message,
})
throw new Error(message)
}
}
window.doctorCall = { start, hangup }
initializeQtWebChannel()
createApp(App, {
phase: readonly(phase),
statusText: readonly(statusText),
}).mount('#app')
emit({ source: 'doctor-call', event: 'ready' })
+127
View File
@@ -0,0 +1,127 @@
:root {
font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
color: #f7f8fa;
background: #0b0f14;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
* {
box-sizing: border-box;
}
html,
body,
#app {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
}
button,
input {
font: inherit;
}
.call-stage {
position: relative;
width: 100%;
height: 100%;
min-height: 420px;
overflow: hidden;
background:
radial-gradient(circle at 50% 35%, rgba(39, 74, 83, 0.22), transparent 38%),
#0b0f14;
}
.call-kit,
.call-stage :is(.TUICallKit-desktop, .TUICallKit-mobile, #tuicallkit-id) {
width: 100% !important;
height: 100% !important;
max-width: none !important;
max-height: none !important;
}
.status-card {
position: absolute;
inset: 50% auto auto 50%;
display: grid;
grid-template-columns: 12px minmax(0, 1fr);
gap: 18px;
width: min(520px, calc(100% - 48px));
padding: 30px 32px;
transform: translate(-50%, -50%);
border: 1px solid rgba(255, 255, 255, 0.09);
border-radius: 20px;
background: rgba(19, 25, 32, 0.9);
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.32);
backdrop-filter: blur(18px);
}
.eyebrow {
margin: 0 0 12px;
color: #8f9ba8;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.status-card h1 {
margin: 0;
font-size: clamp(22px, 3.2vw, 34px);
font-weight: 600;
line-height: 1.25;
}
.status-hint {
margin: 14px 0 0;
color: #9aa5b1;
font-size: 14px;
}
.status-dot {
width: 10px;
height: 10px;
margin-top: 5px;
border-radius: 50%;
background: #77818c;
box-shadow: 0 0 0 5px rgba(119, 129, 140, 0.12);
}
.status-dot--starting,
.status-dot--live {
background: #52c99a;
box-shadow: 0 0 0 5px rgba(82, 201, 154, 0.14);
}
.status-dot--error {
background: #f26d6d;
box-shadow: 0 0 0 5px rgba(242, 109, 109, 0.14);
}
.live-status {
position: absolute;
z-index: 20;
top: 18px;
left: 50%;
display: flex;
align-items: center;
gap: 10px;
padding: 9px 14px;
transform: translateX(-50%);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 999px;
background: rgba(11, 15, 20, 0.76);
color: #e8edf2;
font-size: 13px;
backdrop-filter: blur(14px);
}
.live-status .status-dot {
width: 7px;
height: 7px;
margin: 0;
box-shadow: none;
}