更新bug
This commit is contained in:
File diff suppressed because one or more lines are too long
+113
-113
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -5,8 +5,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="light" />
|
||||
<title>视频面诊</title>
|
||||
<script type="module" crossorigin src="./assets/index-DXNYj41g.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-sts1UBl3.css">
|
||||
<script type="module" crossorigin src="./assets/index-DhmAWjut.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-BsDbRyxy.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
+165
-18
@@ -6,11 +6,19 @@ import type { Ref } from 'vue'
|
||||
interface ChatMessage {
|
||||
id: string
|
||||
mine: boolean
|
||||
type: 'text' | 'image' | 'file' | 'audio' | 'video' | 'system'
|
||||
type: 'text' | 'image' | 'file' | 'audio' | 'video' | 'system' | 'call-status'
|
||||
text: string
|
||||
url: string
|
||||
name: string
|
||||
time: string
|
||||
callStatus?: 'starting' | 'dialing' | 'connected' | 'ended' | 'failed'
|
||||
}
|
||||
|
||||
interface LiveCaption {
|
||||
id: string
|
||||
speaker: string
|
||||
text: string
|
||||
completed: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -24,6 +32,8 @@ const props = defineProps<{
|
||||
notice: Readonly<Ref<string>>
|
||||
hasMoreMessages: Readonly<Ref<boolean>>
|
||||
transcriptionState: Readonly<Ref<string>>
|
||||
localRecordingState: Readonly<Ref<string>>
|
||||
liveCaptions: Readonly<Ref<LiveCaption[]>>
|
||||
onSendText: (text: string) => Promise<void>
|
||||
onSendAttachment: (file: File) => Promise<void>
|
||||
onLoadMore: () => Promise<void>
|
||||
@@ -38,29 +48,81 @@ const actionBusy = ref(false)
|
||||
const localError = ref('')
|
||||
const messageList = ref<HTMLElement | null>(null)
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const screenshotPreview = ref('')
|
||||
const stickToMessageBottom = ref(true)
|
||||
|
||||
const isChat = computed(() => props.mode.value === 'chat')
|
||||
const isCalling = computed(() => ['starting', 'dialing', 'connected'].includes(props.phase.value))
|
||||
const videoVisible = computed(() => !isChat.value || isCalling.value)
|
||||
const canCapture = computed(() => props.phase.value === 'connected')
|
||||
const transcriptionActive = computed(() => props.transcriptionState.value === 'recording')
|
||||
const transcriptionFailed = computed(() => props.transcriptionState.value === 'error')
|
||||
const chatConnectionText = computed(() => {
|
||||
if (!props.chatReady.value) return props.statusText.value
|
||||
if (['starting', 'dialing', 'connected', 'ended', 'error'].includes(props.phase.value)) {
|
||||
return `IM 已连接 · ${props.statusText.value}`
|
||||
}
|
||||
return 'IM 已连接'
|
||||
})
|
||||
const transcriptionActive = computed(() => (
|
||||
props.transcriptionState.value === 'recording'
|
||||
|| props.localRecordingState.value === 'recording'
|
||||
))
|
||||
const transcriptionFailed = computed(() => (
|
||||
props.transcriptionState.value === 'error'
|
||||
|| props.localRecordingState.value === 'error'
|
||||
))
|
||||
const transcriptionStatusText = computed(() => {
|
||||
if (props.transcriptionState.value === 'starting') return '自动录音启动中…'
|
||||
if (props.transcriptionState.value === 'recording') return '自动录音并转文字中'
|
||||
if (props.transcriptionState.value === 'stopping') return '正在保存录音文字…'
|
||||
if (props.transcriptionState.value === 'error') return '自动录音转文字失败'
|
||||
return '自动录音已结束'
|
||||
const localState = props.localRecordingState.value
|
||||
const textState = props.transcriptionState.value
|
||||
if (localState === 'uploading') return '正在上传本机录音到 COS…'
|
||||
if (localState === 'stopping' || textState === 'stopping') return '正在保存录音与转写文字…'
|
||||
if (localState === 'starting' || textState === 'starting') return '自动录音与转写启动中…'
|
||||
if (localState === 'recording' && textState === 'recording') return '本机录音与实时转写中'
|
||||
if (localState === 'recording') return '本机录音中,实时转写未就绪'
|
||||
if (textState === 'recording') return '实时转写中,本机录音未就绪'
|
||||
if (localState === 'error' && textState === 'error') return '本机录音与实时转写均失败'
|
||||
if (localState === 'error') return '本机录音失败,实时转写仍在运行'
|
||||
if (textState === 'error') return '实时转写失败,本机录音仍在运行'
|
||||
return '录音与转写已结束'
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.messages.value.length,
|
||||
async () => {
|
||||
if (!stickToMessageBottom.value) return
|
||||
await nextTick()
|
||||
if (messageList.value) messageList.value.scrollTop = messageList.value.scrollHeight
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [props.patientName.value, props.mode.value],
|
||||
async () => {
|
||||
stickToMessageBottom.value = true
|
||||
await nextTick()
|
||||
if (messageList.value) messageList.value.scrollTop = messageList.value.scrollHeight
|
||||
},
|
||||
)
|
||||
|
||||
function handleMessageScroll(): void {
|
||||
const container = messageList.value
|
||||
if (!container) return
|
||||
stickToMessageBottom.value = (
|
||||
container.scrollHeight - container.scrollTop - container.clientHeight <= 96
|
||||
)
|
||||
}
|
||||
|
||||
async function loadEarlierMessages(): Promise<void> {
|
||||
const container = messageList.value
|
||||
if (!container || actionBusy.value) return
|
||||
const previousHeight = container.scrollHeight
|
||||
const previousTop = container.scrollTop
|
||||
stickToMessageBottom.value = false
|
||||
await runAction(props.onLoadMore)
|
||||
await nextTick()
|
||||
container.scrollTop = previousTop + (container.scrollHeight - previousHeight)
|
||||
handleMessageScroll()
|
||||
}
|
||||
|
||||
async function runAction(action: () => Promise<void>): Promise<void> {
|
||||
if (actionBusy.value) return
|
||||
actionBusy.value = true
|
||||
@@ -113,9 +175,35 @@ async function captureScreenshot(): Promise<void> {
|
||||
const context = canvas.getContext('2d')
|
||||
if (!context) throw new Error('无法创建截图画布')
|
||||
context.drawImage(video, 0, 0, canvas.width, canvas.height)
|
||||
await props.onSaveScreenshot(canvas.toDataURL('image/jpeg', 0.9))
|
||||
screenshotPreview.value = canvas.toDataURL('image/jpeg', 0.9)
|
||||
})
|
||||
}
|
||||
|
||||
function discardScreenshot(): void {
|
||||
if (actionBusy.value) return
|
||||
screenshotPreview.value = ''
|
||||
}
|
||||
|
||||
async function confirmScreenshot(): Promise<void> {
|
||||
if (!screenshotPreview.value || actionBusy.value) return
|
||||
actionBusy.value = true
|
||||
localError.value = ''
|
||||
try {
|
||||
await props.onSaveScreenshot(screenshotPreview.value)
|
||||
screenshotPreview.value = ''
|
||||
} catch (error) {
|
||||
localError.value = error instanceof Error ? error.message : '截图上传失败,请稍后重试'
|
||||
} finally {
|
||||
actionBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.phase.value,
|
||||
(value) => {
|
||||
if (value !== 'connected') screenshotPreview.value = ''
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -127,7 +215,7 @@ async function captureScreenshot(): Promise<void> {
|
||||
<h1>{{ patientName.value }}</h1>
|
||||
<p>
|
||||
<span class="connection-dot" :class="{ 'connection-dot--online': chatReady.value }" />
|
||||
{{ chatReady.value ? 'IM 已连接' : statusText.value }}
|
||||
{{ chatConnectionText }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -150,13 +238,13 @@ async function captureScreenshot(): Promise<void> {
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div ref="messageList" class="message-list" aria-live="polite">
|
||||
<div ref="messageList" class="message-list" aria-live="polite" @scroll="handleMessageScroll">
|
||||
<button
|
||||
v-if="hasMoreMessages.value"
|
||||
class="load-more"
|
||||
type="button"
|
||||
:disabled="chatBusy.value"
|
||||
@click="runAction(onLoadMore)"
|
||||
@click="loadEarlierMessages"
|
||||
>
|
||||
{{ chatBusy.value ? '正在读取…' : '查看更早消息' }}
|
||||
</button>
|
||||
@@ -171,10 +259,26 @@ async function captureScreenshot(): Promise<void> {
|
||||
v-for="message in messages.value"
|
||||
:key="message.id"
|
||||
class="message-row"
|
||||
:class="{ 'message-row--mine': message.mine }"
|
||||
:class="{
|
||||
'message-row--mine': message.mine,
|
||||
'message-row--call-status': message.type === 'call-status',
|
||||
}"
|
||||
>
|
||||
<div class="message-meta">{{ message.mine ? '我' : patientName.value }} · {{ message.time }}</div>
|
||||
<div class="message-bubble">
|
||||
<template v-if="message.type === 'call-status'">
|
||||
<div
|
||||
class="call-status-event"
|
||||
:class="`call-status-event--${message.callStatus || 'ended'}`"
|
||||
role="status"
|
||||
:aria-label="`${message.text},${message.time}`"
|
||||
>
|
||||
<span class="call-status-event__icon" aria-hidden="true">▣</span>
|
||||
<strong>{{ message.text }}</strong>
|
||||
<time>{{ message.time }}</time>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="message-meta">{{ message.mine ? '我' : patientName.value }} · {{ message.time }}</div>
|
||||
<div class="message-bubble">
|
||||
<p v-if="message.type === 'text'">{{ message.text }}</p>
|
||||
<img
|
||||
v-else-if="message.type === 'image' && message.url"
|
||||
@@ -193,8 +297,9 @@ async function captureScreenshot(): Promise<void> {
|
||||
</a>
|
||||
<audio v-else-if="message.type === 'audio' && message.url" :src="message.url" controls />
|
||||
<video v-else-if="message.type === 'video' && message.url" class="message-video" :src="message.url" controls />
|
||||
<p v-else>{{ message.text }}</p>
|
||||
</div>
|
||||
<p v-else>{{ message.text }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
@@ -251,6 +356,19 @@ async function captureScreenshot(): Promise<void> {
|
||||
{{ statusText.value }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="canCapture && liveCaptions.value.length"
|
||||
class="live-captions"
|
||||
role="log"
|
||||
aria-live="polite"
|
||||
aria-label="实时语音字幕"
|
||||
>
|
||||
<p v-for="caption in liveCaptions.value" :key="caption.id">
|
||||
<strong>{{ caption.speaker }}</strong>
|
||||
<span>{{ caption.text }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="isCalling" class="video-actions">
|
||||
<div
|
||||
v-if="canCapture"
|
||||
@@ -271,7 +389,7 @@ async function captureScreenshot(): Promise<void> {
|
||||
:disabled="!canCapture || actionBusy"
|
||||
@click="captureScreenshot"
|
||||
>
|
||||
截屏并保存患者资料
|
||||
截屏预览
|
||||
</button>
|
||||
<button class="hangup-button" type="button" :disabled="actionBusy" @click="runAction(onHangup)">
|
||||
结束视频
|
||||
@@ -281,6 +399,35 @@ async function captureScreenshot(): Promise<void> {
|
||||
<div v-if="localError || notice.value" class="video-notice" :class="{ 'video-notice--error': localError }">
|
||||
{{ localError || notice.value }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="screenshotPreview"
|
||||
class="screenshot-dialog-backdrop"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="screenshot-preview-title"
|
||||
@click.self="discardScreenshot"
|
||||
>
|
||||
<section class="screenshot-dialog">
|
||||
<header>
|
||||
<div>
|
||||
<p class="eyebrow">视频截图确认</p>
|
||||
<h2 id="screenshot-preview-title">确认画面后再保存到患者资料</h2>
|
||||
</div>
|
||||
<button type="button" aria-label="关闭截图预览" :disabled="actionBusy" @click="discardScreenshot">×</button>
|
||||
</header>
|
||||
<div class="screenshot-preview-frame">
|
||||
<img :src="screenshotPreview" alt="本次视频截图预览">
|
||||
</div>
|
||||
<p class="screenshot-dialog__hint">只有点击“确认并上传”后,截图才会上传并写入患者资料。</p>
|
||||
<footer>
|
||||
<button class="screenshot-cancel" type="button" :disabled="actionBusy" @click="discardScreenshot">取消</button>
|
||||
<button class="screenshot-confirm" type="button" :disabled="actionBusy" @click="confirmScreenshot">
|
||||
{{ actionBusy ? '正在上传…' : '确认并上传' }}
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
Vendored
+13
@@ -29,6 +29,8 @@ interface DoctorConsultationApi {
|
||||
startVideo(): Promise<void>
|
||||
hangup(): Promise<void>
|
||||
hostCallReady(ok: boolean, message?: string): void
|
||||
recordingResult(ok: boolean, message: string): void
|
||||
roomBindingResult(roomId: string, ok: boolean, message: string): void
|
||||
screenshotResult(ok: boolean, message: string): void
|
||||
transcriptionResult(
|
||||
operation: 'start' | 'segment' | 'stop',
|
||||
@@ -37,11 +39,22 @@ interface DoctorConsultationApi {
|
||||
ok: boolean,
|
||||
message: string,
|
||||
): void
|
||||
localRecordingResult(
|
||||
operation: 'start' | 'chunk' | 'finish',
|
||||
sessionId: string,
|
||||
sequence: number,
|
||||
ok: boolean,
|
||||
message: string,
|
||||
): void
|
||||
}
|
||||
|
||||
interface QtVideoBridge {
|
||||
notify?: (payload: string) => void
|
||||
saveScreenshot?: (dataUrl: string) => void
|
||||
startLocalAudioRecording?: (sessionId: string, mimeType: string) => void
|
||||
appendLocalAudioChunk?: (sessionId: string, sequence: number, encoded: string) => void
|
||||
finishLocalAudioRecording?: (sessionId: string, totalBytes: number) => void
|
||||
abortLocalAudioRecording?: (sessionId: string) => void
|
||||
}
|
||||
|
||||
interface Window {
|
||||
|
||||
+707
-33
@@ -14,8 +14,10 @@ import './style.css'
|
||||
|
||||
type CallPhase = 'ready' | 'starting' | 'dialing' | 'connected' | 'ended' | 'error'
|
||||
type CompanionMode = 'chat' | 'video'
|
||||
type ChatMessageType = 'text' | 'image' | 'file' | 'audio' | 'video' | 'system'
|
||||
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
|
||||
@@ -35,6 +37,14 @@ interface UiChatMessage {
|
||||
url: string
|
||||
name: string
|
||||
time: string
|
||||
callStatus?: VideoCallStatus
|
||||
}
|
||||
|
||||
interface UiLiveCaption {
|
||||
id: string
|
||||
speaker: string
|
||||
text: string
|
||||
completed: boolean
|
||||
}
|
||||
|
||||
interface BridgeMessage {
|
||||
@@ -109,6 +119,25 @@ interface PendingSegment {
|
||||
attempts: number
|
||||
}
|
||||
|
||||
interface PendingLocalRecordingReply {
|
||||
resolve: (value: boolean) => void
|
||||
promise: Promise<boolean>
|
||||
}
|
||||
|
||||
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<CallPhase>('ready')
|
||||
const statusText = ref('正在连接问诊服务')
|
||||
const patientName = ref('患者')
|
||||
@@ -119,6 +148,8 @@ const chatBusy = ref(false)
|
||||
const notice = ref('')
|
||||
const hasMoreMessages = ref(false)
|
||||
const transcriptionState = ref<TranscriptionState>('idle')
|
||||
const localRecordingState = ref<LocalRecordingState>('idle')
|
||||
const liveCaptions = ref<UiLiveCaption[]>([])
|
||||
|
||||
let activeConfig: NormalizedCallConfig | null = null
|
||||
let chat: any = null
|
||||
@@ -129,6 +160,10 @@ 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<string, PendingTranscriptionReply>()
|
||||
const pendingTranscriptionStops = new Map<string, PendingTranscriptionReply>()
|
||||
@@ -143,10 +178,34 @@ let transcriptionStopPromise: Promise<void> | null = null
|
||||
let hangupNotification: Promise<void> | null = null
|
||||
let callCycleGeneration = 0
|
||||
let autoTranscriptionAttemptedGeneration = -1
|
||||
let autoLocalRecordingAttemptedGeneration = -1
|
||||
let lastTranscriberMessageAt = 0
|
||||
let transcriberStoppedAt = 0
|
||||
const acknowledgedSegmentIds = new Set<string>()
|
||||
const pendingSegments = new Map<string, PendingSegment>()
|
||||
const pendingLocalRecordingStarts = new Map<string, PendingLocalRecordingReply>()
|
||||
const pendingLocalRecordingFinishes = new Map<string, PendingLocalRecordingReply>()
|
||||
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<string>()
|
||||
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<void> = Promise.resolve()
|
||||
let localRecordingStartPromise: Promise<void> | null = null
|
||||
let localRecordingStopPromise: Promise<void> | null = null
|
||||
let localRecordingFatalError = ''
|
||||
let liveCaptionClearTimer: number | null = null
|
||||
|
||||
function initializeQtWebChannel(): void {
|
||||
const transport = window.qt?.webChannelTransport
|
||||
@@ -361,6 +420,29 @@ function mergeMessages(rawList: any[], prepend = false): void {
|
||||
})
|
||||
}
|
||||
|
||||
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}`
|
||||
@@ -465,11 +547,19 @@ async function loadMessages(prepend: boolean): Promise<void> {
|
||||
if (!chat || !activeConfig || chatBusy.value) return
|
||||
chatBusy.value = true
|
||||
try {
|
||||
const response = await chat.getMessageList({
|
||||
const request: {
|
||||
conversationID: string
|
||||
count: number
|
||||
nextReqMessageID?: string
|
||||
} = {
|
||||
conversationID: `C2C${activeConfig.targetUserId}`,
|
||||
nextReqMessageID: prepend ? nextReqMessageID : '',
|
||||
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 ?? '')
|
||||
@@ -539,9 +629,454 @@ async function sendAttachment(file: File): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
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<boolean> {
|
||||
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<boolean>((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<boolean> {
|
||||
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<boolean>((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<void> {
|
||||
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<TrtcAudioCloud> | 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<HTMLMediaElement>('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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void>((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<void> {
|
||||
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 {
|
||||
const random = window.crypto?.randomUUID?.()
|
||||
return random ? `call-${random}` : `call-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
// 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<boolean> {
|
||||
@@ -615,19 +1150,59 @@ function getTranscriberManager(): RealtimeTranscriberManager {
|
||||
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,
|
||||
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
|
||||
|| message.isCompleted !== true
|
||||
) 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)
|
||||
@@ -666,8 +1241,11 @@ function subscribeTranscriber(): {
|
||||
const manager = getTranscriberManager()
|
||||
const listener: RealtimeTranscriberListener = {
|
||||
onReceiveTranscriberMessage: handleTranscriberMessage,
|
||||
onRealtimeTranscriberStarted: () => undefined,
|
||||
onRealtimeTranscriberStopped: (_roomId, robotId) => {
|
||||
onRealtimeTranscriberStarted: (roomId) => {
|
||||
observeRoomId(roomId)
|
||||
},
|
||||
onRealtimeTranscriberStopped: (roomId, robotId) => {
|
||||
observeRoomId(roomId)
|
||||
if (robotId !== transcriberRobotId) return
|
||||
transcriberRunning = false
|
||||
transcriberStoppedAt = Date.now()
|
||||
@@ -675,7 +1253,8 @@ function subscribeTranscriber(): {
|
||||
void stopTranscription('partial', true)
|
||||
}
|
||||
},
|
||||
onRealtimeTranscriberError: (_roomId, robotId, _error, errorMessage) => {
|
||||
onRealtimeTranscriberError: (roomId, robotId, _error, errorMessage) => {
|
||||
observeRoomId(roomId)
|
||||
if (robotId !== transcriberRobotId || transcriptionState.value === 'stopping') return
|
||||
notice.value = safeErrorMessage(new Error(errorMessage), '实时语音转写发生错误')
|
||||
void stopTranscription('partial', true)
|
||||
@@ -738,6 +1317,7 @@ async function performStartTranscription(): Promise<void> {
|
||||
transcriptionSessionId = sessionId
|
||||
acknowledgedSegmentIds.clear()
|
||||
pendingSegments.clear()
|
||||
clearLiveCaptions()
|
||||
lastTranscriberMessageAt = Date.now()
|
||||
transcriberStoppedAt = 0
|
||||
notice.value = '正在准备录音文字存储…'
|
||||
@@ -841,6 +1421,7 @@ async function stopTranscription(
|
||||
transcriptionSessionId = ''
|
||||
acknowledgedSegmentIds.clear()
|
||||
pendingSegments.clear()
|
||||
clearLiveCaptions()
|
||||
})().finally(() => {
|
||||
transcriptionStopPromise = null
|
||||
})
|
||||
@@ -889,46 +1470,83 @@ function notifyHangup(status = 'ended'): Promise<void> {
|
||||
endNotified = true
|
||||
phase.value = 'ended'
|
||||
statusText.value = mode.value === 'chat' ? '视频通话已结束,IM 保持连接' : '视频问诊已结束'
|
||||
appendVideoCallStatus('ended', '视频通话已结束')
|
||||
hangupNotification = (async () => {
|
||||
try {
|
||||
if (transcriptionState.value !== 'idle') await stopTranscription('completed')
|
||||
} catch (error) {
|
||||
notice.value = safeErrorMessage(error, '录音文字收尾失败,请稍后检查面诊记录')
|
||||
console.warn('[doctor-consultation] 录音文字收尾失败', notice.value)
|
||||
} finally {
|
||||
emit({
|
||||
source: 'doctor-call',
|
||||
event: 'hangup',
|
||||
diagnosisId: activeConfig?.diagnosisId,
|
||||
status,
|
||||
})
|
||||
const operations: Promise<void>[] = []
|
||||
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 readRoomId(): string {
|
||||
const raw = TUIStore.getData(StoreName.CALL, NAME.ROOM_ID)
|
||||
function normalizeRoomId(raw: unknown): string {
|
||||
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)
|
||||
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
|
||||
emit({ source: 'doctor-call', event: 'room', diagnosisId: activeConfig?.diagnosisId, roomId })
|
||||
return true
|
||||
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<void> {
|
||||
for (let attempt = 0; attempt < 40 && activeConfig && !endNotified; attempt += 1) {
|
||||
if (emitRoomId()) return
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 50))
|
||||
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
|
||||
@@ -939,6 +1557,7 @@ function handleStatusChanged(payload: unknown): void {
|
||||
const cycle = callCycleGeneration
|
||||
phase.value = 'connected'
|
||||
statusText.value = '视频问诊进行中'
|
||||
appendVideoCallStatus('connected', '视频通话已接通')
|
||||
void pollRoomId()
|
||||
if (autoTranscriptionAttemptedGeneration !== cycle) {
|
||||
autoTranscriptionAttemptedGeneration = cycle
|
||||
@@ -947,10 +1566,18 @@ function handleStatusChanged(payload: unknown): void {
|
||||
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)
|
||||
}
|
||||
@@ -998,8 +1625,13 @@ async function startVideo(): Promise<void> {
|
||||
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 || '服务器未能创建视频通话记录')
|
||||
@@ -1012,7 +1644,12 @@ async function startVideo(): Promise<void> {
|
||||
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,
|
||||
@@ -1023,6 +1660,7 @@ async function startVideo(): Promise<void> {
|
||||
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)
|
||||
@@ -1058,6 +1696,25 @@ 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<void> {
|
||||
if (activeConfig) await close()
|
||||
activeConfig = normalizeConfig(config)
|
||||
@@ -1070,14 +1727,20 @@ async function open(config: DoctorCallConfig): Promise<void> {
|
||||
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 {
|
||||
@@ -1103,6 +1766,12 @@ async function close(): Promise<void> {
|
||||
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'
|
||||
@@ -1114,8 +1783,11 @@ window.doctorConsultation = {
|
||||
startVideo,
|
||||
hangup,
|
||||
hostCallReady,
|
||||
recordingResult,
|
||||
roomBindingResult,
|
||||
screenshotResult,
|
||||
transcriptionResult,
|
||||
localRecordingResult,
|
||||
}
|
||||
window.doctorCall = { start: open, hangup }
|
||||
initializeQtWebChannel()
|
||||
@@ -1131,6 +1803,8 @@ createApp(App, {
|
||||
notice: readonly(notice),
|
||||
hasMoreMessages: readonly(hasMoreMessages),
|
||||
transcriptionState: readonly(transcriptionState),
|
||||
localRecordingState: readonly(localRecordingState),
|
||||
liveCaptions: readonly(liveCaptions),
|
||||
onSendText: sendText,
|
||||
onSendAttachment: sendAttachment,
|
||||
onLoadMore: () => loadMessages(true),
|
||||
|
||||
@@ -151,6 +151,57 @@ button:disabled { cursor: not-allowed; opacity: .55; }
|
||||
margin: 12px 0;
|
||||
}
|
||||
.message-row--mine { align-items: flex-end; }
|
||||
.message-row--call-status {
|
||||
align-items: center;
|
||||
margin: 16px 0;
|
||||
}
|
||||
.call-status-event {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 32px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #dce2f2;
|
||||
border-radius: 999px;
|
||||
color: #617092;
|
||||
background: rgba(255, 255, 255, .92);
|
||||
font-size: 12px;
|
||||
box-shadow: 0 4px 14px rgba(17, 31, 70, .04);
|
||||
}
|
||||
.call-status-event__icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 7px;
|
||||
color: #5761f4;
|
||||
background: #eef0ff;
|
||||
font-size: 11px;
|
||||
}
|
||||
.call-status-event strong {
|
||||
color: #34425f;
|
||||
font-weight: 700;
|
||||
}
|
||||
.call-status-event time {
|
||||
color: #8b97b2;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.call-status-event--connected {
|
||||
border-color: #bdebdc;
|
||||
background: #f2fbf7;
|
||||
}
|
||||
.call-status-event--connected .call-status-event__icon {
|
||||
color: #11986f;
|
||||
background: #dff7ee;
|
||||
}
|
||||
.call-status-event--failed {
|
||||
border-color: #f3c8cf;
|
||||
background: #fff5f6;
|
||||
}
|
||||
.call-status-event--failed .call-status-event__icon {
|
||||
color: #c43f50;
|
||||
background: #ffe6e9;
|
||||
}
|
||||
.message-meta { margin: 0 8px 5px; color: #8995a9; font-size: 11px; }
|
||||
.message-bubble {
|
||||
max-width: min(72%, 620px);
|
||||
@@ -285,6 +336,41 @@ button:disabled { cursor: not-allowed; opacity: .55; }
|
||||
}
|
||||
.live-status .status-dot { width: 7px; height: 7px; margin: 0; box-shadow: none; }
|
||||
|
||||
.live-captions {
|
||||
position: absolute;
|
||||
z-index: 38;
|
||||
left: 50%;
|
||||
bottom: 92px;
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
width: min(820px, calc(100% - 360px));
|
||||
transform: translateX(-50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.live-captions p {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 8px 14px;
|
||||
border: 1px solid rgba(255, 255, 255, .2);
|
||||
border-radius: 10px;
|
||||
color: #fff;
|
||||
background: rgba(9, 13, 20, .78);
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, .2);
|
||||
font-size: 16px;
|
||||
line-height: 1.55;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.live-captions strong {
|
||||
color: #aeb8ff;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.live-captions span { min-width: 0; word-break: break-word; }
|
||||
|
||||
.video-actions {
|
||||
position: absolute;
|
||||
z-index: 40;
|
||||
@@ -336,10 +422,80 @@ button:disabled { cursor: not-allowed; opacity: .55; }
|
||||
}
|
||||
.video-notice--error { border-color: rgba(242, 109, 109, .4); color: #ffe4e7; background: rgba(100, 31, 40, .88); }
|
||||
|
||||
.screenshot-dialog-backdrop {
|
||||
position: absolute;
|
||||
z-index: 120;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: rgba(7, 11, 19, .76);
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
.screenshot-dialog {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto auto;
|
||||
gap: 14px;
|
||||
width: min(920px, 92vw);
|
||||
max-height: calc(100vh - 48px);
|
||||
padding: 20px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #e2e7f4;
|
||||
border-radius: 18px;
|
||||
color: #111f46;
|
||||
background: #fff;
|
||||
box-shadow: 0 28px 90px rgba(4, 10, 27, .34);
|
||||
}
|
||||
.screenshot-dialog > header,
|
||||
.screenshot-dialog > footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
.screenshot-dialog h2 { margin: 2px 0 0; font-size: 20px; line-height: 1.35; }
|
||||
.screenshot-dialog .eyebrow { margin: 0; color: #5761f4; }
|
||||
.screenshot-dialog > header > button {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 0;
|
||||
border-radius: 9px;
|
||||
color: #7886aa;
|
||||
background: #f2f4fb;
|
||||
font-size: 24px;
|
||||
}
|
||||
.screenshot-preview-frame {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 260px;
|
||||
overflow: hidden;
|
||||
border-radius: 12px;
|
||||
background: #0b0f16;
|
||||
}
|
||||
.screenshot-preview-frame img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: min(62vh, 650px);
|
||||
object-fit: contain;
|
||||
}
|
||||
.screenshot-dialog__hint { margin: 0; color: #617092; font-size: 13px; }
|
||||
.screenshot-dialog > footer { justify-content: flex-end; }
|
||||
.screenshot-dialog > footer button {
|
||||
min-width: 112px;
|
||||
padding: 10px 18px;
|
||||
border-radius: 9px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.screenshot-cancel { border: 1px solid #dfe4f1; color: #3f4e75; background: #fff; }
|
||||
.screenshot-confirm { border: 1px solid #5761f4; color: #fff; background: #5761f4; }
|
||||
.screenshot-confirm:hover { background: #4c57e9; }
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.consultation-shell { min-width: 620px; }
|
||||
.message-list { padding-inline: 18px; }
|
||||
.message-bubble { max-width: 82%; }
|
||||
.live-captions { width: calc(100% - 36px); bottom: 88px; }
|
||||
.live-captions p { font-size: 14px; }
|
||||
}
|
||||
|
||||
/* Doctor workstation blue-white subwindow contract. Video pixels remain on
|
||||
|
||||
Reference in New Issue
Block a user