603 lines
21 KiB
Vue
603 lines
21 KiB
Vue
<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>
|