新增功能
This commit is contained in:
@@ -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<string, any>) {
|
||||
return request.post({ url: '/file/addCate', params })
|
||||
}
|
||||
|
||||
+28
-4
@@ -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 })
|
||||
}
|
||||
|
||||
// ========== 小程序分享 ==========
|
||||
|
||||
// 生成小程序码
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string>()
|
||||
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<string>()
|
||||
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 等可能用独立 <audio> 播远端,不挂在 video 的 srcObject 上 */
|
||||
function collectAudioTracksFromAudioElements(root?: HTMLElement | null): MediaStreamTrack[] {
|
||||
const seen = new Set<string>()
|
||||
const out: MediaStreamTrack[] = []
|
||||
const lists: HTMLAudioElement[] = [
|
||||
...document.body.querySelectorAll('audio')
|
||||
] as HTMLAudioElement[]
|
||||
if (root) lists.push(...(root.querySelectorAll('audio') as unknown as HTMLAudioElement[]))
|
||||
const kit = document.getElementById('tuicallkit-id')
|
||||
if (kit) lists.push(...(kit.querySelectorAll('audio') as unknown as HTMLAudioElement[]))
|
||||
const dedupEl = new Set<Element>()
|
||||
for (const a of lists) {
|
||||
if (dedupEl.has(a)) continue
|
||||
dedupEl.add(a)
|
||||
const so = a.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 resolveRecordingVideos(preferred: HTMLElement): HTMLVideoElement[] {
|
||||
const seen = new Set<Element>()
|
||||
const uniq = (list: HTMLVideoElement[]) => {
|
||||
const out: HTMLVideoElement[] = []
|
||||
for (const v of list) {
|
||||
if (seen.has(v)) continue
|
||||
seen.add(v)
|
||||
out.push(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const fromPreferred = [...preferred.querySelectorAll('video')] as HTMLVideoElement[]
|
||||
if (fromPreferred.length > 0) return uniq(fromPreferred)
|
||||
|
||||
const kit = document.getElementById('tuicallkit-id')
|
||||
if (kit) {
|
||||
const fromKit = [...kit.querySelectorAll('video')] as HTMLVideoElement[]
|
||||
if (fromKit.length > 0) return uniq(fromKit)
|
||||
}
|
||||
|
||||
const bodyVideos = [...document.body.querySelectorAll('video')] as HTMLVideoElement[]
|
||||
const withLiveVideo = bodyVideos.filter((v) => {
|
||||
const so = v.srcObject
|
||||
if (!(so instanceof MediaStream)) return false
|
||||
return so.getVideoTracks().some((t) => t.readyState === 'live')
|
||||
})
|
||||
return uniq(withLiveVideo)
|
||||
}
|
||||
|
||||
export class CallLocalRecorder {
|
||||
private canvas: HTMLCanvasElement | null = null
|
||||
private ctx: CanvasRenderingContext2D | null = null
|
||||
private rafId = 0
|
||||
private container: HTMLElement | null = null
|
||||
private mediaRecorder: MediaRecorder | null = null
|
||||
private chunks: Blob[] = []
|
||||
private mimeType = ''
|
||||
private audioCtx: AudioContext | null = null
|
||||
private audioDestination: MediaStreamAudioDestinationNode | null = null
|
||||
private wiredTrackIds = new Set<string>()
|
||||
private canvasVideoTrack: CanvasCaptureVideoTrack | null = null
|
||||
private silentOsc: OscillatorNode | null = null
|
||||
private silentGain: GainNode | null = null
|
||||
/** 本机麦克风:TRTC 常不把本地麦混进远端画面用的 MediaStream,需单独拉一路混音 */
|
||||
private micStream: MediaStream | null = null
|
||||
private stopResultPromise: Promise<Blob | null> | null = null
|
||||
|
||||
isRecording(): boolean {
|
||||
return this.mediaRecorder !== null && this.mediaRecorder.state === 'recording'
|
||||
}
|
||||
|
||||
beginStopNow(): void {
|
||||
if (this.stopResultPromise) return
|
||||
|
||||
cancelAnimationFrame(this.rafId)
|
||||
this.rafId = 0
|
||||
|
||||
const mr = this.mediaRecorder
|
||||
const mimeFallback = this.mimeType
|
||||
|
||||
if (!mr || mr.state === 'inactive') {
|
||||
return
|
||||
}
|
||||
|
||||
this.stopResultPromise = new Promise((resolve) => {
|
||||
mr.onstop = () => {
|
||||
this.teardownAudioGraph()
|
||||
const type = mr.mimeType || mimeFallback || 'video/webm'
|
||||
const blob =
|
||||
this.chunks.length > 0 ? new Blob(this.chunks, { type }) : null
|
||||
this.chunks = []
|
||||
this.mediaRecorder = null
|
||||
this.container = null
|
||||
this.canvas = null
|
||||
this.ctx = null
|
||||
this.canvasVideoTrack = null
|
||||
resolve(blob && blob.size > 0 ? blob : null)
|
||||
}
|
||||
try {
|
||||
const m = mr as MediaRecorder & { requestData?: () => void }
|
||||
m.requestData?.()
|
||||
mr.stop()
|
||||
} catch {
|
||||
this.teardownAudioGraph()
|
||||
this.chunks = []
|
||||
this.mediaRecorder = null
|
||||
this.container = null
|
||||
this.canvas = null
|
||||
this.ctx = null
|
||||
this.canvasVideoTrack = null
|
||||
resolve(null)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private teardownAudioGraph(): void {
|
||||
try {
|
||||
this.silentOsc?.stop()
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
this.silentOsc = null
|
||||
this.silentGain = null
|
||||
if (this.micStream) {
|
||||
for (const t of this.micStream.getTracks()) {
|
||||
try {
|
||||
t.stop()
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
}
|
||||
this.micStream = null
|
||||
}
|
||||
this.wiredTrackIds.clear()
|
||||
this.audioDestination = null
|
||||
if (this.audioCtx) {
|
||||
void this.audioCtx.close().catch(() => {})
|
||||
this.audioCtx = null
|
||||
}
|
||||
}
|
||||
|
||||
private connectTrackToMixer(track: MediaStreamTrack): void {
|
||||
if (track.kind !== 'audio' || track.readyState === 'ended' || !track.enabled) return
|
||||
if (!this.audioCtx || !this.audioDestination) return
|
||||
if (this.wiredTrackIds.has(track.id)) return
|
||||
try {
|
||||
const src = this.audioCtx.createMediaStreamSource(new MediaStream([track]))
|
||||
const gain = this.audioCtx.createGain()
|
||||
gain.gain.value = 1.25
|
||||
src.connect(gain)
|
||||
gain.connect(this.audioDestination)
|
||||
this.wiredTrackIds.add(track.id)
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
}
|
||||
|
||||
/** 接通后单独混本机麦(与画面里的远端轨并存,避免「只有画面没医生声音」) */
|
||||
private attachMicForMixer(): void {
|
||||
if (!this.audioCtx || !this.audioDestination) return
|
||||
const md = navigator.mediaDevices
|
||||
if (!md?.getUserMedia) return
|
||||
void md.getUserMedia({ audio: true, video: false }).then((stream) => {
|
||||
if (!this.audioDestination || !this.audioCtx) {
|
||||
stream.getTracks().forEach((t) => t.stop())
|
||||
return
|
||||
}
|
||||
if (this.micStream) {
|
||||
stream.getTracks().forEach((t) => t.stop())
|
||||
return
|
||||
}
|
||||
this.micStream = stream
|
||||
for (const t of stream.getAudioTracks()) {
|
||||
this.connectTrackToMixer(t)
|
||||
}
|
||||
}).catch(() => {
|
||||
/* 无权限或已占用时仅录远端 */
|
||||
})
|
||||
}
|
||||
|
||||
private wireIncomingAudio(): void {
|
||||
if (!this.audioCtx || !this.audioDestination || !this.container) return
|
||||
if (this.audioCtx.state === 'suspended') {
|
||||
void this.audioCtx.resume().catch(() => {})
|
||||
}
|
||||
const videos = resolveRecordingVideos(this.container)
|
||||
const kit = document.getElementById('tuicallkit-id')
|
||||
const audioTags = [
|
||||
...document.body.querySelectorAll('audio'),
|
||||
...this.container.querySelectorAll('audio'),
|
||||
...(kit ? kit.querySelectorAll('audio') : [])
|
||||
] as HTMLAudioElement[]
|
||||
|
||||
const seenEl = new Set<Element>()
|
||||
const mediaEls: HTMLMediaElement[] = []
|
||||
for (const v of videos) {
|
||||
if (!seenEl.has(v)) {
|
||||
seenEl.add(v)
|
||||
mediaEls.push(v)
|
||||
}
|
||||
}
|
||||
for (const a of audioTags) {
|
||||
if (!seenEl.has(a)) {
|
||||
seenEl.add(a)
|
||||
mediaEls.push(a)
|
||||
}
|
||||
}
|
||||
|
||||
for (const el of mediaEls) {
|
||||
const so = el.srcObject
|
||||
if (so instanceof MediaStream) {
|
||||
for (const t of so.getAudioTracks()) {
|
||||
this.connectTrackToMixer(t)
|
||||
}
|
||||
}
|
||||
if (el instanceof HTMLVideoElement) {
|
||||
if (el.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
|
||||
const cap = el.captureStream?.()
|
||||
if (cap) {
|
||||
for (const t of cap.getAudioTracks()) {
|
||||
this.connectTrackToMixer(t)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
cancelAnimationFrame(this.rafId)
|
||||
this.rafId = 0
|
||||
this.stopResultPromise = null
|
||||
this.canvasVideoTrack = null
|
||||
if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') {
|
||||
try {
|
||||
this.mediaRecorder.stop()
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
}
|
||||
this.mediaRecorder = null
|
||||
this.chunks = []
|
||||
this.canvas = null
|
||||
this.ctx = null
|
||||
this.container = null
|
||||
this.mimeType = ''
|
||||
this.teardownAudioGraph()
|
||||
}
|
||||
|
||||
start(container: HTMLElement): boolean {
|
||||
if (typeof MediaRecorder === 'undefined') return false
|
||||
this.reset()
|
||||
this.container = container
|
||||
const mime = pickRecorderMime()
|
||||
this.mimeType = mime || 'video/webm'
|
||||
|
||||
this.canvas = document.createElement('canvas')
|
||||
this.canvas.width = 1280
|
||||
this.canvas.height = 720
|
||||
this.ctx = this.canvas.getContext('2d', { alpha: false })
|
||||
if (!this.ctx) return false
|
||||
|
||||
const canvasVideoStream = this.canvas.captureStream(30)
|
||||
const videoTracks = canvasVideoStream.getVideoTracks()
|
||||
this.canvasVideoTrack = (videoTracks[0] as CanvasCaptureVideoTrack) ?? null
|
||||
|
||||
const videos = resolveRecordingVideos(container)
|
||||
const AudioCtxCtor = getAudioContextCtor()
|
||||
let outStream: MediaStream
|
||||
|
||||
if (AudioCtxCtor) {
|
||||
try {
|
||||
this.audioCtx = new AudioCtxCtor()
|
||||
this.audioDestination = this.audioCtx.createMediaStreamDestination()
|
||||
void this.audioCtx.resume().catch(() => {})
|
||||
this.wireIncomingAudio()
|
||||
this.attachMicForMixer()
|
||||
const mixed = this.audioDestination.stream.getAudioTracks()
|
||||
// 极低电平直流激励,避免「仅 WebRTC 音轨 + suspended/间歇」时 mux 无输出
|
||||
const osc = this.audioCtx.createOscillator()
|
||||
const gain = this.audioCtx.createGain()
|
||||
gain.gain.value = 1e-5
|
||||
osc.frequency.value = 20
|
||||
osc.connect(gain)
|
||||
gain.connect(this.audioDestination)
|
||||
osc.start()
|
||||
this.silentOsc = osc
|
||||
this.silentGain = gain
|
||||
outStream = new MediaStream([...videoTracks, ...mixed])
|
||||
} catch {
|
||||
this.teardownAudioGraph()
|
||||
outStream = this.buildStreamWithoutMixer(videoTracks, videos)
|
||||
}
|
||||
} else {
|
||||
outStream = this.buildStreamWithoutMixer(videoTracks, videos)
|
||||
}
|
||||
|
||||
try {
|
||||
this.mediaRecorder = mime
|
||||
? new MediaRecorder(outStream, { mimeType: mime })
|
||||
: new MediaRecorder(outStream)
|
||||
} catch {
|
||||
try {
|
||||
this.mediaRecorder = new MediaRecorder(outStream)
|
||||
} catch {
|
||||
this.reset()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
this.chunks = []
|
||||
this.mediaRecorder.ondataavailable = (e) => {
|
||||
if (e.data && e.data.size > 0) this.chunks.push(e.data)
|
||||
}
|
||||
this.mediaRecorder.onerror = (ev) => {
|
||||
console.error('[call-local-recorder] MediaRecorder 错误', ev)
|
||||
}
|
||||
|
||||
const tick = () => {
|
||||
if (!this.ctx || !this.canvas || !this.container) return
|
||||
this.wireIncomingAudio()
|
||||
const vids = resolveRecordingVideos(this.container)
|
||||
drawVideosGrid(this.ctx, vids, this.canvas.width, this.canvas.height)
|
||||
this.canvasVideoTrack?.requestFrame?.()
|
||||
this.rafId = requestAnimationFrame(tick)
|
||||
}
|
||||
this.rafId = requestAnimationFrame(tick)
|
||||
|
||||
try {
|
||||
this.mediaRecorder.start(250)
|
||||
} catch {
|
||||
this.reset()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private buildStreamWithoutMixer(
|
||||
videoTracks: MediaStreamTrack[],
|
||||
videos: HTMLVideoElement[]
|
||||
): MediaStream {
|
||||
const seen = new Set<string>()
|
||||
const audio: MediaStreamTrack[] = []
|
||||
const push = (tracks: MediaStreamTrack[]) => {
|
||||
for (const t of tracks) {
|
||||
if (seen.has(t.id)) continue
|
||||
seen.add(t.id)
|
||||
audio.push(t)
|
||||
}
|
||||
}
|
||||
push(collectAudioTracksFromVideos(videos))
|
||||
push(collectAudioTracksFromCaptureStream(videos))
|
||||
push(collectAudioTracksFromAudioElements(this.container))
|
||||
return new MediaStream([...videoTracks, ...audio])
|
||||
}
|
||||
|
||||
async stop(): Promise<Blob | null> {
|
||||
this.beginStopNow()
|
||||
const p = this.stopResultPromise
|
||||
if (!p) return null
|
||||
return p
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
:title="dialogTitle"
|
||||
width="760px"
|
||||
destroy-on-close
|
||||
append-to-body
|
||||
:close-on-click-modal="false"
|
||||
class="assistant-watch-call-dialog"
|
||||
@closed="onClosed"
|
||||
>
|
||||
<div v-if="loading" class="watch-state">正在连接房间…</div>
|
||||
<div v-else-if="errorMsg" class="watch-state watch-error">{{ errorMsg }}</div>
|
||||
<div v-show="!loading && !errorMsg" ref="gridRef" class="watch-grid" />
|
||||
<template #footer>
|
||||
<span class="watch-hint">仅观看,不会开启摄像头与麦克风</span>
|
||||
<el-button type="primary" @click="visible = false">离开</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import TRTC from 'trtc-sdk-v5'
|
||||
import type { TRTCStreamType } from 'trtc-sdk-v5'
|
||||
import { getAssistantWatchCallParams } from '@/api/tcm'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
diagnosisId: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [boolean]
|
||||
closed: []
|
||||
}>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v: boolean) => emit('update:modelValue', v)
|
||||
})
|
||||
|
||||
const gridRef = ref<HTMLElement | null>(null)
|
||||
const loading = ref(false)
|
||||
const errorMsg = ref('')
|
||||
const dialogTitle = ref('旁观视频通话')
|
||||
|
||||
type TileEntry = { wrap: HTMLElement; userId: string; streamType: TRTCStreamType }
|
||||
const tiles = new Map<string, TileEntry>()
|
||||
|
||||
let trtc: ReturnType<typeof TRTC.create> | null = null
|
||||
let joinSession = 0
|
||||
|
||||
function tileKey(userId: string, streamType: TRTCStreamType) {
|
||||
return `${userId}\u0000${String(streamType)}`
|
||||
}
|
||||
|
||||
function remoteLabel(userId: string) {
|
||||
if (userId.startsWith('patient_')) return '患者'
|
||||
if (userId.startsWith('doctor_')) return '医护'
|
||||
return userId
|
||||
}
|
||||
|
||||
async function onRemoteVideoAvailable(event: { userId: string; streamType: TRTCStreamType }) {
|
||||
if (!trtc || !gridRef.value) return
|
||||
if (event.streamType !== TRTC.TYPE.STREAM_TYPE_MAIN) return
|
||||
|
||||
const key = tileKey(event.userId, event.streamType)
|
||||
if (tiles.has(key)) return
|
||||
|
||||
const wrap = document.createElement('div')
|
||||
wrap.className = 'watch-tile'
|
||||
const cap = document.createElement('div')
|
||||
cap.className = 'watch-tile-cap'
|
||||
cap.textContent = remoteLabel(event.userId)
|
||||
const view = document.createElement('div')
|
||||
view.className = 'watch-tile-view'
|
||||
wrap.appendChild(cap)
|
||||
wrap.appendChild(view)
|
||||
gridRef.value.appendChild(wrap)
|
||||
tiles.set(key, { wrap, userId: event.userId, streamType: event.streamType })
|
||||
|
||||
try {
|
||||
await trtc.startRemoteVideo({
|
||||
userId: event.userId,
|
||||
streamType: event.streamType,
|
||||
view
|
||||
})
|
||||
} catch (e) {
|
||||
console.warn('[AssistantWatchCall] startRemoteVideo', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function onRemoteVideoUnavailable(event: { userId: string; streamType: TRTCStreamType }) {
|
||||
if (!trtc) return
|
||||
const key = tileKey(event.userId, event.streamType)
|
||||
const t = tiles.get(key)
|
||||
if (!t) return
|
||||
try {
|
||||
await trtc.stopRemoteVideo({ userId: event.userId, streamType: event.streamType })
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
t.wrap.remove()
|
||||
tiles.delete(key)
|
||||
}
|
||||
|
||||
function bindTrtcEvents() {
|
||||
if (!trtc) return
|
||||
trtc.on(TRTC.EVENT.REMOTE_VIDEO_AVAILABLE, onRemoteVideoAvailable)
|
||||
trtc.on(TRTC.EVENT.REMOTE_VIDEO_UNAVAILABLE, onRemoteVideoUnavailable)
|
||||
}
|
||||
|
||||
function unbindTrtcEvents() {
|
||||
if (!trtc) return
|
||||
trtc.off(TRTC.EVENT.REMOTE_VIDEO_AVAILABLE, onRemoteVideoAvailable)
|
||||
trtc.off(TRTC.EVENT.REMOTE_VIDEO_UNAVAILABLE, onRemoteVideoUnavailable)
|
||||
}
|
||||
|
||||
async function cleanup() {
|
||||
unbindTrtcEvents()
|
||||
if (trtc) {
|
||||
for (const [, t] of tiles) {
|
||||
try {
|
||||
await trtc.stopRemoteVideo({ userId: t.userId, streamType: t.streamType })
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
t.wrap.remove()
|
||||
}
|
||||
tiles.clear()
|
||||
if (gridRef.value) gridRef.value.innerHTML = ''
|
||||
try {
|
||||
await trtc.exitRoom()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
trtc.destroy()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
trtc = null
|
||||
} else {
|
||||
tiles.clear()
|
||||
if (gridRef.value) gridRef.value.innerHTML = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function joinRoom() {
|
||||
const session = ++joinSession
|
||||
await cleanup()
|
||||
if (!props.diagnosisId) {
|
||||
errorMsg.value = '诊单无效'
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
errorMsg.value = ''
|
||||
dialogTitle.value = '旁观视频通话'
|
||||
|
||||
try {
|
||||
const data = (await getAssistantWatchCallParams({
|
||||
diagnosis_id: props.diagnosisId
|
||||
})) as {
|
||||
sdkAppId: number
|
||||
userId: string
|
||||
userSig: string
|
||||
roomId?: number
|
||||
strRoomId?: string
|
||||
patientName?: string
|
||||
}
|
||||
|
||||
if (session !== joinSession) {
|
||||
return
|
||||
}
|
||||
|
||||
if (data.patientName) {
|
||||
dialogTitle.value = `旁观视频通话 · ${data.patientName}`
|
||||
}
|
||||
|
||||
trtc = TRTC.create()
|
||||
bindTrtcEvents()
|
||||
|
||||
const roomCfg = {
|
||||
sdkAppId: data.sdkAppId,
|
||||
userId: data.userId,
|
||||
userSig: data.userSig,
|
||||
autoReceiveAudio: true,
|
||||
autoReceiveVideo: true,
|
||||
...(data.roomId != null && data.roomId > 0
|
||||
? { roomId: data.roomId }
|
||||
: { strRoomId: data.strRoomId as string })
|
||||
}
|
||||
if (!(data.roomId != null && data.roomId > 0) && !data.strRoomId) {
|
||||
throw new Error('缺少房间号')
|
||||
}
|
||||
|
||||
await trtc.enterRoom(roomCfg)
|
||||
if (session !== joinSession) {
|
||||
await cleanup()
|
||||
return
|
||||
}
|
||||
loading.value = false
|
||||
} catch (e: unknown) {
|
||||
loading.value = false
|
||||
let msg = '进入房间失败'
|
||||
if (typeof e === 'string') msg = e
|
||||
else if (e && typeof e === 'object') {
|
||||
const o = e as { msg?: string; message?: string }
|
||||
if (o.msg) msg = String(o.msg)
|
||||
else if (o.message) msg = String(o.message)
|
||||
}
|
||||
errorMsg.value = msg
|
||||
await cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
function onClosed() {
|
||||
void cleanup()
|
||||
loading.value = false
|
||||
errorMsg.value = ''
|
||||
dialogTitle.value = '旁观视频通话'
|
||||
emit('closed')
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.modelValue, props.diagnosisId] as const,
|
||||
([open, id]) => {
|
||||
if (!open) {
|
||||
joinSession++
|
||||
void cleanup()
|
||||
return
|
||||
}
|
||||
if (id > 0) {
|
||||
void joinRoom()
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.watch-state {
|
||||
min-height: 200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
.watch-error {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
.watch-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 12px;
|
||||
min-height: 220px;
|
||||
}
|
||||
.watch-tile {
|
||||
background: #0f0f0f;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
aspect-ratio: 16 / 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.watch-tile-cap {
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
color: #e5e5e5;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
.watch-tile-view {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
}
|
||||
.watch-hint {
|
||||
float: left;
|
||||
line-height: 32px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -5,11 +5,10 @@
|
||||
show-icon
|
||||
:closable="false"
|
||||
class="mb-4"
|
||||
title="视频通话与云端录制"
|
||||
title="视频通话与录制回放"
|
||||
>
|
||||
<p class="call-record-tip">
|
||||
下列为与本诊单关联的通话记录。若在腾讯云 TRTC
|
||||
控制台开启了云端录制并填写回调地址,录制生成后会自动写入「录制回放」。
|
||||
下列为与本诊单关联的通话记录。医生接通并同步房间号后,服务端会尝试自动发起「云端混流录制」(需配置 CAM 密钥与云点播等);录制完成后由腾讯云回调写入回放地址。同时,管理端浏览器会进行「本地录制」,挂断后自动上传并合并到本条记录。
|
||||
</p>
|
||||
<p class="call-record-tip muted">
|
||||
回调 URL 示例:<code>{{ callbackHint }}</code>
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
<div class="im-chat-record-panel">
|
||||
<el-alert type="info" show-icon :closable="false" class="mb-4" title="说明">
|
||||
<p class="panel-tip">
|
||||
展示腾讯云 IM 单聊记录:已合并患者 <code>{{ patientImHint }}</code> 与
|
||||
<strong>所有医生 / 医助账号</strong>(<code>doctor_*</code>)分别产生的会话,按时间排序。
|
||||
展示腾讯云 IM 单聊记录:已合并患者 与
|
||||
<strong>所有医生 / 医助账号</strong>分别产生的会话,按时间排序。
|
||||
数据来自 IM 漫游;若未开通漫游或某账号与该患者无会话,对应侧无消息。
|
||||
</p>
|
||||
</el-alert>
|
||||
|
||||
@@ -587,13 +587,19 @@
|
||||
<el-empty v-else description="请先保存诊单,开方后病历将在此显示" />
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="回访记录" name="visit" :disabled="!formData.id" v-perms="['tcm.diagnosis/huifang']" v-if="hasPermission(['tcm.diagnosis/huifang'])">
|
||||
<el-tab-pane
|
||||
v-if="hasPermission(['tcm.diagnosis/huifang'])"
|
||||
label="视频录制回放记录"
|
||||
name="visit"
|
||||
:disabled="!formData.id"
|
||||
lazy
|
||||
>
|
||||
<call-record-panel
|
||||
v-if="formData.id"
|
||||
ref="callRecordPanelRef"
|
||||
:diagnosis-id="Number(formData.id)"
|
||||
/>
|
||||
<el-empty v-else description="请先保存诊单后再查看回访记录" />
|
||||
<el-empty v-else description="请先保存诊单后再查看视频录制回放记录" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane
|
||||
v-if="hasPermission(['tcm.diagnosis/chat'])"
|
||||
@@ -642,6 +648,7 @@ import { ElMessage } from 'element-plus'
|
||||
import { QuestionFilled } from '@element-plus/icons-vue'
|
||||
import BloodRecordList from './components/BloodRecordList.vue'
|
||||
import CaseRecordList from './components/CaseRecordList.vue'
|
||||
import CallRecordPanel from './components/CallRecordPanel.vue'
|
||||
import ImChatRecordPanel from './components/ImChatRecordPanel.vue'
|
||||
import TcmPrescription from '@/components/tcm-prescription/index.vue'
|
||||
|
||||
@@ -660,7 +667,11 @@ const activeTab = ref('basic')
|
||||
const drawerTitle = computed(() => (mode.value === 'add' ? '新增诊单' : '编辑诊单'))
|
||||
|
||||
watch([visible, activeTab], () => {
|
||||
if (visible.value && activeTab.value === 'chat' && !hasPermission(['tcm.diagnosis/chat'])) {
|
||||
if (!visible.value) return
|
||||
if (activeTab.value === 'chat' && !hasPermission(['tcm.diagnosis/chat'])) {
|
||||
activeTab.value = 'basic'
|
||||
}
|
||||
if (activeTab.value === 'visit' && !hasPermission(['tcm.diagnosis/huifang'])) {
|
||||
activeTab.value = 'basic'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -136,6 +136,30 @@
|
||||
<span v-else class="status-unprescribed">未开方</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="视频旁观" width="120" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<div class="video-watch-col">
|
||||
<template v-if="canWatchCallEntry(row)">
|
||||
<template v-if="watchCallShowEnterButton(row)">
|
||||
<el-tooltip :content="watchCallEnterTooltip(row)" placement="top">
|
||||
<span class="video-watch-trigger">
|
||||
<el-button
|
||||
type="primary"
|
||||
link
|
||||
size="small"
|
||||
@click="onWatchCallEntryClick(row)"
|
||||
>
|
||||
进入旁观
|
||||
</el-button>
|
||||
</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
<span v-else class="video-watch-muted">{{ watchCallAssistantIdleText(row) }}</span>
|
||||
</template>
|
||||
<span v-else class="video-watch-muted">{{ watchCallPublicStatus(row) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="260" fixed="right" align="left">
|
||||
<template #default="{ row }">
|
||||
<div class="action-cell">
|
||||
@@ -180,6 +204,11 @@
|
||||
<edit-popup ref="editRef" @success="getLists" />
|
||||
<detail-popup ref="detailRef" />
|
||||
<appointment-popup ref="appointmentRef" @success="getLists" />
|
||||
<assistant-watch-call-dialog
|
||||
v-model="watchCallVisible"
|
||||
:diagnosis-id="watchCallDiagnosisId"
|
||||
@closed="getLists"
|
||||
/>
|
||||
|
||||
<!-- 小程序二维码弹窗 -->
|
||||
<el-dialog
|
||||
@@ -465,6 +494,7 @@ import { Loading, Warning, List, CircleCheck, Clock, ArrowDown, RefreshRight, Pi
|
||||
import EditPopup from './edit.vue'
|
||||
import DetailPopup from './detail.vue'
|
||||
import AppointmentPopup from './appointment.vue'
|
||||
import AssistantWatchCallDialog from './components/AssistantWatchCallDialog.vue'
|
||||
import useUserStore from '@/stores/modules/user'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { nextTick } from 'vue'
|
||||
@@ -681,6 +711,77 @@ const detailRef = ref()
|
||||
const appointmentRef = ref()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const watchCallVisible = ref(false)
|
||||
const watchCallDiagnosisId = ref(0)
|
||||
|
||||
const isAssignedAssistant = (row: any) => {
|
||||
const aid = row.assistant_id ?? row.assistant
|
||||
if (aid === null || aid === undefined || aid === '') return false
|
||||
return Number(aid) === Number(userStore.userInfo?.id)
|
||||
}
|
||||
|
||||
const openWatchCall = (row: any) => {
|
||||
watchCallDiagnosisId.value = Number(row.id) || 0
|
||||
watchCallVisible.value = true
|
||||
}
|
||||
|
||||
type VideoCallHint = {
|
||||
state: string
|
||||
label: string
|
||||
start_time?: number
|
||||
end_time?: number
|
||||
}
|
||||
|
||||
const canWatchCallEntry = (row: any) =>
|
||||
isAssignedAssistant(row) && hasPermission(['tcm.diagnosis/watchCall'])
|
||||
|
||||
const watchCallState = (row: any) => (row.video_call_hint?.state as string) || 'none'
|
||||
|
||||
const watchCallLiveTooltip = (row: any) => {
|
||||
const h = row.video_call_hint as VideoCallHint | undefined
|
||||
const base = '点击进入实时房间(仅观看,不推流、不上麦)'
|
||||
if (h?.start_time) {
|
||||
return `${base}。开始时间:${formatDateTime(h.start_time)}`
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
/** 通话进行中或已发起待同步房间时,都显示「进入旁观」入口(避免仅 live 才有按钮导致看不见) */
|
||||
const watchCallShowEnterButton = (row: any) => {
|
||||
const st = watchCallState(row)
|
||||
return st === 'live' || st === 'pending_room'
|
||||
}
|
||||
|
||||
const watchCallEnterTooltip = (row: any) => {
|
||||
if (watchCallState(row) === 'pending_room') {
|
||||
return '医生尚未接通或未同步房间号,接通后再点此进入'
|
||||
}
|
||||
return watchCallLiveTooltip(row)
|
||||
}
|
||||
|
||||
const onWatchCallEntryClick = (row: any) => {
|
||||
if (watchCallState(row) !== 'live') {
|
||||
feedback.msgWarning('医生尚未接通或未同步房间号,请稍后再试')
|
||||
return
|
||||
}
|
||||
openWatchCall(row)
|
||||
}
|
||||
|
||||
const watchCallAssistantIdleText = (row: any) => {
|
||||
const h = row.video_call_hint as VideoCallHint | undefined
|
||||
if (h?.label) return h.label
|
||||
return '暂无可旁观通话'
|
||||
}
|
||||
|
||||
const watchCallPublicStatus = (row: any) => {
|
||||
const st = watchCallState(row)
|
||||
if (st === 'none') return '—'
|
||||
if (st === 'live') return '通话中'
|
||||
if (st === 'pending_room') return '接通中'
|
||||
const h = row.video_call_hint as VideoCallHint | undefined
|
||||
return h?.label || '—'
|
||||
}
|
||||
|
||||
// 创建订单相关
|
||||
const createOrderVisible = ref(false)
|
||||
const orderFormRef = ref()
|
||||
@@ -1443,6 +1544,22 @@ onMounted(async () => {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.video-watch-col {
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
/* el-tooltip 单个子节点为行内按钮时,包一层避免触发区域为 0 或错位 */
|
||||
.video-watch-trigger {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.video-watch-muted {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.text-danger {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user