This commit is contained in:
Your Name
2026-08-27 14:23:23 +08:00
parent b5b14516a1
commit 2fa8492c56
27 changed files with 3832 additions and 1435 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
@@ -6,8 +6,8 @@
<meta name="color-scheme" content="light" />
<link rel="icon" type="image/png" href="./favicon.png" />
<title>视频面诊</title>
<script type="module" crossorigin src="./assets/index-DhmAWjut.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-BsDbRyxy.css">
<script type="module" crossorigin src="./assets/index-B_ek5NUi.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-BMSk91Wa.css">
</head>
<body>
<div id="app"></div>
+220 -57
View File
@@ -18,9 +18,39 @@ interface LiveCaption {
id: string
speaker: string
text: string
time: string
completed: boolean
}
interface PatientCase {
diagnosisId: string
name: string
gender: string
age: string
height: string
weight: string
diagnosisDate: string
appointmentDate: string
clinicalDiagnosis: string
chiefComplaint: string
presentIllness: string
pastHistory: string
allergyHistory: string
personalHistory: string
familyHistory: string
currentMedication: string
tongue: string
pulse: string
prescriptionOpinion: string
remark: string
}
interface CaseField {
label: string
value: string
risk?: boolean
}
const props = defineProps<{
phase: Readonly<Ref<string>>
statusText: Readonly<Ref<string>>
@@ -34,12 +64,14 @@ const props = defineProps<{
transcriptionState: Readonly<Ref<string>>
localRecordingState: Readonly<Ref<string>>
liveCaptions: Readonly<Ref<LiveCaption[]>>
patientCase: Readonly<Ref<PatientCase>>
onSendText: (text: string) => Promise<void>
onSendAttachment: (file: File) => Promise<void>
onLoadMore: () => Promise<void>
onReconnectChat: () => Promise<void>
onStartVideo: () => Promise<void>
onHangup: () => Promise<void>
onOpenDiagnosis: () => Promise<void>
onSaveScreenshot: (dataUrl: string) => Promise<void>
}>()
@@ -50,6 +82,7 @@ const messageList = ref<HTMLElement | null>(null)
const fileInput = ref<HTMLInputElement | null>(null)
const screenshotPreview = ref('')
const stickToMessageBottom = ref(true)
const transcriptList = ref<HTMLElement | null>(null)
const isChat = computed(() => props.mode.value === 'chat')
const isCalling = computed(() => ['starting', 'dialing', 'connected'].includes(props.phase.value))
@@ -84,6 +117,41 @@ const transcriptionStatusText = computed(() => {
if (textState === 'error') return '实时转写失败,本机录音仍在运行'
return '录音与转写已结束'
})
const patientMetaText = computed(() => {
const detail = props.patientCase.value
return [
detail.gender,
detail.age ? `${detail.age}` : '',
detail.height ? `${detail.height} cm` : '',
detail.weight ? `${detail.weight} kg` : '',
].filter(Boolean).join(' · ') || '基础资料待补充'
})
const patientVisitText = computed(() => {
const detail = props.patientCase.value
if (detail.appointmentDate) return `预约 ${detail.appointmentDate}`
if (detail.diagnosisDate) return `诊断 ${detail.diagnosisDate}`
return `诊单 ${detail.diagnosisId || '—'}`
})
const caseFields = computed<CaseField[]>(() => {
const detail = props.patientCase.value
const allergyRisk = Boolean(detail.allergyHistory) && !/^(无|否|未发现|无过敏史|none|no)$/i.test(
detail.allergyHistory.trim(),
)
return [
{ label: '临床诊断', value: detail.clinicalDiagnosis },
{ label: '主诉', value: detail.chiefComplaint },
{ label: '现病史', value: detail.presentIllness },
{ label: '当前用药', value: detail.currentMedication },
{ label: '过敏史', value: detail.allergyHistory, risk: allergyRisk },
{ label: '既往史', value: detail.pastHistory },
{ label: '个人史', value: detail.personalHistory },
{ label: '家族史', value: detail.familyHistory },
{ label: '舌象', value: detail.tongue },
{ label: '脉象', value: detail.pulse },
{ label: '处方意见', value: detail.prescriptionOpinion },
{ label: '病例备注', value: detail.remark },
].filter((item) => Boolean(item.value))
})
watch(
() => props.messages.value.length,
@@ -103,6 +171,17 @@ watch(
},
)
watch(
() => {
const last = props.liveCaptions.value.at(-1)
return `${props.liveCaptions.value.length}:${last?.id || ''}:${last?.text || ''}`
},
async () => {
await nextTick()
if (transcriptList.value) transcriptList.value.scrollTop = transcriptList.value.scrollHeight
},
)
function handleMessageScroll(): void {
const container = messageList.value
if (!container) return
@@ -335,70 +414,154 @@ watch(
</footer>
</section>
<section v-if="videoVisible" class="video-layer" :class="{ 'video-layer--overlay': isChat }">
<TUICallKit
class="call-kit"
:allowed-minimized="false"
:allowed-full-screen="true"
/>
<section
v-if="videoVisible"
class="video-layer"
:class="{
'video-layer--overlay': isChat,
'video-layer--with-rail': isCalling,
}"
>
<div class="video-stage">
<TUICallKit
class="call-kit"
:allowed-minimized="false"
:allowed-full-screen="false"
/>
<section v-if="phase.value === 'ready' || phase.value === 'starting' || phase.value === 'error'" class="status-card">
<span class="status-dot" :class="`status-dot--${phase.value}`" aria-hidden="true" />
<div>
<p class="eyebrow">中医视频问诊</p>
<h2>{{ statusText.value }}</h2>
<p class="status-hint">视频通话凭证仅由业务服务器签发</p>
<section v-if="phase.value === 'ready' || phase.value === 'starting' || phase.value === 'error'" class="status-card">
<span class="status-dot" :class="`status-dot--${phase.value}`" aria-hidden="true" />
<div>
<p class="eyebrow">中医视频问诊</p>
<h2>{{ statusText.value }}</h2>
<p class="status-hint">视频通话凭证仅由业务服务器签发</p>
</div>
</section>
<div v-else class="live-status" role="status">
<span class="status-dot status-dot--live" aria-hidden="true" />
{{ statusText.value }}
</div>
</section>
<div v-else class="live-status" role="status">
<span class="status-dot status-dot--live" aria-hidden="true" />
{{ statusText.value }}
</div>
<div v-if="isCalling" class="video-actions">
<div
v-if="canCapture"
class="recording-status"
:class="{
'recording-status--active': transcriptionActive,
'recording-status--error': transcriptionFailed,
}"
role="status"
aria-live="polite"
>
<span class="recording-indicator" aria-hidden="true" />
{{ transcriptionStatusText }}
</div>
<button
class="capture-button"
type="button"
:disabled="!canCapture || actionBusy"
@click="captureScreenshot"
>
截屏预览
</button>
<button class="hangup-button" type="button" :disabled="actionBusy" @click="runAction(onHangup)">
结束视频
</button>
</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"
class="recording-status"
:class="{
'recording-status--active': transcriptionActive,
'recording-status--error': transcriptionFailed,
}"
role="status"
aria-live="polite"
>
<span class="recording-indicator" aria-hidden="true" />
{{ transcriptionStatusText }}
<div v-if="localError || notice.value" class="video-notice" :class="{ 'video-notice--error': localError }">
{{ localError || notice.value }}
</div>
<button
class="capture-button"
type="button"
:disabled="!canCapture || actionBusy"
@click="captureScreenshot"
>
截屏预览
</button>
<button class="hangup-button" type="button" :disabled="actionBusy" @click="runAction(onHangup)">
结束视频
</button>
</div>
<div v-if="localError || notice.value" class="video-notice" :class="{ 'video-notice--error': localError }">
{{ localError || notice.value }}
</div>
<aside v-if="isCalling" class="consultation-rail" aria-label="患者病例与实时对话">
<header class="consultation-rail__header">
<div class="consultation-rail__avatar" aria-hidden="true">
{{ (patientCase.value.name || patientName.value).slice(0, 1) }}
</div>
<div class="consultation-rail__identity">
<strong>{{ patientCase.value.name || patientName.value }}</strong>
<span>{{ patientMetaText }}</span>
</div>
<div class="consultation-rail__actions">
<span class="diagnosis-chip">诊单 {{ patientCase.value.diagnosisId || '—' }}</span>
<button
class="open-diagnosis-button"
type="button"
:disabled="actionBusy"
aria-label="打开完整诊单"
@click="runAction(onOpenDiagnosis)"
>
打开诊单
<span aria-hidden="true"></span>
</button>
</div>
</header>
<section class="case-panel" aria-labelledby="patient-case-title">
<header class="rail-section-heading">
<div>
<span class="rail-section-heading__index">01</span>
<h2 id="patient-case-title">患者病例</h2>
</div>
<span>{{ patientVisitText }}</span>
</header>
<div class="case-panel__content">
<div v-if="caseFields.length" class="case-field-list">
<article
v-for="field in caseFields"
:key="field.label"
class="case-field"
:class="{ 'case-field--risk': field.risk }"
>
<span>{{ field.label }}</span>
<p>{{ field.value }}</p>
</article>
</div>
<div v-else class="rail-empty rail-empty--case">
<strong>暂无已填写的病例内容</strong>
<span>可继续通话已补充的病例会在下次打开时显示</span>
</div>
</div>
</section>
<section class="transcript-panel" aria-labelledby="live-transcript-title">
<header class="rail-section-heading">
<div>
<span class="rail-section-heading__index">02</span>
<h2 id="live-transcript-title">实时对话</h2>
</div>
<span class="transcript-state" :class="{ 'transcript-state--active': transcriptionActive }">
{{ transcriptionActive ? '转写中' : '等待语音' }}
</span>
</header>
<div
ref="transcriptList"
class="live-captions"
role="log"
aria-live="polite"
aria-label="实时语音字幕"
>
<div v-if="!liveCaptions.value.length" class="rail-empty">
<strong>对话文字会显示在这里</strong>
<span>接通后自动识别医生与患者语音并保留本次通话内容</span>
</div>
<article
v-for="caption in liveCaptions.value"
:key="caption.id"
class="caption-entry"
:class="{ 'caption-entry--partial': !caption.completed }"
>
<header>
<strong>{{ caption.speaker }}</strong>
<time>{{ caption.time }}</time>
</header>
<p>{{ caption.text }}</p>
</article>
</div>
</section>
</aside>
<div
v-if="screenshotPreview"
+24
View File
@@ -15,9 +15,33 @@ interface DoctorCallConfig {
patientUserId?: string
diagnosisId: number | string
patientName?: string
patientCase?: DoctorPatientCase
mode?: 'chat' | 'video'
}
interface DoctorPatientCase {
diagnosisId?: number | string
name?: string
gender?: string
age?: string | number
height?: string | number
weight?: string | number
diagnosisDate?: string
appointmentDate?: string
clinicalDiagnosis?: string
chiefComplaint?: string
presentIllness?: string
pastHistory?: string
allergyHistory?: string
personalHistory?: string
familyHistory?: string
currentMedication?: string
tongue?: string
pulse?: string
prescriptionOpinion?: string
remark?: string
}
interface DoctorCallApi {
start(config: DoctorCallConfig): Promise<void>
hangup(): Promise<void>
+196 -44
View File
@@ -26,6 +26,7 @@ interface NormalizedCallConfig {
targetUserId: string
diagnosisId: number | string
patientName: string
patientCase: UiPatientCase
mode: CompanionMode
}
@@ -44,9 +45,33 @@ interface UiLiveCaption {
id: string
speaker: string
text: string
time: string
completed: boolean
}
interface UiPatientCase {
diagnosisId: string
name: string
gender: string
age: string
height: string
weight: string
diagnosisDate: string
appointmentDate: string
clinicalDiagnosis: string
chiefComplaint: string
presentIllness: string
pastHistory: string
allergyHistory: string
personalHistory: string
familyHistory: string
currentMedication: string
tongue: string
pulse: string
prescriptionOpinion: string
remark: string
}
interface BridgeMessage {
source: 'doctor-call'
event:
@@ -56,6 +81,7 @@ interface BridgeMessage {
| 'room'
| 'hangup'
| 'error'
| 'open-diagnosis-request'
| 'transcription-start-request'
| 'transcription-segment'
| 'transcription-stop'
@@ -127,6 +153,11 @@ interface PendingLocalRecordingReply {
interface TrtcAudioTrackEvent {
userId?: string
track?: MediaStreamTrack
sourceTrack?: MediaStreamTrack
}
interface TrtcRemoteAudioEvent {
userId?: string
}
interface TrtcAudioCloud {
@@ -135,7 +166,9 @@ interface TrtcAudioCloud {
processed?: boolean
} | string): MediaStreamTrack | null
on?(event: 'track', handler: (event: TrtcAudioTrackEvent) => void): void
on?(event: 'remote-audio-available', handler: (event: TrtcRemoteAudioEvent) => void): void
off?(event: 'track', handler: (event: TrtcAudioTrackEvent) => void): void
off?(event: 'remote-audio-available', handler: (event: TrtcRemoteAudioEvent) => void): void
}
const phase = ref<CallPhase>('ready')
@@ -150,6 +183,7 @@ const hasMoreMessages = ref(false)
const transcriptionState = ref<TranscriptionState>('idle')
const localRecordingState = ref<LocalRecordingState>('idle')
const liveCaptions = ref<UiLiveCaption[]>([])
const patientCase = ref<UiPatientCase>(emptyPatientCase())
let activeConfig: NormalizedCallConfig | null = null
let chat: any = null
@@ -192,6 +226,7 @@ let localAudioContext: AudioContext | null = null
let localAudioDestination: MediaStreamAudioDestinationNode | null = null
let localAudioCloud: TrtcAudioCloud | null = null
let localAudioTrackHandler: ((event: TrtcAudioTrackEvent) => void) | null = null
let localRemoteAudioAvailableHandler: ((event: TrtcRemoteAudioEvent) => void) | null = null
let localAudioSources: MediaStreamAudioSourceNode[] = []
let localAudioTrackIds = new Set<string>()
let localAudioOwnedTracks: MediaStreamTrack[] = []
@@ -205,8 +240,6 @@ 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
const QWebChannel = window.QWebChannel
@@ -259,11 +292,80 @@ function emit(message: BridgeMessage): void {
}
}
async function openDiagnosis(): Promise<void> {
if (!activeConfig) throw new Error('诊单上下文尚未就绪')
emit({
source: 'doctor-call',
event: 'open-diagnosis-request',
diagnosisId: activeConfig.diagnosisId,
})
}
function cleanString(value: unknown, field: string): string {
if (typeof value !== 'string' || value.trim() === '') throw new Error(`${field}不能为空`)
return value.trim()
}
function optionalText(value: unknown, maxLength = 2000): string {
if (value === undefined || value === null) return ''
return String(value).trim().slice(0, maxLength)
}
function emptyPatientCase(): UiPatientCase {
return {
diagnosisId: '',
name: '',
gender: '',
age: '',
height: '',
weight: '',
diagnosisDate: '',
appointmentDate: '',
clinicalDiagnosis: '',
chiefComplaint: '',
presentIllness: '',
pastHistory: '',
allergyHistory: '',
personalHistory: '',
familyHistory: '',
currentMedication: '',
tongue: '',
pulse: '',
prescriptionOpinion: '',
remark: '',
}
}
function normalizePatientCase(
value: DoctorPatientCase | undefined,
diagnosisId: number | string,
fallbackName: string,
): UiPatientCase {
const source = value && typeof value === 'object' ? value : {}
return {
diagnosisId: optionalText(source.diagnosisId ?? diagnosisId, 80),
name: optionalText(source.name || fallbackName, 120) || fallbackName,
gender: optionalText(source.gender, 20),
age: optionalText(source.age, 20),
height: optionalText(source.height, 20),
weight: optionalText(source.weight, 20),
diagnosisDate: optionalText(source.diagnosisDate, 80),
appointmentDate: optionalText(source.appointmentDate, 80),
clinicalDiagnosis: optionalText(source.clinicalDiagnosis),
chiefComplaint: optionalText(source.chiefComplaint),
presentIllness: optionalText(source.presentIllness),
pastHistory: optionalText(source.pastHistory),
allergyHistory: optionalText(source.allergyHistory),
personalHistory: optionalText(source.personalHistory),
familyHistory: optionalText(source.familyHistory),
currentMedication: optionalText(source.currentMedication),
tongue: optionalText(source.tongue),
pulse: optionalText(source.pulse),
prescriptionOpinion: optionalText(source.prescriptionOpinion),
remark: optionalText(source.remark),
}
}
function normalizeConfig(config: DoctorCallConfig): NormalizedCallConfig {
if (!config || typeof config !== 'object') throw new Error('问诊配置无效')
const SDKAppID = Number(config.SDKAppID ?? config.sdkAppId)
@@ -274,15 +376,17 @@ function normalizeConfig(config: DoctorCallConfig): NormalizedCallConfig {
throw new Error('诊单ID不能为空')
}
const normalizedPatientName = typeof config.patientName === 'string' && config.patientName.trim()
? config.patientName.trim()
: '患者'
return {
SDKAppID,
userID: cleanString(config.userID ?? config.userId, '医生用户ID'),
userSig: cleanString(config.userSig, '用户签名'),
targetUserId: cleanString(config.targetUserId ?? config.patientUserId, '患者用户ID'),
diagnosisId: typeof diagnosisId === 'string' ? diagnosisId.trim() : diagnosisId,
patientName: typeof config.patientName === 'string' && config.patientName.trim()
? config.patientName.trim()
: '患者',
patientName: normalizedPatientName,
patientCase: normalizePatientCase(config.patientCase, diagnosisId, normalizedPatientName),
mode: config.mode === 'chat' ? 'chat' : 'video',
}
}
@@ -747,30 +851,50 @@ function attachLocalRecordingTrack(
return true
}
function attachDoctorAudioTrack(cloud: TrtcAudioCloud): boolean {
if (typeof cloud.getAudioTrack !== 'function') return false
let attached = false
try {
attached = attachLocalRecordingTrack(cloud.getAudioTrack({ processed: true }), 'doctor')
} catch {
// Some TRTC versions do not support processed tracks.
}
if (!attached) {
try {
attached = attachLocalRecordingTrack(cloud.getAudioTrack(), 'doctor')
} catch {
// The microphone fallback below remains available.
}
}
return attached
}
function attachPatientAudioTrack(cloud: TrtcAudioCloud, userId: string): boolean {
if (typeof cloud.getAudioTrack !== 'function' || !userId) return false
let attached = false
try {
attached = attachLocalRecordingTrack(cloud.getAudioTrack({
userId,
processed: true,
}), 'patient')
} catch {
// Some TRTC versions do not expose a processed remote track.
}
if (!attached) {
try {
attached = attachLocalRecordingTrack(cloud.getAudioTrack(userId), 'patient')
} catch {
// Remote audio can become available a few frames after connected.
}
}
return attached
}
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.
}
}
if (cloud) {
attachDoctorAudioTrack(cloud)
attachPatientAudioTrack(cloud, activeConfig.targetUserId)
}
for (const media of document.querySelectorAll<HTMLMediaElement>('video, audio')) {
@@ -852,8 +976,20 @@ async function cleanupLocalRecordingGraph(): Promise<void> {
// The call engine may already have released its event dispatcher.
}
}
if (
localAudioCloud
&& localRemoteAudioAvailableHandler
&& typeof localAudioCloud.off === 'function'
) {
try {
localAudioCloud.off('remote-audio-available', localRemoteAudioAvailableHandler)
} catch {
// The call engine may already have released its event dispatcher.
}
}
localAudioCloud = null
localAudioTrackHandler = null
localRemoteAudioAvailableHandler = null
for (const source of localAudioSources) {
try {
source.disconnect()
@@ -910,15 +1046,26 @@ async function performStartLocalRecording(): Promise<void> {
localAudioDestination = context.createMediaStreamDestination()
const cloud = getTrtcAudioCloud()
localAudioCloud = cloud
localAudioTrackHandler = (event) => attachLocalRecordingTrack(
event.track,
event.userId === activeConfig?.userID
localAudioTrackHandler = (event) => {
const sourceKind = !event.userId || event.userId === activeConfig?.userID
? 'doctor'
: event.userId === activeConfig?.targetUserId
? 'patient'
: 'unknown',
)
if (typeof cloud?.on === 'function') cloud.on('track', localAudioTrackHandler)
: 'unknown'
if (!attachLocalRecordingTrack(event.track, sourceKind)) {
attachLocalRecordingTrack(event.sourceTrack, sourceKind)
}
}
localRemoteAudioAvailableHandler = (event) => {
const userId = event.userId || activeConfig?.targetUserId || ''
if (cloud && userId === activeConfig?.targetUserId) {
attachPatientAudioTrack(cloud, userId)
}
}
if (typeof cloud?.on === 'function') {
cloud.on('track', localAudioTrackHandler)
cloud.on('remote-audio-available', localRemoteAudioAvailableHandler)
}
if (context.state === 'suspended') await context.resume()
await waitForCallAudioTracks(cloud, sessionId)
startCallAudioDiscovery(cloud)
@@ -1151,10 +1298,6 @@ function getTranscriberManager(): RealtimeTranscriberManager {
}
function clearLiveCaptions(): void {
if (liveCaptionClearTimer !== null) {
window.clearTimeout(liveCaptionClearTimer)
liveCaptionClearTimer = null
}
liveCaptions.value = []
}
@@ -1170,19 +1313,26 @@ function showLiveCaption(message: RealtimeTranscriberMessage): void {
: speakerUserId === activeConfig.targetUserId
? patientName.value || '患者'
: '对话'
const rawTimestamp = Number(message.timestamp)
const captionTimestamp = Number.isFinite(rawTimestamp) && rawTimestamp > 0
? (rawTimestamp < 1_000_000_000_000 ? rawTimestamp * 1000 : rawTimestamp)
: Date.now()
const caption: UiLiveCaption = {
id,
speaker,
text: text.slice(0, 500),
time: new Date(captionTimestamp).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
}),
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)
// Keep the current call's transcript visible in the side rail. Repeated
// partial updates replace the same segment and the bounded history prevents
// long calls from growing memory without limit.
liveCaptions.value = [...previous, caption].slice(-120)
}
function handleTranscriberMessage(
@@ -1421,7 +1571,6 @@ async function stopTranscription(
transcriptionSessionId = ''
acknowledgedSegmentIds.clear()
pendingSegments.clear()
clearLiveCaptions()
})().finally(() => {
transcriptionStopPromise = null
})
@@ -1720,6 +1869,7 @@ async function open(config: DoctorCallConfig): Promise<void> {
activeConfig = normalizeConfig(config)
mode.value = activeConfig.mode
patientName.value = activeConfig.patientName
patientCase.value = activeConfig.patientCase
messages.value = []
nextReqMessageID = ''
hasMoreMessages.value = false
@@ -1805,12 +1955,14 @@ createApp(App, {
transcriptionState: readonly(transcriptionState),
localRecordingState: readonly(localRecordingState),
liveCaptions: readonly(liveCaptions),
patientCase: readonly(patientCase),
onSendText: sendText,
onSendAttachment: sendAttachment,
onLoadMore: () => loadMessages(true),
onReconnectChat: reconnectChat,
onStartVideo: startVideo,
onHangup: hangup,
onOpenDiagnosis: openDiagnosis,
onSaveScreenshot: saveScreenshot,
}).mount('#app')
+299 -29
View File
@@ -272,6 +272,8 @@ button:disabled { cursor: not-allowed; opacity: .55; }
.video-layer {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr);
width: 100%;
height: 100%;
min-height: 420px;
@@ -281,8 +283,21 @@ button:disabled { cursor: not-allowed; opacity: .55; }
radial-gradient(circle at 50% 35%, rgba(60, 86, 130, .28), transparent 38%),
#090d14;
}
.video-layer--with-rail {
grid-template-columns: minmax(0, 1fr) clamp(330px, 31vw, 380px);
}
.video-layer--overlay { position: fixed; z-index: 1000; inset: 0; }
.video-stage {
position: relative;
min-width: 0;
min-height: 0;
overflow: hidden;
background:
radial-gradient(circle at 50% 35%, rgba(60, 86, 130, .28), transparent 38%),
#090d14;
}
.call-kit,
.video-layer :is(.TUICallKit-desktop, .TUICallKit-mobile, #tuicallkit-id) {
width: 100% !important;
@@ -336,40 +351,249 @@ 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;
.consultation-rail {
position: relative;
z-index: 55;
display: grid;
gap: 7px;
width: min(820px, calc(100% - 360px));
transform: translateX(-50%);
pointer-events: none;
grid-template-rows: auto minmax(0, 1.08fr) minmax(0, .92fr);
min-width: 0;
min-height: 0;
overflow: hidden;
border-left: 1px solid #dce3f0;
color: #15233a;
background: #f6f8fc;
box-shadow: -16px 0 38px rgba(4, 12, 26, .16);
}
.live-captions p {
.consultation-rail__header {
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;
grid-template-columns: 42px minmax(0, 1fr) auto;
gap: 11px;
align-items: center;
min-height: 74px;
padding: 13px 16px;
border-bottom: 1px solid #e2e7f1;
background: #fff;
}
.consultation-rail__avatar {
display: grid;
place-items: center;
width: 42px;
height: 42px;
border-radius: 13px;
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);
background: #5761f4;
font-size: 17px;
font-weight: 700;
}
.live-captions strong {
color: #aeb8ff;
.consultation-rail__identity {
display: grid;
gap: 3px;
min-width: 0;
}
.consultation-rail__identity strong {
overflow: hidden;
color: #111f46;
font-size: 16px;
line-height: 1.3;
text-overflow: ellipsis;
white-space: nowrap;
}
.live-captions span { min-width: 0; word-break: break-word; }
.consultation-rail__identity span {
overflow: hidden;
color: #7886a3;
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.diagnosis-chip {
padding: 5px 7px;
border-radius: 6px;
color: #4a56d5;
background: #eef0ff;
font-size: 10px;
font-weight: 700;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.consultation-rail__actions {
display: grid;
flex: 0 0 auto;
justify-items: end;
gap: 5px;
}
.open-diagnosis-button {
display: inline-flex;
align-items: center;
gap: 4px;
min-height: 28px;
padding: 5px 8px;
border: 1px solid #cfd5ff;
border-radius: 7px;
color: #3f4bc5;
background: #fff;
font-size: 11px;
font-weight: 700;
white-space: nowrap;
}
.open-diagnosis-button:hover,
.open-diagnosis-button:focus-visible {
border-color: #6874e7;
background: #f3f4ff;
outline: none;
}
.open-diagnosis-button:disabled { cursor: not-allowed; opacity: .55; }
.case-panel,
.transcript-panel {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
min-height: 0;
overflow: hidden;
}
.case-panel { border-bottom: 1px solid #dde4f0; }
.rail-section-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 48px;
padding: 10px 16px;
border-bottom: 1px solid #e5e9f2;
background: rgba(255, 255, 255, .72);
}
.rail-section-heading > div {
display: flex;
align-items: center;
gap: 8px;
}
.rail-section-heading h2 {
margin: 0;
color: #1b2945;
font-size: 14px;
font-weight: 700;
letter-spacing: -.01em;
}
.rail-section-heading > span {
overflow: hidden;
max-width: 154px;
color: #8b97ad;
font-size: 10px;
text-overflow: ellipsis;
white-space: nowrap;
}
.rail-section-heading__index {
color: #6874e7;
font-family: "Cascadia Mono", "SFMono-Regular", Consolas, monospace;
font-size: 10px;
font-weight: 700;
}
.case-panel__content,
.live-captions {
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-color: #b9c3d5 transparent;
scrollbar-width: thin;
}
.case-panel__content { padding: 5px 16px 14px; }
.case-field-list { display: grid; }
.case-field {
display: grid;
grid-template-columns: 72px minmax(0, 1fr);
gap: 10px;
padding: 10px 0;
border-bottom: 1px solid #e5e9f2;
}
.case-field:last-child { border-bottom: 0; }
.case-field > span {
padding-top: 2px;
color: #7a879d;
font-size: 11px;
font-weight: 600;
}
.case-field p {
margin: 0;
color: #263551;
font-size: 12px;
line-height: 1.55;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.case-field--risk > span { color: #bc3e4e; }
.case-field--risk p {
color: #9f3141;
font-weight: 600;
}
.live-captions {
display: grid;
align-content: start;
gap: 9px;
padding: 12px 14px 18px;
background: #f2f5fa;
}
.caption-entry {
padding: 10px 11px;
border-left: 2px solid #6571e8;
border-radius: 0 9px 9px 0;
background: #fff;
box-shadow: 0 4px 14px rgba(30, 46, 76, .045);
}
.caption-entry--partial { border-left-color: #9ca6b7; opacity: .78; }
.caption-entry header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 5px;
}
.caption-entry strong {
color: #4a56d5;
font-size: 11px;
}
.caption-entry time {
color: #9aa5b7;
font-size: 10px;
font-variant-numeric: tabular-nums;
}
.caption-entry p {
margin: 0;
color: #24334e;
font-size: 13px;
line-height: 1.58;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.transcript-state {
position: relative;
padding-left: 12px;
}
.transcript-state::before {
position: absolute;
top: 50%;
left: 0;
width: 6px;
height: 6px;
transform: translateY(-50%);
border-radius: 50%;
background: #a5adbb;
content: "";
}
.transcript-state--active { color: #168260 !important; }
.transcript-state--active::before {
background: #24b987;
box-shadow: 0 0 0 3px rgba(36, 185, 135, .12);
}
.rail-empty {
display: grid;
gap: 5px;
align-content: center;
min-height: 112px;
padding: 18px;
color: #8a96aa;
text-align: center;
}
.rail-empty strong { color: #53617a; font-size: 12px; }
.rail-empty span { font-size: 11px; line-height: 1.55; }
.rail-empty--case { min-height: 100%; }
.video-actions {
position: absolute;
@@ -494,8 +718,54 @@ button:disabled { cursor: not-allowed; opacity: .55; }
.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; }
}
@media (max-width: 980px) {
.consultation-shell { min-width: 760px; }
.video-layer--with-rail { grid-template-columns: minmax(0, 1fr) 310px; }
.video-actions {
right: 12px;
bottom: 14px;
left: 12px;
flex-wrap: wrap;
justify-content: flex-end;
}
.recording-status {
flex-basis: 100%;
width: fit-content;
margin-left: auto;
font-size: 11px;
}
.consultation-rail__header { padding-inline: 12px; }
.diagnosis-chip { display: none; }
.case-panel__content { padding-inline: 12px; }
.rail-section-heading { padding-inline: 12px; }
}
@media (max-width: 700px) {
.consultation-shell { min-width: 0; }
.video-layer,
.video-stage { min-height: 280px; }
.video-layer--with-rail { grid-template-columns: minmax(0, 1fr); }
.consultation-rail { display: none; }
.video-actions {
right: 10px;
bottom: 10px;
left: auto;
gap: 6px;
}
.recording-status,
.capture-button { display: none; }
.video-actions button {
padding: 8px 11px;
border-radius: 8px;
font-size: 11px;
}
.live-status {
top: 10px;
padding: 7px 10px;
font-size: 11px;
}
}
/* Doctor workstation blue-white subwindow contract. Video pixels remain on