更新
This commit is contained in:
+247
-17
@@ -1,33 +1,263 @@
|
||||
<script setup lang="ts">
|
||||
import { TUICallKit } from '@trtc/calls-uikit-vue'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
defineProps<{
|
||||
interface ChatMessage {
|
||||
id: string
|
||||
mine: boolean
|
||||
type: 'text' | 'image' | 'file' | 'audio' | 'video' | 'system'
|
||||
text: string
|
||||
url: string
|
||||
name: string
|
||||
time: string
|
||||
}
|
||||
|
||||
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>>
|
||||
hasMoreMessages: Readonly<Ref<boolean>>
|
||||
onSendText: (text: string) => Promise<void>
|
||||
onSendAttachment: (file: File) => Promise<void>
|
||||
onLoadMore: () => Promise<void>
|
||||
onReconnectChat: () => Promise<void>
|
||||
onStartVideo: () => Promise<void>
|
||||
onHangup: () => 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 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')
|
||||
|
||||
watch(
|
||||
() => props.messages.value.length,
|
||||
async () => {
|
||||
await nextTick()
|
||||
if (messageList.value) messageList.value.scrollTop = messageList.value.scrollHeight
|
||||
},
|
||||
)
|
||||
|
||||
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)
|
||||
await props.onSaveScreenshot(canvas.toDataURL('image/jpeg', 0.9))
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="call-stage">
|
||||
<TUICallKit
|
||||
class="call-kit"
|
||||
:allowed-minimized="false"
|
||||
:allowed-full-screen="true"
|
||||
/>
|
||||
<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 }" />
|
||||
{{ chatReady.value ? 'IM 已连接' : statusText.value }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
v-if="!chatReady.value"
|
||||
class="secondary-action"
|
||||
type="button"
|
||||
:disabled="actionBusy"
|
||||
@click="runAction(onReconnectChat)"
|
||||
>
|
||||
重新连接 IM
|
||||
</button>
|
||||
<button
|
||||
class="primary-action"
|
||||
type="button"
|
||||
:disabled="actionBusy || isCalling || !chatReady.value"
|
||||
@click="runAction(onStartVideo)"
|
||||
>
|
||||
<span aria-hidden="true">▣</span>
|
||||
{{ isCalling ? '视频通话中' : '发起视频' }}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<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>
|
||||
<h1>{{ statusText.value }}</h1>
|
||||
<p v-if="phase.value === 'ready'" class="status-hint">通话凭证仅由业务后端签发</p>
|
||||
<div ref="messageList" class="message-list" aria-live="polite">
|
||||
<button
|
||||
v-if="hasMoreMessages.value"
|
||||
class="load-more"
|
||||
type="button"
|
||||
:disabled="chatBusy.value"
|
||||
@click="runAction(onLoadMore)"
|
||||
>
|
||||
{{ 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 }"
|
||||
>
|
||||
<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>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<footer class="composer">
|
||||
<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>
|
||||
|
||||
<div v-else class="live-status" role="status">
|
||||
<span class="status-dot status-dot--live" aria-hidden="true" />
|
||||
{{ statusText.value }}
|
||||
</div>
|
||||
<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="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">
|
||||
<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>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user