This commit is contained in:
Your Name
2026-03-23 18:02:16 +08:00
parent 18fee2e8f8
commit 250d173c2f
525 changed files with 10659 additions and 793 deletions
+604 -10
View File
@@ -67,9 +67,31 @@
@mousedown="onCallKitDragStart"
>
<span class="drag-handle-text">视频通话</span>
<el-icon class="drag-handle-icon"><Rank /></el-icon>
<div class="call-kit-drag-handle-actions">
<el-button
type="primary"
link
class="call-kit-screenshot-btn"
:loading="captureUploading"
@mousedown.stop
@click.stop="handleCallKitScreenshot"
>
<el-icon><Camera /></el-icon>
<span class="call-kit-screenshot-text">截屏</span>
</el-button>
<el-button
type="danger"
link
class="call-kit-close-btn"
@mousedown.stop
@click.stop="handleCallKitClose"
>
<el-icon><Close /></el-icon>
</el-button>
<el-icon class="drag-handle-icon"><Rank /></el-icon>
</div>
</div>
<div class="call-kit-content">
<div ref="callKitContentRef" class="call-kit-content">
<TUICallKit
:allowedMinimized="true"
:allowedFullScreen="true"
@@ -82,10 +104,23 @@
</template>
<script setup lang="ts">
import { ref, nextTick, watch, computed } from 'vue'
import { Loading, Close, Rank } from '@element-plus/icons-vue'
import { getCallSignature } from '@/api/tcm'
import { ref, nextTick, watch, computed, onMounted, onUnmounted } from 'vue'
import { Loading, Close, Rank, Camera } from '@element-plus/icons-vue'
import { uploadImageBlob } from '@/api/file'
import {
bindCallRoom,
getCallSignature,
startCall,
tcmDiagnosisDetail,
tcmDiagnosisEdit
} from '@/api/tcm'
import { captureVideoFrameFromElement } from '@/utils/call-video-screenshot'
import feedback from '@/utils/feedback'
import {
formatTUICallUserError,
getTUICallPackageArrearsMessage,
isTUICallPackageAbilityError
} from '@/utils/tuicall-error'
import useUserStore from '@/stores/modules/user'
import {
ConversationList,
@@ -103,7 +138,15 @@ import {
} from '@tencentcloud/chat-uikit-vue3'
import TencentCloudChat from '@tencentcloud/chat'
import '@/assets/chat-uikit.css'
import { TUICallKit, TUICallKitServer, TUICallType } from '@tencentcloud/call-uikit-vue'
import {
TUICallKit,
TUICallKitServer,
TUICallType,
TUIStore,
StoreName,
NAME
} from '@tencentcloud/call-uikit-vue'
import { TUICallEvent } from '@tencentcloud/call-engine-js'
const assistant_ids=ref('');
const visible = ref(false)
const isReady = ref(false)
@@ -120,6 +163,445 @@ const { login, logout } = useLoginState()
const { setActiveConversation, createC2CConversation, activeConversation } = useConversationListState()
const userStore = useUserStore()
/** TUICallKit CallStatus(与 @tencentcloud/call-uikit-vue 一致) */
const CALL_STATUS_IDLE = 'idle'
const CALL_STATUS_CALLING = 'calling'
const CALL_STATUS_CONNECTED = 'connected'
/** 通话绑定 TRTC 房间号到诊单(云端录制回调按 room_id 关联) */
const lastBoundRoomKey = ref('')
let tearDownCallRoomBinding: (() => void) | null = null
let bindRoomRetryTimer: ReturnType<typeof setTimeout> | null = null
let bindRoomRetryAttempts = 0
const BIND_ROOM_MAX_ATTEMPTS = 40
const BIND_ROOM_RETRY_MS = 100
/** 从 TUIStore 读取房间号;默认 0 表示尚未分配 */
function readRoomIdFromStore(): string {
const raw = TUIStore.getData(StoreName.CALL, NAME.ROOM_ID)
if (raw === null || raw === undefined) return ''
if (typeof raw === 'number') {
if (raw === 0) return ''
return String(raw)
}
const s = String(raw).trim()
if (!s || s === '0') return ''
return s
}
/** 引擎实例上可能存在房间号(不同版本字段名不一,仅作兜底) */
function readRoomIdFromEngineFallback(): string {
try {
const eng = TUICallKitServer.getTUICallEngineInstance?.() as Record<string, unknown> | null
if (!eng) return ''
const candidates = [eng.roomId, eng.roomID, eng._roomId, eng._roomID, eng.strRoomID]
for (const c of candidates) {
if (c === null || c === undefined || c === '') continue
const s = String(c).trim()
if (s && s !== '0') return s
}
} catch {
//
}
return ''
}
function readAnyRoomId(): string {
return readRoomIdFromStore() || readRoomIdFromEngineFallback()
}
/** 从引擎 calls/call/groupCall 的 Promise 返回值里解析房间号(含 data 嵌套) */
function normalizeRoomPair(roomID: unknown, strRoomID: unknown): string {
if (strRoomID != null && String(strRoomID).trim() !== '' && String(strRoomID) !== '0') {
return String(strRoomID).trim()
}
if (typeof roomID === 'number' && roomID > 0) return String(roomID)
if (typeof roomID === 'string') {
const t = roomID.trim()
if (t && t !== '0') return t
}
return ''
}
function extractRoomFromEngineResult(ret: unknown): string {
if (ret == null || typeof ret !== 'object') return ''
const r = ret as Record<string, unknown>
const direct =
normalizeRoomPair(r.roomID, r.strRoomID) ||
normalizeRoomPair(r.roomId, r.strRoomId)
if (direct) return direct
const d = r.data
if (d && typeof d === 'object') {
const dd = d as Record<string, unknown>
const nested =
normalizeRoomPair(dd.roomID, dd.strRoomID) ||
normalizeRoomPair(dd.roomId, dd.strRoomId)
if (nested) return nested
}
return ''
}
function clearBindRoomRetry() {
if (bindRoomRetryTimer) {
clearTimeout(bindRoomRetryTimer)
bindRoomRetryTimer = null
}
bindRoomRetryAttempts = 0
}
/** 在 calling / connected 阶段轮询房间号(ROOM_ID 可能比 status 晚一拍) */
function scheduleBindRoomRetry() {
clearBindRoomRetry()
const tick = () => {
bindRoomRetryTimer = null
const status = TUIStore.getData(StoreName.CALL, NAME.CALL_STATUS)
if (status === CALL_STATUS_IDLE) return
if (status !== CALL_STATUS_CALLING && status !== CALL_STATUS_CONNECTED) return
const rid = readAnyRoomId()
if (rid) {
void doBindCallRoom(rid)
return
}
bindRoomRetryAttempts++
if (bindRoomRetryAttempts >= BIND_ROOM_MAX_ATTEMPTS) {
console.warn(
'[chat-dialog] 仍未解析到有效房间号(已重试',
BIND_ROOM_MAX_ATTEMPTS,
'次)'
)
return
}
bindRoomRetryTimer = setTimeout(tick, BIND_ROOM_RETRY_MS)
}
bindRoomRetryTimer = setTimeout(tick, 0)
}
/**
* 在 TUICallKitServer.call / calls / groupCall 执行完毕后,引擎已返回 roomId,
* _updateCallStoreAfterCall 会写入 TUIStore;工具栏多走 call() 而非 calls(),必须 hook 这三者。
*/
async function flushAndBindRoomAfterTUICallApi(apiName: string) {
if (patientId.value == null || diagnosisId.value == null) return
await nextTick()
for (let i = 0; i < 40; i++) {
const rid = readAnyRoomId()
if (rid) {
await doBindCallRoom(rid)
return
}
await new Promise((r) => setTimeout(r, 50))
}
console.warn('[chat-dialog] 发起通话后仍未解析到房间号(API:', apiName, ')')
}
/**
* 在 TUICallEngine 上包装 calls/call/groupCall:房间号往往在 Promise 返回值里,
* 仅靠 TUIStore 轮询可能拿不到或晚一拍。
*/
function installEngineRoomCapture() {
const w = window as unknown as Record<string, unknown>
if (w.__zytEngineRoomCapture__) return
const eng = TUICallKitServer.getTUICallEngineInstance?.() as
| (Record<string, unknown> & { __zytEngineRoomCapture__?: boolean })
| null
| undefined
if (!eng || typeof eng.calls !== 'function') return
w.__zytEngineRoomCapture__ = true
eng.__zytEngineRoomCapture__ = true
const wrap = (methodName: 'calls' | 'call' | 'groupCall') => {
const original = eng[methodName]
if (typeof original !== 'function') return
eng[methodName] = async function patched(this: unknown, ...args: unknown[]) {
const ret = await (original as (...a: unknown[]) => Promise<unknown>).apply(eng, args)
const fromRet = extractRoomFromEngineResult(ret)
if (fromRet) {
void doBindCallRoom(fromRet)
} else if (import.meta.env.DEV) {
console.debug('[chat-dialog] engine.' + methodName + ' 返回未含 roomID,将回退读 Store', ret)
}
await flushAndBindRoomAfterTUICallApi('engine.' + methodName)
return ret
}
}
wrap('calls')
wrap('call')
wrap('groupCall')
}
let engineRoomEventHandlers: Array<{ event: TUICallEvent; fn: (e: unknown) => void }> = []
function bindEngineRoomEvents() {
try {
type EngineWithEvents = Record<string, unknown> & {
__zytRoomEvents?: boolean
on?: (event: TUICallEvent | string, fn: (e: unknown) => void) => void
}
const eng = TUICallKitServer.getTUICallEngineInstance?.() as EngineWithEvents | null | undefined
const subscribe = eng?.on
if (!eng || typeof subscribe !== 'function' || eng.__zytRoomEvents) return
eng.__zytRoomEvents = true
const onRoomHint = (ev: unknown) => {
const fromEv = extractRoomFromEngineResult(ev)
if (fromEv) {
void doBindCallRoom(fromEv)
return
}
void tryBindRoomIfReady()
}
const events = [TUICallEvent.USER_ENTER, TUICallEvent.ON_CALL_BEGIN] as const
for (const event of events) {
subscribe.call(eng, event, onRoomHint)
engineRoomEventHandlers.push({ event, fn: onRoomHint })
}
} catch (e) {
console.warn('[chat-dialog] 绑定引擎房间事件失败', e)
}
}
function unbindEngineRoomEvents() {
try {
const eng = TUICallKitServer.getTUICallEngineInstance?.() as
| ({ off?: (e: TUICallEvent, fn: (ev: unknown) => void) => void } & {
__zytRoomEvents?: boolean
})
| null
| undefined
if (!eng?.off) {
engineRoomEventHandlers = []
return
}
for (const { event, fn } of engineRoomEventHandlers) {
eng.off(event, fn)
}
eng.__zytRoomEvents = false
} catch {
//
}
engineRoomEventHandlers = []
}
function installTUICallKitRoomHooks() {
const w = window as unknown as Record<string, unknown>
if (w.__zytTUICallRoomHooks__) return
w.__zytTUICallRoomHooks__ = true
const server = TUICallKitServer as unknown as Record<string, (...args: unknown[]) => Promise<unknown>>
const wrap = (methodName: 'calls' | 'call' | 'groupCall') => {
const original = server[methodName]
if (typeof original !== 'function') return
server[methodName] = async function patched(...args: unknown[]) {
const result = await original.apply(server, args)
await flushAndBindRoomAfterTUICallApi(methodName)
return result
}
}
wrap('calls')
wrap('call')
wrap('groupCall')
}
async function doBindCallRoom(rid: string) {
if (patientId.value == null || diagnosisId.value == null) return
const key = `${diagnosisId.value}:${rid}`
if (key === lastBoundRoomKey.value) {
clearBindRoomRetry()
return
}
lastBoundRoomKey.value = key
try {
await bindCallRoom({ diagnosis_id: diagnosisId.value, room_id: rid })
clearBindRoomRetry()
console.log('[chat-dialog] bindCallRoom 成功', { room_id: rid })
} catch (e) {
console.warn('[chat-dialog] bindCallRoom 失败', e)
lastBoundRoomKey.value = ''
}
}
async function tryBindRoomIfReady() {
if (patientId.value == null || diagnosisId.value == null) return
const status = TUIStore.getData(StoreName.CALL, NAME.CALL_STATUS)
if (status === CALL_STATUS_IDLE) {
clearBindRoomRetry()
lastBoundRoomKey.value = ''
return
}
if (status !== CALL_STATUS_CALLING && status !== CALL_STATUS_CONNECTED) return
await nextTick()
const rid = readAnyRoomId()
if (rid) {
await doBindCallRoom(rid)
return
}
scheduleBindRoomRetry()
}
async function ensureCallRecordStarted() {
if (patientId.value == null || diagnosisId.value == null) return
try {
await startCall({
diagnosis_id: diagnosisId.value,
patient_id: patientId.value,
call_type: 2
})
} catch (e) {
console.warn('[chat-dialog] startCall 记录失败', e)
}
}
function setupCallRoomBinding() {
tearDownCallRoomBinding?.()
const onCallStatus = (newData?: string) => {
if (newData === CALL_STATUS_IDLE) {
clearBindRoomRetry()
lastBoundRoomKey.value = ''
return
}
if (newData === CALL_STATUS_CONNECTED) {
void tryBindRoomIfReady()
}
}
const onRoomId = () => {
void tryBindRoomIfReady()
}
TUIStore.watch(StoreName.CALL, {
[NAME.CALL_STATUS]: onCallStatus,
[NAME.ROOM_ID]: onRoomId
})
tearDownCallRoomBinding = () => {
try {
TUIStore.unwatch(StoreName.CALL, {
[NAME.CALL_STATUS]: onCallStatus,
[NAME.ROOM_ID]: onRoomId
})
} catch {
//
}
clearBindRoomRetry()
tearDownCallRoomBinding = null
}
}
/** 防止 console + 引擎事件 与 unhandledrejection 连续弹多次 */
let lastPackageToastAt = 0
const PACKAGE_TOAST_DEBOUNCE_MS = 2600
function notifyPackageArrearsOnce() {
const now = Date.now()
if (now - lastPackageToastAt < PACKAGE_TOAST_DEBOUNCE_MS) return
lastPackageToastAt = now
feedback.msgError(getTUICallPackageArrearsMessage())
showCallKitWindow.value = false
}
/**
* SDK 在 calls 失败时往往只 console.error(见 CallService),Chat UIKit 工具栏又会吞掉 Promise
* 故需:1) 挂钩 console 识别套餐文案;2) 监听 TUICallEngine 的 error 事件。
*/
let savedConsoleError: typeof console.error | null = null
let consoleErrorHooked = false
let engineErrorHandler: ((e: unknown) => void) | null = null
function hookConsoleErrorForPackageHint() {
if (consoleErrorHooked) return
savedConsoleError = console.error.bind(console)
consoleErrorHooked = true
console.error = (...args: unknown[]) => {
savedConsoleError!(...args)
if (!visible.value || !isCallReady.value) return
const text = args
.map((a) => {
if (typeof a === 'string') return a
if (a instanceof Error) return a.message
try {
return JSON.stringify(a)
} catch {
return String(a)
}
})
.join(' ')
if (isTUICallPackageAbilityError(text)) notifyPackageArrearsOnce()
}
}
function unhookConsoleErrorForPackageHint() {
if (consoleErrorHooked && savedConsoleError) {
console.error = savedConsoleError
}
consoleErrorHooked = false
savedConsoleError = null
}
function bindEnginePackageErrorListener() {
try {
const engine = TUICallKitServer.getTUICallEngineInstance?.()
if (!engine?.on) return
if (engineErrorHandler) {
engine.off?.('error', engineErrorHandler)
}
engineErrorHandler = (payload: unknown) => {
if (!visible.value || !isCallReady.value) return
if (isTUICallPackageAbilityError(payload)) notifyPackageArrearsOnce()
}
engine.on('error', engineErrorHandler)
} catch (e) {
console.warn('绑定 TUICallEngine error 监听失败:', e)
}
}
function unbindEnginePackageErrorListener() {
try {
const engine = TUICallKitServer.getTUICallEngineInstance?.()
if (engine?.off && engineErrorHandler) {
engine.off('error', engineErrorHandler)
}
} catch {
//
}
engineErrorHandler = null
}
/** 医生端进入会话后发给患者的信令:患者端不展示气泡,仅用于更新「已进入诊室」状态 */
async function sendDoctorEnteredConsultRoomSignal(sdkAppId: number, toUserId: string) {
try {
const chat = TencentCloudChat.create({ SDKAppID: sdkAppId })
const payload = {
businessID: 'doctor_entered_consult_room',
time: Date.now()
}
const message = chat.createCustomMessage({
to: toUserId,
conversationType: TencentCloudChat.TYPES.CONV_C2C,
payload: {
data: JSON.stringify(payload)
}
})
await chat.sendMessage(message)
console.log('已发送医生进入诊室信令(患者端聊天列表不展示)')
} catch (e) {
console.warn('发送医生进入诊室信令失败:', e)
}
}
/** 未捕获 Promise(若存在) */
const onTuiCallUnhandledRejection = (e: PromiseRejectionEvent) => {
if (!visible.value || !isCallReady.value) return
if (isTUICallPackageAbilityError(e.reason)) notifyPackageArrearsOnce()
}
onMounted(() => {
hookConsoleErrorForPackageHint()
window.addEventListener('unhandledrejection', onTuiCallUnhandledRejection)
})
onUnmounted(() => {
tearDownCallRoomBinding?.()
unbindEngineRoomEvents()
unhookConsoleErrorForPackageHint()
unbindEnginePackageErrorListener()
window.removeEventListener('unhandledrejection', onTuiCallUnhandledRejection)
})
// 更新用户头像到 IM
const updateUserAvatarToIM = async (avatarUrl: string,appid: number) => {
try {
@@ -244,15 +726,28 @@ const open = async (data: { patientId: number; patientName: string; diagnosisId?
;(window as any).__TUICallKitInited__ = true
} catch (err: any) {
console.warn('TUICallKit 初始化失败,聊天仍可用,音视频通话不可用:', err?.message || err)
if (isTUICallPackageAbilityError(err)) {
notifyPackageArrearsOnce()
}
}
}
isCallReady.value = !!(window as any).__TUICallKitInited__
if (isCallReady.value) {
// 仅在通话时显示视频窗口,无通话时隐藏
// 仅在通话时显示视频窗口,无通话时隐藏;发起通话前落库、接通后绑定房间号(云端录制)
TUICallKitServer.setCallback({
beforeCalling: () => { showCallKitWindow.value = true },
afterCalling: () => { showCallKitWindow.value = false }
beforeCalling: () => {
showCallKitWindow.value = true
void ensureCallRecordStarted()
},
afterCalling: () => {
showCallKitWindow.value = false
}
})
installTUICallKitRoomHooks()
installEngineRoomCapture()
bindEngineRoomEvents()
setupCallRoomBinding()
bindEnginePackageErrorListener()
}
console.log('Chat UIKit 登录成功,患者用户ID:', patientUserId.value)
@@ -285,6 +780,8 @@ const open = async (data: { patientId: number; patientName: string; diagnosisId?
// 激活该会话
setActiveConversation(conversationId.value)
console.log('已激活会话')
await sendDoctorEnteredConsultRoomSignal(sdkAPPid, patientUserId.value)
} catch (err) {
console.error('创建/激活会话失败:', err)
}
@@ -338,7 +835,11 @@ const startGroupVideoCall = async () => {
})
} catch (err: any) {
console.error('发起群视频失败:', err)
feedback.msgError(err?.message || '发起群视频失败')
if (isTUICallPackageAbilityError(err)) {
notifyPackageArrearsOnce()
} else {
feedback.msgError(formatTUICallUserError(err, '发起群视频失败'))
}
}
}
@@ -354,6 +855,8 @@ const posStartY = ref(0)
// TUICallKit 视频窗口拖动
const callKitWrapperRef = ref<HTMLElement>()
const callKitContentRef = ref<HTMLElement>()
const captureUploading = ref(false)
const callKitX = ref(0)
const callKitY = ref(0)
const isCallKitDragging = ref(false)
@@ -389,6 +892,67 @@ const onCallKitDragEnd = () => {
document.removeEventListener('mouseup', onCallKitDragEnd)
}
/** 截取当前视频画面并上传,同步到对应患者诊单的「舌苔照片」 */
const handleCallKitScreenshot = async () => {
if (patientId.value == null) {
feedback.msgWarning('缺少患者信息,无法同步舌苔照片')
return
}
const el = callKitContentRef.value
if (!el) return
captureUploading.value = true
try {
const blob = await captureVideoFrameFromElement(el)
if (!blob) {
feedback.msgWarning('截屏失败,请确认视频画面已加载')
return
}
const data = await uploadImageBlob(blob, `callshot-${Date.now()}.jpg`)
const path = data.url || data.uri
if (!path) {
feedback.msgError('上传未返回图片路径')
return
}
if (diagnosisId.value == null) {
feedback.msgWarning('请先保存诊单后再从视频同步舌苔照片')
return
}
const detail = await tcmDiagnosisDetail({ id: diagnosisId.value })
if (Number(detail.patient_id) !== Number(patientId.value)) {
feedback.msgError('患者与当前诊单不匹配,无法同步')
return
}
const previous: string[] = [...((detail.tongue_images || []) as string[])]
if (previous.length >= 9) {
feedback.msgWarning('舌苔照片已达上限 9 张')
return
}
const next = [...previous, path]
const payload = JSON.parse(JSON.stringify(detail)) as Record<string, unknown>
payload.tongue_images = next
await tcmDiagnosisEdit(payload)
feedback.msgSuccess('舌苔照片已同步到诊单')
} catch (e: unknown) {
const err = e as Error
console.error(err)
feedback.msgError(err?.message || '上传失败')
} finally {
captureUploading.value = false
}
}
/** 关闭视频浮窗并挂断当前通话 */
const handleCallKitClose = async () => {
if (isCallReady.value) {
try {
await TUICallKitServer.hangup()
} catch (err) {
console.warn('关闭视频窗口时挂断失败:', err)
}
}
showCallKitWindow.value = false
}
const windowStyle = computed(() => ({
left: `${posX.value}px`,
top: `${posY.value}px`
@@ -580,6 +1144,36 @@ defineExpose({ open })
color: #fff;
}
.call-kit-drag-handle-actions {
display: flex;
align-items: center;
gap: 4px;
}
.call-kit-screenshot-btn {
padding: 4px 6px;
color: #79bbff;
&:hover {
color: #a0cfff;
}
}
.call-kit-screenshot-text {
margin-left: 2px;
font-size: 12px;
}
.call-kit-close-btn {
cursor: pointer;
padding: 4px;
color: #f56c6c;
&:hover {
color: #f89898;
}
}
.drag-handle-icon {
color: #999;
font-size: 16px;