diff --git a/LOCAL_RECORDING_DEBUG_GUIDE.md b/LOCAL_RECORDING_DEBUG_GUIDE.md new file mode 100644 index 00000000..f1e11114 --- /dev/null +++ b/LOCAL_RECORDING_DEBUG_GUIDE.md @@ -0,0 +1,358 @@ +# 本地录制调试指南(第二版) + +## 问题描述 +本地录制总是失败,`blob` 为空,导致无法保存通话录制文件。 + +## 根本原因分析 + +从实际日志分析发现: +1. **录制启动太晚**:原来的 1200ms 延迟导致录制在对方挂断后才启动 +2. **SDK 提前销毁轨道**:`stopLocalVideo/stopLocalAudio` 在 `exitRoom` 之前就销毁了视频轨道 +3. **时间窗口太短**:从对方离开到轨道销毁只有约 200-300ms + +关键时间线(问题场景): +``` +15:30:51.841 - 对方离开(USER_LEAVE 事件) +15:30:51.xxx - beginStopNow 被调用(但录制还未启动) +15:30:52.072 - stopLocalVideo/stopLocalAudio(轨道被销毁) +15:30:52.102 - exitRoom +15:30:52.374 - MediaRecorder 已启动(太晚了!) +15:30:52.xxx - 数据块数: 0(因为轨道已销毁) +``` + +## 已实施的修复(第二版) + +### 1. 大幅缩短录制启动延迟 +- **从 1200ms 缩短到 500ms** +- 使用 300ms 间隔重试(最多 10 次) +- 更快地检测和启动录制 + +```typescript +// 延迟 500ms 后开始尝试(给视频元素一点加载时间) +startLocalRecTimer = setTimeout(() => attemptStartRecording(0), 500) +``` + +### 2. 在通话结束事件时立即停止录制 +监听 TUICallEngine 事件,在检测到通话结束时立即停止: + +```typescript +const onEarlyStopRecording = () => { + console.log('[chat-dialog] 检测到通话结束事件,立即停止录制') + if (localCallRecorder.isRecording()) { + console.log('[chat-dialog] 录制进行中,触发 beginStopNow') + localCallRecorder.beginStopNow() + } +} +// 监听: CALL_END, USER_LEAVE, ON_CALL_NOT_CONNECTED +``` + +### 3. 缩短停止延迟 +- **从 300ms 缩短到 100ms** +- 减少等待时间,避免 SDK 提前销毁轨道 + +```typescript +// 缩短延迟到 100ms,避免 SDK 提前销毁轨道 +setTimeout(() => { + mr.stop() +}, 100) +``` + +### 4. 增强日志输出 +在关键位置添加了详细的 console.log: + +- **录制启动阶段**: + - 检查容器和通话状态 + - 视频元素数量和就绪状态 + - MediaRecorder 创建和启动状态 + - Canvas 和音频混音初始化 + +- **录制进行阶段**: + - 数据块收集(ondataavailable) + - MediaRecorder 状态变化 + +- **录制停止阶段**: + - beginStopNow 调用时机 + - 数据块数量 + - Blob 生成结果 + +### 5. 智能视频元素检测 +改进了视频元素查找逻辑: + +```typescript +// 1. 首先检查容器内的视频 +let videos = Array.from(root.querySelectorAll('video')) + +// 2. 如果没有,检查 body(TUICallKit Teleport) +if (videos.length === 0) { + const bodyVideos = Array.from(document.body.querySelectorAll('video')) + videos = bodyVideos.filter(v => { + const so = v.srcObject + if (!(so instanceof MediaStream)) return false + return so.getVideoTracks().some(t => t.readyState === 'live') + }) +} + +// 3. 检查视频是否有有效数据 +const hasReadyVideo = videos.some(v => + v.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA +) +``` + +## 调试步骤 + +### 1. 打开浏览器控制台 +在发起视频通话前,打开浏览器开发者工具(F12),切换到 Console 标签。 + +### 2. 发起视频通话 +点击视频通话按钮,观察控制台输出。 + +### 3. 关键日志检查点 + +#### 通话接通后(约 0.5-1 秒) +应该看到: +``` +[chat-dialog] 尝试启动本地录制(第 1 次) +[chat-dialog] 在 body 中找到活跃视频: 2 +[chat-dialog] 视频元素已就绪,立即启动录制 +[call-local-recorder] 使用 MIME 类型: video/webm;codecs=vp9,opus +[call-local-recorder] Canvas 视频轨道数: 1 +[call-local-recorder] 找到视频元素数量: 2 +[call-local-recorder] 输出流轨道: { video: 1, audio: 2 } +[call-local-recorder] MediaRecorder 创建成功 +[call-local-recorder] MediaRecorder.start() 调用成功 +[call-local-recorder] MediaRecorder 已启动 +[chat-dialog] ✅ 本地录制已成功启动 +``` + +#### 录制过程中(每 250ms) +应该看到: +``` +[call-local-recorder] 收到数据块,大小: 12345 (总计: 1 块) +[call-local-recorder] 收到数据块,大小: 15678 (总计: 2 块) +... +``` + +**重要**:如果没有看到数据块,说明录制有问题! + +#### 对方挂断或己方挂断时 +应该看到: +``` +[chat-dialog] 检测到通话结束事件,立即停止录制 +[chat-dialog] 录制进行中,触发 beginStopNow +[call-local-recorder] beginStopNow 被调用 +[call-local-recorder] 准备停止 MediaRecorder,当前状态: recording 已收集数据块: 15 +[call-local-recorder] 调用 requestData() +[call-local-recorder] 延迟后数据块数: 16 +[call-local-recorder] 调用 stop() +[call-local-recorder] MediaRecorder onstop 触发,数据块数: 16 +[call-local-recorder] 生成 Blob: 1234567 bytes, type: video/webm +[chat-dialog] localCallRecorder.stop() 返回: 1234567 bytes +[chat-dialog] 开始上传录制文件 +[chat-dialog] 上传成功,文件 URL: https://... +[chat-dialog] 关联录制文件到诊单 +本地录制已上传并关联到通话记录 +``` + +## 常见问题诊断 + +### 问题 1:录制启动太晚 +**症状**: +``` +[call-local-recorder] MediaRecorder 已启动 +(出现在 stopLocalVideo 之后) +``` + +**原因**: +- 视频元素加载慢 +- 重试次数不够 + +**解决**: +- 已缩短初始延迟到 500ms +- 增加重试次数到 10 次 +- 每次重试间隔 300ms + +### 问题 2:找不到视频元素 +**症状**: +``` +[chat-dialog] 未找到视频元素,300ms 后重试 +(重复多次) +``` + +**原因**: +- TUICallKit 将视频 Teleport 到 body +- 视频元素尚未渲染 + +**解决**: +- 代码已添加 body 视频检查 +- 添加了活跃视频流过滤 + +### 问题 3:视频元素未就绪 +**症状**: +``` +[chat-dialog] 视频元素未就绪,300ms 后重试 +``` + +**原因**: +- 视频流尚未加载 +- readyState < HAVE_CURRENT_DATA + +**解决**: +- 代码已添加就绪检查和重试 +- 最多重试 10 次 + +### 问题 4:没有收到数据块 +**症状**: +- 录制启动成功 +- 但从未看到 "收到数据块" 日志 +- 最终 `数据块数: 0` + +**可能原因**: +1. Canvas 绘制失败(跨域问题) +2. MediaRecorder 不支持当前 MIME 类型 +3. 视频流被中断或为空 + +**检查方法**: +```javascript +// 在控制台手动检查 +const videos = document.querySelectorAll('video') +videos.forEach((v, i) => { + console.log(`Video ${i}:`, { + readyState: v.readyState, + videoWidth: v.videoWidth, + videoHeight: v.videoHeight, + srcObject: v.srcObject, + videoTracks: v.srcObject?.getVideoTracks().map(t => ({ + id: t.id, + label: t.label, + readyState: t.readyState, + enabled: t.enabled + })), + audioTracks: v.srcObject?.getAudioTracks().map(t => ({ + id: t.id, + label: t.label, + readyState: t.readyState, + enabled: t.enabled + })) + }) +}) +``` + +### 问题 5:通话时间过短 +**症状**: +``` +[call-local-recorder] 生成 Blob: null +或 +[call-local-recorder] 生成 Blob: 1234 bytes(很小) +``` + +**原因**: +- 通话时长 < 1 秒 +- 录制启动后立即挂断 + +**解决**: +- 确保通话时长 > 2 秒 +- 测试时保持通话至少 5 秒 + +### 问题 6:SDK 提前销毁轨道 +**症状**: +``` +[call-local-recorder] MediaRecorder 错误 +或 +数据块数为 0 +``` + +**原因**: +- SDK exitRoom 在 MediaRecorder.stop() 之前执行 +- 视频轨道被销毁 + +**解决**: +- 已在 USER_LEAVE 事件时立即停止录制 +- 已在多个挂断入口添加钩子 +- 缩短停止延迟到 100ms + +## 测试建议 + +### 最小测试场景 +1. 发起视频通话 +2. 等待接通(看到对方画面) +3. **保持通话至少 5 秒**(重要!) +4. 正常挂断 +5. 检查控制台日志和录制结果 + +### 关键检查点 +- [ ] 录制在接通后 1 秒内启动 +- [ ] 看到 "✅ 本地录制已成功启动" +- [ ] 看到持续的 "收到数据块" 日志 +- [ ] 数据块数 > 0(至少 4-5 块) +- [ ] 生成的 Blob 大小 > 10KB +- [ ] 上传成功并关联到诊单 + +### 压力测试场景 +1. 快速挂断(2-3 秒) +2. 对方挂断 +3. 网络中断 +4. 切换会话后挂断 +5. 关闭聊天窗口 + +## 预期改进效果 + +### 修复前 +``` +通话接通 -> 等待 1200ms -> 尝试启动录制 -> 对方挂断 -> SDK 销毁轨道 -> 录制失败 +``` + +### 修复后 +``` +通话接通 -> 等待 500ms -> 快速启动录制 -> 开始收集数据 -> +检测到挂断 -> 立即停止录制 -> 生成 Blob -> 上传成功 +``` + +## 浏览器兼容性 + +### 支持的浏览器 +- Chrome/Edge 85+ +- Firefox 78+ +- Safari 14.1+ + +### MIME 类型优先级 +1. `video/webm;codecs=vp9,opus` (最佳) +2. `video/webm;codecs=vp8,opus` +3. `video/webm;codecs=vp9` +4. `video/webm` (兜底) + +### 检查浏览器支持 +```javascript +const mimes = [ + 'video/webm;codecs=vp9,opus', + 'video/webm;codecs=vp8,opus', + 'video/webm;codecs=vp9', + 'video/webm' +] +mimes.forEach(m => { + console.log(m, MediaRecorder.isTypeSupported(m)) +}) +``` + +## 性能优化建议 + +1. **Canvas 分辨率**:当前 1280x720,可根据需要调整 +2. **数据块间隔**:当前 250ms,可调整为 500ms 减少开销 +3. **音频混音**:如果不需要音频,可以禁用 AudioContext + +## 下一步优化(如果问题仍存在) + +1. **进一步缩短启动延迟**:从 500ms 减少到 300ms +2. **增加录制状态监控**:定期检查 MediaRecorder 状态 +3. **添加降级方案**:如果本地录制失败,提示用户依赖云端录制 +4. **添加手动录制控制**:让用户手动开始/停止录制 +5. **使用 MutationObserver**:监听视频元素的添加,立即启动录制 + +## 联系支持 + +如果按照本指南操作后问题仍未解决,请提供: +1. 完整的控制台日志(从通话开始到结束) +2. 浏览器版本和操作系统 +3. 通话时长(秒) +4. 是否看到视频画面 +5. 是否有任何错误提示 +6. 数据块数量(如果有) diff --git a/QUICK_FIX_REFERENCE.md b/QUICK_FIX_REFERENCE.md new file mode 100644 index 00000000..b84371d3 --- /dev/null +++ b/QUICK_FIX_REFERENCE.md @@ -0,0 +1,83 @@ +# 本地录制修复 - 快速参考 + +## 🔍 问题 +本地录制总是失败,blob 为空 + +## 🎯 根本原因 +录制启动太晚(1200ms),在对方挂断后才启动,此时 SDK 已销毁视频轨道 + +## ✅ 解决方案 + +### 1. 缩短启动延迟 +``` +1200ms → 500ms +``` + +### 2. 快速重试 +``` +300ms 间隔,最多 10 次 +``` + +### 3. 立即停止录制 +``` +监听 USER_LEAVE 事件,立即调用 beginStopNow() +``` + +### 4. 缩短停止延迟 +``` +300ms → 100ms +``` + +## 📊 测试检查清单 + +### 启动阶段(接通后 1 秒内) +- [ ] 看到 "尝试启动本地录制" +- [ ] 看到 "视频元素已就绪" +- [ ] 看到 "✅ 本地录制已成功启动" + +### 录制阶段(每 250ms) +- [ ] 看到 "收到数据块,大小: XXX" +- [ ] 数据块数持续增加 + +### 停止阶段(挂断时) +- [ ] 看到 "检测到通话结束事件" +- [ ] 看到 "已收集数据块: X"(X > 0) +- [ ] 看到 "生成 Blob: XXX bytes"(不是 null) +- [ ] 看到 "本地录制已上传并关联到通话记录" + +## 🚨 常见问题 + +### 问题:数据块数为 0 +**原因**:录制启动太晚或视频流无效 +**检查**: +```javascript +// 控制台执行 +document.querySelectorAll('video').forEach((v, i) => { + console.log(`Video ${i}:`, v.readyState, v.srcObject) +}) +``` + +### 问题:Blob 为 null +**原因**:通话时间太短(< 2 秒) +**解决**:保持通话至少 5 秒 + +### 问题:录制启动失败 +**原因**:找不到视频元素 +**检查**:是否看到 "未找到视频元素" 日志 + +## 📝 修改的文件 +1. `admin/src/components/chat-dialog/index.vue` +2. `admin/src/utils/call-local-recorder.ts` + +## 🔗 详细文档 +- `LOCAL_RECORDING_DEBUG_GUIDE.md` - 完整调试指南 +- `RECORDING_FIX_SUMMARY.md` - 修复总结 +- `admin/test-local-recording.html` - 测试工具 + +## 💡 测试建议 +1. 清除浏览器缓存 +2. 打开控制台(F12) +3. 发起视频通话 +4. 保持通话 5 秒以上 +5. 正常挂断 +6. 检查日志和录制结果 diff --git a/RECORDING_FIX_SUMMARY.md b/RECORDING_FIX_SUMMARY.md new file mode 100644 index 00000000..e84a3b50 --- /dev/null +++ b/RECORDING_FIX_SUMMARY.md @@ -0,0 +1,144 @@ +# 本地录制修复总结 + +## 问题根源 + +通过分析实际日志发现,本地录制失败的根本原因是: + +1. **录制启动太晚**:1200ms 延迟导致录制在对方挂断后才启动 +2. **SDK 提前销毁轨道**:`stopLocalVideo/stopLocalAudio` 在录制完成前就销毁了视频轨道 +3. **时间窗口极短**:从对方离开到轨道销毁只有 200-300ms + +## 关键时间线(问题场景) + +``` +15:30:51.841 - 对方离开(USER_LEAVE 事件) +15:30:51.xxx - beginStopNow 被调用(但录制还未启动!) +15:30:52.072 - stopLocalVideo/stopLocalAudio(轨道被销毁) +15:30:52.102 - exitRoom +15:30:52.374 - MediaRecorder 已启动(太晚了!) +15:30:52.xxx - 数据块数: 0(因为轨道已销毁) +``` + +## 修复方案 + +### 1. 大幅缩短录制启动延迟 +- **从 1200ms 缩短到 500ms** +- 使用 300ms 间隔快速重试(最多 10 次) +- 目标:在通话接通后 1 秒内启动录制 + +### 2. 在通话结束事件时立即停止录制 +- 监听 `TUICallEvent.USER_LEAVE`、`CALL_END`、`ON_CALL_NOT_CONNECTED` +- 检测到事件时立即调用 `beginStopNow()` +- 抢在 SDK 销毁轨道前完成录制 + +### 3. 缩短停止延迟 +- **从 300ms 缩短到 100ms** +- 减少等待时间,避免 SDK 提前销毁轨道 + +### 4. 智能视频元素检测 +- 首先检查容器内的视频 +- 如果没有,检查 body(TUICallKit Teleport) +- 过滤出有活跃视频流的元素 +- 验证 readyState >= HAVE_CURRENT_DATA + +## 修改的文件 + +1. **admin/src/components/chat-dialog/index.vue** + - 缩短录制启动延迟(1200ms → 500ms) + - 添加快速重试机制(300ms 间隔,最多 10 次) + - 在 `bindEngineRoomEvents` 中添加立即停止逻辑 + - 增强日志输出 + +2. **admin/src/utils/call-local-recorder.ts** + - 缩短停止延迟(300ms → 100ms) + - 增强日志输出 + - 添加详细的状态跟踪 + +3. **LOCAL_RECORDING_DEBUG_GUIDE.md** + - 完整的调试指南 + - 常见问题诊断 + - 测试建议 + +4. **admin/test-local-recording.html** + - 独立的测试工具 + - 浏览器兼容性检测 + - MediaRecorder 功能测试 + +## 测试步骤 + +### 1. 清除浏览器缓存 +确保使用最新的代码。 + +### 2. 打开控制台 +F12 打开开发者工具,切换到 Console 标签。 + +### 3. 发起视频通话 +点击视频通话按钮。 + +### 4. 观察关键日志 + +#### 接通后应该看到(1 秒内): +``` +[chat-dialog] 尝试启动本地录制(第 1 次) +[chat-dialog] 视频元素已就绪,立即启动录制 +[call-local-recorder] MediaRecorder.start() 调用成功 +[call-local-recorder] MediaRecorder 已启动 +[chat-dialog] ✅ 本地录制已成功启动 +``` + +#### 通话过程中应该看到(每 250ms): +``` +[call-local-recorder] 收到数据块,大小: 12345 (总计: 1 块) +[call-local-recorder] 收到数据块,大小: 15678 (总计: 2 块) +``` + +#### 挂断时应该看到: +``` +[chat-dialog] 检测到通话结束事件,立即停止录制 +[call-local-recorder] beginStopNow 被调用 +[call-local-recorder] 准备停止 MediaRecorder,当前状态: recording 已收集数据块: 15 +[call-local-recorder] 生成 Blob: 1234567 bytes +[chat-dialog] 开始上传录制文件 +本地录制已上传并关联到通话记录 +``` + +### 5. 验证结果 +- 检查诊单是否有录制文件 +- 播放录制文件验证内容 + +## 预期效果 + +### 修复前 +- 录制成功率:0% +- 数据块数:0 +- Blob 大小:null + +### 修复后 +- 录制成功率:>95%(通话时长 > 2 秒) +- 数据块数:>= 通话秒数 * 4 +- Blob 大小:>= 通话秒数 * 50KB + +## 注意事项 + +1. **最短通话时长**:建议至少 2 秒,否则可能来不及启动录制 +2. **浏览器兼容性**:Chrome/Edge 85+、Firefox 78+、Safari 14.1+ +3. **网络状况**:网络差时视频流可能不稳定,影响录制质量 +4. **权限问题**:确保浏览器已授予摄像头和麦克风权限 + +## 如果问题仍然存在 + +1. 查看完整的控制台日志 +2. 确认录制是否在接通后 1 秒内启动 +3. 确认是否有 "收到数据块" 日志 +4. 检查通话时长是否 > 2 秒 +5. 参考 `LOCAL_RECORDING_DEBUG_GUIDE.md` 进行详细诊断 + +## 进一步优化方向 + +如果修复后仍有问题,可以考虑: + +1. **进一步缩短启动延迟**:300ms 或更短 +2. **使用 MutationObserver**:监听视频元素添加,立即启动 +3. **添加录制状态监控**:定期检查 MediaRecorder 状态 +4. **添加降级方案**:本地录制失败时提示用户 +5. **添加手动控制**:让用户手动开始/停止录制 diff --git a/admin/package-lock.json b/admin/package-lock.json index d7f63f51..f9a0dac8 100644 --- a/admin/package-lock.json +++ b/admin/package-lock.json @@ -29,6 +29,7 @@ "lodash-es": "^4.17.21", "nprogress": "^0.2.0", "pinia": "^2.3.1", + "trtc-sdk-v5": "^5.16.0", "vue": "^3.5.13", "vue-clipboard3": "^2.0.0", "vue-echarts": "^6.7.3", @@ -13065,15 +13066,37 @@ "trtc-sdk-v5": "5.15.3-beta.12" } }, - "node_modules/trtc-sdk-v5": { + "node_modules/trtc-cloud-js-sdk/node_modules/trtc-sdk-v5": { "version": "5.15.3-beta.12", - "resolved": "https://registry.npmjs.org/trtc-sdk-v5/-/trtc-sdk-v5-5.15.3-beta.12.tgz", + "resolved": "https://registry.npmmirror.com/trtc-sdk-v5/-/trtc-sdk-v5-5.15.3-beta.12.tgz", "integrity": "sha512-CE3mQTj7gB4RIiSGFT5x7GNfT1KP5VVu3C5A8V4Op4nxa4AzCtS4Y6TMuIHH8yGccuzcNLUXYOzov3wxdzvaFw==", "license": "ISC", "dependencies": { "webrtc-adapter": "^8.2.3" } }, + "node_modules/trtc-sdk-v5": { + "version": "5.16.0", + "resolved": "https://registry.npmmirror.com/trtc-sdk-v5/-/trtc-sdk-v5-5.16.0.tgz", + "integrity": "sha512-T+D+YOFyC9PeKizKAq/vBOyrtb5RyBhDHjgKZin9D2HqDs8Of8I6BCdJW0D9zXk8piXqHahD+1Cc4HRk7fqhBQ==", + "license": "ISC", + "dependencies": { + "webrtc-adapter": "^9.0.4" + } + }, + "node_modules/trtc-sdk-v5/node_modules/webrtc-adapter": { + "version": "9.0.4", + "resolved": "https://registry.npmmirror.com/webrtc-adapter/-/webrtc-adapter-9.0.4.tgz", + "integrity": "sha512-5ZZY1+lGq8LEKuDlg9M2RPJHlH3R7OVwyHqMcUsLKCgd9Wvf+QrFTCItkXXYPmrJn8H6gRLXbSgxLLdexiqHxw==", + "license": "BSD-3-Clause", + "dependencies": { + "sdp": "^3.2.0" + }, + "engines": { + "node": ">=6.0.0", + "npm": ">=3.10.0" + } + }, "node_modules/ts-api-utils": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", diff --git a/admin/package.json b/admin/package.json index 5c54b340..4be5a68b 100644 --- a/admin/package.json +++ b/admin/package.json @@ -31,6 +31,7 @@ "lodash-es": "^4.17.21", "nprogress": "^0.2.0", "pinia": "^2.3.1", + "trtc-sdk-v5": "^5.16.0", "vue": "^3.5.13", "vue-clipboard3": "^2.0.0", "vue-echarts": "^6.7.3", diff --git a/admin/src/api/file.ts b/admin/src/api/file.ts index 1c90c5f9..27e5ba5e 100644 --- a/admin/src/api/file.ts +++ b/admin/src/api/file.ts @@ -26,6 +26,28 @@ export async function uploadImageBlob(file: Blob, filename = 'screenshot.jpg') { return json.data as { id: number; uri: string; url: string } } +/** 本地上传视频(管理端 /upload/video),返回 data 含 uri 完整访问地址、url 相对路径 */ +export async function uploadVideoBlob(file: Blob, filename = 'recording.webm') { + const appStore = useAppStore() + const formData = new FormData() + formData.append('file', file, filename) + formData.append('cid', '0') + const url = `${config.baseUrl}${config.urlPrefix}/upload/video` + const res = await fetch(url, { + method: 'POST', + headers: { + token: getToken() || '', + version: appStore.config.version || '' + }, + body: formData + }) + const json = await res.json() + if (json.code !== RequestCodeEnum.SUCCESS) { + throw new Error(json.msg || '上传失败') + } + return json.data as { id: number; uri: string; url: string } +} + export function fileCateAdd(params: Record) { return request.post({ url: '/file/addCate', params }) } diff --git a/admin/src/api/tcm.ts b/admin/src/api/tcm.ts index ecb2abc7..9e6de6e7 100644 --- a/admin/src/api/tcm.ts +++ b/admin/src/api/tcm.ts @@ -89,9 +89,12 @@ export function getRecordsByPatient(params: any) { // ========== 音视频通话 ========== -// 获取通话签名 +// 获取通话签名(忽略取消令牌,避免 open + 会话切换 watch 重复请求互斥取消) export function getCallSignature(params: any) { - return request.post({ url: '/tcm.diagnosis/getCallSignature', params }) + return request.post( + { url: '/tcm.diagnosis/getCallSignature', params }, + { ignoreCancelToken: true, isOpenRetry: false } + ) } // 发起通话 @@ -99,9 +102,12 @@ export function startCall(params: any) { return request.post({ url: '/tcm.diagnosis/startCall', params }) } -// 结束通话 +// 结束通话(忽略取消令牌:idle / afterCalling 可能并发触发相同请求体) export function endCall(params: any) { - return request.post({ url: '/tcm.diagnosis/endCall', params }) + return request.post( + { url: '/tcm.diagnosis/endCall', params }, + { ignoreCancelToken: true, isOpenRetry: false } + ) } // 获取通话记录 @@ -114,6 +120,24 @@ export function bindCallRoom(params: { diagnosis_id: number; room_id: string }) return request.post({ url: '/tcm.diagnosis/bindCallRoom', params }) } +/** 医生接通并绑定房间后:发起腾讯云云端混流录制 */ +export function startCloudRecording(params: { diagnosis_id: number }) { + return request.post({ url: '/tcm.diagnosis/startCloudRecording', params }) +} + +/** 本地录制完成后,将已上传视频的访问地址写入通话记录(忽略取消令牌,避免与其它 POST 互斥取消) */ +export function attachLocalCallRecording(params: { diagnosis_id: number; file_url: string }) { + return request.post( + { url: '/tcm.diagnosis/attachLocalCallRecording', params }, + { ignoreCancelToken: true, isOpenRetry: false } + ) +} + +/** 医助旁观当前进行中的视频通话(进房参数,Web TRTC 只拉流) */ +export function getAssistantWatchCallParams(params: { diagnosis_id: number }) { + return request.get({ url: '/tcm.diagnosis/watchCall', params }) +} + // ========== 小程序分享 ========== // 生成小程序码 diff --git a/admin/src/components/chat-dialog/index.vue b/admin/src/components/chat-dialog/index.vue index 5374d4e4..0d7bc732 100644 --- a/admin/src/components/chat-dialog/index.vue +++ b/admin/src/components/chat-dialog/index.vue @@ -66,7 +66,10 @@ class="call-kit-drag-handle" @mousedown="onCallKitDragStart" > - 视频通话 +
+ 视频通话 + 本地录制中 +
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 | null = null +let finalizeLocalRecordingPromise: Promise | null = null +/** 本次振铃/通话对应的 startCall 落库(beforeCalling 发起,bind/上传前须 await) */ +let pendingCallRecordStart: Promise = Promise.resolve() +/** 接通前快照诊单 ID,避免会话切换等导致上传阶段 diagnosisId 丢失 */ +let recordingDiagnosisIdSnapshot: number | null = null + +function clearLocalRecordingStartTimer() { + if (startLocalRecTimer) { + clearTimeout(startLocalRecTimer) + startLocalRecTimer = null + } +} + +function finalizeLocalRecordingAndAttach(): Promise { + 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 + if (w.__zytHangupPreStop__) return + const server = TUICallKitServer as unknown as Record & { + 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 + if (w.__zytTrtcPreStop__) return + const eng = TUICallKitServer.getTUICallEngineInstance?.() as + | { trtcCloud?: Record } + | 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; diff --git a/admin/src/utils/call-local-recorder.ts b/admin/src/utils/call-local-recorder.ts new file mode 100644 index 00000000..78c28332 --- /dev/null +++ b/admin/src/utils/call-local-recorder.ts @@ -0,0 +1,480 @@ +/** + * 浏览器端本地录制:将通话区域内多路 video 合成到 canvas 后 MediaRecorder + * 音频从各 video 的 srcObject 汇入 Web Audio 混音;Chrome 下 canvas 2D 的 captureStream 需每帧 requestFrame 才会稳定产出编码数据 + */ + +function pickRecorderMime(): string { + const candidates = [ + 'video/webm;codecs=vp9,opus', + 'video/webm;codecs=vp8,opus', + 'video/webm;codecs=vp9', + 'video/webm;codecs=vp8', + 'video/webm' + ] + for (const m of candidates) { + if (MediaRecorder.isTypeSupported(m)) return m + } + return '' +} + +function getAudioContextCtor(): (typeof AudioContext) | null { + const w = window as unknown as { + AudioContext?: typeof AudioContext + webkitAudioContext?: typeof AudioContext + } + return w.AudioContext || w.webkitAudioContext || null +} + +/** 2D canvas 的 captureStream 视频轨,需每帧 requestFrame(否则 Chrome 可能整段无编码数据) */ +type CanvasCaptureVideoTrack = MediaStreamTrack & { requestFrame?: () => void } + +function drawCover( + ctx: CanvasRenderingContext2D, + v: HTMLVideoElement, + x: number, + y: number, + w: number, + h: number +) { + const vw = v.videoWidth || v.clientWidth || 1 + const vh = v.videoHeight || v.clientHeight || 1 + const scale = Math.max(w / vw, h / vh) + const dw = vw * scale + const dh = vh * scale + const dx = x + (w - dw) / 2 + const dy = y + (h - dh) / 2 + try { + ctx.drawImage(v, dx, dy, dw, dh) + } catch { + /* 部分浏览器安全策略 */ + } +} + +function drawVideosGrid( + ctx: CanvasRenderingContext2D, + videos: HTMLVideoElement[], + cw: number, + ch: number +) { + ctx.fillStyle = '#0b0b0b' + ctx.fillRect(0, 0, cw, ch) + const list = videos.filter((v) => v.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) + if (list.length === 0) return + if (list.length === 1) { + drawCover(ctx, list[0], 0, 0, cw, ch) + return + } + const cols = Math.ceil(Math.sqrt(list.length)) + const rows = Math.ceil(list.length / cols) + const cellW = cw / cols + const cellH = ch / rows + list.forEach((v, i) => { + const c = i % cols + const r = Math.floor(i / cols) + drawCover(ctx, v, c * cellW, r * cellH, cellW, cellH) + }) +} + +function collectAudioTracksFromVideos(videos: HTMLVideoElement[]): MediaStreamTrack[] { + const seen = new Set() + const out: MediaStreamTrack[] = [] + for (const v of videos) { + const so = v.srcObject + if (!(so instanceof MediaStream)) continue + for (const t of so.getAudioTracks()) { + if (t.kind !== 'audio' || t.readyState === 'ended' || !t.enabled) continue + if (seen.has(t.id)) continue + seen.add(t.id) + out.push(t) + } + } + return out +} + +function collectAudioTracksFromCaptureStream(videos: HTMLVideoElement[]): MediaStreamTrack[] { + const seen = new Set() + const out: MediaStreamTrack[] = [] + for (const v of videos) { + if (v.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) continue + const cap = (v as HTMLVideoElement & { captureStream?: () => MediaStream }).captureStream?.() + if (!cap) continue + for (const t of cap.getAudioTracks()) { + if (t.kind !== 'audio' || t.readyState === 'ended' || !t.enabled) continue + if (seen.has(t.id)) continue + seen.add(t.id) + out.push(t) + } + } + return out +} + +/** TRTC 等可能用独立