first commit

This commit is contained in:
Your Name
2026-09-08 11:40:15 +08:00
commit a5353f7eb5
9568 changed files with 1646214 additions and 0 deletions
+35
View File
@@ -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;都不可用时只输出不含票据的控制台状态。
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+16
View File
@@ -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>
+15
View File
@@ -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>
File diff suppressed because it is too large Load Diff
+26
View File
@@ -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"
}
}
+286
View File
@@ -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>
+56
View File
@@ -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
+433
View File
@@ -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);
}
+17
View File
@@ -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"]
}
+12
View File
@@ -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',
},
})