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
+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' })