更新
This commit is contained in:
+91
-91
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -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>
|
||||
|
||||
@@ -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 || '本次挂号不支持该通话方式'))
|
||||
}
|
||||
}
|
||||
Vendored
+7
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
Reference in New Issue
Block a user