新增功能
This commit is contained in:
@@ -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[]
|
||||
|
||||
// 如果容器内没有,检查 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()
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user