Files
zyt/admin/src/components/chat-dialog/index.vue
T
2026-04-30 12:19:04 +08:00

1640 lines
51 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 会调 CreateCloudRecordingRecordParams.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[]
// 如果容器内没有,检查 bodyTUICallKit 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
},
// 须先 endCallDeleteCloudRecording),再 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>