更新
This commit is contained in:
+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"
|
||||
|
||||
Reference in New Issue
Block a user