This commit is contained in:
Your Name
2026-09-09 15:47:48 +08:00
parent bd5d5c5f08
commit cb10e75ead
98 changed files with 7031 additions and 804 deletions
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -6,7 +6,7 @@
<meta name="color-scheme" content="light" />
<link rel="icon" type="image/png" href="./favicon.png" />
<title>视频面诊</title>
<script type="module" crossorigin src="./assets/index-B_ek5NUi.js"></script>
<script type="module" crossorigin src="./assets/index-CNqfE3p9.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-BMSk91Wa.css">
</head>
<body>
+6
View File
@@ -60,6 +60,8 @@ const props = defineProps<{
chatReady: Readonly<Ref<boolean>>
chatBusy: Readonly<Ref<boolean>>
notice: Readonly<Ref<string>>
canVideoCall: Readonly<Ref<boolean>>
callDisabledReason: Readonly<Ref<string>>
hasMoreMessages: Readonly<Ref<boolean>>
transcriptionState: Readonly<Ref<string>>
localRecordingState: Readonly<Ref<string>>
@@ -308,6 +310,7 @@ watch(
</button>
<button
class="primary-action"
v-if="canVideoCall.value"
type="button"
:disabled="actionBusy || isCalling || !chatReady.value"
@click="runAction(onStartVideo)"
@@ -383,6 +386,9 @@ watch(
</div>
<footer class="composer">
<div v-if="callDisabledReason.value" class="inline-notice" role="status">
{{ callDisabledReason.value }}
</div>
<div v-if="localError || notice.value" class="inline-notice" :class="{ 'inline-notice--error': localError }">
{{ localError || notice.value }}
</div>
@@ -0,0 +1,29 @@
type Guard = (callType: number) => Promise<void>
/** Each SDK entrypoint uses the current conversation, including after async work. */
export function installAppointmentCallGuard(api: Record<string, any>): (guard: Guard) => () => void {
let activeGuard: Guard | undefined
for (const method of ['call', 'calls', 'groupCall']) {
const original = api[method]
if (typeof original !== 'function') continue
api[method] = async function (params: any, ...rest: any[]) {
const guard = activeGuard
if (!guard) throw new Error('请先打开本次挂号的聊天窗口')
const type = params?.type === 1 ? 1 : 2
const dispatched = { ...params, type }
await guard(type)
if (activeGuard !== guard) throw new Error('当前问诊已切换,请重新发起通话')
return original.call(this, dispatched, ...rest)
}
}
return (guard: Guard) => {
activeGuard = guard
return () => { if (activeGuard === guard) activeGuard = undefined }
}
}
export function assertCallPolicy(policy: Record<string, unknown>, type: number): void {
if ((type === 1 ? policy.can_audio_call : policy.can_video_call) !== true) {
throw new Error(String(policy.call_disabled_reason || '本次挂号不支持该通话方式'))
}
}
+7
View File
@@ -6,6 +6,12 @@ declare module 'tim-upload-plugin' {
}
interface DoctorCallConfig {
patientId?: number | string | null
appointmentId?: number
appointment_type?: unknown
can_video_call?: unknown
can_audio_call?: unknown
call_disabled_reason?: string
SDKAppID?: number | string
sdkAppId?: number | string
userID?: string
@@ -53,6 +59,7 @@ interface DoctorConsultationApi {
startVideo(): Promise<void>
hangup(): Promise<void>
hostCallReady(ok: boolean, message?: string): void
callPolicyResult(requestId: string, policy: Record<string, unknown>): void
recordingResult(ok: boolean, message: string): void
roomBindingResult(roomId: string, ok: boolean, message: string): void
screenshotResult(ok: boolean, message: string): void
+93 -6
View File
@@ -10,6 +10,7 @@ import {
} from '@trtc/calls-uikit-vue'
import App from './App.vue'
import { assertCallPolicy, installAppointmentCallGuard } from './appointment-call-guard'
import './style.css'
type CallPhase = 'ready' | 'starting' | 'dialing' | 'connected' | 'ended' | 'error'
@@ -20,6 +21,8 @@ type TranscriptionState = 'idle' | 'starting' | 'recording' | 'stopping' | 'erro
type LocalRecordingState = 'idle' | 'starting' | 'recording' | 'stopping' | 'uploading' | 'error'
interface NormalizedCallConfig {
appointmentId: number
policy: Record<string, unknown>
SDKAppID: number
userID: string
userSig: string
@@ -77,6 +80,7 @@ interface BridgeMessage {
event:
| 'ready'
| 'call-start-request'
| 'call-policy-request'
| 'status'
| 'room'
| 'hangup'
@@ -86,6 +90,8 @@ interface BridgeMessage {
| 'transcription-segment'
| 'transcription-stop'
diagnosisId?: number | string
callType?: number
requestId?: string
status?: string
roomId?: string
message?: string
@@ -179,6 +185,13 @@ const messages = ref<UiChatMessage[]>([])
const chatReady = ref(false)
const chatBusy = ref(false)
const notice = ref('')
const canVideoCall = ref(false)
const callDisabledReason = ref('正在确认本次挂号通话权限')
const registerCallGuard = installAppointmentCallGuard(TUICallKitAPI)
let releaseCallGuard: (() => void) | undefined
let contextGeneration = 0
let policySequence = 0
const pendingCallPolicies = new Map<string, (policy: Record<string, unknown>) => void>()
const hasMoreMessages = ref(false)
const transcriptionState = ref<TranscriptionState>('idle')
const localRecordingState = ref<LocalRecordingState>('idle')
@@ -381,6 +394,13 @@ function normalizeConfig(config: DoctorCallConfig): NormalizedCallConfig {
: '患者'
return {
SDKAppID,
appointmentId: Number(config.appointmentId || 0),
policy: {
can_video_call: config.can_video_call,
can_audio_call: config.can_audio_call,
appointment_type: config.appointment_type,
call_disabled_reason: config.call_disabled_reason,
},
userID: cleanString(config.userID ?? config.userId, '医生用户ID'),
userSig: cleanString(config.userSig, '用户签名'),
targetUserId: cleanString(config.targetUserId ?? config.patientUserId, '患者用户ID'),
@@ -1740,13 +1760,42 @@ TUICallKitAPI.setCallback({
TUICallKitAPI.setLanguage('zh-cn')
TUICallKitAPI.enableFloatWindow(false)
function requestHostCallStart(): Promise<boolean> {
function syncCallPolicy(policy: Record<string, unknown>): void {
canVideoCall.value = policy.can_video_call === true
callDisabledReason.value = policy.appointment_type === 'text'
? '图文问诊,不支持音视频通话'
: String(policy.call_disabled_reason || (canVideoCall.value ? '' : '本次挂号不支持视频通话'))
}
function requestCallPolicy(): Promise<Record<string, unknown>> {
if (!window.qtVideoBridge?.notify) return Promise.reject(new Error('无法确认本次挂号通话权限'))
const requestId = `${contextGeneration}:${++policySequence}`
return new Promise((resolve) => {
pendingCallPolicies.set(requestId, resolve)
emit({ source: 'doctor-call', event: 'call-policy-request', requestId, diagnosisId: activeConfig?.diagnosisId })
window.setTimeout(() => {
const pending = pendingCallPolicies.get(requestId)
if (!pending) return
pendingCallPolicies.delete(requestId)
pending({ call_disabled_reason: '通话权限确认超时,请重试' })
}, 15000)
})
}
function callPolicyResult(requestId: string, policy: Record<string, unknown>): void {
const resolve = pendingCallPolicies.get(requestId)
if (!resolve) return
pendingCallPolicies.delete(requestId)
resolve(policy)
}
function requestHostCallStart(callType = 2): Promise<boolean> {
if (!activeConfig) return Promise.resolve(false)
if (!window.qtVideoBridge?.notify) return Promise.resolve(true)
if (!window.qtVideoBridge?.notify) return Promise.resolve(false)
return new Promise<boolean>((resolve) => {
const currentResolver = resolve
resolveHostCallReady = currentResolver
emit({ source: 'doctor-call', event: 'call-start-request', diagnosisId: activeConfig?.diagnosisId })
emit({ source: 'doctor-call', event: 'call-start-request', diagnosisId: activeConfig?.diagnosisId, callType })
window.setTimeout(() => {
if (resolveHostCallReady !== currentResolver) return
resolveHostCallReady = null
@@ -1765,9 +1814,12 @@ function hostCallReady(ok: boolean, message = ''): void {
async function startVideo(): Promise<void> {
if (!activeConfig) throw new Error('问诊配置尚未准备好')
if (starting || !endNotified) throw new Error('已有视频通话正在进行')
const config = activeConfig
const generation = contextGeneration
starting = true
try {
if (hangupNotification) await hangupNotification
if (activeConfig !== config || generation !== contextGeneration) throw new Error('当前问诊已切换')
if (!activeConfig || !endNotified) throw new Error('已有视频通话正在进行')
hangupNotification = null
callCycleGeneration += 1
@@ -1782,8 +1834,6 @@ async function startVideo(): Promise<void> {
localRecordingSessionId = ''
localRecordingMimeType = ''
notice.value = ''
const allowed = await requestHostCallStart()
if (!allowed) throw new Error(notice.value || '服务器未能创建视频通话记录')
await TUICallKitAPI.init({
SDKAppID: activeConfig.SDKAppID,
userID: activeConfig.userID,
@@ -1791,6 +1841,7 @@ async function startVideo(): Promise<void> {
...(chat ? { tim: chat, isFromChat: true } : {}),
})
await nextTick()
if (activeConfig !== config || generation !== contextGeneration) throw new Error('当前问诊已切换')
phase.value = 'dialing'
statusText.value = '正在呼叫患者'
appendVideoCallStatus('dialing', '正在呼叫患者')
@@ -1807,11 +1858,12 @@ async function startVideo(): Promise<void> {
emit({ source: 'doctor-call', event: 'status', diagnosisId: activeConfig.diagnosisId, status: 'dialing' })
} catch (error) {
const message = safeErrorMessage(error, '无法发起视频通话')
if (activeConfig !== config || generation !== contextGeneration) throw new Error(message)
phase.value = 'error'
statusText.value = message
appendVideoCallStatus('failed', `视频通话发起失败:${message}`)
endNotified = true
emit({ source: 'doctor-call', event: 'error', diagnosisId: activeConfig.diagnosisId, message })
emit({ source: 'doctor-call', event: 'error', diagnosisId: activeConfig?.diagnosisId, message })
throw new Error(message)
} finally {
starting = false
@@ -1867,6 +1919,31 @@ function roomBindingResult(roomId: string, ok: boolean, message: string): void {
async function open(config: DoctorCallConfig): Promise<void> {
if (activeConfig) await close()
activeConfig = normalizeConfig(config)
const current = activeConfig
const generation = ++contextGeneration
syncCallPolicy(current.policy)
releaseCallGuard = registerCallGuard(async (type) => {
const assertCurrent = () => {
if (activeConfig !== current || generation !== contextGeneration) {
throw new Error('当前问诊已切换,请重新发起通话')
}
if (document.visibilityState === 'hidden') throw new Error('请先返回本次挂号的聊天窗口')
}
const refresh = async () => {
assertCurrent()
const policy = await requestCallPolicy()
assertCurrent()
syncCallPolicy(policy)
if (policy.appointmentId !== current.appointmentId) throw new Error('本次挂号已变更,请重新打开聊天窗口')
assertCallPolicy(policy, type)
}
await refresh()
if (!await requestHostCallStart(type)) throw new Error(notice.value || '服务器未能创建通话记录')
assertCurrent()
// Refresh again after asynchronous record creation, immediately before SDK dispatch.
await refresh()
assertCurrent()
})
mode.value = activeConfig.mode
patientName.value = activeConfig.patientName
patientCase.value = activeConfig.patientCase
@@ -1908,6 +1985,13 @@ async function open(config: DoctorCallConfig): Promise<void> {
}
async function close(): Promise<void> {
releaseCallGuard?.()
releaseCallGuard = undefined
contextGeneration += 1
for (const resolve of pendingCallPolicies.values()) resolve({})
pendingCallPolicies.clear()
resolveHostCallReady?.(false)
resolveHostCallReady = null
if (!endNotified) await hangup()
if (hangupNotification) await hangupNotification
unsubscribeTranscriber()
@@ -1933,6 +2017,7 @@ window.doctorConsultation = {
startVideo,
hangup,
hostCallReady,
callPolicyResult,
recordingResult,
roomBindingResult,
screenshotResult,
@@ -1951,6 +2036,8 @@ createApp(App, {
chatReady: readonly(chatReady),
chatBusy: readonly(chatBusy),
notice: readonly(notice),
canVideoCall: readonly(canVideoCall),
callDisabledReason: readonly(callDisabledReason),
hasMoreMessages: readonly(hasMoreMessages),
transcriptionState: readonly(transcriptionState),
localRecordingState: readonly(localRecordingState),
@@ -0,0 +1,68 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const ts = require('typescript')
const source = fs.readFileSync(path.join(__dirname, '../src/appointment-call-guard.ts'), 'utf8')
const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText
const target = { exports: {} }
new Function('module', 'exports', compiled)(target, target.exports)
const { installAppointmentCallGuard, assertCallPolicy } = target.exports
for (const method of ['call', 'calls', 'groupCall']) {
test(`${method}: current server policy controls every invocation`, async () => {
const dispatched = []
const api = { [method]: async (params) => dispatched.push(params.type) }
const register = installAppointmentCallGuard(api)
await assert.rejects(api[method]({ type: 2 }), /请先打开/)
let serverPolicy = { can_video_call: true, can_audio_call: true }
let refreshes = 0
register(async (type) => { refreshes++; assertCallPolicy(serverPolicy, type) })
await api[method]({ type: 2 })
serverPolicy = { appointment_type: 'text', can_audio_call: false, can_video_call: false }
await assert.rejects(api[method]({ type: 2 }))
await assert.rejects(api[method]({ type: 1 }))
assert.deepEqual(dispatched, [2])
assert.equal(refreshes, 3)
})
test(`${method}: close or replacement rejects an in-flight authorization`, async () => {
let resume
let dispatched = 0
const api = { [method]: async () => dispatched++ }
const register = installAppointmentCallGuard(api)
const release = register(() => new Promise((resolve) => { resume = resolve }))
const pending = api[method]({ type: 2 })
release()
register(async () => {})
resume()
await assert.rejects(pending, /已切换/)
assert.equal(dispatched, 0)
release() // Releasing the old window cannot remove the replacement guard.
await api[method]({ type: 1 })
assert.equal(dispatched, 1)
})
}
test('strict booleans fail closed, audio and video permissions remain separate', () => {
for (const value of [undefined, null, false, 0, 1, 'true', '1']) {
assert.throws(() => assertCallPolicy({ can_video_call: value }, 2))
assert.throws(() => assertCallPolicy({ can_audio_call: value }, 1))
}
assert.doesNotThrow(() => assertCallPolicy({ can_audio_call: true }, 1))
assert.throws(() => assertCallPolicy({ can_audio_call: true }, 2))
})
test('an awaiting caller cannot change the authorized media type', async () => {
let resume
let actual
const api = { calls: async (params) => { actual = params.type } }
const register = installAppointmentCallGuard(api)
register(() => new Promise((resolve) => { resume = resolve }))
const params = { type: 1 }
const pending = api.calls(params)
params.type = 2
resume()
await pending
assert.equal(actual, 1)
})