import TencentCloudChat from '@tencentcloud/lite-chat' import TIMUploadPlugin from 'tim-upload-plugin' 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' type CompanionMode = 'chat' | 'video' type ChatMessageType = 'text' | 'image' | 'file' | 'audio' | 'video' | 'system' | 'call-status' type VideoCallStatus = 'starting' | 'dialing' | 'connected' | 'ended' | 'failed' type TranscriptionState = 'idle' | 'starting' | 'recording' | 'stopping' | 'error' type LocalRecordingState = 'idle' | 'starting' | 'recording' | 'stopping' | 'uploading' | 'error' interface NormalizedCallConfig { SDKAppID: number userID: string userSig: string targetUserId: string diagnosisId: number | string patientName: string mode: CompanionMode } interface UiChatMessage { id: string mine: boolean type: ChatMessageType text: string url: string name: string time: string callStatus?: VideoCallStatus } interface UiLiveCaption { id: string speaker: string text: string completed: boolean } interface BridgeMessage { source: 'doctor-call' event: | 'ready' | 'call-start-request' | 'status' | 'room' | 'hangup' | 'error' | 'transcription-start-request' | 'transcription-segment' | 'transcription-stop' diagnosisId?: number | string status?: string roomId?: string message?: string sessionId?: string language?: string segment?: { segment_id: string speaker_user_id: string speaker_role: 'doctor' | 'patient' | 'unknown' timestamp: number text: string } } interface RealtimeTranscriberMessage { segmentId: string speakerUserId: string sourceText: string timestamp: number isCompleted: boolean } interface RealtimeTranscriberListener { onReceiveTranscriberMessage: ( roomId: string | number, message: RealtimeTranscriberMessage, ) => void onRealtimeTranscriberStarted: ( roomId: string | number, robotId: string, sourceLanguage: string, ) => void onRealtimeTranscriberStopped: (roomId: string | number, robotId: string) => void onRealtimeTranscriberError: ( roomId: string | number, robotId: string, error: number, errorMessage: string, ) => void } interface RealtimeTranscriberManager { addListener(listener: RealtimeTranscriberListener): void removeListener(listener: RealtimeTranscriberListener): void startRealtimeTranscriber(config: { sourceLanguage: string }): Promise stopRealtimeTranscriber(robotId: string): Promise } interface PendingTranscriptionReply { sessionId: string resolve: (value: boolean) => void promise: Promise } interface PendingSegment { message: BridgeMessage attempts: number } interface PendingLocalRecordingReply { resolve: (value: boolean) => void promise: Promise } interface TrtcAudioTrackEvent { userId?: string track?: MediaStreamTrack } interface TrtcAudioCloud { getAudioTrack?(configOrUserId?: { userId?: string processed?: boolean } | string): MediaStreamTrack | null on?(event: 'track', handler: (event: TrtcAudioTrackEvent) => void): void off?(event: 'track', handler: (event: TrtcAudioTrackEvent) => void): void } const phase = ref('ready') const statusText = ref('正在连接问诊服务') const patientName = ref('患者') const mode = ref('video') const messages = ref([]) const chatReady = ref(false) const chatBusy = ref(false) const notice = ref('') const hasMoreMessages = ref(false) const transcriptionState = ref('idle') const localRecordingState = ref('idle') const liveCaptions = ref([]) let activeConfig: NormalizedCallConfig | null = null let chat: any = null let chatReadyPromise: Promise | null = null let resolveChatReady: (() => void) | null = null let rejectChatReady: ((reason?: unknown) => void) | null = null let nextReqMessageID = '' let endNotified = true let starting = false let emittedRoomId = '' let pendingRoomId = '' let boundRoomId = '' let roomBindingSentAt = 0 let roomBindingAttempts = 0 let resolveHostCallReady: ((value: boolean) => void) | null = null const pendingTranscriptionStarts = new Map() const pendingTranscriptionStops = new Map() let transcriptionSessionId = '' let transcriptionGeneration = 0 let transcriberRunning = false let transcriberManager: RealtimeTranscriberManager | null = null let transcriberListener: RealtimeTranscriberListener | null = null let transcriberRobotId = '' let transcriptionStartPromise: Promise | null = null let transcriptionStopPromise: Promise | null = null let hangupNotification: Promise | null = null let callCycleGeneration = 0 let autoTranscriptionAttemptedGeneration = -1 let autoLocalRecordingAttemptedGeneration = -1 let lastTranscriberMessageAt = 0 let transcriberStoppedAt = 0 const acknowledgedSegmentIds = new Set() const pendingSegments = new Map() const pendingLocalRecordingStarts = new Map() const pendingLocalRecordingFinishes = new Map() let localRecordingSessionId = '' let localRecordingMimeType = '' let localRecorder: MediaRecorder | null = null let localAudioContext: AudioContext | null = null let localAudioDestination: MediaStreamAudioDestinationNode | null = null let localAudioCloud: TrtcAudioCloud | null = null let localAudioTrackHandler: ((event: TrtcAudioTrackEvent) => void) | null = null let localAudioSources: MediaStreamAudioSourceNode[] = [] let localAudioTrackIds = new Set() let localAudioOwnedTracks: MediaStreamTrack[] = [] let localAudioDiscoveryTimer: number | null = null let localAudioHasDoctorSource = false let localAudioHasPatientSource = false let localRecordingAttachedSourceCount = 0 let localRecordingChunkSequence = 0 let localRecordingBytes = 0 let localRecordingChunkChain: Promise = Promise.resolve() let localRecordingStartPromise: Promise | null = null let localRecordingStopPromise: Promise | null = null let localRecordingFatalError = '' let liveCaptionClearTimer: number | null = null 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-consultation] Qt 通信通道初始化失败') } } function postToHost(message: BridgeMessage): boolean { let delivered = false try { if (window.parent && window.parent !== window) { window.parent.postMessage(message, '*') delivered = true } } catch { // The Qt bridge remains the primary transport. } try { if (window.opener && !window.opener.closed) { window.opener.postMessage(message, '*') delivered = true } } catch { // A detached opener is harmless. } 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 { // Fall through to browser messaging when the bridge has detached. } } if (!postToHost(message)) { console.info('[doctor-consultation]', 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 SDKAppID = Number(config.SDKAppID ?? config.sdkAppId) if (!Number.isSafeInteger(SDKAppID) || SDKAppID <= 0) throw new Error('SDKAppID 必须是正整数') const diagnosisId = config.diagnosisId if (diagnosisId === undefined || diagnosisId === null || String(diagnosisId).trim() === '') { throw new Error('诊单ID不能为空') } return { SDKAppID, userID: cleanString(config.userID ?? config.userId, '医生用户ID'), userSig: cleanString(config.userSig, '用户签名'), targetUserId: cleanString(config.targetUserId ?? config.patientUserId, '患者用户ID'), diagnosisId: typeof diagnosisId === 'string' ? diagnosisId.trim() : diagnosisId, patientName: typeof config.patientName === 'string' && config.patientName.trim() ? config.patientName.trim() : '患者', mode: config.mode === 'chat' ? 'chat' : 'video', } } function safeErrorMessage(error: unknown, fallback = '问诊服务发生未知错误'): string { let message = error instanceof Error ? error.message : fallback if (activeConfig?.userSig) message = message.split(activeConfig.userSig).join('[已隐藏]') message = message .replace(/(user\s*sig\s*[:=]\s*)[^\s,;&]+/gi, '$1[已隐藏]') .slice(0, 400) if (/user\s+not\s+logged\s+in|not\s+logged\s+in/i.test(message)) { return 'IM 登录已失效,请关闭其他患者 IM 窗口后重新打开本会话' } if (/sdk.*not.*ready|not.*ready/i.test(message)) { return 'IM 连接尚未就绪,请稍后重试' } if (/user\s*sig.*expired|signature.*expired/i.test(message)) { return 'IM 登录凭证已过期,请关闭窗口后重新打开' } return message || fallback } function timeText(raw: unknown): string { const seconds = Number(raw) const date = Number.isFinite(seconds) && seconds > 0 ? new Date(seconds * 1000) : new Date() return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false }) } function messageUrl(payload: any): string { if (!payload || typeof payload !== 'object') return '' if (typeof payload.url === 'string') return payload.url if (typeof payload.fileUrl === 'string') return payload.fileUrl if (typeof payload.videoUrl === 'string') return payload.videoUrl if (typeof payload.remoteAudioUrl === 'string') return payload.remoteAudioUrl const images = Array.isArray(payload.imageInfoArray) ? payload.imageInfoArray : [] const image = images.find((item: any) => item?.type === 0) ?? images.at(-1) ?? images[0] return typeof image?.url === 'string' ? image.url : '' } function parseJsonObject(value: unknown): Record | null { if (value && typeof value === 'object' && !Array.isArray(value)) { return value as Record } if (typeof value !== 'string' || !value.trim()) return null try { const parsed = JSON.parse(value) return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record : null } catch { return null } } function customMessageContent(payload: any): { hidden: boolean; text: string } { const outer = parseJsonObject(payload?.data) ?? parseJsonObject(payload) ?? {} const inner = parseJsonObject(outer.data) ?? {} const businessID = outer.businessID ?? outer.businessId ?? inner.businessID ?? inner.businessId if (businessID === 1 || String(businessID).toLowerCase().includes('call')) { return { hidden: true, text: '' } } const command = String(outer.command ?? outer.cmd ?? inner.command ?? inner.cmd ?? '').toLowerCase() if (command.includes('call') || command.includes('invite')) { return { hidden: true, text: '' } } const text = [ payload?.description, outer.text, outer.content, outer.message, outer.tips, inner.text, inner.content, inner.message, ].find((value) => typeof value === 'string' && value.trim()) return { hidden: false, text: text ? String(text).trim() : '系统自定义消息' } } function normalizeMessage(raw: any): UiChatMessage | null { const payload = raw?.payload ?? {} const rawType = String(raw?.type ?? '') let type: ChatMessageType = 'system' let text = '系统消息' if (rawType === TencentCloudChat.TYPES.MSG_TEXT) { type = 'text' text = String(payload.text ?? '') } else if (rawType === TencentCloudChat.TYPES.MSG_IMAGE) { type = 'image' text = '[图片]' } else if (rawType === TencentCloudChat.TYPES.MSG_FILE) { type = 'file' text = '[文件]' } else if (rawType === TencentCloudChat.TYPES.MSG_AUDIO) { type = 'audio' text = '[语音]' } else if (rawType === TencentCloudChat.TYPES.MSG_VIDEO) { type = 'video' text = '[视频]' } else if (rawType === TencentCloudChat.TYPES.MSG_CUSTOM) { const custom = customMessageContent(payload) if (custom.hidden) return null text = custom.text } else if (rawType === TencentCloudChat.TYPES.MSG_FACE) { text = '[表情消息]' } else if (rawType === TencentCloudChat.TYPES.MSG_LOCATION) { text = `[位置] ${String(payload.description ?? payload.name ?? '').trim()}`.trim() } else if (rawType === TencentCloudChat.TYPES.MSG_MERGER) { text = `[合并消息] ${String(payload.title ?? '').trim()}`.trim() } else if (rawType === TencentCloudChat.TYPES.MSG_GRP_TIP) { text = '[群组通知]' } else if (rawType === TencentCloudChat.TYPES.MSG_GRP_SYS_NOTICE) { text = '[群组系统通知]' } return { id: String(raw?.ID ?? raw?.id ?? `${raw?.time ?? Date.now()}-${Math.random()}`), mine: Boolean(raw?.flow === 'out' || raw?.from === activeConfig?.userID), type, text, url: messageUrl(payload), name: String(payload.fileName ?? payload.name ?? (type === 'file' ? '文件' : '')), time: timeText(raw?.time), } } function mergeMessages(rawList: any[], prepend = false): void { const incoming = rawList .map(normalizeMessage) .filter((item): item is UiChatMessage => item !== null) const combined = prepend ? [...incoming, ...messages.value] : [...messages.value, ...incoming] const seen = new Set() messages.value = combined.filter((item) => { if (seen.has(item.id)) return false seen.add(item.id) return true }) } function appendVideoCallStatus(callStatus: VideoCallStatus, text: string): void { if (!activeConfig || activeConfig.mode !== 'chat') return const id = `local-video-call-${callCycleGeneration}-${callStatus}` const message: UiChatMessage = { id, mine: false, type: 'call-status', text, url: '', name: '', time: timeText(undefined), callStatus, } const existingIndex = messages.value.findIndex((item) => item.id === id) if (existingIndex < 0) { messages.value = [...messages.value, message] return } const next = [...messages.value] next[existingIndex] = { ...next[existingIndex], ...message } messages.value = next } function onMessageReceived(event: any): void { if (!activeConfig) return const expected = `C2C${activeConfig.targetUserId}` const list = Array.isArray(event?.data) ? event.data : [] const matched = list.filter((item: any) => { const conversationID = String(item?.conversationID ?? '') return conversationID === expected || item?.from === activeConfig?.targetUserId }) if (!matched.length) return mergeMessages(matched) void chat?.setMessageRead?.({ conversationID: expected }) } function onSdkReady(): void { chatReady.value = true statusText.value = 'IM 已连接' notice.value = '' resolveChatReady?.() resolveChatReady = null rejectChatReady = null } function onSdkNotReady(event: any): void { const wasReady = chatReady.value chatReady.value = false statusText.value = 'IM 连接已断开' if (wasReady) { notice.value = safeErrorMessage(event?.data?.message ?? event?.message, 'IM 连接已断开,请重新打开会话') } } function onKickedOut(event: any): void { chatReady.value = false statusText.value = 'IM 已在其他窗口登录' const type = String(event?.data?.type ?? event?.data ?? '') notice.value = type.includes('userSigExpired') ? 'IM 登录凭证已过期,请关闭窗口后重新打开' : '当前账号已在另一个 IM 窗口登录,请关闭其他患者 IM 窗口后重新打开本会话' } function onNetStateChange(event: any): void { const state = String(event?.data?.state ?? event?.data ?? '').toUpperCase() if (state.includes('DISCONNECTED')) { statusText.value = 'IM 网络连接中断,正在自动恢复' } else if (state.includes('CONNECTED') && chatReady.value) { statusText.value = 'IM 已连接' } } async function loginChat(): Promise { if (!activeConfig) throw new Error('问诊配置尚未准备好') chatReady.value = false statusText.value = '正在连接患者 IM' chat = TencentCloudChat.create({ SDKAppID: activeConfig.SDKAppID }) chat.setLogLevel(2) chat.registerPlugin({ 'tim-upload-plugin': TIMUploadPlugin }) chat.on(TencentCloudChat.EVENT.SDK_READY, onSdkReady) chat.on(TencentCloudChat.EVENT.SDK_NOT_READY, onSdkNotReady) chat.on(TencentCloudChat.EVENT.KICKED_OUT, onKickedOut) chat.on(TencentCloudChat.EVENT.NET_STATE_CHANGE, onNetStateChange) chat.on(TencentCloudChat.EVENT.MESSAGE_RECEIVED, onMessageReceived) chatReadyPromise = new Promise((resolve, reject) => { resolveChatReady = resolve rejectChatReady = reject }) await chat.login({ userID: activeConfig.userID, userSig: activeConfig.userSig }) await Promise.race([ chatReadyPromise, new Promise((_, reject) => window.setTimeout(() => reject(new Error('IM 连接超时')), 15000)), ]) await loadMessages(false) } async function logoutChat(): Promise { if (!chat) return try { chat.off(TencentCloudChat.EVENT.SDK_READY, onSdkReady) chat.off(TencentCloudChat.EVENT.SDK_NOT_READY, onSdkNotReady) chat.off(TencentCloudChat.EVENT.KICKED_OUT, onKickedOut) chat.off(TencentCloudChat.EVENT.NET_STATE_CHANGE, onNetStateChange) chat.off(TencentCloudChat.EVENT.MESSAGE_RECEIVED, onMessageReceived) await chat.logout() } catch { // Closing the desktop window must not be blocked by a stale IM connection. } finally { chat = null chatReady.value = false chatReadyPromise = null resolveChatReady = null rejectChatReady = null } } async function reconnectChat(): Promise { if (!activeConfig || activeConfig.mode !== 'chat') throw new Error('当前不是 IM 会话') notice.value = '' await logoutChat() await loginChat() } async function loadMessages(prepend: boolean): Promise { if (!chat || !activeConfig || chatBusy.value) return chatBusy.value = true try { const request: { conversationID: string count: number nextReqMessageID?: string } = { conversationID: `C2C${activeConfig.targetUserId}`, count: 30, } // Tencent Cloud IM requires the first roaming-history request to omit the // pagination cursor. Passing an empty string is not equivalent and can // return an empty page even though the C2C conversation has history. if (prepend && nextReqMessageID) request.nextReqMessageID = nextReqMessageID const response = await chat.getMessageList(request) const data = response?.data ?? {} const list = Array.isArray(data.messageList) ? data.messageList : [] nextReqMessageID = String(data.nextReqMessageID ?? '') hasMoreMessages.value = !Boolean(data.isCompleted) && Boolean(nextReqMessageID) mergeMessages(list, prepend) await chat.setMessageRead({ conversationID: `C2C${activeConfig.targetUserId}` }) } catch (error) { notice.value = `读取消息失败:${safeErrorMessage(error)}` } finally { chatBusy.value = false } } async function sendText(text: string): Promise { if ( !chat || !activeConfig || !chatReady.value || !chat.isReady?.() || chat.getLoginUser?.() !== activeConfig.userID ) { chatReady.value = false statusText.value = 'IM 登录已失效' throw new Error('IM 登录已失效,请关闭其他患者 IM 窗口后重新打开本会话') } const content = text.trim() if (!content) return try { const message = chat.createTextMessage({ to: activeConfig.targetUserId, conversationType: TencentCloudChat.TYPES.CONV_C2C, payload: { text: content }, }) const response = await chat.sendMessage(message) mergeMessages([response?.data?.message ?? response?.data ?? message]) } catch (error) { throw new Error(safeErrorMessage(error, '消息发送失败')) } } async function sendAttachment(file: File): Promise { if ( !chat || !activeConfig || !chatReady.value || !chat.isReady?.() || chat.getLoginUser?.() !== activeConfig.userID ) { chatReady.value = false statusText.value = 'IM 登录已失效' throw new Error('IM 登录已失效,请关闭其他患者 IM 窗口后重新打开本会话') } if (file.size > 20 * 1024 * 1024) throw new Error('附件不能超过 20MB') const base = { to: activeConfig.targetUserId, conversationType: TencentCloudChat.TYPES.CONV_C2C, payload: { file }, } const message = file.type.startsWith('image/') ? chat.createImageMessage(base) : chat.createFileMessage(base) try { const response = await chat.sendMessage(message) mergeMessages([response?.data?.message ?? response?.data ?? message]) } catch (error) { throw new Error(safeErrorMessage(error, '附件发送失败')) } } function newLocalRecordingSessionId(): string { const random = window.crypto?.randomUUID?.().replaceAll('-', '') const entropy = random || `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}` return `audio-${entropy.replace(/[^a-zA-Z0-9]/g, '').slice(0, 32)}` } function selectLocalRecordingMimeType(): string { if (typeof MediaRecorder === 'undefined') return '' const candidates = [ 'audio/webm;codecs=opus', 'audio/webm', 'audio/ogg;codecs=opus', 'audio/ogg', ] return candidates.find((candidate) => MediaRecorder.isTypeSupported(candidate)) ?? '' } function requestLocalRecordingStart(sessionId: string, mimeType: string): Promise { const bridge = window.qtVideoBridge if (!bridge || typeof bridge.startLocalAudioRecording !== 'function') { return Promise.resolve(false) } const existing = pendingLocalRecordingStarts.get(sessionId) if (existing) return existing.promise const pending = {} as PendingLocalRecordingReply pending.promise = new Promise((resolve) => { pending.resolve = resolve }) pendingLocalRecordingStarts.set(sessionId, pending) bridge.startLocalAudioRecording(sessionId, mimeType) window.setTimeout(() => { if (pendingLocalRecordingStarts.get(sessionId) !== pending) return pendingLocalRecordingStarts.delete(sessionId) pending.resolve(false) }, 15000) return pending.promise } function requestLocalRecordingFinish(sessionId: string, totalBytes: number): Promise { const bridge = window.qtVideoBridge if (!bridge || typeof bridge.finishLocalAudioRecording !== 'function') { return Promise.resolve(false) } const existing = pendingLocalRecordingFinishes.get(sessionId) if (existing) return existing.promise const pending = {} as PendingLocalRecordingReply pending.promise = new Promise((resolve) => { pending.resolve = resolve }) pendingLocalRecordingFinishes.set(sessionId, pending) bridge.finishLocalAudioRecording(sessionId, totalBytes) window.setTimeout(() => { if (pendingLocalRecordingFinishes.get(sessionId) !== pending) return pendingLocalRecordingFinishes.delete(sessionId) pending.resolve(false) }, 180000) return pending.promise } function bytesToBase64(bytes: Uint8Array): string { let binary = '' for (let index = 0; index < bytes.length; index += 1) { binary += String.fromCharCode(bytes[index]) } return window.btoa(binary) } async function sendLocalRecordingBlob(blob: Blob, sessionId: string): Promise { if (!blob.size || sessionId !== localRecordingSessionId) return const bridge = window.qtVideoBridge if (!bridge || typeof bridge.appendLocalAudioChunk !== 'function') { throw new Error('桌面端本地录音分片通道不可用') } const bytes = new Uint8Array(await blob.arrayBuffer()) const maxChunkBytes = 8 * 1024 for (let offset = 0; offset < bytes.length; offset += maxChunkBytes) { const chunk = bytes.subarray(offset, Math.min(offset + maxChunkBytes, bytes.length)) bridge.appendLocalAudioChunk( sessionId, localRecordingChunkSequence, bytesToBase64(chunk), ) localRecordingChunkSequence += 1 localRecordingBytes += chunk.length } } function getTrtcAudioCloud(): TrtcAudioCloud | null { const engine = TUICallKitAPI.getTUICallEngineInstance?.() const cloud = engine?.getTRTCCloudInstance?.() as Partial | null return cloud ? cloud as TrtcAudioCloud : null } function attachLocalRecordingTrack( track: MediaStreamTrack | null | undefined, sourceKind: 'doctor' | 'patient' | 'unknown' = 'unknown', ): boolean { const context = localAudioContext const destination = localAudioDestination if ( !context || !destination || !track || track.kind !== 'audio' || track.readyState === 'ended' ) return false if (sourceKind === 'doctor') localAudioHasDoctorSource = true if (sourceKind === 'patient') localAudioHasPatientSource = true if (localAudioTrackIds.has(track.id)) return true const source = context.createMediaStreamSource(new MediaStream([track])) source.connect(destination) localAudioSources.push(source) localAudioTrackIds.add(track.id) localRecordingAttachedSourceCount += 1 track.addEventListener('ended', () => localAudioTrackIds.delete(track.id), { once: true }) return true } function attachCurrentCallAudioTracks(cloud: TrtcAudioCloud | null): void { if (!activeConfig) return if (typeof cloud?.getAudioTrack === 'function') { try { attachLocalRecordingTrack(cloud.getAudioTrack({ processed: true }), 'doctor') } catch { try { attachLocalRecordingTrack(cloud.getAudioTrack(), 'doctor') } catch { // The rendered media elements below remain a supported fallback. } } try { attachLocalRecordingTrack(cloud.getAudioTrack({ userId: activeConfig.targetUserId, processed: true, }), 'patient') } catch { try { attachLocalRecordingTrack(cloud.getAudioTrack(activeConfig.targetUserId), 'patient') } catch { // Remote audio can become available a few frames after connected. } } } for (const media of document.querySelectorAll('video, audio')) { const stream = media.srcObject if (!(stream instanceof MediaStream)) continue const sourceKind = media.muted ? 'doctor' : 'patient' for (const track of stream.getAudioTracks()) { attachLocalRecordingTrack(track, sourceKind) } } } async function attachDoctorMicrophoneFallback(): Promise { if (localAudioHasDoctorSource || !navigator.mediaDevices?.getUserMedia) return const stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true, }, video: false, }) const tracks = stream.getAudioTracks() localAudioOwnedTracks.push(...tracks) for (const track of tracks) attachLocalRecordingTrack(track, 'doctor') } async function waitForCallAudioTracks( cloud: TrtcAudioCloud | null, sessionId: string, timeoutMs = 7000, ): Promise { const startedAt = Date.now() let microphoneAttempted = false while (Date.now() - startedAt < timeoutMs) { if (sessionId !== localRecordingSessionId || endNotified) { throw new Error('视频已结束,本机录音未启动') } attachCurrentCallAudioTracks(cloud) const elapsed = Date.now() - startedAt if (!localAudioHasDoctorSource && !microphoneAttempted && elapsed >= 800) { microphoneAttempted = true try { await attachDoctorMicrophoneFallback() } catch (error) { console.warn( '[doctor-consultation] 本机麦克风录音兜底不可用', safeErrorMessage(error), ) } } if ( localAudioTrackIds.size > 0 && (localAudioHasDoctorSource && localAudioHasPatientSource || elapsed >= 2200) ) return await new Promise((resolve) => window.setTimeout(resolve, 160)) } if (localAudioTrackIds.size <= 0) { throw new Error('未检测到医生或患者的实时语音音轨,请检查麦克风权限') } } function startCallAudioDiscovery(cloud: TrtcAudioCloud | null): void { if (localAudioDiscoveryTimer !== null) window.clearInterval(localAudioDiscoveryTimer) localAudioDiscoveryTimer = window.setInterval(() => { attachCurrentCallAudioTracks(cloud) }, 500) } async function cleanupLocalRecordingGraph(): Promise { if (localAudioDiscoveryTimer !== null) { window.clearInterval(localAudioDiscoveryTimer) localAudioDiscoveryTimer = null } if (localAudioCloud && localAudioTrackHandler && typeof localAudioCloud.off === 'function') { try { localAudioCloud.off('track', localAudioTrackHandler) } catch { // The call engine may already have released its event dispatcher. } } localAudioCloud = null localAudioTrackHandler = null for (const source of localAudioSources) { try { source.disconnect() } catch { // A closed AudioContext has already disconnected its graph. } } localAudioSources = [] localAudioTrackIds.clear() for (const track of localAudioOwnedTracks) track.stop() localAudioOwnedTracks = [] localAudioHasDoctorSource = false localAudioHasPatientSource = false localRecordingAttachedSourceCount = 0 const context = localAudioContext localAudioContext = null localAudioDestination = null if (context && context.state !== 'closed') { try { await context.close() } catch { // Closing the consultation must not be blocked by a released device. } } localRecorder = null } async function performStartLocalRecording(): Promise { if (!activeConfig || phase.value !== 'connected' || endNotified) { throw new Error('视频接通后才能启动本机录音') } if (!window.qtVideoBridge?.startLocalAudioRecording) { throw new Error('桌面端本机录音存储通道不可用') } const mimeType = selectLocalRecordingMimeType() if (!mimeType) throw new Error('当前浏览器不支持 Opus 本机录音') localRecordingState.value = 'starting' localRecordingFatalError = '' localRecordingChunkSequence = 0 localRecordingBytes = 0 localRecordingChunkChain = Promise.resolve() localRecordingAttachedSourceCount = 0 localAudioHasDoctorSource = false localAudioHasPatientSource = false const sessionId = newLocalRecordingSessionId() localRecordingSessionId = sessionId localRecordingMimeType = mimeType try { const AudioContextConstructor = window.AudioContext const context = new AudioContextConstructor() localAudioContext = context localAudioDestination = context.createMediaStreamDestination() const cloud = getTrtcAudioCloud() localAudioCloud = cloud localAudioTrackHandler = (event) => attachLocalRecordingTrack( event.track, event.userId === activeConfig?.userID ? 'doctor' : event.userId === activeConfig?.targetUserId ? 'patient' : 'unknown', ) if (typeof cloud?.on === 'function') cloud.on('track', localAudioTrackHandler) if (context.state === 'suspended') await context.resume() await waitForCallAudioTracks(cloud, sessionId) startCallAudioDiscovery(cloud) const storageReady = await requestLocalRecordingStart(sessionId, mimeType) if (!storageReady || sessionId !== localRecordingSessionId || endNotified) { throw new Error(notice.value || '服务端未能创建本机录音临时文件') } const destination = localAudioDestination if (!destination) throw new Error('本机录音混音器未就绪') if (localRecordingAttachedSourceCount <= 0) { throw new Error('本机录音没有连接到医生或患者语音') } const recorder = new MediaRecorder(destination.stream, { mimeType }) localRecorder = recorder recorder.addEventListener('dataavailable', (event) => { if (!event.data.size || sessionId !== localRecordingSessionId) return localRecordingChunkChain = localRecordingChunkChain .then(() => sendLocalRecordingBlob(event.data, sessionId)) .catch((error) => { localRecordingFatalError = safeErrorMessage(error, '本机录音分片保存失败') localRecordingState.value = 'error' notice.value = localRecordingFatalError window.qtVideoBridge?.abortLocalAudioRecording?.(sessionId) if (recorder.state !== 'inactive') recorder.stop() }) }) recorder.addEventListener('error', (event) => { localRecordingFatalError = safeErrorMessage( (event as ErrorEvent).error, '浏览器本机录音发生错误', ) localRecordingState.value = 'error' notice.value = localRecordingFatalError }) recorder.start(1000) localRecordingState.value = 'recording' notice.value = '腾讯云混流视频、本机语音录音和实时转写均已启动' } catch (error) { window.qtVideoBridge?.abortLocalAudioRecording?.(sessionId) await cleanupLocalRecordingGraph() if (sessionId === localRecordingSessionId) { localRecordingSessionId = '' localRecordingMimeType = '' localRecordingState.value = 'error' } throw new Error(safeErrorMessage(error, '本机语音录音启动失败')) } } function startLocalRecording(): Promise { if (localRecordingStartPromise) return localRecordingStartPromise if (localRecordingState.value === 'recording') return Promise.resolve() const operation = performStartLocalRecording() const tracked = operation.finally(() => { if (localRecordingStartPromise === tracked) localRecordingStartPromise = null }) localRecordingStartPromise = tracked return tracked } async function performStopLocalRecording(): Promise { const startInFlight = localRecordingStartPromise if (startInFlight) { try { await startInFlight } catch { return } } const sessionId = localRecordingSessionId const recorder = localRecorder if (!sessionId || !recorder) { if (localRecordingState.value !== 'error') localRecordingState.value = 'idle' return } localRecordingState.value = 'stopping' try { if (recorder.state !== 'inactive') { await new Promise((resolve) => { recorder.addEventListener('stop', () => resolve(), { once: true }) recorder.stop() }) } await localRecordingChunkChain if (localRecordingFatalError) throw new Error(localRecordingFatalError) if (localRecordingAttachedSourceCount <= 0) { throw new Error('本机录音没有连接到有效语音音轨') } if (localRecordingBytes < 1024) { throw new Error('本机录音文件为空或不完整,已阻止上传空文件') } localRecordingState.value = 'uploading' notice.value = '正在把本机语音录音上传到 COS…' const persisted = await requestLocalRecordingFinish(sessionId, localRecordingBytes) if (!persisted) throw new Error(notice.value || '本机语音录音上传 COS 失败') localRecordingState.value = 'idle' notice.value = '混流视频、本机录音和转写文字均已归档到本次通话记录' } catch (error) { window.qtVideoBridge?.abortLocalAudioRecording?.(sessionId) localRecordingState.value = 'error' throw new Error(safeErrorMessage(error, '本机语音录音保存失败')) } finally { await cleanupLocalRecordingGraph() if (sessionId === localRecordingSessionId) { localRecordingSessionId = '' localRecordingMimeType = '' } } } function stopLocalRecording(): Promise { if (localRecordingStopPromise) return localRecordingStopPromise if (localRecordingState.value === 'idle' && !localRecordingSessionId) return Promise.resolve() const operation = performStopLocalRecording() const tracked = operation.finally(() => { if (localRecordingStopPromise === tracked) localRecordingStopPromise = null }) localRecordingStopPromise = tracked return tracked } function localRecordingResult( operation: 'start' | 'chunk' | 'finish', sessionId: string, _sequence: number, ok: boolean, message: string, ): void { if (operation === 'start') { const pending = pendingLocalRecordingStarts.get(sessionId) if (!pending) return pendingLocalRecordingStarts.delete(sessionId) if (message && sessionId === localRecordingSessionId) notice.value = message pending.resolve(Boolean(ok)) } else if (operation === 'finish') { const pending = pendingLocalRecordingFinishes.get(sessionId) if (!pending) return pendingLocalRecordingFinishes.delete(sessionId) if (message && sessionId === localRecordingSessionId) notice.value = message pending.resolve(Boolean(ok)) } else if (!ok && sessionId === localRecordingSessionId) { localRecordingFatalError = message || '本机录音分片保存失败' localRecordingState.value = 'error' notice.value = localRecordingFatalError window.qtVideoBridge?.abortLocalAudioRecording?.(sessionId) if (localRecorder && localRecorder.state !== 'inactive') localRecorder.stop() } } function newTranscriptionSessionId(): string { // Keep this identity within 32 ASCII characters. Some upgraded deployments // still have the legacy varchar(32) column; a UUID with hyphens was silently // truncated there, so the first transcript segment no longer matched the // session that startCallTranscription had acknowledged. const random = window.crypto?.randomUUID?.().replaceAll('-', '') const entropy = random || `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}` return `tr-${entropy.replace(/[^a-zA-Z0-9]/g, '').slice(0, 28)}` } function requestTranscriptionStart(sessionId: string): Promise { if (!activeConfig || !window.qtVideoBridge?.notify) return Promise.resolve(false) const existing = pendingTranscriptionStarts.get(sessionId) if (existing) return existing.promise const pending = {} as PendingTranscriptionReply pending.sessionId = sessionId pending.promise = new Promise((resolve) => { pending.resolve = resolve }) pendingTranscriptionStarts.set(sessionId, pending) emit({ source: 'doctor-call', event: 'transcription-start-request', diagnosisId: activeConfig?.diagnosisId, sessionId, language: 'zh', }) window.setTimeout(() => { if (pendingTranscriptionStarts.get(sessionId) !== pending) return pendingTranscriptionStarts.delete(sessionId) pending.resolve(false) }, 15000) return pending.promise } function requestTranscriptionStop( sessionId: string, status: 'completed' | 'partial' | 'failed', ): Promise { if (!activeConfig || !sessionId || !window.qtVideoBridge?.notify) { return Promise.resolve(false) } const existing = pendingTranscriptionStops.get(sessionId) if (existing) return existing.promise const pending = {} as PendingTranscriptionReply pending.sessionId = sessionId pending.promise = new Promise((resolve) => { pending.resolve = resolve }) pendingTranscriptionStops.set(sessionId, pending) emit({ source: 'doctor-call', event: 'transcription-stop', diagnosisId: activeConfig?.diagnosisId, sessionId, status, }) window.setTimeout(() => { if (pendingTranscriptionStops.get(sessionId) !== pending) return pendingTranscriptionStops.delete(sessionId) pending.resolve(false) }, 15000) return pending.promise } function getTranscriberManager(): RealtimeTranscriberManager { const engine = TUICallKitAPI.getTUICallEngineInstance?.() const cloud = engine?.getTRTCCloudInstance?.() const manager = cloud?.getAITranscriberManager?.() as Partial | null if ( !manager || typeof manager.addListener !== 'function' || typeof manager.removeListener !== 'function' || typeof manager.startRealtimeTranscriber !== 'function' || typeof manager.stopRealtimeTranscriber !== 'function' ) { throw new Error('当前视频服务未开通实时语音转写') } return manager as RealtimeTranscriberManager } function clearLiveCaptions(): void { if (liveCaptionClearTimer !== null) { window.clearTimeout(liveCaptionClearTimer) liveCaptionClearTimer = null } liveCaptions.value = [] } function showLiveCaption(message: RealtimeTranscriberMessage): void { if (!activeConfig) return const text = String(message.sourceText ?? '').trim() if (!text) return const speakerUserId = String(message.speakerUserId ?? '').trim() const segmentId = String(message.segmentId ?? '').trim() const id = segmentId || `speaker-${speakerUserId || 'unknown'}` const speaker = speakerUserId === activeConfig.userID ? '医生' : speakerUserId === activeConfig.targetUserId ? patientName.value || '患者' : '对话' const caption: UiLiveCaption = { id, speaker, text: text.slice(0, 500), completed: message.isCompleted === true, } const previous = liveCaptions.value.filter((item) => item.id !== id) liveCaptions.value = [...previous, caption].slice(-2) if (liveCaptionClearTimer !== null) window.clearTimeout(liveCaptionClearTimer) liveCaptionClearTimer = window.setTimeout(() => { liveCaptions.value = [] liveCaptionClearTimer = null }, message.isCompleted === true ? 9000 : 5000) } function handleTranscriberMessage( roomId: string | number, message: RealtimeTranscriberMessage, ): void { // The realtime transcriber always identifies the active TRTC room. Use it // as a second authoritative source because some CallKit versions clear the // ROOM_ID store before the desktop host has persisted it. observeRoomId(roomId) if ( !activeConfig || !['recording', 'stopping'].includes(transcriptionState.value) || !transcriptionSessionId ) return lastTranscriberMessageAt = Date.now() const segmentId = String(message.segmentId ?? '').trim() const text = String(message.sourceText ?? '').trim() showLiveCaption(message) if (message.isCompleted !== true) return if ( !segmentId || acknowledgedSegmentIds.has(segmentId) || pendingSegments.has(segmentId) || !text ) return const speakerUserId = String(message.speakerUserId ?? '').trim() const bridgeMessage: BridgeMessage = { source: 'doctor-call', event: 'transcription-segment', diagnosisId: activeConfig.diagnosisId, sessionId: transcriptionSessionId, segment: { segment_id: segmentId.slice(0, 160), speaker_user_id: speakerUserId.slice(0, 160), speaker_role: speakerUserId === activeConfig.userID ? 'doctor' : speakerUserId === activeConfig.targetUserId ? 'patient' : 'unknown', timestamp: Math.max(0, Math.trunc(Number(message.timestamp) || 0)), text: text.slice(0, 4000), }, } pendingSegments.set(segmentId, { message: bridgeMessage, attempts: 1 }) emit(bridgeMessage) } function subscribeTranscriber(): { manager: RealtimeTranscriberManager listener: RealtimeTranscriberListener } { if (transcriberManager && transcriberListener) { return { manager: transcriberManager, listener: transcriberListener } } const manager = getTranscriberManager() const listener: RealtimeTranscriberListener = { onReceiveTranscriberMessage: handleTranscriberMessage, onRealtimeTranscriberStarted: (roomId) => { observeRoomId(roomId) }, onRealtimeTranscriberStopped: (roomId, robotId) => { observeRoomId(roomId) if (robotId !== transcriberRobotId) return transcriberRunning = false transcriberStoppedAt = Date.now() if (transcriptionState.value === 'recording') { void stopTranscription('partial', true) } }, onRealtimeTranscriberError: (roomId, robotId, _error, errorMessage) => { observeRoomId(roomId) if (robotId !== transcriberRobotId || transcriptionState.value === 'stopping') return notice.value = safeErrorMessage(new Error(errorMessage), '实时语音转写发生错误') void stopTranscription('partial', true) }, } manager.addListener(listener) transcriberManager = manager transcriberListener = listener return { manager, listener } } function unsubscribeTranscriber( manager = transcriberManager, listener = transcriberListener, ): void { if (manager && listener) { try { manager.removeListener(listener) } catch { // A destroyed call engine has already released the listener. } } if (transcriberManager === manager && transcriberListener === listener) { transcriberManager = null transcriberListener = null } } function transcriptionTokenIsCurrent(token: number, sessionId: string): boolean { return ( token === transcriptionGeneration && sessionId === transcriptionSessionId && transcriptionState.value === 'starting' && phase.value === 'connected' && !endNotified ) } async function waitForPendingSegments(timeoutMs = 3000, quietMs = 300): Promise { const startedAt = Date.now() const deadline = Date.now() + timeoutMs while (Date.now() < deadline) { const stoppedOrSettled = transcriberStoppedAt > 0 || Date.now() - startedAt >= 500 const quiet = Date.now() - lastTranscriberMessageAt >= quietMs if (stoppedOrSettled && quiet && pendingSegments.size === 0) return true await new Promise((resolve) => window.setTimeout(resolve, 50)) } return false } async function performStartTranscription(): Promise { if (!activeConfig || phase.value !== 'connected') throw new Error('视频接通后才能开始录音') if (transcriptionState.value !== 'idle' && transcriptionState.value !== 'error') { throw new Error('录音任务正在处理中') } transcriptionState.value = 'starting' const sessionId = newTranscriptionSessionId() const token = ++transcriptionGeneration transcriptionSessionId = sessionId acknowledgedSegmentIds.clear() pendingSegments.clear() clearLiveCaptions() lastTranscriberMessageAt = Date.now() transcriberStoppedAt = 0 notice.value = '正在准备录音文字存储…' const storageReady = await requestTranscriptionStart(sessionId) if (!storageReady) { if (!transcriptionTokenIsCurrent(token, sessionId)) return transcriptionState.value = 'error' transcriptionSessionId = '' throw new Error(notice.value || '服务端无法保存本次面诊对话文字') } if (!transcriptionTokenIsCurrent(token, sessionId)) { await requestTranscriptionStop(sessionId, 'partial') return } let ownedManager: RealtimeTranscriberManager | null = null let ownedListener: RealtimeTranscriberListener | null = null try { const subscribed = subscribeTranscriber() const { manager, listener } = subscribed ownedManager = manager ownedListener = listener const robotId = await manager.startRealtimeTranscriber({ sourceLanguage: 'zh', }) if (!robotId) throw new Error('当前腾讯云项目未开通实时语音转写') if (!transcriptionTokenIsCurrent(token, sessionId)) { await manager.stopRealtimeTranscriber(robotId) unsubscribeTranscriber(manager, listener) return } transcriberRobotId = robotId transcriberRunning = true transcriptionState.value = 'recording' notice.value = '正在录音并实时转换为对话文字' } catch (error) { unsubscribeTranscriber(ownedManager, ownedListener) await requestTranscriptionStop(sessionId, 'failed') if (sessionId === transcriptionSessionId) transcriptionState.value = 'error' const message = safeErrorMessage(error, '录音转文字启动失败') notice.value = message if (sessionId === transcriptionSessionId) transcriptionSessionId = '' throw new Error(message) } } function startTranscription(): Promise { if (transcriptionStartPromise) return transcriptionStartPromise const operation = performStartTranscription() const tracked = operation.finally(() => { if (transcriptionStartPromise === tracked) transcriptionStartPromise = null }) transcriptionStartPromise = tracked return transcriptionStartPromise } async function stopTranscription( status: 'completed' | 'partial' | 'failed' = 'completed', managerAlreadyStopped = false, ): Promise { if (transcriptionStopPromise) return transcriptionStopPromise if (transcriptionState.value === 'idle') return if (!transcriptionSessionId) { transcriptionState.value = 'idle' return } const sessionId = transcriptionSessionId const startInFlight = transcriptionStartPromise transcriptionGeneration += 1 transcriptionState.value = 'stopping' transcriptionStopPromise = (async () => { if (startInFlight) { try { await startInFlight } catch { // Its failure is materialized as a failed/partial transcript below. } } let sdkStopped = managerAlreadyStopped const manager = transcriberManager const robotId = transcriberRobotId if (!managerAlreadyStopped && transcriberRunning && manager && robotId) { try { await manager.stopRealtimeTranscriber(robotId) sdkStopped = true } catch (error) { console.warn('[doctor-consultation] 停止实时转写失败', safeErrorMessage(error)) } } transcriberRunning = false transcriberRobotId = '' const segmentsComplete = await waitForPendingSegments() unsubscribeTranscriber() const finalStatus = sdkStopped && segmentsComplete ? status : 'partial' const persisted = await requestTranscriptionStop(sessionId, finalStatus) if (sessionId !== transcriptionSessionId) return transcriptionState.value = persisted ? 'idle' : 'error' notice.value = persisted ? '本次面诊对话文字已保存到视频记录' : '对话文字未能完整确认保存,请稍后在面诊记录中检查' transcriptionSessionId = '' acknowledgedSegmentIds.clear() pendingSegments.clear() clearLiveCaptions() })().finally(() => { transcriptionStopPromise = null }) return transcriptionStopPromise } function transcriptionResult( operation: 'start' | 'segment' | 'stop', sessionId: string, segmentId: string, ok: boolean, message: string, ): void { if (operation === 'start') { const pending = pendingTranscriptionStarts.get(sessionId) if (!pending) return pendingTranscriptionStarts.delete(sessionId) if (message && sessionId === transcriptionSessionId) notice.value = message pending.resolve(Boolean(ok)) } else if (operation === 'stop') { const pending = pendingTranscriptionStops.get(sessionId) if (!pending) return pendingTranscriptionStops.delete(sessionId) if (message && sessionId === transcriptionSessionId) notice.value = message pending.resolve(Boolean(ok)) } else if (operation === 'segment' && sessionId === transcriptionSessionId) { const pending = pendingSegments.get(segmentId) if (!pending) return if (ok) { pendingSegments.delete(segmentId) acknowledgedSegmentIds.add(segmentId) } else if (pending.attempts < 3) { pending.attempts += 1 window.setTimeout(() => { if (pendingSegments.get(segmentId) === pending) emit(pending.message) }, pending.attempts * 250) } else { notice.value = message || '部分对话文字保存失败,本次记录将标记为部分保存' } } } function notifyHangup(status = 'ended'): Promise { if (hangupNotification) return hangupNotification if (endNotified) return Promise.resolve() endNotified = true phase.value = 'ended' statusText.value = mode.value === 'chat' ? '视频通话已结束,IM 保持连接' : '视频问诊已结束' appendVideoCallStatus('ended', '视频通话已结束') hangupNotification = (async () => { const operations: Promise[] = [] if (localRecordingState.value !== 'idle' || localRecordingSessionId) { operations.push(stopLocalRecording()) } if (transcriptionState.value !== 'idle') { operations.push(stopTranscription('completed')) } const results = await Promise.allSettled(operations) const failures = results .filter((result): result is PromiseRejectedResult => result.status === 'rejected') .map((result) => safeErrorMessage(result.reason, '录音资料收尾失败')) if (failures.length) { notice.value = failures.join(';').slice(0, 400) console.warn('[doctor-consultation] 录音资料收尾失败', notice.value) } emit({ source: 'doctor-call', event: 'hangup', diagnosisId: activeConfig?.diagnosisId, status, }) })() return hangupNotification } function normalizeRoomId(raw: unknown): string { if (raw === undefined || raw === null) return '' const value = String(raw).trim() return value && value !== '0' ? value : '' } function readRoomId(): string { return normalizeRoomId(TUIStore.getData(StoreName.CALL, NAME.ROOM_ID)) } function observeRoomId(rawRoomId: unknown): boolean { if (!activeConfig || endNotified) return false const roomId = normalizeRoomId(rawRoomId) if (!roomId) return false if (boundRoomId) return boundRoomId === roomId const now = Date.now() const retryDelay = Math.min(10_000, 750 * (2 ** Math.min(roomBindingAttempts, 4))) if (pendingRoomId === roomId && now - roomBindingSentAt < retryDelay) return false emittedRoomId = roomId pendingRoomId = roomId roomBindingSentAt = now roomBindingAttempts += 1 emit({ source: 'doctor-call', event: 'room', diagnosisId: activeConfig.diagnosisId, roomId }) return false } function emitRoomId(): boolean { return observeRoomId(readRoomId()) } async function pollRoomId(): Promise { const cycle = callCycleGeneration while (activeConfig && !endNotified && cycle === callCycleGeneration) { if (boundRoomId) return emitRoomId() await new Promise((resolve) => window.setTimeout(resolve, 250)) } } function handleRoomIdChanged(): void { emitRoomId() } const roomIdWatchOptions = { [NAME.ROOM_ID]: handleRoomIdChanged, } TUIStore.watch(StoreName.CALL, roomIdWatchOptions) 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-')) { if (endNotified || !activeConfig) return const cycle = callCycleGeneration phase.value = 'connected' statusText.value = '视频问诊进行中' appendVideoCallStatus('connected', '视频通话已接通') void pollRoomId() if (autoTranscriptionAttemptedGeneration !== cycle) { autoTranscriptionAttemptedGeneration = cycle void startTranscription().catch((error) => { if (cycle !== callCycleGeneration || endNotified) return notice.value = safeErrorMessage(error, '自动录音转文字启动失败') }) } if (autoLocalRecordingAttemptedGeneration !== cycle) { autoLocalRecordingAttemptedGeneration = cycle void startLocalRecording().catch((error) => { if (cycle !== callCycleGeneration || endNotified) return notice.value = safeErrorMessage(error, '本机语音录音启动失败') }) } } else if (status === 'calling' || status.startsWith('dialing')) { if (endNotified || !activeConfig) return phase.value = 'dialing' statusText.value = '正在等待患者接听' appendVideoCallStatus('dialing', '正在等待患者接听') } else if (status === 'idle' && activeConfig && !starting) { void notifyHangup(status) } emit({ source: 'doctor-call', event: 'status', diagnosisId: activeConfig?.diagnosisId, status }) } TUICallKitAPI.setCallback({ statusChanged: handleStatusChanged, afterCalling: () => { void notifyHangup('after-calling') }, }) TUICallKitAPI.setLanguage('zh-cn') TUICallKitAPI.enableFloatWindow(false) function requestHostCallStart(): Promise { if (!activeConfig) return Promise.resolve(false) if (!window.qtVideoBridge?.notify) return Promise.resolve(true) return new Promise((resolve) => { const currentResolver = resolve resolveHostCallReady = currentResolver emit({ source: 'doctor-call', event: 'call-start-request', diagnosisId: activeConfig?.diagnosisId }) window.setTimeout(() => { if (resolveHostCallReady !== currentResolver) return resolveHostCallReady = null resolve(false) }, 15000) }) } function hostCallReady(ok: boolean, message = ''): void { const resolver = resolveHostCallReady resolveHostCallReady = null if (message) notice.value = message resolver?.(Boolean(ok)) } async function startVideo(): Promise { if (!activeConfig) throw new Error('问诊配置尚未准备好') if (starting || !endNotified) throw new Error('已有视频通话正在进行') starting = true try { if (hangupNotification) await hangupNotification if (!activeConfig || !endNotified) throw new Error('已有视频通话正在进行') hangupNotification = null callCycleGeneration += 1 endNotified = false phase.value = 'starting' statusText.value = '正在创建安全视频通话' appendVideoCallStatus('starting', '正在创建安全视频通话') transcriptionState.value = 'idle' transcriptionSessionId = '' clearLiveCaptions() localRecordingState.value = 'idle' localRecordingSessionId = '' localRecordingMimeType = '' notice.value = '' const allowed = await requestHostCallStart() if (!allowed) throw new Error(notice.value || '服务器未能创建视频通话记录') await TUICallKitAPI.init({ SDKAppID: activeConfig.SDKAppID, userID: activeConfig.userID, userSig: activeConfig.userSig, ...(chat ? { tim: chat, isFromChat: true } : {}), }) await nextTick() phase.value = 'dialing' statusText.value = '正在呼叫患者' appendVideoCallStatus('dialing', '正在呼叫患者') emittedRoomId = '' pendingRoomId = '' boundRoomId = '' roomBindingSentAt = 0 roomBindingAttempts = 0 await TUICallKitAPI.calls({ userIDList: [activeConfig.targetUserId], type: TUICallType.VIDEO_CALL, }) void pollRoomId() emit({ source: 'doctor-call', event: 'status', diagnosisId: activeConfig.diagnosisId, status: 'dialing' }) } catch (error) { const message = safeErrorMessage(error, '无法发起视频通话') phase.value = 'error' statusText.value = message appendVideoCallStatus('failed', `视频通话发起失败:${message}`) endNotified = true emit({ source: 'doctor-call', event: 'error', diagnosisId: activeConfig.diagnosisId, message }) throw new Error(message) } finally { starting = false } } async function hangup(): Promise { if (!activeConfig) return if (endNotified) { if (hangupNotification) await hangupNotification return } try { await TUICallKitAPI.hangup() await notifyHangup('local-hangup') } catch (error) { const message = safeErrorMessage(error, '结束视频通话失败') emit({ source: 'doctor-call', event: 'error', diagnosisId: activeConfig.diagnosisId, message }) throw new Error(message) } } async function saveScreenshot(dataUrl: string): Promise { const bridge = window.qtVideoBridge if (!bridge || typeof bridge.saveScreenshot !== 'function') throw new Error('桌面端截图保存通道不可用') bridge.saveScreenshot(dataUrl) notice.value = '正在保存截图到患者舌像资料…' } function screenshotResult(ok: boolean, message: string): void { notice.value = message || (ok ? '截图已保存到患者舌像资料' : '截图保存失败') } function recordingResult(ok: boolean, message: string): void { notice.value = message || (ok ? '通话已自动录制,结束后将由云端完成 COS 文件收尾。' : '自动云端录制未启动,请结束本次通话并检查 COS 配置。') } function roomBindingResult(roomId: string, ok: boolean, message: string): void { const normalized = normalizeRoomId(roomId) if (!normalized || (emittedRoomId && normalized !== emittedRoomId)) return if (ok) { boundRoomId = normalized pendingRoomId = '' } else if (pendingRoomId === normalized) { // Keep the failed room pending so pollRoomId retries it with exponential // backoff. The desktop lifecycle releases only failed claims. } recordingResult(ok, message) } async function open(config: DoctorCallConfig): Promise { if (activeConfig) await close() activeConfig = normalizeConfig(config) mode.value = activeConfig.mode patientName.value = activeConfig.patientName messages.value = [] nextReqMessageID = '' hasMoreMessages.value = false endNotified = true hangupNotification = null callCycleGeneration += 1 autoTranscriptionAttemptedGeneration = -1 autoLocalRecordingAttemptedGeneration = -1 phase.value = 'ready' transcriptionState.value = 'idle' transcriptionSessionId = '' clearLiveCaptions() transcriptionGeneration += 1 transcriberRunning = false transcriberRobotId = '' acknowledgedSegmentIds.clear() pendingSegments.clear() localRecordingState.value = 'idle' localRecordingSessionId = '' localRecordingMimeType = '' localRecordingFatalError = '' notice.value = '' if (activeConfig.mode === 'chat') { try { await loginChat() } catch (error) { const message = safeErrorMessage(error, 'IM 连接失败') phase.value = 'error' statusText.value = message emit({ source: 'doctor-call', event: 'error', diagnosisId: activeConfig.diagnosisId, message }) throw new Error(message) } } else { await startVideo() } } async function close(): Promise { if (!endNotified) await hangup() if (hangupNotification) await hangupNotification unsubscribeTranscriber() transcriptionGeneration += 1 for (const pending of pendingTranscriptionStarts.values()) pending.resolve(false) for (const pending of pendingTranscriptionStops.values()) pending.resolve(false) pendingTranscriptionStarts.clear() pendingTranscriptionStops.clear() clearLiveCaptions() for (const pending of pendingLocalRecordingStarts.values()) pending.resolve(false) for (const pending of pendingLocalRecordingFinishes.values()) pending.resolve(false) pendingLocalRecordingStarts.clear() pendingLocalRecordingFinishes.clear() await cleanupLocalRecordingGraph() await logoutChat() activeConfig = null phase.value = 'ended' } window.doctorConsultation = { open, close, startVideo, hangup, hostCallReady, recordingResult, roomBindingResult, screenshotResult, transcriptionResult, localRecordingResult, } window.doctorCall = { start: open, hangup } initializeQtWebChannel() createApp(App, { phase: readonly(phase), statusText: readonly(statusText), patientName: readonly(patientName), mode: readonly(mode), messages: readonly(messages), chatReady: readonly(chatReady), chatBusy: readonly(chatBusy), notice: readonly(notice), hasMoreMessages: readonly(hasMoreMessages), transcriptionState: readonly(transcriptionState), localRecordingState: readonly(localRecordingState), liveCaptions: readonly(liveCaptions), onSendText: sendText, onSendAttachment: sendAttachment, onLoadMore: () => loadMessages(true), onReconnectChat: reconnectChat, onStartVideo: startVideo, onHangup: hangup, onSaveScreenshot: saveScreenshot, }).mount('#app') emit({ source: 'doctor-call', event: 'ready' })