更新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
+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