更新bug

This commit is contained in:
Your Name
2026-08-20 17:47:14 +08:00
parent 35f91ee37a
commit 5794f60c5d
67 changed files with 9257 additions and 1287 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -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
View File
@@ -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>
+13
View File
@@ -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
View File
@@ -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),
+156
View File
@@ -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