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"