This commit is contained in:
Your Name
2026-08-12 11:03:28 +08:00
parent 09d3fcbf82
commit bc9ad1d3cd
32 changed files with 4586 additions and 2341 deletions
-1
View File
@@ -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}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -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>
+1 -1
View File
@@ -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>
+8
View File
@@ -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",
+2
View File
@@ -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
View File
@@ -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>
+18
View File
@@ -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
View File
@@ -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' })
+251 -60
View File
@@ -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%; }
}