更新
This commit is contained in:
File diff suppressed because one or more lines are too long
+108
-108
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -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
@@ -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"
|
||||
|
||||
Vendored
+24
@@ -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
@@ -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')
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user