first commit
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
# Doctor Video Companion
|
||||
|
||||
这是桌面端专用的最小 Vue 3 / Vite 视频页面。它与 admin 的独立视频组件保持同一条 SDK 主链,直接使用固定版本 `@trtc/calls-uikit-vue@4.4.6`。
|
||||
|
||||
## 构建
|
||||
|
||||
```bash
|
||||
cd video_companion
|
||||
npm ci
|
||||
npm run build
|
||||
```
|
||||
|
||||
构建产物位于 `video_companion/dist/`。Vite 使用相对资源路径,因此产物既能由 HTTPS 托管,也能由 QtWebEngine 从本地安装目录加载。
|
||||
|
||||
桌面端当前只在隔离的 QtWebEngine 中加载此页面。页面不会自行向后端领取通话票据,因此在后端提供服务端签发的一次性 handoff 前,系统浏览器模式与自动浏览器降级均被禁用。
|
||||
|
||||
## 宿主契约
|
||||
|
||||
页面加载后会暴露:
|
||||
|
||||
```ts
|
||||
window.doctorCall.start({
|
||||
SDKAppID: 1400000000, // 也接受 sdkAppId
|
||||
userID: 'doctor_42', // 也接受 userId
|
||||
userSig: '<后端短时票据>',
|
||||
targetUserId: 'patient_8', // 也接受 patientUserId
|
||||
diagnosisId: 123,
|
||||
})
|
||||
|
||||
await window.doctorCall.hangup()
|
||||
```
|
||||
|
||||
`userSig` 必须由业务后端签发。此页面不会生成 UserSig,也不接受或使用 SDKSecretKey。
|
||||
|
||||
状态、错误与挂断消息优先调用 `window.qtVideoBridge.notify(JSON.stringify(message))`。没有 Qt bridge 时,页面使用 `postMessage` 通知父窗口或 opener;都不可用时只输出不含票据的控制台状态。
|
||||
+632
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="light" />
|
||||
<title>视频面诊</title>
|
||||
<script type="module" crossorigin src="./assets/index-DXNYj41g.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-sts1UBl3.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<!-- QWebEngine 提供此资源;普通浏览器中加载失败不影响页面运行。 -->
|
||||
<script src="qrc:///qtwebchannel/qwebchannel.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="light" />
|
||||
<title>视频面诊</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<!-- QWebEngine 提供此资源;普通浏览器中加载失败不影响页面运行。 -->
|
||||
<script src="qrc:///qtwebchannel/qwebchannel.js"></script>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1612
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "doctor-video-companion",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"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": {
|
||||
"@vitejs/plugin-vue": "5.2.1",
|
||||
"typescript": "5.7.3",
|
||||
"vite": "6.1.1",
|
||||
"vue-tsc": "2.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
<script setup lang="ts">
|
||||
import { TUICallKit } from '@trtc/calls-uikit-vue'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
interface ChatMessage {
|
||||
id: string
|
||||
mine: boolean
|
||||
type: 'text' | 'image' | 'file' | 'audio' | 'video' | 'system'
|
||||
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>>
|
||||
transcriptionState: Readonly<Ref<string>>
|
||||
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')
|
||||
const transcriptionActive = computed(() => props.transcriptionState.value === 'recording')
|
||||
const transcriptionFailed = computed(() => props.transcriptionState.value === 'error')
|
||||
const transcriptionStatusText = computed(() => {
|
||||
if (props.transcriptionState.value === 'starting') return '自动录音启动中…'
|
||||
if (props.transcriptionState.value === 'recording') return '自动录音并转文字中'
|
||||
if (props.transcriptionState.value === 'stopping') return '正在保存录音文字…'
|
||||
if (props.transcriptionState.value === 'error') return '自动录音转文字失败'
|
||||
return '自动录音已结束'
|
||||
})
|
||||
|
||||
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="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>
|
||||
|
||||
<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>
|
||||
|
||||
<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">
|
||||
<div
|
||||
v-if="canCapture"
|
||||
class="recording-status"
|
||||
:class="{
|
||||
'recording-status--active': transcriptionActive,
|
||||
'recording-status--error': transcriptionFailed,
|
||||
}"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span class="recording-indicator" aria-hidden="true" />
|
||||
{{ transcriptionStatusText }}
|
||||
</div>
|
||||
<button
|
||||
class="capture-button"
|
||||
type="button"
|
||||
:disabled="!canCapture || actionBusy"
|
||||
@click="captureScreenshot"
|
||||
>
|
||||
截屏并保存患者资料
|
||||
</button>
|
||||
<button class="hangup-button" type="button" :disabled="actionBusy" @click="runAction(onHangup)">
|
||||
结束视频
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="localError || notice.value" class="video-notice" :class="{ 'video-notice--error': localError }">
|
||||
{{ localError || notice.value }}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
Vendored
+56
@@ -0,0 +1,56 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module 'tim-upload-plugin' {
|
||||
const plugin: unknown
|
||||
export default plugin
|
||||
}
|
||||
|
||||
interface DoctorCallConfig {
|
||||
SDKAppID?: number | string
|
||||
sdkAppId?: number | string
|
||||
userID?: string
|
||||
userId?: string
|
||||
userSig: string
|
||||
targetUserId?: string
|
||||
patientUserId?: string
|
||||
diagnosisId: number | string
|
||||
patientName?: string
|
||||
mode?: 'chat' | 'video'
|
||||
}
|
||||
|
||||
interface DoctorCallApi {
|
||||
start(config: DoctorCallConfig): Promise<void>
|
||||
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
|
||||
transcriptionResult(
|
||||
operation: 'start' | 'segment' | 'stop',
|
||||
sessionId: string,
|
||||
segmentId: string,
|
||||
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 (
|
||||
transport: unknown,
|
||||
callback: (channel: { objects: { qtVideoBridge?: QtVideoBridge } }) => void,
|
||||
) => unknown
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,433 @@
|
||||
:root {
|
||||
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; }
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
button,
|
||||
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;
|
||||
}
|
||||
|
||||
.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(60, 86, 130, .28), transparent 38%),
|
||||
#090d14;
|
||||
}
|
||||
.video-layer--overlay { position: fixed; z-index: 1000; inset: 0; }
|
||||
|
||||
.call-kit,
|
||||
.video-layer :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, .1);
|
||||
border-radius: 20px;
|
||||
background: rgba(19, 25, 32, .92);
|
||||
box-shadow: 0 24px 70px rgba(0, 0, 0, .32);
|
||||
}
|
||||
.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, .12);
|
||||
}
|
||||
.status-dot--starting,
|
||||
.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;
|
||||
top: 18px;
|
||||
left: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 14px;
|
||||
transform: translateX(-50%);
|
||||
border: 1px solid rgba(255, 255, 255, .1);
|
||||
border-radius: 999px;
|
||||
background: rgba(11, 15, 20, .8);
|
||||
font-size: 13px;
|
||||
}
|
||||
.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; }
|
||||
.recording-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 15px;
|
||||
border: 1px solid rgba(255, 255, 255, .24);
|
||||
border-radius: 10px;
|
||||
color: #fff;
|
||||
background: rgba(22, 29, 39, .88);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.recording-status--active { border-color: rgba(244, 99, 115, .64); background: rgba(126, 35, 50, .9); }
|
||||
.recording-status--error { border-color: rgba(242, 109, 109, .56); color: #ffe4e7; background: rgba(100, 31, 40, .88); }
|
||||
.recording-indicator {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: #f46373;
|
||||
box-shadow: 0 0 0 4px rgba(244, 99, 115, .16);
|
||||
}
|
||||
.recording-status--active .recording-indicator { animation: recording-pulse 1.25s ease-in-out infinite; }
|
||||
@keyframes recording-pulse {
|
||||
50% { box-shadow: 0 0 0 8px rgba(244, 99, 115, .04); opacity: .72; }
|
||||
}
|
||||
.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%; }
|
||||
}
|
||||
|
||||
/* Doctor workstation blue-white subwindow contract. Video pixels remain on
|
||||
the dark stage; every application-owned chrome surface follows the shell. */
|
||||
:root { color: #111f46; background: #eef3fd; }
|
||||
.consultation-shell { background: #eef3fd; }
|
||||
.chat-panel { background: #f7f9fe; }
|
||||
.chat-header,
|
||||
.composer { border-color: #e6eaf5; background: rgba(255, 255, 255, .98); }
|
||||
.patient-avatar { background: linear-gradient(135deg, #5761f4, #7769f7); }
|
||||
.chat-heading p,
|
||||
.empty-chat,
|
||||
.message-meta,
|
||||
.composer-toolbar { color: #7886aa; }
|
||||
.connection-dot--online {
|
||||
background: #17a77d;
|
||||
box-shadow: 0 0 0 3px rgba(23, 167, 125, .12);
|
||||
}
|
||||
.primary-action,
|
||||
.send-button,
|
||||
.capture-button {
|
||||
border-color: #5761f4;
|
||||
background: linear-gradient(90deg, #5761f4, #7769f7);
|
||||
}
|
||||
.primary-action:hover,
|
||||
.send-button:hover,
|
||||
.capture-button:hover { background: #4c57e9; }
|
||||
.secondary-action {
|
||||
border-color: #e6eaf5;
|
||||
color: #3f4e75;
|
||||
}
|
||||
.secondary-action:hover {
|
||||
border-color: #5761f4;
|
||||
color: #4451e2;
|
||||
background: #f0f2ff;
|
||||
}
|
||||
.message-list {
|
||||
background:
|
||||
radial-gradient(circle at 12% 16%, rgba(87, 97, 244, .055), transparent 25%),
|
||||
#f7f9fe;
|
||||
}
|
||||
.load-more,
|
||||
.empty-chat__icon,
|
||||
.message-bubble { border-color: #e6eaf5; }
|
||||
.load-more { color: #7886aa; }
|
||||
.empty-chat__icon { color: #5761f4; }
|
||||
.empty-chat h2 { color: #111f46; }
|
||||
.message-bubble { box-shadow: 0 4px 14px rgba(17, 31, 70, .05); }
|
||||
.message-row--mine .message-bubble { border-color: #5761f4; background: #5761f4; }
|
||||
.composer-toolbar button { color: #4451e2; background: #f0f2ff; }
|
||||
.composer textarea {
|
||||
border-color: #e6eaf5;
|
||||
color: #111f46;
|
||||
background: #fafbfe;
|
||||
}
|
||||
.composer textarea:focus {
|
||||
border-color: #8d9bff;
|
||||
box-shadow: 0 0 0 3px rgba(87, 97, 244, .11);
|
||||
}
|
||||
.video-actions button { border-radius: 9px; }
|
||||
.recording-status {
|
||||
border-color: rgba(230, 234, 245, .88);
|
||||
border-radius: 9px;
|
||||
color: #3f4e75;
|
||||
background: rgba(255, 255, 255, .95);
|
||||
}
|
||||
.recording-status--active,
|
||||
.recording-status--error {
|
||||
border-color: rgba(241, 91, 103, .45);
|
||||
color: #b63849;
|
||||
background: rgba(255, 241, 243, .96);
|
||||
}
|
||||
.recording-indicator {
|
||||
background: #f15b67;
|
||||
box-shadow: 0 0 0 4px rgba(241, 91, 103, .16);
|
||||
}
|
||||
@keyframes recording-pulse {
|
||||
50% { box-shadow: 0 0 0 8px rgba(241, 91, 103, .04); opacity: .72; }
|
||||
}
|
||||
.hangup-button { border-color: #f15b67; background: #f15b67; }
|
||||
.hangup-button:hover { background: #d94857; }
|
||||
.video-notice {
|
||||
border-color: rgba(23, 167, 125, .35);
|
||||
color: #12765b;
|
||||
background: rgba(234, 249, 243, .95);
|
||||
}
|
||||
.video-notice--error {
|
||||
border-color: rgba(241, 91, 103, .4);
|
||||
color: #b63849;
|
||||
background: rgba(255, 241, 243, .96);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
base: './',
|
||||
plugins: [vue()],
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
target: 'chrome100',
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user