1640 lines
51 KiB
Vue
1640 lines
51 KiB
Vue
<template>
|
||
<Teleport to="body">
|
||
<div
|
||
v-show="visible"
|
||
ref="chatWindowRef"
|
||
class="chat-floating-window"
|
||
:class="{ 'chat-floating-window--minimized': isMinimized }"
|
||
:style="windowStyle"
|
||
>
|
||
<div
|
||
class="chat-window-header"
|
||
@mousedown="onHeaderMouseDown"
|
||
>
|
||
<span class="chat-window-title" :title="patientName">与 {{ patientName }} 通讯</span>
|
||
<div class="chat-header-actions" @mousedown.stop>
|
||
<el-button type="danger" link class="chat-close-btn" @click="handleClose">
|
||
<el-icon><Close /></el-icon>
|
||
</el-button>
|
||
<el-tooltip v-if="!isMinimized" content="缩小为标题条,不关闭会话" placement="top">
|
||
<el-button type="primary" link class="chat-minimize-btn" @click="isMinimized = true">
|
||
<el-icon><Fold /></el-icon>
|
||
<span class="chat-header-btn-text">缩小</span>
|
||
</el-button>
|
||
</el-tooltip>
|
||
<el-tooltip v-else content="展开聊天窗口" placement="top">
|
||
<el-button type="primary" link class="chat-expand-btn" @click="isMinimized = false">
|
||
<el-icon><Expand /></el-icon>
|
||
<span class="chat-header-btn-text">展开</span>
|
||
</el-button>
|
||
</el-tooltip>
|
||
|
||
</div>
|
||
</div>
|
||
<div v-show="!isMinimized" class="chat-container">
|
||
<div v-if="!isReady" class="loading-container">
|
||
<el-icon class="is-loading"><Loading /></el-icon>
|
||
<span>{{ loadingText }}</span>
|
||
</div>
|
||
<div v-else-if="error" class="error-container">
|
||
<el-alert :title="error" type="error" :closable="false" />
|
||
</div>
|
||
<div v-else class="chat-content">
|
||
<UIKitProvider language="zh-CN" theme="light">
|
||
<div class="chat-layout">
|
||
<!-- 嵌入式问诊窗口仅与当前患者会话,不展示全局会话列表 -->
|
||
<Chat class="chat-area">
|
||
<MessageList :conversationID="conversationId" :Message="ChatMessageItem" />
|
||
<MessageInput>
|
||
<template #headerToolbar>
|
||
<div class="message-toolbar">
|
||
<EmojiPicker />
|
||
<ImagePicker />
|
||
<FilePicker />
|
||
<!-- 仅在 TUICallKit 初始化成功后展示音视频入口,避免“初始化登录未完成”错误 -->
|
||
<AudioCallPicker v-if="isCallReady" />
|
||
<!-- 视频接通后 doBindCallRoom → 后端 bindCallRoom 会调 CreateCloudRecording,RecordParams.RecordMode=2(混流/合流),见 TrtcCloudRecordingService -->
|
||
<VideoCallPicker v-if="isCallReady" />
|
||
<!-- 群组视频通话(基于 TUICallKitServer.calls,多人通话入口) -->
|
||
<el-button
|
||
v-if="isCallReady"
|
||
size="small"
|
||
type="primary"
|
||
@click="startGroupVideoCall"
|
||
>
|
||
群视频
|
||
</el-button>
|
||
</div>
|
||
</template>
|
||
</MessageInput>
|
||
</Chat>
|
||
</div>
|
||
</UIKitProvider>
|
||
</div>
|
||
</div>
|
||
<!-- 挂载 TUICallKit 组件(可拖动,仅在通话时显示) -->
|
||
<div
|
||
v-if="isCallReady"
|
||
v-show="showCallKitWindow"
|
||
ref="callKitWrapperRef"
|
||
class="chat-dialog-call-kit-wrapper"
|
||
:style="callKitStyle"
|
||
>
|
||
<div
|
||
class="call-kit-drag-handle"
|
||
@mousedown="onCallKitDragStart"
|
||
>
|
||
<div class="call-kit-drag-handle-left">
|
||
<span class="drag-handle-text">视频通话</span>
|
||
<span v-if="localRecordingBadge" class="call-local-recording-hint">本地录制中</span>
|
||
</div>
|
||
<div class="call-kit-drag-handle-actions">
|
||
<el-button
|
||
type="primary"
|
||
link
|
||
class="call-kit-screenshot-btn"
|
||
:loading="captureUploading"
|
||
:disabled="captureUploading"
|
||
@mousedown.stop
|
||
@click.stop.prevent="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 ref="callKitContentRef" class="call-kit-content">
|
||
<TUICallKit
|
||
:allowedMinimized="true"
|
||
:allowedFullScreen="true"
|
||
class="chat-dialog-call-kit"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Teleport>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, nextTick, watch, computed, onMounted, onUnmounted } from 'vue'
|
||
import { Loading, Close, Rank, Camera, Fold, Expand } from '@element-plus/icons-vue'
|
||
import { uploadImageBlob, uploadVideoBlob } from '@/api/file'
|
||
import { addDoctorNote } from '@/api/patient'
|
||
import {
|
||
attachLocalCallRecording,
|
||
bindCallRoom,
|
||
endCall,
|
||
getCallSignature,
|
||
startCall
|
||
} from '@/api/tcm'
|
||
import { CallLocalRecorder } from '@/utils/call-local-recorder'
|
||
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 {
|
||
Chat,
|
||
MessageList,
|
||
MessageInput,
|
||
UIKitProvider,
|
||
useLoginState,
|
||
useConversationListState,
|
||
EmojiPicker,
|
||
ImagePicker,
|
||
FilePicker,
|
||
AudioCallPicker,
|
||
VideoCallPicker
|
||
} from '@tencentcloud/chat-uikit-vue3'
|
||
import ChatMessageItem from './ChatMessageItem.vue'
|
||
import TencentCloudChat from '@tencentcloud/chat'
|
||
import TUIChatEngine from '@tencentcloud/chat-uikit-engine'
|
||
import { imCustomDataIndicatesVideoHangup } from '@/utils/im-call-hangup-detect'
|
||
import '@/assets/chat-uikit.css'
|
||
import {
|
||
TUICallKit,
|
||
TUICallKitServer,
|
||
TUICallType,
|
||
TUIStore,
|
||
StoreName,
|
||
NAME
|
||
} from '@tencentcloud/call-uikit-vue'
|
||
import { TUICallEvent } from '@tencentcloud/call-engine-js'
|
||
const assistant_ids=ref('');
|
||
/** 缩小为仅标题条,便于医生操作后台其他页面(不登出、不挂断) */
|
||
const isMinimized = ref(false)
|
||
const visible = ref(false)
|
||
const isReady = ref(false)
|
||
const error = ref('')
|
||
const patientName = ref('')
|
||
const patientId = ref<number | null>(null)
|
||
const diagnosisId = ref<number | null>(null)
|
||
const loadingText = ref('正在初始化...')
|
||
const patientUserId = ref('')
|
||
const conversationId = ref('')
|
||
const isCallReady = ref(false)
|
||
const showCallKitWindow = ref(false) // 仅在通话中显示视频窗口
|
||
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
|
||
|
||
const localCallRecorder = new CallLocalRecorder()
|
||
/** 与 server .env [trtc] ISLOCHOSTVOD 一致:由 getCallSignature 下发,false 时不做浏览器本地录制 */
|
||
const isLochostVodEnabled = ref(false)
|
||
const localRecordingBadge = ref(false)
|
||
let localRecordingRound = 0
|
||
let startLocalRecTimer: ReturnType<typeof setTimeout> | null = null
|
||
let finalizeLocalRecordingPromise: Promise<void> | null = null
|
||
/** 本次振铃/通话对应的 startCall 落库(beforeCalling 发起,bind/上传前须 await) */
|
||
let pendingCallRecordStart: Promise<void> = Promise.resolve()
|
||
/** 接通前快照诊单 ID,避免会话切换等导致上传阶段 diagnosisId 丢失 */
|
||
let recordingDiagnosisIdSnapshot: number | null = null
|
||
|
||
function clearLocalRecordingStartTimer() {
|
||
if (startLocalRecTimer) {
|
||
clearTimeout(startLocalRecTimer)
|
||
startLocalRecTimer = null
|
||
}
|
||
}
|
||
|
||
function syncLochostVodFromSignature(res: { isLochostVod?: boolean }) {
|
||
isLochostVodEnabled.value = Boolean(res?.isLochostVod)
|
||
}
|
||
|
||
function finalizeLocalRecordingAndAttach(): Promise<void> {
|
||
if (finalizeLocalRecordingPromise) {
|
||
return finalizeLocalRecordingPromise
|
||
}
|
||
finalizeLocalRecordingPromise = (async () => {
|
||
clearLocalRecordingStartTimer()
|
||
localRecordingBadge.value = false
|
||
if (!isLochostVodEnabled.value) {
|
||
return
|
||
}
|
||
localCallRecorder.beginStopNow()
|
||
const id = recordingDiagnosisIdSnapshot ?? diagnosisId.value
|
||
try {
|
||
await pendingCallRecordStart
|
||
} catch {
|
||
//
|
||
}
|
||
const blob = await localCallRecorder.stop()
|
||
if (!blob) {
|
||
console.warn(
|
||
'[chat-dialog] 本地录制无有效文件(若通话较长仍出现,请换 Chrome 并看控制台是否有 MediaRecorder 报错)'
|
||
)
|
||
return
|
||
}
|
||
if (id == null) {
|
||
feedback.msgWarning('缺少诊单信息,本地录制无法保存到通话记录')
|
||
return
|
||
}
|
||
try {
|
||
const data = await uploadVideoBlob(blob, `call-rec-${id}-${Date.now()}.webm`)
|
||
const fileUrl = data.uri || data.url
|
||
if (!fileUrl) {
|
||
feedback.msgError('上传成功但未返回文件地址')
|
||
return
|
||
}
|
||
await attachLocalCallRecording({ diagnosis_id: id, file_url: fileUrl })
|
||
feedback.msgSuccess('本地录制已上传并关联到通话记录')
|
||
} catch (e: unknown) {
|
||
const msg = e instanceof Error ? e.message : String(e)
|
||
console.warn('[chat-dialog] 本地录制上传/关联失败', e)
|
||
feedback.msgError(msg || '本地录制上传失败')
|
||
}
|
||
})().finally(() => {
|
||
finalizeLocalRecordingPromise = null
|
||
})
|
||
return finalizeLocalRecordingPromise
|
||
}
|
||
|
||
/** 从 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')
|
||
}
|
||
|
||
/** hangup 时 SDK 会立刻 exitRoom;在 hangup 入口同步停录,避免轨道被掐断后无数据 */
|
||
function installHangupPreStopHook() {
|
||
const w = window as unknown as Record<string, unknown>
|
||
if (w.__zytHangupPreStop__) return
|
||
const server = TUICallKitServer as unknown as Record<string, unknown> & {
|
||
hangup?: (...args: unknown[]) => unknown
|
||
}
|
||
const raw = server.hangup
|
||
if (typeof raw !== 'function') return
|
||
w.__zytHangupPreStop__ = true
|
||
server.hangup = function patchedHangup(this: unknown, ...args: unknown[]) {
|
||
localCallRecorder.beginStopNow()
|
||
return raw.apply(TUICallKitServer, args)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* UI 挂断时往往先调 TRTC stopLocalVideo/stopLocalAudio,再 hangup;必须在它们之前停 MediaRecorder。
|
||
*/
|
||
function installTrtcCloudPreStopHook() {
|
||
const w = window as unknown as Record<string, unknown>
|
||
if (w.__zytTrtcPreStop__) return
|
||
const eng = TUICallKitServer.getTUICallEngineInstance?.() as
|
||
| { trtcCloud?: Record<string, unknown> }
|
||
| null
|
||
| undefined
|
||
const trtc = eng?.trtcCloud
|
||
if (!trtc || typeof trtc !== 'object') return
|
||
let patched = 0
|
||
const methods = ['stopLocalVideo', 'stopLocalAudio'] as const
|
||
for (const methodName of methods) {
|
||
const orig = trtc[methodName]
|
||
if (typeof orig !== 'function') continue
|
||
trtc[methodName] = function patchedTrtc(this: unknown, ...args: unknown[]) {
|
||
localCallRecorder.beginStopNow()
|
||
return (orig as (...a: unknown[]) => unknown).apply(trtc, args)
|
||
}
|
||
patched++
|
||
}
|
||
if (patched > 0) {
|
||
w.__zytTrtcPreStop__ = true
|
||
}
|
||
}
|
||
|
||
let engineRoomEventHandlers: Array<{ event: TUICallEvent; fn: (e: unknown) => void }> = []
|
||
|
||
/** IM MESSAGE_RECEIVED:对端挂断等信令走 TIM,与引擎事件双通道;需在 reportCallEnded 之后绑定 */
|
||
let imHangupMessageHandler: ((event: { data?: unknown }) => void) | null = null
|
||
let lastHangupCloudStopAt = 0
|
||
const HANGUP_CLOUD_STOP_DEBOUNCE_MS = 1500
|
||
|
||
/** 将 tcm_call_record 置结束并 DeleteCloudRecording(合流停录) */
|
||
async function reportCallEnded() {
|
||
const id = recordingDiagnosisIdSnapshot ?? diagnosisId.value
|
||
if (id == null) {
|
||
console.warn('[chat-dialog] endCall 跳过:无诊单 ID(快照与当前均为空)')
|
||
return
|
||
}
|
||
try {
|
||
await endCall({ diagnosis_id: id })
|
||
console.log('[chat-dialog] endCall 已同步', { diagnosis_id: id })
|
||
} catch (e) {
|
||
console.warn('[chat-dialog] endCall 失败', e)
|
||
}
|
||
}
|
||
|
||
/** 引擎 CALL_END / IM 挂断信令共用防抖,避免重复 DeleteCloudRecording */
|
||
function debouncedStopRecordingAndEndCloud(source: string) {
|
||
const now = Date.now()
|
||
if (now - lastHangupCloudStopAt < HANGUP_CLOUD_STOP_DEBOUNCE_MS) return
|
||
lastHangupCloudStopAt = now
|
||
console.log('[chat-dialog] 挂断停合流:', source)
|
||
if (localCallRecorder.isRecording()) {
|
||
localCallRecorder.beginStopNow()
|
||
}
|
||
void reportCallEnded()
|
||
}
|
||
|
||
function bindImHangupMessageListener() {
|
||
unbindImHangupMessageListener()
|
||
try {
|
||
const chat = TUIChatEngine?.chat as { on?: (ev: string, fn: (e: { data?: unknown }) => void) => void; off?: (ev: string, fn: (e: { data?: unknown }) => void) => void } | undefined
|
||
if (!chat?.on) {
|
||
console.warn('[chat-dialog] TUIChatEngine.chat 不可用,无法监听 IM 视频挂断信令')
|
||
return
|
||
}
|
||
imHangupMessageHandler = (event: { data?: unknown }) => {
|
||
if (!visible.value) return
|
||
const pu = patientUserId.value
|
||
if (!pu) return
|
||
const expectConv = pu.startsWith('C2C') ? pu : `C2C${pu}`
|
||
const list = Array.isArray(event.data) ? event.data : []
|
||
for (const m of list as Array<Record<string, unknown>>) {
|
||
if (!m || m.conversationID !== expectConv) continue
|
||
const t = m.type as string | number | undefined
|
||
const isCustom =
|
||
t === TencentCloudChat.TYPES.MSG_CUSTOM ||
|
||
t === 'TIMCustomElem' ||
|
||
(typeof t === 'string' && /custom/i.test(t))
|
||
if (!isCustom) continue
|
||
const payload = m.payload as Record<string, unknown> | undefined
|
||
const raw = typeof payload?.data === 'string' ? payload.data : ''
|
||
if (!raw || !imCustomDataIndicatesVideoHangup(raw)) continue
|
||
debouncedStopRecordingAndEndCloud('IM')
|
||
break
|
||
}
|
||
}
|
||
chat.on(TencentCloudChat.EVENT.MESSAGE_RECEIVED, imHangupMessageHandler)
|
||
} catch (e) {
|
||
console.warn('[chat-dialog] 绑定 IM 挂断监听失败', e)
|
||
}
|
||
}
|
||
|
||
function unbindImHangupMessageListener() {
|
||
if (!imHangupMessageHandler) return
|
||
try {
|
||
const chat = TUIChatEngine?.chat as { off?: (ev: string, fn: (e: { data?: unknown }) => void) => void } | undefined
|
||
chat?.off?.(TencentCloudChat.EVENT.MESSAGE_RECEIVED, imHangupMessageHandler)
|
||
} catch {
|
||
//
|
||
}
|
||
imHangupMessageHandler = null
|
||
}
|
||
|
||
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 onEarlyStopRecording = () => {
|
||
debouncedStopRecordingAndEndCloud('TUICallEngine')
|
||
}
|
||
const earlyStopEvents = [
|
||
TUICallEvent.CALL_END,
|
||
TUICallEvent.USER_LEAVE,
|
||
TUICallEvent.ON_CALL_NOT_CONNECTED
|
||
] as const
|
||
for (const event of earlyStopEvents) {
|
||
subscribe.call(eng, event, onEarlyStopRecording)
|
||
engineRoomEventHandlers.push({ event, fn: onEarlyStopRecording })
|
||
}
|
||
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) {
|
||
try {
|
||
await pendingCallRecordStart
|
||
} catch {
|
||
//
|
||
}
|
||
if (patientId.value == null || diagnosisId.value == null) return
|
||
const key = `${diagnosisId.value}:${rid}`
|
||
if (key === lastBoundRoomKey.value) {
|
||
clearBindRoomRetry()
|
||
return
|
||
}
|
||
lastBoundRoomKey.value = key
|
||
try {
|
||
const bindRes = (await bindCallRoom({
|
||
diagnosis_id: diagnosisId.value,
|
||
room_id: rid
|
||
})) as { cloud_recording?: { started?: boolean; task_id?: string; message?: string } } | undefined
|
||
clearBindRoomRetry()
|
||
console.log('[chat-dialog] bindCallRoom 成功', { room_id: rid, bindRes })
|
||
const cr = bindRes?.cloud_recording
|
||
if (cr && cr.started === false && cr.message) {
|
||
feedback.msgWarning(
|
||
`云端合流录制未启动(关闭控制台全局录制后仅依赖 API):${cr.message}`
|
||
)
|
||
}
|
||
} 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?.()
|
||
let previousCallStatus =
|
||
(TUIStore.getData(StoreName.CALL, NAME.CALL_STATUS) as string | undefined) ?? CALL_STATUS_IDLE
|
||
|
||
const onCallStatus = (newData?: string) => {
|
||
const next = (newData as string | undefined) ?? CALL_STATUS_IDLE
|
||
if (next === CALL_STATUS_IDLE) {
|
||
if (previousCallStatus === CALL_STATUS_CALLING || previousCallStatus === CALL_STATUS_CONNECTED) {
|
||
localCallRecorder.beginStopNow()
|
||
}
|
||
clearLocalRecordingStartTimer()
|
||
localRecordingBadge.value = false
|
||
if (previousCallStatus === CALL_STATUS_CALLING || previousCallStatus === CALL_STATUS_CONNECTED) {
|
||
void (async () => {
|
||
await reportCallEnded()
|
||
showCallKitWindow.value = false
|
||
await finalizeLocalRecordingAndAttach()
|
||
})()
|
||
}
|
||
clearBindRoomRetry()
|
||
lastBoundRoomKey.value = ''
|
||
previousCallStatus = next
|
||
return
|
||
}
|
||
if (next === CALL_STATUS_CONNECTED) {
|
||
void tryBindRoomIfReady()
|
||
clearLocalRecordingStartTimer()
|
||
if (!isLochostVodEnabled.value) {
|
||
previousCallStatus = next
|
||
return
|
||
}
|
||
const roundAtSchedule = localRecordingRound
|
||
|
||
// 立即尝试启动录制,不要等待 1200ms
|
||
const attemptStartRecording = (retryCount = 0) => {
|
||
if (roundAtSchedule !== localRecordingRound) {
|
||
console.log('[chat-dialog] 录制轮次已变化,取消启动')
|
||
return
|
||
}
|
||
|
||
const root = callKitContentRef.value
|
||
const st = TUIStore.getData(StoreName.CALL, NAME.CALL_STATUS) as string | undefined
|
||
if (!root || st !== CALL_STATUS_CONNECTED) {
|
||
console.warn('[chat-dialog] 录制启动条件不满足', { root: !!root, status: st })
|
||
return
|
||
}
|
||
|
||
console.log(`[chat-dialog] 尝试启动本地录制(第 ${retryCount + 1} 次)`)
|
||
|
||
// 检查视频元素
|
||
let videos = Array.from(root.querySelectorAll('video')) as HTMLVideoElement[]
|
||
|
||
// 如果容器内没有,检查 body(TUICallKit Teleport)
|
||
if (videos.length === 0) {
|
||
const bodyVideos = Array.from(document.body.querySelectorAll('video')) as HTMLVideoElement[]
|
||
videos = bodyVideos.filter(v => {
|
||
const so = v.srcObject
|
||
if (!(so instanceof MediaStream)) return false
|
||
return so.getVideoTracks().some(t => t.readyState === 'live')
|
||
})
|
||
console.log('[chat-dialog] 在 body 中找到活跃视频:', videos.length)
|
||
}
|
||
|
||
if (videos.length === 0) {
|
||
if (retryCount < 10) {
|
||
console.warn('[chat-dialog] 未找到视频元素,300ms 后重试')
|
||
startLocalRecTimer = setTimeout(() => attemptStartRecording(retryCount + 1), 300)
|
||
} else {
|
||
console.error('[chat-dialog] 重试 10 次后仍未找到视频元素,放弃录制')
|
||
}
|
||
return
|
||
}
|
||
|
||
// 检查视频是否有有效数据
|
||
const hasReadyVideo = videos.some(v => v.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA)
|
||
if (!hasReadyVideo) {
|
||
if (retryCount < 10) {
|
||
console.warn('[chat-dialog] 视频元素未就绪,300ms 后重试')
|
||
startLocalRecTimer = setTimeout(() => attemptStartRecording(retryCount + 1), 300)
|
||
} else {
|
||
console.error('[chat-dialog] 重试 10 次后视频仍未就绪,放弃录制')
|
||
}
|
||
return
|
||
}
|
||
|
||
console.log('[chat-dialog] 视频元素已就绪,立即启动录制')
|
||
const ok = localCallRecorder.start(root)
|
||
localRecordingBadge.value = ok
|
||
if (!ok) {
|
||
console.error('[chat-dialog] 本地录制启动失败(MediaRecorder 或画布初始化失败)')
|
||
} else {
|
||
console.log('[chat-dialog] ✅ 本地录制已成功启动')
|
||
}
|
||
}
|
||
|
||
// 延迟 500ms 后开始尝试(给视频元素一点加载时间)
|
||
startLocalRecTimer = setTimeout(() => attemptStartRecording(0), 500)
|
||
} else {
|
||
clearLocalRecordingStartTimer()
|
||
}
|
||
previousCallStatus = next
|
||
}
|
||
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(() => {
|
||
clearLocalRecordingStartTimer()
|
||
localCallRecorder.reset()
|
||
tearDownCallRoomBinding?.()
|
||
unbindEngineRoomEvents()
|
||
unbindImHangupMessageListener()
|
||
unhookConsoleErrorForPackageHint()
|
||
unbindEnginePackageErrorListener()
|
||
window.removeEventListener('unhandledrejection', onTuiCallUnhandledRejection)
|
||
})
|
||
|
||
// 更新用户头像到 IM
|
||
const updateUserAvatarToIM = async (avatarUrl: string,appid: number) => {
|
||
try {
|
||
if (!avatarUrl) {
|
||
console.warn('头像 URL 为空,跳过更新')
|
||
return
|
||
}
|
||
|
||
console.log('开始更新头像到 IM:', avatarUrl)
|
||
|
||
// 尝试通过 SDK 实例更新头像
|
||
// 在 UIKit 登录后,SDK 实例应该已初始化
|
||
const updateResult = await new Promise((resolve, reject) => {
|
||
// 延迟执行,确保 SDK 完全初始化
|
||
setTimeout(async () => {
|
||
try {
|
||
// 尝试多种方式获取 SDK 实例
|
||
|
||
let chat = TencentCloudChat.create( { SDKAppID: appid });
|
||
// 调用 updateMyProfile 更新用户头像
|
||
const response = await chat.updateMyProfile({
|
||
avatar: avatarUrl
|
||
})
|
||
|
||
console.log('IM 头像更新成功:', response)
|
||
resolve(response)
|
||
} catch (err) {
|
||
console.error('更新头像异常:', err)
|
||
resolve(null)
|
||
}
|
||
}, 500)
|
||
})
|
||
|
||
} catch (err: any) {
|
||
console.error('IM 头像更新失败:', err.message || err)
|
||
// 不中断主流程,头像更新失败不影响聊天功能
|
||
}
|
||
}
|
||
|
||
// 监听当前激活的会话变化,更新标题
|
||
watch(() => activeConversation.value, async (newConversation) => {
|
||
if (newConversation) {
|
||
// 更新标题为当前会话的显示名称
|
||
patientName.value = newConversation.getShowName() || patientName.value
|
||
console.log('会话切换:', newConversation.conversationID, '名称:', patientName.value, newConversation)
|
||
|
||
if (newConversation.userProfile.userID.includes("doctor")) {
|
||
console.log('当前会话是医生,跳过获取 assistant_id')
|
||
} else {
|
||
try {
|
||
const res = await getCallSignature({
|
||
patient_id: newConversation.userProfile.userID.replace("patient_", ""),
|
||
diagnosis_id: diagnosisId.value
|
||
})
|
||
syncLochostVodFromSignature(res)
|
||
patientUserId.value = res.patientUserId
|
||
assistant_ids.value = res.assistant_id
|
||
console.log('会话切换后获取到 assistant_id:', assistant_ids.value, '完整响应:', res)
|
||
} catch (err) {
|
||
console.error('获取 assistant_id 失败:', err)
|
||
}
|
||
}
|
||
|
||
console.log('会话切换完成,userID:', newConversation.userProfile.userID)
|
||
}
|
||
})
|
||
|
||
const open = async (data: { patientId: number; patientName: string; diagnosisId?: number }) => {
|
||
visible.value = true
|
||
isMinimized.value = false
|
||
posX.value = 100
|
||
posY.value = 80
|
||
resetCallKitPositionToLeft()
|
||
patientName.value = data.patientName
|
||
patientId.value = data.patientId
|
||
diagnosisId.value = data.diagnosisId || null
|
||
error.value = ''
|
||
isReady.value = false
|
||
isCallReady.value = false
|
||
showCallKitWindow.value = false
|
||
loadingText.value = '正在获取签名...'
|
||
|
||
try {
|
||
// 获取医生的签名信息
|
||
const res = await getCallSignature({
|
||
patient_id: data.patientId,
|
||
diagnosis_id: data.diagnosisId || 0
|
||
})
|
||
syncLochostVodFromSignature(res)
|
||
assistant_ids.value = res.assistant_id
|
||
console.log('后端返回的签名数据:', res)
|
||
console.log('assistant_id:', assistant_ids.value)
|
||
|
||
if (!res || !res.userSig) {
|
||
throw new Error('获取签名失败')
|
||
}
|
||
|
||
if (!res.assistant_id) {
|
||
console.warn('警告:后端未返回 assistant_id,群视频通话可能无法正常工作')
|
||
}
|
||
|
||
patientUserId.value = res.patientUserId || `patient_${data.patientId}`
|
||
|
||
loadingText.value = '正在登录聊天...'
|
||
|
||
// 登录 Chat UIKit
|
||
await login({
|
||
sdkAppId: Number(res.sdkAppId),
|
||
userId: res.userId,
|
||
userSig: res.userSig,
|
||
useUploadPlugin: true
|
||
})
|
||
// IM 自定义信令:对端挂断等 → 立即 endCall 停合流(不依赖小程序上报)
|
||
bindImHangupMessageListener()
|
||
const sdkAPPid = Number(res.sdkAppId)
|
||
|
||
// 初始化 TUICallKit(聊天内音视频通话依赖此初始化);只需全局成功一次
|
||
if (!(window as any).__TUICallKitInited__) {
|
||
loadingText.value = '正在初始化通话能力...'
|
||
try {
|
||
await TUICallKitServer.init({
|
||
userID: res.userId,
|
||
userSig: res.userSig,
|
||
SDKAppID: sdkAPPid
|
||
})
|
||
;(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: () => {
|
||
installTrtcCloudPreStopHook()
|
||
clearLocalRecordingStartTimer()
|
||
localCallRecorder.reset()
|
||
localRecordingBadge.value = false
|
||
localRecordingRound++
|
||
recordingDiagnosisIdSnapshot = diagnosisId.value
|
||
resetCallKitPositionToLeft()
|
||
showCallKitWindow.value = true
|
||
pendingCallRecordStart = ensureCallRecordStarted()
|
||
void pendingCallRecordStart
|
||
},
|
||
// 须先 endCall(DeleteCloudRecording),再 await 本地上传;否则上传 WebM 耗时数分钟会拖住云端停录,控制台房间长期「尚未结束」
|
||
afterCalling: () => {
|
||
localCallRecorder.beginStopNow()
|
||
void (async () => {
|
||
await reportCallEnded()
|
||
showCallKitWindow.value = false
|
||
await finalizeLocalRecordingAndAttach()
|
||
})()
|
||
}
|
||
})
|
||
installTUICallKitRoomHooks()
|
||
installEngineRoomCapture()
|
||
installHangupPreStopHook()
|
||
installTrtcCloudPreStopHook()
|
||
bindEngineRoomEvents()
|
||
setupCallRoomBinding()
|
||
bindEnginePackageErrorListener()
|
||
}
|
||
console.log('Chat UIKit 登录成功,患者用户ID:', patientUserId.value)
|
||
|
||
loadingText.value = '加载聊天界面...'
|
||
|
||
// 等待 UIKit 初始化完成后创建并激活会话
|
||
setTimeout(async () => {
|
||
isReady.value = true
|
||
|
||
// 使用 nextTick 确保组件已渲染
|
||
await nextTick()
|
||
|
||
// 增加延迟,确保 TIM SDK 完全初始化
|
||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||
|
||
// 更新用户头像到 IM - 从 Pinia store 获取当前登录用户的头像
|
||
const avatar = userStore.userInfo?.avatar
|
||
console.log('准备更新头像,avatar:', avatar)
|
||
|
||
if (avatar) {
|
||
await updateUserAvatarToIM(avatar,sdkAPPid)
|
||
}
|
||
|
||
// 创建或获取与患者的单聊会话
|
||
try {
|
||
const conversation = await createC2CConversation(patientUserId.value)
|
||
conversationId.value = conversation.conversationID
|
||
console.log('已创建/获取会话:', conversationId.value)
|
||
|
||
// 激活该会话
|
||
setActiveConversation(conversationId.value)
|
||
console.log('已激活会话')
|
||
|
||
await sendDoctorEnteredConsultRoomSignal(sdkAPPid, patientUserId.value)
|
||
} catch (err) {
|
||
console.error('创建/激活会话失败:', err)
|
||
}
|
||
}, 800)
|
||
|
||
} catch (err: any) {
|
||
console.error('初始化聊天失败:', err)
|
||
error.value = err.message || '初始化聊天失败'
|
||
feedback.msgError(error.value)
|
||
}
|
||
}
|
||
|
||
// 群组视频通话(多人通话):当前医生作为发起方,默认先邀请当前会话患者
|
||
const startGroupVideoCall = async () => {
|
||
if (!isCallReady.value) {
|
||
feedback.msgWarning('通话服务初始化中,请稍后再试')
|
||
return
|
||
}
|
||
|
||
if (!patientUserId.value) {
|
||
feedback.msgWarning('未找到患者通话ID,无法发起群视频')
|
||
return
|
||
}
|
||
|
||
try {
|
||
// 确保 assistant_id 存在
|
||
if (!assistant_ids.value) {
|
||
console.warn('assistant_id 为空,尝试重新获取')
|
||
const res = await getCallSignature({
|
||
patient_id: patientId.value!,
|
||
diagnosis_id: diagnosisId.value
|
||
})
|
||
syncLochostVodFromSignature(res)
|
||
assistant_ids.value = res.assistant_id
|
||
console.log('重新获取 assistant_id:', assistant_ids.value)
|
||
}
|
||
|
||
// 构建用户列表,过滤掉空值
|
||
const userIDList = [patientUserId.value, assistant_ids.value].filter(id => id)
|
||
|
||
if (userIDList.length < 2) {
|
||
feedback.msgWarning('助理ID获取失败,无法发起群视频')
|
||
console.error('userIDList 不完整:', userIDList, 'assistant_ids:', assistant_ids.value)
|
||
return
|
||
}
|
||
|
||
console.log('发起群视频通话,参与者:', userIDList)
|
||
|
||
await TUICallKitServer.calls({
|
||
userIDList,
|
||
type: TUICallType.VIDEO_CALL
|
||
})
|
||
} catch (err: any) {
|
||
console.error('发起群视频失败:', err)
|
||
if (isTUICallPackageAbilityError(err)) {
|
||
notifyPackageArrearsOnce()
|
||
} else {
|
||
feedback.msgError(formatTUICallUserError(err, '发起群视频失败'))
|
||
}
|
||
}
|
||
}
|
||
|
||
// 浮动窗口位置与拖动
|
||
const chatWindowRef = ref<HTMLElement>()
|
||
const posX = ref(100)
|
||
const posY = ref(80)
|
||
const isDragging = ref(false)
|
||
const dragStartX = ref(0)
|
||
const dragStartY = ref(0)
|
||
const posStartX = ref(0)
|
||
const posStartY = ref(0)
|
||
|
||
// TUICallKit 视频窗口拖动
|
||
const callKitWrapperRef = ref<HTMLElement>()
|
||
const callKitContentRef = ref<HTMLElement>()
|
||
const captureUploading = ref(false)
|
||
/** 与 .chat-dialog-call-kit-wrapper 尺寸一致 */
|
||
const CALL_KIT_FLOAT_W = 375
|
||
const CALL_KIT_FLOAT_H = 667
|
||
const CALL_KIT_EDGE = 16
|
||
|
||
const callKitX = ref(0)
|
||
const callKitY = ref(0)
|
||
/** 视频浮窗默认靠左、垂直居中(每次发起通话也会重置,避免沿用上次拖动位置) */
|
||
function resetCallKitPositionToLeft() {
|
||
callKitX.value = CALL_KIT_EDGE
|
||
callKitY.value = Math.max(
|
||
CALL_KIT_EDGE,
|
||
Math.floor((window.innerHeight - CALL_KIT_FLOAT_H) / 2)
|
||
)
|
||
}
|
||
|
||
const isCallKitDragging = ref(false)
|
||
const callKitDragStartX = ref(0)
|
||
const callKitDragStartY = ref(0)
|
||
const callKitPosStartX = ref(0)
|
||
const callKitPosStartY = ref(0)
|
||
|
||
const callKitStyle = computed(() => ({
|
||
left: `${callKitX.value}px`,
|
||
top: `${callKitY.value}px`
|
||
}))
|
||
|
||
const onCallKitDragStart = (e: MouseEvent) => {
|
||
isCallKitDragging.value = true
|
||
callKitDragStartX.value = e.clientX
|
||
callKitDragStartY.value = e.clientY
|
||
callKitPosStartX.value = callKitX.value
|
||
callKitPosStartY.value = callKitY.value
|
||
document.addEventListener('mousemove', onCallKitDragMove)
|
||
document.addEventListener('mouseup', onCallKitDragEnd)
|
||
}
|
||
|
||
const onCallKitDragMove = (e: MouseEvent) => {
|
||
if (!isCallKitDragging.value) return
|
||
callKitX.value = Math.max(0, callKitPosStartX.value + e.clientX - callKitDragStartX.value)
|
||
callKitY.value = Math.max(0, callKitPosStartY.value + e.clientY - callKitDragStartY.value)
|
||
}
|
||
|
||
const onCallKitDragEnd = () => {
|
||
isCallKitDragging.value = false
|
||
document.removeEventListener('mousemove', onCallKitDragMove)
|
||
document.removeEventListener('mouseup', onCallKitDragEnd)
|
||
}
|
||
|
||
/** 截取当前视频画面并上传,保存到医生备注 */
|
||
const handleCallKitScreenshot = async () => {
|
||
if (captureUploading.value) return
|
||
captureUploading.value = true
|
||
try {
|
||
if (patientId.value == null) {
|
||
feedback.msgWarning('缺少患者信息,无法同步舌苔照片')
|
||
return
|
||
}
|
||
const wrap = callKitWrapperRef.value
|
||
const content = callKitContentRef.value
|
||
const root = wrap || content
|
||
if (!root) return
|
||
const blob = await captureVideoFrameFromElement(root, {
|
||
extraRoots: [content].filter(Boolean) as HTMLElement[]
|
||
})
|
||
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
|
||
}
|
||
await addDoctorNote({ diagnosis_id: diagnosisId.value, tongue_images: [path] })
|
||
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 {
|
||
localCallRecorder.beginStopNow()
|
||
await finalizeLocalRecordingAndAttach()
|
||
await TUICallKitServer.hangup()
|
||
} catch (err) {
|
||
console.warn('关闭视频窗口时挂断失败:', err)
|
||
}
|
||
}
|
||
showCallKitWindow.value = false
|
||
}
|
||
|
||
const windowStyle = computed(() => ({
|
||
left: `${posX.value}px`,
|
||
top: `${posY.value}px`
|
||
}))
|
||
|
||
const onHeaderMouseDown = (e: MouseEvent) => {
|
||
const t = e.target as HTMLElement
|
||
if (t.closest('.chat-close-btn') || t.closest('.chat-header-actions')) return
|
||
isDragging.value = true
|
||
dragStartX.value = e.clientX
|
||
dragStartY.value = e.clientY
|
||
posStartX.value = posX.value
|
||
posStartY.value = posY.value
|
||
document.addEventListener('mousemove', onHeaderMouseMove)
|
||
document.addEventListener('mouseup', onHeaderMouseUp)
|
||
}
|
||
|
||
const onHeaderMouseMove = (e: MouseEvent) => {
|
||
if (!isDragging.value) return
|
||
posX.value = Math.max(0, posStartX.value + e.clientX - dragStartX.value)
|
||
posY.value = Math.max(0, posStartY.value + e.clientY - dragStartY.value)
|
||
}
|
||
|
||
const onHeaderMouseUp = () => {
|
||
isDragging.value = false
|
||
document.removeEventListener('mousemove', onHeaderMouseMove)
|
||
document.removeEventListener('mouseup', onHeaderMouseUp)
|
||
}
|
||
|
||
const handleClose = async () => {
|
||
unbindImHangupMessageListener()
|
||
// 关闭时如有通话中/拨通中,先结束本地录制再挂断(不依赖悬浮窗是否显示)
|
||
if (isCallReady.value) {
|
||
const st = TUIStore.getData(StoreName.CALL, NAME.CALL_STATUS)
|
||
if (st === CALL_STATUS_CALLING || st === CALL_STATUS_CONNECTED) {
|
||
try {
|
||
localCallRecorder.beginStopNow()
|
||
await finalizeLocalRecordingAndAttach()
|
||
await TUICallKitServer.hangup()
|
||
} catch (err) {
|
||
console.warn('挂断通话时出错:', err)
|
||
}
|
||
}
|
||
}
|
||
if (diagnosisId.value != null) {
|
||
await reportCallEnded()
|
||
}
|
||
try {
|
||
// 登出 Chat
|
||
// await logout()
|
||
} catch (err) {
|
||
console.error('登出失败:', err)
|
||
}
|
||
|
||
visible.value = false
|
||
showCallKitWindow.value = false
|
||
// isReady.value = false
|
||
// error.value = ''
|
||
// patientId.value = null
|
||
// diagnosisId.value = null
|
||
// patientUserId.value = ''
|
||
// conversationId.value = ''
|
||
}
|
||
|
||
defineExpose({ open })
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
/* 浮动窗口:无遮罩,不遮挡页面,可任意拖动 */
|
||
.chat-floating-window {
|
||
position: fixed;
|
||
width: 600px;
|
||
height: 75vh;
|
||
min-height: 400px;
|
||
max-height: 90vh;
|
||
background: #fff;
|
||
border-radius: 8px;
|
||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.15);
|
||
z-index: 2501;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
|
||
&--minimized {
|
||
width: min(360px, 92vw);
|
||
height: auto;
|
||
min-height: 0;
|
||
max-height: none;
|
||
box-shadow: 0 2px 16px rgba(0, 0, 0, 0.12);
|
||
}
|
||
}
|
||
|
||
.chat-window-header {
|
||
padding: 16px 20px;
|
||
border-bottom: 1px solid #e4e7ed;
|
||
background: #fff;
|
||
cursor: move;
|
||
user-select: none;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
flex-shrink: 0;
|
||
|
||
.chat-window-title {
|
||
font-size: 16px;
|
||
font-weight: 500;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
padding-right: 8px;
|
||
}
|
||
|
||
.chat-header-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 2px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.chat-header-btn-text {
|
||
margin-left: 2px;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.chat-close-btn,
|
||
.chat-minimize-btn,
|
||
.chat-expand-btn {
|
||
cursor: pointer;
|
||
flex-shrink: 0;
|
||
}
|
||
}
|
||
|
||
.chat-container {
|
||
flex: 1;
|
||
min-height: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.loading-container {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 12px;
|
||
color: #909399;
|
||
|
||
.el-icon {
|
||
font-size: 32px;
|
||
}
|
||
}
|
||
|
||
.error-container {
|
||
width: 100%;
|
||
padding: 20px;
|
||
}
|
||
|
||
.chat-content {
|
||
width: 100%;
|
||
height: 100%;
|
||
overflow: hidden;
|
||
position: relative;
|
||
}
|
||
|
||
.chat-layout {
|
||
display: flex;
|
||
height: 100%;
|
||
width: 100%;
|
||
|
||
.chat-area {
|
||
flex: 1;
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
position: relative;
|
||
}
|
||
|
||
.empty-chat {
|
||
flex: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
}
|
||
|
||
/* 消息工具栏样式 */
|
||
.message-toolbar {
|
||
display: flex;
|
||
gap: 8px;
|
||
align-items: center;
|
||
}
|
||
|
||
/* TUICallKit 可拖动容器 - 手机尺寸 (375x667) */
|
||
.chat-dialog-call-kit-wrapper {
|
||
position: fixed;
|
||
width: 375px;
|
||
height: 667px;
|
||
max-width: 90vw;
|
||
z-index: 300000;
|
||
background: #1a1a1a;
|
||
border-radius: 24px;
|
||
overflow: hidden;
|
||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.call-kit-drag-handle {
|
||
height: 32px;
|
||
padding: 0 12px;
|
||
background: #2a2a2a;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
cursor: move;
|
||
user-select: none;
|
||
flex-shrink: 0;
|
||
|
||
.call-kit-drag-handle-left {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
min-width: 0;
|
||
}
|
||
|
||
.drag-handle-text {
|
||
font-size: 13px;
|
||
color: #fff;
|
||
}
|
||
|
||
.call-local-recording-hint {
|
||
font-size: 11px;
|
||
color: #f89898;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
}
|
||
|
||
.call-kit-content {
|
||
flex: 1;
|
||
min-height: 0;
|
||
position: relative;
|
||
}
|
||
|
||
.chat-dialog-call-kit {
|
||
position: absolute !important;
|
||
top: 0 !important;
|
||
left: 0 !important;
|
||
right: 0 !important;
|
||
bottom: 0 !important;
|
||
width: 100% !important;
|
||
height: 100% !important;
|
||
transform: none !important;
|
||
max-width: none !important;
|
||
max-height: none !important;
|
||
border-radius: 0 0 24px 24px;
|
||
}
|
||
</style>
|
||
|
||
<style scoped lang="scss">
|
||
/* 全局样式:确保表情选择器等弹出层有足够高的 z-index */
|
||
.el-popper,
|
||
.el-picker-panel,
|
||
[data-reka-popper],
|
||
[data-reka-popper-content-wrapper],
|
||
[class*="emoji-picker"],
|
||
[class*="picker-popup"],
|
||
[class*="tui-picker"],
|
||
body > div[id^="el-popper-container"],
|
||
body > div[data-reka-focus-guard] {
|
||
z-index: 9999 !important;
|
||
}
|
||
|
||
/* 确保聊天窗口内的弹出层也能正确显示 */
|
||
.chat-floating-window [data-reka-popper-content-wrapper] {
|
||
z-index: 9999 !important;
|
||
}
|
||
|
||
</style>
|