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