更新
This commit is contained in:
@@ -1 +0,0 @@
|
||||
:root{font-family:Inter,PingFang SC,Microsoft YaHei,system-ui,sans-serif;color:#f7f8fa;background:#0b0f14;font-synthesis:none;text-rendering:optimizeLegibility}*{box-sizing:border-box}html,body,#app{width:100%;height:100%;margin:0;overflow:hidden}button,input{font:inherit}.call-stage{position:relative;width:100%;height:100%;min-height:420px;overflow:hidden;background:radial-gradient(circle at 50% 35%,rgba(39,74,83,.22),transparent 38%),#0b0f14}.call-kit,.call-stage :is(.TUICallKit-desktop,.TUICallKit-mobile,#tuicallkit-id){width:100%!important;height:100%!important;max-width:none!important;max-height:none!important}.status-card{position:absolute;inset:50% auto auto 50%;display:grid;grid-template-columns:12px minmax(0,1fr);gap:18px;width:min(520px,calc(100% - 48px));padding:30px 32px;transform:translate(-50%,-50%);border:1px solid rgba(255,255,255,.09);border-radius:20px;background:#131920e6;box-shadow:0 24px 70px #00000052;backdrop-filter:blur(18px)}.eyebrow{margin:0 0 12px;color:#8f9ba8;font-size:12px;font-weight:700;letter-spacing:.16em;text-transform:uppercase}.status-card h1{margin:0;font-size:clamp(22px,3.2vw,34px);font-weight:600;line-height:1.25}.status-hint{margin:14px 0 0;color:#9aa5b1;font-size:14px}.status-dot{width:10px;height:10px;margin-top:5px;border-radius:50%;background:#77818c;box-shadow:0 0 0 5px #77818c1f}.status-dot--starting,.status-dot--live{background:#52c99a;box-shadow:0 0 0 5px #52c99a24}.status-dot--error{background:#f26d6d;box-shadow:0 0 0 5px #f26d6d24}.live-status{position:absolute;z-index:20;top:18px;left:50%;display:flex;align-items:center;gap:10px;padding:9px 14px;transform:translate(-50%);border:1px solid rgba(255,255,255,.1);border-radius:999px;background:#0b0f14c2;color:#e8edf2;font-size:13px;backdrop-filter:blur(14px)}.live-status .status-dot{width:7px;height:7px;margin:0;box-shadow:none}
|
||||
+632
File diff suppressed because one or more lines are too long
-532
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Vendored
+3
-3
@@ -3,10 +3,10 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<meta name="color-scheme" content="light" />
|
||||
<title>视频面诊</title>
|
||||
<script type="module" crossorigin src="./assets/index-le5ZH3pL.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-BuO_uDya.css">
|
||||
<script type="module" crossorigin src="./assets/index-DwSVWep6.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-qOBmgxQV.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<meta name="color-scheme" content="light" />
|
||||
<title>视频面诊</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Generated
+8
@@ -8,7 +8,9 @@
|
||||
"name": "doctor-video-companion",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@tencentcloud/lite-chat": "1.6.18",
|
||||
"@trtc/calls-uikit-vue": "4.4.6",
|
||||
"tim-upload-plugin": "1.4.3",
|
||||
"vue": "3.5.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -1432,6 +1434,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tim-upload-plugin": {
|
||||
"version": "1.4.3",
|
||||
"resolved": "https://registry.npmjs.org/tim-upload-plugin/-/tim-upload-plugin-1.4.3.tgz",
|
||||
"integrity": "sha512-3ZmbA36dr3eG9YGDon9MLBUtbNawYWkL+TBa+VS0Uviguc7PlVSOIVRG2C4irXX16slDT2Kj+HAZapp+Xqp2xg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/trtc-cloud-js-sdk": {
|
||||
"version": "2.10.19",
|
||||
"resolved": "https://registry.npmjs.org/trtc-cloud-js-sdk/-/trtc-cloud-js-sdk-2.10.19.tgz",
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
"preview": "vite preview --port 4173"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tencentcloud/lite-chat": "1.6.18",
|
||||
"@trtc/calls-uikit-vue": "4.4.6",
|
||||
"tim-upload-plugin": "1.4.3",
|
||||
"vue": "3.5.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
+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>
|
||||
|
||||
Vendored
+18
@@ -1,5 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module 'tim-upload-plugin' {
|
||||
const plugin: unknown
|
||||
export default plugin
|
||||
}
|
||||
|
||||
interface DoctorCallConfig {
|
||||
SDKAppID?: number | string
|
||||
sdkAppId?: number | string
|
||||
@@ -9,6 +14,8 @@ interface DoctorCallConfig {
|
||||
targetUserId?: string
|
||||
patientUserId?: string
|
||||
diagnosisId: number | string
|
||||
patientName?: string
|
||||
mode?: 'chat' | 'video'
|
||||
}
|
||||
|
||||
interface DoctorCallApi {
|
||||
@@ -16,12 +23,23 @@ interface DoctorCallApi {
|
||||
hangup(): Promise<void>
|
||||
}
|
||||
|
||||
interface DoctorConsultationApi {
|
||||
open(config: DoctorCallConfig): Promise<void>
|
||||
close(): Promise<void>
|
||||
startVideo(): Promise<void>
|
||||
hangup(): Promise<void>
|
||||
hostCallReady(ok: boolean, message?: string): void
|
||||
screenshotResult(ok: boolean, message: string): void
|
||||
}
|
||||
|
||||
interface QtVideoBridge {
|
||||
notify?: (payload: string) => void
|
||||
saveScreenshot?: (dataUrl: string) => void
|
||||
}
|
||||
|
||||
interface Window {
|
||||
doctorCall: DoctorCallApi
|
||||
doctorConsultation: DoctorConsultationApi
|
||||
qtVideoBridge?: QtVideoBridge
|
||||
qt?: { webChannelTransport?: unknown }
|
||||
QWebChannel?: new (
|
||||
|
||||
+470
-92
@@ -1,3 +1,5 @@
|
||||
import TencentCloudChat from '@tencentcloud/lite-chat'
|
||||
import TIMUploadPlugin from 'tim-upload-plugin'
|
||||
import { createApp, nextTick, readonly, ref } from 'vue'
|
||||
import {
|
||||
NAME,
|
||||
@@ -11,6 +13,8 @@ import App from './App.vue'
|
||||
import './style.css'
|
||||
|
||||
type CallPhase = 'ready' | 'starting' | 'dialing' | 'connected' | 'ended' | 'error'
|
||||
type CompanionMode = 'chat' | 'video'
|
||||
type ChatMessageType = 'text' | 'image' | 'file' | 'audio' | 'video' | 'system'
|
||||
|
||||
interface NormalizedCallConfig {
|
||||
SDKAppID: number
|
||||
@@ -18,11 +22,23 @@ interface NormalizedCallConfig {
|
||||
userSig: string
|
||||
targetUserId: string
|
||||
diagnosisId: number | string
|
||||
patientName: string
|
||||
mode: CompanionMode
|
||||
}
|
||||
|
||||
interface UiChatMessage {
|
||||
id: string
|
||||
mine: boolean
|
||||
type: ChatMessageType
|
||||
text: string
|
||||
url: string
|
||||
name: string
|
||||
time: string
|
||||
}
|
||||
|
||||
interface BridgeMessage {
|
||||
source: 'doctor-call'
|
||||
event: 'ready' | 'status' | 'room' | 'hangup' | 'error'
|
||||
event: 'ready' | 'call-start-request' | 'status' | 'room' | 'hangup' | 'error'
|
||||
diagnosisId?: number | string
|
||||
status?: string
|
||||
roomId?: string
|
||||
@@ -30,11 +46,25 @@ interface BridgeMessage {
|
||||
}
|
||||
|
||||
const phase = ref<CallPhase>('ready')
|
||||
const statusText = ref('等待桌面端发起视频面诊')
|
||||
const statusText = ref('正在连接问诊服务')
|
||||
const patientName = ref('患者')
|
||||
const mode = ref<CompanionMode>('video')
|
||||
const messages = ref<UiChatMessage[]>([])
|
||||
const chatReady = ref(false)
|
||||
const chatBusy = ref(false)
|
||||
const notice = ref('')
|
||||
const hasMoreMessages = ref(false)
|
||||
|
||||
let activeConfig: NormalizedCallConfig | null = null
|
||||
let endNotified = false
|
||||
let chat: any = null
|
||||
let chatReadyPromise: Promise<void> | null = null
|
||||
let resolveChatReady: (() => void) | null = null
|
||||
let rejectChatReady: ((reason?: unknown) => void) | null = null
|
||||
let nextReqMessageID = ''
|
||||
let endNotified = true
|
||||
let starting = false
|
||||
let emittedRoomId = ''
|
||||
let resolveHostCallReady: ((value: boolean) => void) | null = null
|
||||
|
||||
function initializeQtWebChannel(): void {
|
||||
const transport = window.qt?.webChannelTransport
|
||||
@@ -48,31 +78,28 @@ function initializeQtWebChannel(): void {
|
||||
emit({ source: 'doctor-call', event: 'ready' })
|
||||
})
|
||||
} catch {
|
||||
console.warn('[doctor-call] Qt WebChannel 初始化失败,将使用 postMessage 通知')
|
||||
console.warn('[doctor-consultation] Qt 通信通道初始化失败')
|
||||
}
|
||||
}
|
||||
|
||||
function postToHost(message: BridgeMessage): boolean {
|
||||
let delivered = false
|
||||
|
||||
try {
|
||||
if (window.parent && window.parent !== window) {
|
||||
window.parent.postMessage(message, '*')
|
||||
delivered = true
|
||||
}
|
||||
} catch {
|
||||
// Cross-origin host may reject access; opener remains available as a fallback.
|
||||
// The Qt bridge remains the primary transport.
|
||||
}
|
||||
|
||||
try {
|
||||
if (window.opener && !window.opener.closed) {
|
||||
window.opener.postMessage(message, '*')
|
||||
delivered = true
|
||||
}
|
||||
} catch {
|
||||
// The console fallback below is intentionally non-sensitive.
|
||||
// A detached opener is harmless.
|
||||
}
|
||||
|
||||
return delivered
|
||||
}
|
||||
|
||||
@@ -83,62 +110,358 @@ function emit(message: BridgeMessage): void {
|
||||
bridge.notify(JSON.stringify(message))
|
||||
return
|
||||
} catch {
|
||||
// A detached WebChannel object is equivalent to an unavailable bridge.
|
||||
// Fall through to browser messaging when the bridge has detached.
|
||||
}
|
||||
}
|
||||
|
||||
if (!postToHost(message)) {
|
||||
console.info('[doctor-call]', message.event, message.status ?? message.message ?? '')
|
||||
console.info('[doctor-consultation]', message.event, message.status ?? message.message ?? '')
|
||||
}
|
||||
}
|
||||
|
||||
function cleanString(value: unknown, field: string): string {
|
||||
if (typeof value !== 'string' || value.trim() === '') {
|
||||
throw new Error(`${field} 不能为空`)
|
||||
}
|
||||
if (typeof value !== 'string' || value.trim() === '') throw new Error(`${field}不能为空`)
|
||||
return value.trim()
|
||||
}
|
||||
|
||||
function normalizeConfig(config: DoctorCallConfig): NormalizedCallConfig {
|
||||
if (!config || typeof config !== 'object') throw new Error('通话配置无效')
|
||||
|
||||
const rawSdkAppId = config.SDKAppID ?? config.sdkAppId
|
||||
const SDKAppID = Number(rawSdkAppId)
|
||||
if (!Number.isSafeInteger(SDKAppID) || SDKAppID <= 0) {
|
||||
throw new Error('SDKAppID 必须是正整数')
|
||||
}
|
||||
if (!config || typeof config !== 'object') throw new Error('问诊配置无效')
|
||||
const SDKAppID = Number(config.SDKAppID ?? config.sdkAppId)
|
||||
if (!Number.isSafeInteger(SDKAppID) || SDKAppID <= 0) throw new Error('SDKAppID 必须是正整数')
|
||||
|
||||
const diagnosisId = config.diagnosisId
|
||||
if (
|
||||
diagnosisId === undefined
|
||||
|| diagnosisId === null
|
||||
|| (typeof diagnosisId === 'string' && diagnosisId.trim() === '')
|
||||
) {
|
||||
throw new Error('diagnosisId 不能为空')
|
||||
if (diagnosisId === undefined || diagnosisId === null || String(diagnosisId).trim() === '') {
|
||||
throw new Error('诊单ID不能为空')
|
||||
}
|
||||
|
||||
return {
|
||||
SDKAppID,
|
||||
userID: cleanString(config.userID ?? config.userId, 'userID'),
|
||||
userSig: cleanString(config.userSig, 'userSig'),
|
||||
targetUserId: cleanString(config.targetUserId ?? config.patientUserId, 'targetUserId'),
|
||||
userID: cleanString(config.userID ?? config.userId, '医生用户ID'),
|
||||
userSig: cleanString(config.userSig, '用户签名'),
|
||||
targetUserId: cleanString(config.targetUserId ?? config.patientUserId, '患者用户ID'),
|
||||
diagnosisId: typeof diagnosisId === 'string' ? diagnosisId.trim() : diagnosisId,
|
||||
patientName: typeof config.patientName === 'string' && config.patientName.trim()
|
||||
? config.patientName.trim()
|
||||
: '患者',
|
||||
mode: config.mode === 'chat' ? 'chat' : 'video',
|
||||
}
|
||||
}
|
||||
|
||||
function safeErrorMessage(error: unknown): string {
|
||||
let message = error instanceof Error ? error.message : '视频通话发生未知错误'
|
||||
if (activeConfig?.userSig) message = message.split(activeConfig.userSig).join('[REDACTED]')
|
||||
return message
|
||||
.replace(/(user\s*sig\s*[:=]\s*)[^\s,;&]+/gi, '$1[REDACTED]')
|
||||
function safeErrorMessage(error: unknown, fallback = '问诊服务发生未知错误'): string {
|
||||
let message = error instanceof Error ? error.message : fallback
|
||||
if (activeConfig?.userSig) message = message.split(activeConfig.userSig).join('[已隐藏]')
|
||||
message = message
|
||||
.replace(/(user\s*sig\s*[:=]\s*)[^\s,;&]+/gi, '$1[已隐藏]')
|
||||
.slice(0, 400)
|
||||
if (/user\s+not\s+logged\s+in|not\s+logged\s+in/i.test(message)) {
|
||||
return 'IM 登录已失效,请关闭其他患者 IM 窗口后重新打开本会话'
|
||||
}
|
||||
if (/sdk.*not.*ready|not.*ready/i.test(message)) {
|
||||
return 'IM 连接尚未就绪,请稍后重试'
|
||||
}
|
||||
if (/user\s*sig.*expired|signature.*expired/i.test(message)) {
|
||||
return 'IM 登录凭证已过期,请关闭窗口后重新打开'
|
||||
}
|
||||
return message || fallback
|
||||
}
|
||||
|
||||
function timeText(raw: unknown): string {
|
||||
const seconds = Number(raw)
|
||||
const date = Number.isFinite(seconds) && seconds > 0 ? new Date(seconds * 1000) : new Date()
|
||||
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false })
|
||||
}
|
||||
|
||||
function messageUrl(payload: any): string {
|
||||
if (!payload || typeof payload !== 'object') return ''
|
||||
if (typeof payload.url === 'string') return payload.url
|
||||
if (typeof payload.fileUrl === 'string') return payload.fileUrl
|
||||
if (typeof payload.videoUrl === 'string') return payload.videoUrl
|
||||
if (typeof payload.remoteAudioUrl === 'string') return payload.remoteAudioUrl
|
||||
const images = Array.isArray(payload.imageInfoArray) ? payload.imageInfoArray : []
|
||||
const image = images.find((item: any) => item?.type === 0) ?? images.at(-1) ?? images[0]
|
||||
return typeof image?.url === 'string' ? image.url : ''
|
||||
}
|
||||
|
||||
function parseJsonObject(value: unknown): Record<string, any> | null {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
return value as Record<string, any>
|
||||
}
|
||||
if (typeof value !== 'string' || !value.trim()) return null
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? parsed as Record<string, any>
|
||||
: null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function customMessageContent(payload: any): { hidden: boolean; text: string } {
|
||||
const outer = parseJsonObject(payload?.data) ?? parseJsonObject(payload) ?? {}
|
||||
const inner = parseJsonObject(outer.data) ?? {}
|
||||
const businessID = outer.businessID ?? outer.businessId ?? inner.businessID ?? inner.businessId
|
||||
if (businessID === 1 || String(businessID).toLowerCase().includes('call')) {
|
||||
return { hidden: true, text: '' }
|
||||
}
|
||||
const command = String(outer.command ?? outer.cmd ?? inner.command ?? inner.cmd ?? '').toLowerCase()
|
||||
if (command.includes('call') || command.includes('invite')) {
|
||||
return { hidden: true, text: '' }
|
||||
}
|
||||
const text = [
|
||||
payload?.description,
|
||||
outer.text,
|
||||
outer.content,
|
||||
outer.message,
|
||||
outer.tips,
|
||||
inner.text,
|
||||
inner.content,
|
||||
inner.message,
|
||||
].find((value) => typeof value === 'string' && value.trim())
|
||||
return { hidden: false, text: text ? String(text).trim() : '系统自定义消息' }
|
||||
}
|
||||
|
||||
function normalizeMessage(raw: any): UiChatMessage | null {
|
||||
const payload = raw?.payload ?? {}
|
||||
const rawType = String(raw?.type ?? '')
|
||||
let type: ChatMessageType = 'system'
|
||||
let text = '系统消息'
|
||||
if (rawType === TencentCloudChat.TYPES.MSG_TEXT) {
|
||||
type = 'text'
|
||||
text = String(payload.text ?? '')
|
||||
} else if (rawType === TencentCloudChat.TYPES.MSG_IMAGE) {
|
||||
type = 'image'
|
||||
text = '[图片]'
|
||||
} else if (rawType === TencentCloudChat.TYPES.MSG_FILE) {
|
||||
type = 'file'
|
||||
text = '[文件]'
|
||||
} else if (rawType === TencentCloudChat.TYPES.MSG_AUDIO) {
|
||||
type = 'audio'
|
||||
text = '[语音]'
|
||||
} else if (rawType === TencentCloudChat.TYPES.MSG_VIDEO) {
|
||||
type = 'video'
|
||||
text = '[视频]'
|
||||
} else if (rawType === TencentCloudChat.TYPES.MSG_CUSTOM) {
|
||||
const custom = customMessageContent(payload)
|
||||
if (custom.hidden) return null
|
||||
text = custom.text
|
||||
} else if (rawType === TencentCloudChat.TYPES.MSG_FACE) {
|
||||
text = '[表情消息]'
|
||||
} else if (rawType === TencentCloudChat.TYPES.MSG_LOCATION) {
|
||||
text = `[位置] ${String(payload.description ?? payload.name ?? '').trim()}`.trim()
|
||||
} else if (rawType === TencentCloudChat.TYPES.MSG_MERGER) {
|
||||
text = `[合并消息] ${String(payload.title ?? '').trim()}`.trim()
|
||||
} else if (rawType === TencentCloudChat.TYPES.MSG_GRP_TIP) {
|
||||
text = '[群组通知]'
|
||||
} else if (rawType === TencentCloudChat.TYPES.MSG_GRP_SYS_NOTICE) {
|
||||
text = '[群组系统通知]'
|
||||
}
|
||||
return {
|
||||
id: String(raw?.ID ?? raw?.id ?? `${raw?.time ?? Date.now()}-${Math.random()}`),
|
||||
mine: Boolean(raw?.flow === 'out' || raw?.from === activeConfig?.userID),
|
||||
type,
|
||||
text,
|
||||
url: messageUrl(payload),
|
||||
name: String(payload.fileName ?? payload.name ?? (type === 'file' ? '文件' : '')),
|
||||
time: timeText(raw?.time),
|
||||
}
|
||||
}
|
||||
|
||||
function mergeMessages(rawList: any[], prepend = false): void {
|
||||
const incoming = rawList
|
||||
.map(normalizeMessage)
|
||||
.filter((item): item is UiChatMessage => item !== null)
|
||||
const combined = prepend ? [...incoming, ...messages.value] : [...messages.value, ...incoming]
|
||||
const seen = new Set<string>()
|
||||
messages.value = combined.filter((item) => {
|
||||
if (seen.has(item.id)) return false
|
||||
seen.add(item.id)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function onMessageReceived(event: any): void {
|
||||
if (!activeConfig) return
|
||||
const expected = `C2C${activeConfig.targetUserId}`
|
||||
const list = Array.isArray(event?.data) ? event.data : []
|
||||
const matched = list.filter((item: any) => {
|
||||
const conversationID = String(item?.conversationID ?? '')
|
||||
return conversationID === expected || item?.from === activeConfig?.targetUserId
|
||||
})
|
||||
if (!matched.length) return
|
||||
mergeMessages(matched)
|
||||
void chat?.setMessageRead?.({ conversationID: expected })
|
||||
}
|
||||
|
||||
function onSdkReady(): void {
|
||||
chatReady.value = true
|
||||
statusText.value = 'IM 已连接'
|
||||
notice.value = ''
|
||||
resolveChatReady?.()
|
||||
resolveChatReady = null
|
||||
rejectChatReady = null
|
||||
}
|
||||
|
||||
function onSdkNotReady(event: any): void {
|
||||
const wasReady = chatReady.value
|
||||
chatReady.value = false
|
||||
statusText.value = 'IM 连接已断开'
|
||||
if (wasReady) {
|
||||
notice.value = safeErrorMessage(event?.data?.message ?? event?.message, 'IM 连接已断开,请重新打开会话')
|
||||
}
|
||||
}
|
||||
|
||||
function onKickedOut(event: any): void {
|
||||
chatReady.value = false
|
||||
statusText.value = 'IM 已在其他窗口登录'
|
||||
const type = String(event?.data?.type ?? event?.data ?? '')
|
||||
notice.value = type.includes('userSigExpired')
|
||||
? 'IM 登录凭证已过期,请关闭窗口后重新打开'
|
||||
: '当前账号已在另一个 IM 窗口登录,请关闭其他患者 IM 窗口后重新打开本会话'
|
||||
}
|
||||
|
||||
function onNetStateChange(event: any): void {
|
||||
const state = String(event?.data?.state ?? event?.data ?? '').toUpperCase()
|
||||
if (state.includes('DISCONNECTED')) {
|
||||
statusText.value = 'IM 网络连接中断,正在自动恢复'
|
||||
} else if (state.includes('CONNECTED') && chatReady.value) {
|
||||
statusText.value = 'IM 已连接'
|
||||
}
|
||||
}
|
||||
|
||||
async function loginChat(): Promise<void> {
|
||||
if (!activeConfig) throw new Error('问诊配置尚未准备好')
|
||||
chatReady.value = false
|
||||
statusText.value = '正在连接患者 IM'
|
||||
chat = TencentCloudChat.create({ SDKAppID: activeConfig.SDKAppID })
|
||||
chat.setLogLevel(2)
|
||||
chat.registerPlugin({ 'tim-upload-plugin': TIMUploadPlugin })
|
||||
chat.on(TencentCloudChat.EVENT.SDK_READY, onSdkReady)
|
||||
chat.on(TencentCloudChat.EVENT.SDK_NOT_READY, onSdkNotReady)
|
||||
chat.on(TencentCloudChat.EVENT.KICKED_OUT, onKickedOut)
|
||||
chat.on(TencentCloudChat.EVENT.NET_STATE_CHANGE, onNetStateChange)
|
||||
chat.on(TencentCloudChat.EVENT.MESSAGE_RECEIVED, onMessageReceived)
|
||||
chatReadyPromise = new Promise<void>((resolve, reject) => {
|
||||
resolveChatReady = resolve
|
||||
rejectChatReady = reject
|
||||
})
|
||||
await chat.login({ userID: activeConfig.userID, userSig: activeConfig.userSig })
|
||||
await Promise.race([
|
||||
chatReadyPromise,
|
||||
new Promise<void>((_, reject) => window.setTimeout(() => reject(new Error('IM 连接超时')), 15000)),
|
||||
])
|
||||
await loadMessages(false)
|
||||
}
|
||||
|
||||
async function logoutChat(): Promise<void> {
|
||||
if (!chat) return
|
||||
try {
|
||||
chat.off(TencentCloudChat.EVENT.SDK_READY, onSdkReady)
|
||||
chat.off(TencentCloudChat.EVENT.SDK_NOT_READY, onSdkNotReady)
|
||||
chat.off(TencentCloudChat.EVENT.KICKED_OUT, onKickedOut)
|
||||
chat.off(TencentCloudChat.EVENT.NET_STATE_CHANGE, onNetStateChange)
|
||||
chat.off(TencentCloudChat.EVENT.MESSAGE_RECEIVED, onMessageReceived)
|
||||
await chat.logout()
|
||||
} catch {
|
||||
// Closing the desktop window must not be blocked by a stale IM connection.
|
||||
} finally {
|
||||
chat = null
|
||||
chatReady.value = false
|
||||
chatReadyPromise = null
|
||||
resolveChatReady = null
|
||||
rejectChatReady = null
|
||||
}
|
||||
}
|
||||
|
||||
async function reconnectChat(): Promise<void> {
|
||||
if (!activeConfig || activeConfig.mode !== 'chat') throw new Error('当前不是 IM 会话')
|
||||
notice.value = ''
|
||||
await logoutChat()
|
||||
await loginChat()
|
||||
}
|
||||
|
||||
async function loadMessages(prepend: boolean): Promise<void> {
|
||||
if (!chat || !activeConfig || chatBusy.value) return
|
||||
chatBusy.value = true
|
||||
try {
|
||||
const response = await chat.getMessageList({
|
||||
conversationID: `C2C${activeConfig.targetUserId}`,
|
||||
nextReqMessageID: prepend ? nextReqMessageID : '',
|
||||
count: 30,
|
||||
})
|
||||
const data = response?.data ?? {}
|
||||
const list = Array.isArray(data.messageList) ? data.messageList : []
|
||||
nextReqMessageID = String(data.nextReqMessageID ?? '')
|
||||
hasMoreMessages.value = !Boolean(data.isCompleted) && Boolean(nextReqMessageID)
|
||||
mergeMessages(list, prepend)
|
||||
await chat.setMessageRead({ conversationID: `C2C${activeConfig.targetUserId}` })
|
||||
} catch (error) {
|
||||
notice.value = `读取消息失败:${safeErrorMessage(error)}`
|
||||
} finally {
|
||||
chatBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function sendText(text: string): Promise<void> {
|
||||
if (
|
||||
!chat
|
||||
|| !activeConfig
|
||||
|| !chatReady.value
|
||||
|| !chat.isReady?.()
|
||||
|| chat.getLoginUser?.() !== activeConfig.userID
|
||||
) {
|
||||
chatReady.value = false
|
||||
statusText.value = 'IM 登录已失效'
|
||||
throw new Error('IM 登录已失效,请关闭其他患者 IM 窗口后重新打开本会话')
|
||||
}
|
||||
const content = text.trim()
|
||||
if (!content) return
|
||||
try {
|
||||
const message = chat.createTextMessage({
|
||||
to: activeConfig.targetUserId,
|
||||
conversationType: TencentCloudChat.TYPES.CONV_C2C,
|
||||
payload: { text: content },
|
||||
})
|
||||
const response = await chat.sendMessage(message)
|
||||
mergeMessages([response?.data?.message ?? response?.data ?? message])
|
||||
} catch (error) {
|
||||
throw new Error(safeErrorMessage(error, '消息发送失败'))
|
||||
}
|
||||
}
|
||||
|
||||
async function sendAttachment(file: File): Promise<void> {
|
||||
if (
|
||||
!chat
|
||||
|| !activeConfig
|
||||
|| !chatReady.value
|
||||
|| !chat.isReady?.()
|
||||
|| chat.getLoginUser?.() !== activeConfig.userID
|
||||
) {
|
||||
chatReady.value = false
|
||||
statusText.value = 'IM 登录已失效'
|
||||
throw new Error('IM 登录已失效,请关闭其他患者 IM 窗口后重新打开本会话')
|
||||
}
|
||||
if (file.size > 20 * 1024 * 1024) throw new Error('附件不能超过 20MB')
|
||||
const base = {
|
||||
to: activeConfig.targetUserId,
|
||||
conversationType: TencentCloudChat.TYPES.CONV_C2C,
|
||||
payload: { file },
|
||||
}
|
||||
const message = file.type.startsWith('image/')
|
||||
? chat.createImageMessage(base)
|
||||
: chat.createFileMessage(base)
|
||||
try {
|
||||
const response = await chat.sendMessage(message)
|
||||
mergeMessages([response?.data?.message ?? response?.data ?? message])
|
||||
} catch (error) {
|
||||
throw new Error(safeErrorMessage(error, '附件发送失败'))
|
||||
}
|
||||
}
|
||||
|
||||
function notifyHangup(status = 'ended'): void {
|
||||
if (endNotified) return
|
||||
endNotified = true
|
||||
phase.value = 'ended'
|
||||
statusText.value = '视频面诊已结束'
|
||||
statusText.value = mode.value === 'chat' ? '视频通话已结束,IM 保持连接' : '视频问诊已结束'
|
||||
emit({
|
||||
source: 'doctor-call',
|
||||
event: 'hangup',
|
||||
@@ -158,12 +481,7 @@ function emitRoomId(): boolean {
|
||||
const roomId = readRoomId()
|
||||
if (!roomId || roomId === emittedRoomId) return Boolean(roomId)
|
||||
emittedRoomId = roomId
|
||||
emit({
|
||||
source: 'doctor-call',
|
||||
event: 'room',
|
||||
diagnosisId: activeConfig?.diagnosisId,
|
||||
roomId,
|
||||
})
|
||||
emit({ source: 'doctor-call', event: 'room', diagnosisId: activeConfig?.diagnosisId, roomId })
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -179,10 +497,9 @@ function handleStatusChanged(payload: unknown): void {
|
||||
? (payload as { newStatus?: unknown }).newStatus
|
||||
: payload
|
||||
const status = typeof value === 'string' ? value : 'unknown'
|
||||
|
||||
if (status === 'connected' || status.startsWith('calling-')) {
|
||||
phase.value = 'connected'
|
||||
statusText.value = '视频面诊进行中'
|
||||
statusText.value = '视频问诊进行中'
|
||||
void pollRoomId()
|
||||
} else if (status === 'calling' || status.startsWith('dialing')) {
|
||||
phase.value = 'dialing'
|
||||
@@ -190,70 +507,71 @@ function handleStatusChanged(payload: unknown): void {
|
||||
} else if (status === 'idle' && activeConfig && !starting) {
|
||||
notifyHangup(status)
|
||||
}
|
||||
|
||||
emit({
|
||||
source: 'doctor-call',
|
||||
event: 'status',
|
||||
diagnosisId: activeConfig?.diagnosisId,
|
||||
status,
|
||||
})
|
||||
emit({ source: 'doctor-call', event: 'status', diagnosisId: activeConfig?.diagnosisId, status })
|
||||
}
|
||||
|
||||
TUICallKitAPI.setCallback({
|
||||
statusChanged: handleStatusChanged,
|
||||
afterCalling: () => notifyHangup('after-calling'),
|
||||
})
|
||||
TUICallKitAPI.setLanguage('zh-cn')
|
||||
TUICallKitAPI.enableFloatWindow(false)
|
||||
|
||||
async function start(config: DoctorCallConfig): Promise<void> {
|
||||
if (starting || (activeConfig && !endNotified)) {
|
||||
throw new Error('已有视频通话正在进行')
|
||||
}
|
||||
function requestHostCallStart(): Promise<boolean> {
|
||||
if (!activeConfig) return Promise.resolve(false)
|
||||
if (!window.qtVideoBridge?.notify) return Promise.resolve(true)
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const currentResolver = resolve
|
||||
resolveHostCallReady = currentResolver
|
||||
emit({ source: 'doctor-call', event: 'call-start-request', diagnosisId: activeConfig?.diagnosisId })
|
||||
window.setTimeout(() => {
|
||||
if (resolveHostCallReady !== currentResolver) return
|
||||
resolveHostCallReady = null
|
||||
resolve(false)
|
||||
}, 15000)
|
||||
})
|
||||
}
|
||||
|
||||
function hostCallReady(ok: boolean, message = ''): void {
|
||||
const resolver = resolveHostCallReady
|
||||
resolveHostCallReady = null
|
||||
if (message) notice.value = message
|
||||
resolver?.(Boolean(ok))
|
||||
}
|
||||
|
||||
async function startVideo(): Promise<void> {
|
||||
if (!activeConfig) throw new Error('问诊配置尚未准备好')
|
||||
if (starting || !endNotified) throw new Error('已有视频通话正在进行')
|
||||
starting = true
|
||||
endNotified = false
|
||||
phase.value = 'starting'
|
||||
statusText.value = '正在初始化安全通话'
|
||||
|
||||
statusText.value = '正在创建安全视频通话'
|
||||
notice.value = ''
|
||||
try {
|
||||
const normalized = normalizeConfig(config)
|
||||
activeConfig = normalized
|
||||
emittedRoomId = ''
|
||||
|
||||
const allowed = await requestHostCallStart()
|
||||
if (!allowed) throw new Error(notice.value || '服务器未能创建视频通话记录')
|
||||
await TUICallKitAPI.init({
|
||||
SDKAppID: normalized.SDKAppID,
|
||||
userID: normalized.userID,
|
||||
userSig: normalized.userSig,
|
||||
SDKAppID: activeConfig.SDKAppID,
|
||||
userID: activeConfig.userID,
|
||||
userSig: activeConfig.userSig,
|
||||
...(chat ? { tim: chat, isFromChat: true } : {}),
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
phase.value = 'dialing'
|
||||
statusText.value = '正在呼叫患者'
|
||||
|
||||
emittedRoomId = ''
|
||||
await TUICallKitAPI.calls({
|
||||
userIDList: [normalized.targetUserId],
|
||||
userIDList: [activeConfig.targetUserId],
|
||||
type: TUICallType.VIDEO_CALL,
|
||||
})
|
||||
void pollRoomId()
|
||||
|
||||
emit({
|
||||
source: 'doctor-call',
|
||||
event: 'status',
|
||||
diagnosisId: normalized.diagnosisId,
|
||||
status: 'dialing',
|
||||
})
|
||||
emit({ source: 'doctor-call', event: 'status', diagnosisId: activeConfig.diagnosisId, status: 'dialing' })
|
||||
} catch (error) {
|
||||
const message = safeErrorMessage(error)
|
||||
const message = safeErrorMessage(error, '无法发起视频通话')
|
||||
phase.value = 'error'
|
||||
statusText.value = message
|
||||
emit({
|
||||
source: 'doctor-call',
|
||||
event: 'error',
|
||||
diagnosisId: activeConfig?.diagnosisId,
|
||||
message,
|
||||
})
|
||||
activeConfig = null
|
||||
endNotified = true
|
||||
emit({ source: 'doctor-call', event: 'error', diagnosisId: activeConfig.diagnosisId, message })
|
||||
throw new Error(message)
|
||||
} finally {
|
||||
starting = false
|
||||
@@ -262,28 +580,88 @@ async function start(config: DoctorCallConfig): Promise<void> {
|
||||
|
||||
async function hangup(): Promise<void> {
|
||||
if (!activeConfig || endNotified) return
|
||||
|
||||
try {
|
||||
await TUICallKitAPI.hangup()
|
||||
notifyHangup('local-hangup')
|
||||
} catch (error) {
|
||||
const message = safeErrorMessage(error)
|
||||
emit({
|
||||
source: 'doctor-call',
|
||||
event: 'error',
|
||||
diagnosisId: activeConfig.diagnosisId,
|
||||
message,
|
||||
})
|
||||
const message = safeErrorMessage(error, '结束视频通话失败')
|
||||
emit({ source: 'doctor-call', event: 'error', diagnosisId: activeConfig.diagnosisId, message })
|
||||
throw new Error(message)
|
||||
}
|
||||
}
|
||||
|
||||
window.doctorCall = { start, hangup }
|
||||
async function saveScreenshot(dataUrl: string): Promise<void> {
|
||||
const bridge = window.qtVideoBridge
|
||||
if (!bridge || typeof bridge.saveScreenshot !== 'function') throw new Error('桌面端截图保存通道不可用')
|
||||
bridge.saveScreenshot(dataUrl)
|
||||
notice.value = '正在保存截图到患者舌像资料…'
|
||||
}
|
||||
|
||||
function screenshotResult(ok: boolean, message: string): void {
|
||||
notice.value = message || (ok ? '截图已保存到患者舌像资料' : '截图保存失败')
|
||||
}
|
||||
|
||||
async function open(config: DoctorCallConfig): Promise<void> {
|
||||
if (activeConfig) await close()
|
||||
activeConfig = normalizeConfig(config)
|
||||
mode.value = activeConfig.mode
|
||||
patientName.value = activeConfig.patientName
|
||||
messages.value = []
|
||||
nextReqMessageID = ''
|
||||
hasMoreMessages.value = false
|
||||
endNotified = true
|
||||
phase.value = 'ready'
|
||||
notice.value = ''
|
||||
if (activeConfig.mode === 'chat') {
|
||||
try {
|
||||
await loginChat()
|
||||
} catch (error) {
|
||||
const message = safeErrorMessage(error, 'IM 连接失败')
|
||||
phase.value = 'error'
|
||||
statusText.value = message
|
||||
emit({ source: 'doctor-call', event: 'error', diagnosisId: activeConfig.diagnosisId, message })
|
||||
throw new Error(message)
|
||||
}
|
||||
} else {
|
||||
await startVideo()
|
||||
}
|
||||
}
|
||||
|
||||
async function close(): Promise<void> {
|
||||
if (!endNotified) await hangup()
|
||||
await logoutChat()
|
||||
activeConfig = null
|
||||
phase.value = 'ended'
|
||||
}
|
||||
|
||||
window.doctorConsultation = {
|
||||
open,
|
||||
close,
|
||||
startVideo,
|
||||
hangup,
|
||||
hostCallReady,
|
||||
screenshotResult,
|
||||
}
|
||||
window.doctorCall = { start: open, hangup }
|
||||
initializeQtWebChannel()
|
||||
|
||||
createApp(App, {
|
||||
phase: readonly(phase),
|
||||
statusText: readonly(statusText),
|
||||
patientName: readonly(patientName),
|
||||
mode: readonly(mode),
|
||||
messages: readonly(messages),
|
||||
chatReady: readonly(chatReady),
|
||||
chatBusy: readonly(chatBusy),
|
||||
notice: readonly(notice),
|
||||
hasMoreMessages: readonly(hasMoreMessages),
|
||||
onSendText: sendText,
|
||||
onSendAttachment: sendAttachment,
|
||||
onLoadMore: () => loadMessages(true),
|
||||
onReconnectChat: reconnectChat,
|
||||
onStartVideo: startVideo,
|
||||
onHangup: hangup,
|
||||
onSaveScreenshot: saveScreenshot,
|
||||
}).mount('#app')
|
||||
|
||||
emit({ source: 'doctor-call', event: 'ready' })
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
:root {
|
||||
font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
|
||||
color: #f7f8fa;
|
||||
background: #0b0f14;
|
||||
font-family: "Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC", system-ui, sans-serif;
|
||||
color: #132238;
|
||||
background: #eef3fb;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html,
|
||||
body,
|
||||
@@ -20,23 +18,222 @@ body,
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
font: inherit;
|
||||
textarea,
|
||||
input { font: inherit; }
|
||||
|
||||
button { cursor: pointer; }
|
||||
button:disabled { cursor: not-allowed; opacity: .55; }
|
||||
|
||||
.consultation-shell {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 760px;
|
||||
min-height: 540px;
|
||||
background: #eef3fb;
|
||||
}
|
||||
|
||||
.call-stage {
|
||||
.chat-panel {
|
||||
display: grid;
|
||||
grid-template-rows: 74px minmax(0, 1fr) auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #f7f9fd;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid #d7dfef;
|
||||
background: rgba(255, 255, 255, .96);
|
||||
}
|
||||
|
||||
.patient-avatar {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 14px;
|
||||
color: #fff;
|
||||
background: #5267df;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.chat-heading { min-width: 0; flex: 1; }
|
||||
.chat-heading h1 { margin: 0; font-size: 18px; line-height: 1.4; }
|
||||
.chat-heading p {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
margin: 3px 0 0;
|
||||
color: #71809a;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.connection-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: #a3adbd;
|
||||
}
|
||||
.connection-dot--online { background: #24a77c; box-shadow: 0 0 0 3px rgba(36, 167, 124, .12); }
|
||||
|
||||
.primary-action,
|
||||
.send-button,
|
||||
.capture-button {
|
||||
border: 1px solid #5166df;
|
||||
border-radius: 9px;
|
||||
color: #fff;
|
||||
background: #5267df;
|
||||
font-weight: 600;
|
||||
}
|
||||
.secondary-action {
|
||||
padding: 9px 13px;
|
||||
border: 1px solid #c8d2e7;
|
||||
border-radius: 9px;
|
||||
color: #43526b;
|
||||
background: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
.secondary-action:hover { border-color: #7385e9; color: #4055ca; background: #f5f7ff; }
|
||||
.primary-action { display: flex; gap: 8px; align-items: center; padding: 10px 16px; }
|
||||
.primary-action:hover,
|
||||
.send-button:hover,
|
||||
.capture-button:hover { background: #4055ca; }
|
||||
|
||||
.message-list {
|
||||
overflow-y: auto;
|
||||
padding: 18px max(24px, calc((100% - 920px) / 2));
|
||||
background:
|
||||
radial-gradient(circle at 12% 16%, rgba(82, 103, 223, .055), transparent 25%),
|
||||
#f3f6fb;
|
||||
}
|
||||
|
||||
.load-more {
|
||||
display: block;
|
||||
margin: 0 auto 18px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid #d6deed;
|
||||
border-radius: 999px;
|
||||
color: #66758e;
|
||||
background: #fff;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.empty-chat {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
margin-top: min(16vh, 110px);
|
||||
color: #7c89a0;
|
||||
text-align: center;
|
||||
}
|
||||
.empty-chat__icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 62px;
|
||||
height: 62px;
|
||||
margin-bottom: 12px;
|
||||
border: 1px solid #d2daeb;
|
||||
border-radius: 20px;
|
||||
color: #5267df;
|
||||
background: #fff;
|
||||
font-weight: 800;
|
||||
}
|
||||
.empty-chat h2 { margin: 0; color: #34425a; font-size: 17px; }
|
||||
.empty-chat p { margin: 7px 0; font-size: 13px; }
|
||||
|
||||
.message-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
margin: 12px 0;
|
||||
}
|
||||
.message-row--mine { align-items: flex-end; }
|
||||
.message-meta { margin: 0 8px 5px; color: #8995a9; font-size: 11px; }
|
||||
.message-bubble {
|
||||
max-width: min(72%, 620px);
|
||||
padding: 10px 13px;
|
||||
border: 1px solid #d8e0ed;
|
||||
border-radius: 5px 15px 15px 15px;
|
||||
background: #fff;
|
||||
box-shadow: 0 4px 14px rgba(29, 47, 80, .05);
|
||||
line-height: 1.6;
|
||||
word-break: break-word;
|
||||
}
|
||||
.message-row--mine .message-bubble {
|
||||
border-color: #5267df;
|
||||
border-radius: 15px 5px 15px 15px;
|
||||
color: #fff;
|
||||
background: #5267df;
|
||||
}
|
||||
.message-bubble p { margin: 0; white-space: pre-wrap; }
|
||||
.message-image,
|
||||
.message-video { display: block; max-width: 360px; max-height: 280px; border-radius: 9px; }
|
||||
.message-file { display: flex; align-items: center; gap: 8px; color: inherit; text-decoration: none; }
|
||||
.message-bubble audio { max-width: 320px; }
|
||||
|
||||
.composer {
|
||||
padding: 10px 18px 14px;
|
||||
border-top: 1px solid #d7dfef;
|
||||
background: #fff;
|
||||
}
|
||||
.composer-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 30px;
|
||||
color: #8a96a9;
|
||||
font-size: 11px;
|
||||
}
|
||||
.composer-toolbar button {
|
||||
padding: 5px 9px;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
color: #5267df;
|
||||
background: #eef1ff;
|
||||
font-size: 12px;
|
||||
}
|
||||
.composer-row { display: grid; grid-template-columns: minmax(0, 1fr) 86px; gap: 12px; }
|
||||
.composer textarea {
|
||||
width: 100%;
|
||||
min-height: 68px;
|
||||
max-height: 150px;
|
||||
padding: 10px 12px;
|
||||
resize: vertical;
|
||||
border: 1px solid #ced8ea;
|
||||
border-radius: 10px;
|
||||
outline: none;
|
||||
color: #15233a;
|
||||
background: #fbfcff;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.composer textarea:focus { border-color: #6678ed; box-shadow: 0 0 0 3px rgba(82, 103, 223, .11); }
|
||||
.send-button { align-self: end; height: 40px; }
|
||||
|
||||
.inline-notice {
|
||||
margin-bottom: 7px;
|
||||
color: #3f6f62;
|
||||
font-size: 12px;
|
||||
}
|
||||
.inline-notice--error { color: #c14455; }
|
||||
|
||||
.video-layer {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 420px;
|
||||
overflow: hidden;
|
||||
color: #f7f8fa;
|
||||
background:
|
||||
radial-gradient(circle at 50% 35%, rgba(39, 74, 83, 0.22), transparent 38%),
|
||||
#0b0f14;
|
||||
radial-gradient(circle at 50% 35%, rgba(60, 86, 130, .28), transparent 38%),
|
||||
#090d14;
|
||||
}
|
||||
.video-layer--overlay { position: fixed; z-index: 1000; inset: 0; }
|
||||
|
||||
.call-kit,
|
||||
.call-stage :is(.TUICallKit-desktop, .TUICallKit-mobile, #tuicallkit-id) {
|
||||
.video-layer :is(.TUICallKit-desktop, .TUICallKit-mobile, #tuicallkit-id) {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
max-width: none !important;
|
||||
@@ -52,55 +249,25 @@ input {
|
||||
width: min(520px, calc(100% - 48px));
|
||||
padding: 30px 32px;
|
||||
transform: translate(-50%, -50%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.09);
|
||||
border: 1px solid rgba(255, 255, 255, .1);
|
||||
border-radius: 20px;
|
||||
background: rgba(19, 25, 32, 0.9);
|
||||
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.32);
|
||||
backdrop-filter: blur(18px);
|
||||
background: rgba(19, 25, 32, .92);
|
||||
box-shadow: 0 24px 70px rgba(0, 0, 0, .32);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 12px;
|
||||
color: #8f9ba8;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status-card h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(22px, 3.2vw, 34px);
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.status-hint {
|
||||
margin: 14px 0 0;
|
||||
color: #9aa5b1;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.eyebrow { margin: 0 0 12px; color: #a3afbf; font-size: 12px; font-weight: 700; letter-spacing: .12em; }
|
||||
.status-card h2 { margin: 0; font-size: clamp(22px, 3.2vw, 34px); font-weight: 600; line-height: 1.25; }
|
||||
.status-hint { margin: 14px 0 0; color: #9aa5b1; font-size: 14px; }
|
||||
.status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
margin-top: 5px;
|
||||
border-radius: 50%;
|
||||
background: #77818c;
|
||||
box-shadow: 0 0 0 5px rgba(119, 129, 140, 0.12);
|
||||
box-shadow: 0 0 0 5px rgba(119, 129, 140, .12);
|
||||
}
|
||||
|
||||
.status-dot--starting,
|
||||
.status-dot--live {
|
||||
background: #52c99a;
|
||||
box-shadow: 0 0 0 5px rgba(82, 201, 154, 0.14);
|
||||
}
|
||||
|
||||
.status-dot--error {
|
||||
background: #f26d6d;
|
||||
box-shadow: 0 0 0 5px rgba(242, 109, 109, 0.14);
|
||||
}
|
||||
|
||||
.status-dot--live { background: #52c99a; box-shadow: 0 0 0 5px rgba(82, 201, 154, .14); }
|
||||
.status-dot--error { background: #f26d6d; box-shadow: 0 0 0 5px rgba(242, 109, 109, .14); }
|
||||
.live-status {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
@@ -111,17 +278,41 @@ input {
|
||||
gap: 10px;
|
||||
padding: 9px 14px;
|
||||
transform: translateX(-50%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, .1);
|
||||
border-radius: 999px;
|
||||
background: rgba(11, 15, 20, 0.76);
|
||||
color: #e8edf2;
|
||||
background: rgba(11, 15, 20, .8);
|
||||
font-size: 13px;
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
.live-status .status-dot { width: 7px; height: 7px; margin: 0; box-shadow: none; }
|
||||
|
||||
.live-status .status-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
margin: 0;
|
||||
box-shadow: none;
|
||||
.video-actions {
|
||||
position: absolute;
|
||||
z-index: 40;
|
||||
right: 22px;
|
||||
bottom: 24px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
.video-actions button { padding: 10px 15px; border-radius: 10px; font-weight: 600; }
|
||||
.hangup-button { border: 1px solid #b44755; color: #fff; background: rgba(161, 47, 61, .9); }
|
||||
.hangup-button:hover { background: #be394d; }
|
||||
.video-notice {
|
||||
position: absolute;
|
||||
z-index: 40;
|
||||
left: 22px;
|
||||
bottom: 26px;
|
||||
max-width: calc(100% - 420px);
|
||||
padding: 9px 12px;
|
||||
border: 1px solid rgba(82, 201, 154, .35);
|
||||
border-radius: 9px;
|
||||
color: #d9f8eb;
|
||||
background: rgba(20, 78, 62, .84);
|
||||
font-size: 13px;
|
||||
}
|
||||
.video-notice--error { border-color: rgba(242, 109, 109, .4); color: #ffe4e7; background: rgba(100, 31, 40, .88); }
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.consultation-shell { min-width: 620px; }
|
||||
.message-list { padding-inline: 18px; }
|
||||
.message-bubble { max-width: 82%; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user