更新bug
This commit is contained in:
+165
-18
@@ -6,11 +6,19 @@ import type { Ref } from 'vue'
|
||||
interface ChatMessage {
|
||||
id: string
|
||||
mine: boolean
|
||||
type: 'text' | 'image' | 'file' | 'audio' | 'video' | 'system'
|
||||
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
|
||||
completed: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -24,6 +32,8 @@ const props = defineProps<{
|
||||
notice: Readonly<Ref<string>>
|
||||
hasMoreMessages: Readonly<Ref<boolean>>
|
||||
transcriptionState: Readonly<Ref<string>>
|
||||
localRecordingState: Readonly<Ref<string>>
|
||||
liveCaptions: Readonly<Ref<LiveCaption[]>>
|
||||
onSendText: (text: string) => Promise<void>
|
||||
onSendAttachment: (file: File) => Promise<void>
|
||||
onLoadMore: () => Promise<void>
|
||||
@@ -38,29 +48,81 @@ 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 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 transcriptionActive = computed(() => props.transcriptionState.value === 'recording')
|
||||
const transcriptionFailed = computed(() => props.transcriptionState.value === 'error')
|
||||
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(() => {
|
||||
if (props.transcriptionState.value === 'starting') return '自动录音启动中…'
|
||||
if (props.transcriptionState.value === 'recording') return '自动录音并转文字中'
|
||||
if (props.transcriptionState.value === 'stopping') return '正在保存录音文字…'
|
||||
if (props.transcriptionState.value === 'error') return '自动录音转文字失败'
|
||||
return '自动录音已结束'
|
||||
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 '录音与转写已结束'
|
||||
})
|
||||
|
||||
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
|
||||
},
|
||||
)
|
||||
|
||||
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
|
||||
@@ -113,9 +175,35 @@ async function captureScreenshot(): Promise<void> {
|
||||
const context = canvas.getContext('2d')
|
||||
if (!context) throw new Error('无法创建截图画布')
|
||||
context.drawImage(video, 0, 0, canvas.width, canvas.height)
|
||||
await props.onSaveScreenshot(canvas.toDataURL('image/jpeg', 0.9))
|
||||
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>
|
||||
@@ -127,7 +215,7 @@ async function captureScreenshot(): Promise<void> {
|
||||
<h1>{{ patientName.value }}</h1>
|
||||
<p>
|
||||
<span class="connection-dot" :class="{ 'connection-dot--online': chatReady.value }" />
|
||||
{{ chatReady.value ? 'IM 已连接' : statusText.value }}
|
||||
{{ chatConnectionText }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -150,13 +238,13 @@ async function captureScreenshot(): Promise<void> {
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div ref="messageList" class="message-list" aria-live="polite">
|
||||
<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="runAction(onLoadMore)"
|
||||
@click="loadEarlierMessages"
|
||||
>
|
||||
{{ chatBusy.value ? '正在读取…' : '查看更早消息' }}
|
||||
</button>
|
||||
@@ -171,10 +259,26 @@ async function captureScreenshot(): Promise<void> {
|
||||
v-for="message in messages.value"
|
||||
:key="message.id"
|
||||
class="message-row"
|
||||
:class="{ 'message-row--mine': message.mine }"
|
||||
:class="{
|
||||
'message-row--mine': message.mine,
|
||||
'message-row--call-status': message.type === 'call-status',
|
||||
}"
|
||||
>
|
||||
<div class="message-meta">{{ message.mine ? '我' : patientName.value }} · {{ message.time }}</div>
|
||||
<div class="message-bubble">
|
||||
<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"
|
||||
@@ -193,8 +297,9 @@ async function captureScreenshot(): Promise<void> {
|
||||
</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>
|
||||
<p v-else>{{ message.text }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
@@ -251,6 +356,19 @@ async function captureScreenshot(): Promise<void> {
|
||||
{{ statusText.value }}
|
||||
</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"
|
||||
@@ -271,7 +389,7 @@ async function captureScreenshot(): Promise<void> {
|
||||
:disabled="!canCapture || actionBusy"
|
||||
@click="captureScreenshot"
|
||||
>
|
||||
截屏并保存患者资料
|
||||
截屏预览
|
||||
</button>
|
||||
<button class="hangup-button" type="button" :disabled="actionBusy" @click="runAction(onHangup)">
|
||||
结束视频
|
||||
@@ -281,6 +399,35 @@ async function captureScreenshot(): Promise<void> {
|
||||
<div v-if="localError || notice.value" class="video-notice" :class="{ 'video-notice--error': localError }">
|
||||
{{ localError || notice.value }}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user