新增功能

This commit is contained in:
Your Name
2026-03-24 16:32:56 +08:00
parent 250d173c2f
commit 9160c36735
248 changed files with 3063 additions and 250 deletions
+284 -13
View File
@@ -66,7 +66,10 @@
class="call-kit-drag-handle"
@mousedown="onCallKitDragStart"
>
<span class="drag-handle-text">视频通话</span>
<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"
@@ -106,14 +109,17 @@
<script setup lang="ts">
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 { uploadImageBlob, uploadVideoBlob } from '@/api/file'
import {
attachLocalCallRecording,
bindCallRoom,
endCall,
getCallSignature,
startCall,
tcmDiagnosisDetail,
tcmDiagnosisEdit
} from '@/api/tcm'
import { CallLocalRecorder } from '@/utils/call-local-recorder'
import { captureVideoFrameFromElement } from '@/utils/call-video-screenshot'
import feedback from '@/utils/feedback'
import {
@@ -176,6 +182,68 @@ let bindRoomRetryAttempts = 0
const BIND_ROOM_MAX_ATTEMPTS = 40
const BIND_ROOM_RETRY_MS = 100
const localCallRecorder = new CallLocalRecorder()
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 finalizeLocalRecordingAndAttach(): Promise<void> {
if (finalizeLocalRecordingPromise) {
return finalizeLocalRecordingPromise
}
finalizeLocalRecordingPromise = (async () => {
clearLocalRecordingStartTimer()
localRecordingBadge.value = false
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)
@@ -328,6 +396,50 @@ function installEngineRoomCapture() {
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 }> = []
function bindEngineRoomEvents() {
@@ -348,6 +460,22 @@ function bindEngineRoomEvents() {
}
void tryBindRoomIfReady()
}
const onEarlyStopRecording = () => {
console.log('[chat-dialog] 检测到通话结束事件,立即停止录制')
if (localCallRecorder.isRecording()) {
console.log('[chat-dialog] 录制进行中,触发 beginStopNow')
localCallRecorder.beginStopNow()
}
}
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)
@@ -400,6 +528,11 @@ function installTUICallKitRoomHooks() {
}
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) {
@@ -449,17 +582,115 @@ async function ensureCallRecordStarted() {
}
}
/** 将 tcm_call_record 中本条进行中的记录置为已结束(挂断、对端挂断、异常断线、关窗等) */
async function reportCallEnded() {
if (diagnosisId.value == null) return
try {
await endCall({ diagnosis_id: diagnosisId.value })
console.log('[chat-dialog] endCall 已同步')
} catch (e) {
console.warn('[chat-dialog] endCall 失败', e)
}
}
function setupCallRoomBinding() {
tearDownCallRoomBinding?.()
let previousCallStatus =
(TUIStore.getData(StoreName.CALL, NAME.CALL_STATUS) as string | undefined) ?? CALL_STATUS_IDLE
const onCallStatus = (newData?: string) => {
if (newData === CALL_STATUS_IDLE) {
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 finalizeLocalRecordingAndAttach()
showCallKitWindow.value = false
await reportCallEnded()
})()
}
clearBindRoomRetry()
lastBoundRoomKey.value = ''
previousCallStatus = next
return
}
if (newData === CALL_STATUS_CONNECTED) {
if (next === CALL_STATUS_CONNECTED) {
void tryBindRoomIfReady()
clearLocalRecordingStartTimer()
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()
@@ -595,6 +826,8 @@ onMounted(() => {
})
onUnmounted(() => {
clearLocalRecordingStartTimer()
localCallRecorder.reset()
tearDownCallRoomBinding?.()
unbindEngineRoomEvents()
unhookConsoleErrorForPackageHint()
@@ -733,18 +966,33 @@ const open = async (data: { patientId: number; patientName: string; diagnosisId?
}
isCallReady.value = !!(window as any).__TUICallKitInited__
if (isCallReady.value) {
// 仅在通话时显示视频窗口,无通话时隐藏;发起通话前落库、接通后绑定房间号(云端录制)
// 仅在通话时显示视频窗口;发起通话前落库、接通后绑定房间号(自动云端录制 + 浏览器本地录制)
TUICallKitServer.setCallback({
beforeCalling: () => {
installTrtcCloudPreStopHook()
clearLocalRecordingStartTimer()
localCallRecorder.reset()
localRecordingBadge.value = false
localRecordingRound++
recordingDiagnosisIdSnapshot = diagnosisId.value
showCallKitWindow.value = true
void ensureCallRecordStarted()
pendingCallRecordStart = ensureCallRecordStarted()
void pendingCallRecordStart
},
// 同步停录须早于 SDK 内部退房;再 finalize 上传并关窗
afterCalling: () => {
showCallKitWindow.value = false
localCallRecorder.beginStopNow()
void (async () => {
await finalizeLocalRecordingAndAttach()
showCallKitWindow.value = false
await reportCallEnded()
})()
}
})
installTUICallKitRoomHooks()
installEngineRoomCapture()
installHangupPreStopHook()
installTrtcCloudPreStopHook()
bindEngineRoomEvents()
setupCallRoomBinding()
bindEnginePackageErrorListener()
@@ -945,6 +1193,8 @@ const handleCallKitScreenshot = async () => {
const handleCallKitClose = async () => {
if (isCallReady.value) {
try {
localCallRecorder.beginStopNow()
await finalizeLocalRecordingAndAttach()
await TUICallKitServer.hangup()
} catch (err) {
console.warn('关闭视频窗口时挂断失败:', err)
@@ -982,14 +1232,22 @@ const onHeaderMouseUp = () => {
}
const handleClose = async () => {
// 关闭时如有通话中/拨通中,自动挂断
if (isCallReady.value && showCallKitWindow.value) {
try {
await TUICallKitServer.hangup()
} catch (err) {
console.warn('挂断通话时出错:', err)
// 关闭时如有通话中/拨通中,先结束本地录制再挂断(不依赖悬浮窗是否显示)
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()
@@ -1139,11 +1397,24 @@ defineExpose({ open })
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;