Files
zyt/app/video_companion/src/App.vue
T
2026-09-09 15:47:48 +08:00

603 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { TUICallKit } from '@trtc/calls-uikit-vue'
import { computed, nextTick, ref, watch } from 'vue'
import type { Ref } from 'vue'
interface ChatMessage {
id: string
mine: boolean
type: 'text' | 'image' | 'file' | 'audio' | 'video' | 'system' | 'call-status'
text: string
url: string
name: string
time: string
callStatus?: 'starting' | 'dialing' | 'connected' | 'ended' | 'failed'
}
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>>
patientName: Readonly<Ref<string>>
mode: Readonly<Ref<string>>
messages: Readonly<Ref<ChatMessage[]>>
chatReady: Readonly<Ref<boolean>>
chatBusy: Readonly<Ref<boolean>>
notice: Readonly<Ref<string>>
canVideoCall: Readonly<Ref<boolean>>
callDisabledReason: Readonly<Ref<string>>
hasMoreMessages: Readonly<Ref<boolean>>
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>
}>()
const draft = ref('')
const actionBusy = ref(false)
const localError = ref('')
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))
const videoVisible = computed(() => !isChat.value || isCalling.value)
const canCapture = computed(() => props.phase.value === 'connected')
const chatConnectionText = computed(() => {
if (!props.chatReady.value) return props.statusText.value
if (['starting', 'dialing', 'connected', 'ended', 'error'].includes(props.phase.value)) {
return `IM 已连接 · ${props.statusText.value}`
}
return 'IM 已连接'
})
const transcriptionActive = computed(() => (
props.transcriptionState.value === 'recording'
|| props.localRecordingState.value === 'recording'
))
const transcriptionFailed = computed(() => (
props.transcriptionState.value === 'error'
|| props.localRecordingState.value === 'error'
))
const transcriptionStatusText = computed(() => {
const localState = props.localRecordingState.value
const textState = props.transcriptionState.value
if (localState === 'uploading') return '正在上传本机录音到 COS…'
if (localState === 'stopping' || textState === 'stopping') return '正在保存录音与转写文字…'
if (localState === 'starting' || textState === 'starting') return '自动录音与转写启动中…'
if (localState === 'recording' && textState === 'recording') return '本机录音与实时转写中'
if (localState === 'recording') return '本机录音中,实时转写未就绪'
if (textState === 'recording') return '实时转写中,本机录音未就绪'
if (localState === 'error' && textState === 'error') return '本机录音与实时转写均失败'
if (localState === 'error') return '本机录音失败,实时转写仍在运行'
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,
async () => {
if (!stickToMessageBottom.value) return
await nextTick()
if (messageList.value) messageList.value.scrollTop = messageList.value.scrollHeight
},
)
watch(
() => [props.patientName.value, props.mode.value],
async () => {
stickToMessageBottom.value = true
await nextTick()
if (messageList.value) messageList.value.scrollTop = messageList.value.scrollHeight
},
)
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
stickToMessageBottom.value = (
container.scrollHeight - container.scrollTop - container.clientHeight <= 96
)
}
async function loadEarlierMessages(): Promise<void> {
const container = messageList.value
if (!container || actionBusy.value) return
const previousHeight = container.scrollHeight
const previousTop = container.scrollTop
stickToMessageBottom.value = false
await runAction(props.onLoadMore)
await nextTick()
container.scrollTop = previousTop + (container.scrollHeight - previousHeight)
handleMessageScroll()
}
async function runAction(action: () => Promise<void>): Promise<void> {
if (actionBusy.value) return
actionBusy.value = true
localError.value = ''
try {
await action()
} catch (error) {
localError.value = error instanceof Error ? error.message : '操作失败,请稍后重试'
} finally {
actionBusy.value = false
}
}
async function sendText(): Promise<void> {
const content = draft.value.trim()
if (!content) return
await runAction(async () => {
await props.onSendText(content)
draft.value = ''
})
}
async function selectAttachment(event: Event): Promise<void> {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
await runAction(() => props.onSendAttachment(file))
}
function findRemoteVideo(): HTMLVideoElement | null {
const videos = Array.from(document.querySelectorAll('video'))
.filter((video) => video.readyState >= 2 && video.videoWidth > 0 && video.videoHeight > 0)
if (!videos.length) return null
return videos.sort((left, right) => {
const leftArea = left.getBoundingClientRect().width * left.getBoundingClientRect().height
const rightArea = right.getBoundingClientRect().width * right.getBoundingClientRect().height
return rightArea - leftArea
})[0]
}
async function captureScreenshot(): Promise<void> {
await runAction(async () => {
const video = findRemoteVideo()
if (!video) throw new Error('尚未检测到患者视频画面')
const scale = Math.min(1, 1920 / video.videoWidth)
const canvas = document.createElement('canvas')
canvas.width = Math.max(1, Math.round(video.videoWidth * scale))
canvas.height = Math.max(1, Math.round(video.videoHeight * scale))
const context = canvas.getContext('2d')
if (!context) throw new Error('无法创建截图画布')
context.drawImage(video, 0, 0, canvas.width, canvas.height)
screenshotPreview.value = canvas.toDataURL('image/jpeg', 0.9)
})
}
function discardScreenshot(): void {
if (actionBusy.value) return
screenshotPreview.value = ''
}
async function confirmScreenshot(): Promise<void> {
if (!screenshotPreview.value || actionBusy.value) return
actionBusy.value = true
localError.value = ''
try {
await props.onSaveScreenshot(screenshotPreview.value)
screenshotPreview.value = ''
} catch (error) {
localError.value = error instanceof Error ? error.message : '截图上传失败,请稍后重试'
} finally {
actionBusy.value = false
}
}
watch(
() => props.phase.value,
(value) => {
if (value !== 'connected') screenshotPreview.value = ''
},
)
</script>
<template>
<main class="consultation-shell" :class="{ 'consultation-shell--video-only': !isChat }">
<section v-if="isChat" class="chat-panel">
<header class="chat-header">
<div class="patient-avatar" aria-hidden="true">{{ patientName.value.slice(0, 1) }}</div>
<div class="chat-heading">
<h1>{{ patientName.value }}</h1>
<p>
<span class="connection-dot" :class="{ 'connection-dot--online': chatReady.value }" />
{{ chatConnectionText }}
</p>
</div>
<button
v-if="!chatReady.value"
class="secondary-action"
type="button"
:disabled="actionBusy"
@click="runAction(onReconnectChat)"
>
重新连接 IM
</button>
<button
class="primary-action"
v-if="canVideoCall.value"
type="button"
:disabled="actionBusy || isCalling || !chatReady.value"
@click="runAction(onStartVideo)"
>
<span aria-hidden="true">▣</span>
{{ isCalling ? '视频通话中' : '发起视频' }}
</button>
</header>
<div ref="messageList" class="message-list" aria-live="polite" @scroll="handleMessageScroll">
<button
v-if="hasMoreMessages.value"
class="load-more"
type="button"
:disabled="chatBusy.value"
@click="loadEarlierMessages"
>
{{ chatBusy.value ? '正在读取…' : '查看更早消息' }}
</button>
<div v-if="!messages.value.length && !chatBusy.value" class="empty-chat">
<div class="empty-chat__icon" aria-hidden="true">IM</div>
<h2>开始问诊沟通</h2>
<p>消息会通过腾讯云 IM 实时发送给患者。</p>
</div>
<article
v-for="message in messages.value"
:key="message.id"
class="message-row"
:class="{
'message-row--mine': message.mine,
'message-row--call-status': message.type === 'call-status',
}"
>
<template v-if="message.type === 'call-status'">
<div
class="call-status-event"
:class="`call-status-event--${message.callStatus || 'ended'}`"
role="status"
:aria-label="`${message.text},${message.time}`"
>
<span class="call-status-event__icon" aria-hidden="true">▣</span>
<strong>{{ message.text }}</strong>
<time>{{ message.time }}</time>
</div>
</template>
<template v-else>
<div class="message-meta">{{ message.mine ? '我' : patientName.value }} · {{ message.time }}</div>
<div class="message-bubble">
<p v-if="message.type === 'text'">{{ message.text }}</p>
<img
v-else-if="message.type === 'image' && message.url"
class="message-image"
:src="message.url"
alt="问诊图片"
>
<a
v-else-if="message.type === 'file' && message.url"
class="message-file"
:href="message.url"
target="_blank"
rel="noreferrer"
>
<span aria-hidden="true">▤</span>{{ message.name || '查看文件' }}
</a>
<audio v-else-if="message.type === 'audio' && message.url" :src="message.url" controls />
<video v-else-if="message.type === 'video' && message.url" class="message-video" :src="message.url" controls />
<p v-else>{{ message.text }}</p>
</div>
</template>
</article>
</div>
<footer class="composer">
<div v-if="callDisabledReason.value" class="inline-notice" role="status">
{{ callDisabledReason.value }}
</div>
<div v-if="localError || notice.value" class="inline-notice" :class="{ 'inline-notice--error': localError }">
{{ localError || notice.value }}
</div>
<div class="composer-toolbar">
<button type="button" title="发送图片或文件" :disabled="!chatReady.value || actionBusy" @click="fileInput?.click()">
+ 图片/文件
</button>
<input ref="fileInput" type="file" accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt" hidden @change="selectAttachment">
<span>Enter 发送,Shift + Enter 换行</span>
</div>
<div class="composer-row">
<textarea
v-model="draft"
rows="3"
maxlength="3000"
placeholder="输入问诊消息…"
:disabled="!chatReady.value"
@keydown.enter.exact.prevent="sendText"
/>
<button
class="send-button"
type="button"
:disabled="!chatReady.value || !draft.trim() || actionBusy"
@click="sendText"
>
发送
</button>
</div>
</footer>
</section>
<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>
</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="localError || notice.value" class="video-notice" :class="{ 'video-notice--error': localError }">
{{ localError || notice.value }}
</div>
</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"
class="screenshot-dialog-backdrop"
role="dialog"
aria-modal="true"
aria-labelledby="screenshot-preview-title"
@click.self="discardScreenshot"
>
<section class="screenshot-dialog">
<header>
<div>
<p class="eyebrow">视频截图确认</p>
<h2 id="screenshot-preview-title">确认画面后再保存到患者资料</h2>
</div>
<button type="button" aria-label="关闭截图预览" :disabled="actionBusy" @click="discardScreenshot">×</button>
</header>
<div class="screenshot-preview-frame">
<img :src="screenshotPreview" alt="本次视频截图预览">
</div>
<p class="screenshot-dialog__hint">只有点击“确认并上传”后,截图才会上传并写入患者资料。</p>
<footer>
<button class="screenshot-cancel" type="button" :disabled="actionBusy" @click="discardScreenshot">取消</button>
<button class="screenshot-confirm" type="button" :disabled="actionBusy" @click="confirmScreenshot">
{{ actionBusy ? '正在上传…' : '确认并上传' }}
</button>
</footer>
</section>
</div>
</section>
</main>
</template>