新增功能

This commit is contained in:
Your Name
2026-03-24 16:32:56 +08:00
parent 250d173c2f
commit 9160c36735
248 changed files with 3063 additions and 250 deletions
+25 -2
View File
@@ -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",
+1
View File
@@ -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",
+22
View File
@@ -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
View File
@@ -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 })
}
// ========== 小程序分享 ==========
// 生成小程序码
+284 -13
View File
@@ -66,7 +66,10 @@
class="call-kit-drag-handle"
@mousedown="onCallKitDragStart"
>
<span class="drag-handle-text">视频通话</span>
<div class="call-kit-drag-handle-left">
<span class="drag-handle-text">视频通话</span>
<span v-if="localRecordingBadge" class="call-local-recording-hint">本地录制中</span>
</div>
<div class="call-kit-drag-handle-actions">
<el-button
type="primary"
@@ -106,14 +109,17 @@
<script setup lang="ts">
import { ref, nextTick, watch, computed, onMounted, onUnmounted } from 'vue'
import { Loading, Close, Rank, Camera } from '@element-plus/icons-vue'
import { uploadImageBlob } from '@/api/file'
import { uploadImageBlob, uploadVideoBlob } from '@/api/file'
import {
attachLocalCallRecording,
bindCallRoom,
endCall,
getCallSignature,
startCall,
tcmDiagnosisDetail,
tcmDiagnosisEdit
} from '@/api/tcm'
import { CallLocalRecorder } from '@/utils/call-local-recorder'
import { captureVideoFrameFromElement } from '@/utils/call-video-screenshot'
import feedback from '@/utils/feedback'
import {
@@ -176,6 +182,68 @@ let bindRoomRetryAttempts = 0
const BIND_ROOM_MAX_ATTEMPTS = 40
const BIND_ROOM_RETRY_MS = 100
const localCallRecorder = new CallLocalRecorder()
const localRecordingBadge = ref(false)
let localRecordingRound = 0
let startLocalRecTimer: ReturnType<typeof setTimeout> | null = null
let finalizeLocalRecordingPromise: Promise<void> | null = null
/** 本次振铃/通话对应的 startCall 落库(beforeCalling 发起,bind/上传前须 await */
let pendingCallRecordStart: Promise<void> = Promise.resolve()
/** 接通前快照诊单 ID,避免会话切换等导致上传阶段 diagnosisId 丢失 */
let recordingDiagnosisIdSnapshot: number | null = null
function clearLocalRecordingStartTimer() {
if (startLocalRecTimer) {
clearTimeout(startLocalRecTimer)
startLocalRecTimer = null
}
}
function finalizeLocalRecordingAndAttach(): Promise<void> {
if (finalizeLocalRecordingPromise) {
return finalizeLocalRecordingPromise
}
finalizeLocalRecordingPromise = (async () => {
clearLocalRecordingStartTimer()
localRecordingBadge.value = false
localCallRecorder.beginStopNow()
const id = recordingDiagnosisIdSnapshot ?? diagnosisId.value
try {
await pendingCallRecordStart
} catch {
//
}
const blob = await localCallRecorder.stop()
if (!blob) {
console.warn(
'[chat-dialog] 本地录制无有效文件(若通话较长仍出现,请换 Chrome 并看控制台是否有 MediaRecorder 报错)'
)
return
}
if (id == null) {
feedback.msgWarning('缺少诊单信息,本地录制无法保存到通话记录')
return
}
try {
const data = await uploadVideoBlob(blob, `call-rec-${id}-${Date.now()}.webm`)
const fileUrl = data.uri || data.url
if (!fileUrl) {
feedback.msgError('上传成功但未返回文件地址')
return
}
await attachLocalCallRecording({ diagnosis_id: id, file_url: fileUrl })
feedback.msgSuccess('本地录制已上传并关联到通话记录')
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e)
console.warn('[chat-dialog] 本地录制上传/关联失败', e)
feedback.msgError(msg || '本地录制上传失败')
}
})().finally(() => {
finalizeLocalRecordingPromise = null
})
return finalizeLocalRecordingPromise
}
/** 从 TUIStore 读取房间号;默认 0 表示尚未分配 */
function readRoomIdFromStore(): string {
const raw = TUIStore.getData(StoreName.CALL, NAME.ROOM_ID)
@@ -328,6 +396,50 @@ function installEngineRoomCapture() {
wrap('groupCall')
}
/** hangup 时 SDK 会立刻 exitRoom;在 hangup 入口同步停录,避免轨道被掐断后无数据 */
function installHangupPreStopHook() {
const w = window as unknown as Record<string, unknown>
if (w.__zytHangupPreStop__) return
const server = TUICallKitServer as unknown as Record<string, unknown> & {
hangup?: (...args: unknown[]) => unknown
}
const raw = server.hangup
if (typeof raw !== 'function') return
w.__zytHangupPreStop__ = true
server.hangup = function patchedHangup(this: unknown, ...args: unknown[]) {
localCallRecorder.beginStopNow()
return raw.apply(TUICallKitServer, args)
}
}
/**
* UI 挂断时往往先调 TRTC stopLocalVideo/stopLocalAudio,再 hangup;必须在它们之前停 MediaRecorder。
*/
function installTrtcCloudPreStopHook() {
const w = window as unknown as Record<string, unknown>
if (w.__zytTrtcPreStop__) return
const eng = TUICallKitServer.getTUICallEngineInstance?.() as
| { trtcCloud?: Record<string, unknown> }
| null
| undefined
const trtc = eng?.trtcCloud
if (!trtc || typeof trtc !== 'object') return
let patched = 0
const methods = ['stopLocalVideo', 'stopLocalAudio'] as const
for (const methodName of methods) {
const orig = trtc[methodName]
if (typeof orig !== 'function') continue
trtc[methodName] = function patchedTrtc(this: unknown, ...args: unknown[]) {
localCallRecorder.beginStopNow()
return (orig as (...a: unknown[]) => unknown).apply(trtc, args)
}
patched++
}
if (patched > 0) {
w.__zytTrtcPreStop__ = true
}
}
let engineRoomEventHandlers: Array<{ event: TUICallEvent; fn: (e: unknown) => void }> = []
function bindEngineRoomEvents() {
@@ -348,6 +460,22 @@ function bindEngineRoomEvents() {
}
void tryBindRoomIfReady()
}
const onEarlyStopRecording = () => {
console.log('[chat-dialog] 检测到通话结束事件,立即停止录制')
if (localCallRecorder.isRecording()) {
console.log('[chat-dialog] 录制进行中,触发 beginStopNow')
localCallRecorder.beginStopNow()
}
}
const earlyStopEvents = [
TUICallEvent.CALL_END,
TUICallEvent.USER_LEAVE,
TUICallEvent.ON_CALL_NOT_CONNECTED
] as const
for (const event of earlyStopEvents) {
subscribe.call(eng, event, onEarlyStopRecording)
engineRoomEventHandlers.push({ event, fn: onEarlyStopRecording })
}
const events = [TUICallEvent.USER_ENTER, TUICallEvent.ON_CALL_BEGIN] as const
for (const event of events) {
subscribe.call(eng, event, onRoomHint)
@@ -400,6 +528,11 @@ function installTUICallKitRoomHooks() {
}
async function doBindCallRoom(rid: string) {
try {
await pendingCallRecordStart
} catch {
//
}
if (patientId.value == null || diagnosisId.value == null) return
const key = `${diagnosisId.value}:${rid}`
if (key === lastBoundRoomKey.value) {
@@ -449,17 +582,115 @@ async function ensureCallRecordStarted() {
}
}
/** 将 tcm_call_record 中本条进行中的记录置为已结束(挂断、对端挂断、异常断线、关窗等) */
async function reportCallEnded() {
if (diagnosisId.value == null) return
try {
await endCall({ diagnosis_id: diagnosisId.value })
console.log('[chat-dialog] endCall 已同步')
} catch (e) {
console.warn('[chat-dialog] endCall 失败', e)
}
}
function setupCallRoomBinding() {
tearDownCallRoomBinding?.()
let previousCallStatus =
(TUIStore.getData(StoreName.CALL, NAME.CALL_STATUS) as string | undefined) ?? CALL_STATUS_IDLE
const onCallStatus = (newData?: string) => {
if (newData === CALL_STATUS_IDLE) {
const next = (newData as string | undefined) ?? CALL_STATUS_IDLE
if (next === CALL_STATUS_IDLE) {
if (previousCallStatus === CALL_STATUS_CALLING || previousCallStatus === CALL_STATUS_CONNECTED) {
localCallRecorder.beginStopNow()
}
clearLocalRecordingStartTimer()
localRecordingBadge.value = false
if (previousCallStatus === CALL_STATUS_CALLING || previousCallStatus === CALL_STATUS_CONNECTED) {
void (async () => {
await finalizeLocalRecordingAndAttach()
showCallKitWindow.value = false
await reportCallEnded()
})()
}
clearBindRoomRetry()
lastBoundRoomKey.value = ''
previousCallStatus = next
return
}
if (newData === CALL_STATUS_CONNECTED) {
if (next === CALL_STATUS_CONNECTED) {
void tryBindRoomIfReady()
clearLocalRecordingStartTimer()
const roundAtSchedule = localRecordingRound
// 立即尝试启动录制,不要等待 1200ms
const attemptStartRecording = (retryCount = 0) => {
if (roundAtSchedule !== localRecordingRound) {
console.log('[chat-dialog] 录制轮次已变化,取消启动')
return
}
const root = callKitContentRef.value
const st = TUIStore.getData(StoreName.CALL, NAME.CALL_STATUS) as string | undefined
if (!root || st !== CALL_STATUS_CONNECTED) {
console.warn('[chat-dialog] 录制启动条件不满足', { root: !!root, status: st })
return
}
console.log(`[chat-dialog] 尝试启动本地录制(第 ${retryCount + 1} 次)`)
// 检查视频元素
let videos = Array.from(root.querySelectorAll('video')) as HTMLVideoElement[]
// 如果容器内没有,检查 bodyTUICallKit Teleport
if (videos.length === 0) {
const bodyVideos = Array.from(document.body.querySelectorAll('video')) as HTMLVideoElement[]
videos = bodyVideos.filter(v => {
const so = v.srcObject
if (!(so instanceof MediaStream)) return false
return so.getVideoTracks().some(t => t.readyState === 'live')
})
console.log('[chat-dialog] 在 body 中找到活跃视频:', videos.length)
}
if (videos.length === 0) {
if (retryCount < 10) {
console.warn('[chat-dialog] 未找到视频元素,300ms 后重试')
startLocalRecTimer = setTimeout(() => attemptStartRecording(retryCount + 1), 300)
} else {
console.error('[chat-dialog] 重试 10 次后仍未找到视频元素,放弃录制')
}
return
}
// 检查视频是否有有效数据
const hasReadyVideo = videos.some(v => v.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA)
if (!hasReadyVideo) {
if (retryCount < 10) {
console.warn('[chat-dialog] 视频元素未就绪,300ms 后重试')
startLocalRecTimer = setTimeout(() => attemptStartRecording(retryCount + 1), 300)
} else {
console.error('[chat-dialog] 重试 10 次后视频仍未就绪,放弃录制')
}
return
}
console.log('[chat-dialog] 视频元素已就绪,立即启动录制')
const ok = localCallRecorder.start(root)
localRecordingBadge.value = ok
if (!ok) {
console.error('[chat-dialog] 本地录制启动失败(MediaRecorder 或画布初始化失败)')
} else {
console.log('[chat-dialog] ✅ 本地录制已成功启动')
}
}
// 延迟 500ms 后开始尝试(给视频元素一点加载时间)
startLocalRecTimer = setTimeout(() => attemptStartRecording(0), 500)
} else {
clearLocalRecordingStartTimer()
}
previousCallStatus = next
}
const onRoomId = () => {
void tryBindRoomIfReady()
@@ -595,6 +826,8 @@ onMounted(() => {
})
onUnmounted(() => {
clearLocalRecordingStartTimer()
localCallRecorder.reset()
tearDownCallRoomBinding?.()
unbindEngineRoomEvents()
unhookConsoleErrorForPackageHint()
@@ -733,18 +966,33 @@ const open = async (data: { patientId: number; patientName: string; diagnosisId?
}
isCallReady.value = !!(window as any).__TUICallKitInited__
if (isCallReady.value) {
// 仅在通话时显示视频窗口,无通话时隐藏;发起通话前落库、接通后绑定房间号(云端录制)
// 仅在通话时显示视频窗口;发起通话前落库、接通后绑定房间号(自动云端录制 + 浏览器本地录制)
TUICallKitServer.setCallback({
beforeCalling: () => {
installTrtcCloudPreStopHook()
clearLocalRecordingStartTimer()
localCallRecorder.reset()
localRecordingBadge.value = false
localRecordingRound++
recordingDiagnosisIdSnapshot = diagnosisId.value
showCallKitWindow.value = true
void ensureCallRecordStarted()
pendingCallRecordStart = ensureCallRecordStarted()
void pendingCallRecordStart
},
// 同步停录须早于 SDK 内部退房;再 finalize 上传并关窗
afterCalling: () => {
showCallKitWindow.value = false
localCallRecorder.beginStopNow()
void (async () => {
await finalizeLocalRecordingAndAttach()
showCallKitWindow.value = false
await reportCallEnded()
})()
}
})
installTUICallKitRoomHooks()
installEngineRoomCapture()
installHangupPreStopHook()
installTrtcCloudPreStopHook()
bindEngineRoomEvents()
setupCallRoomBinding()
bindEnginePackageErrorListener()
@@ -945,6 +1193,8 @@ const handleCallKitScreenshot = async () => {
const handleCallKitClose = async () => {
if (isCallReady.value) {
try {
localCallRecorder.beginStopNow()
await finalizeLocalRecordingAndAttach()
await TUICallKitServer.hangup()
} catch (err) {
console.warn('关闭视频窗口时挂断失败:', err)
@@ -982,14 +1232,22 @@ const onHeaderMouseUp = () => {
}
const handleClose = async () => {
// 关闭时如有通话中/拨通中,自动挂断
if (isCallReady.value && showCallKitWindow.value) {
try {
await TUICallKitServer.hangup()
} catch (err) {
console.warn('挂断通话时出错:', err)
// 关闭时如有通话中/拨通中,先结束本地录制再挂断(不依赖悬浮窗是否显示)
if (isCallReady.value) {
const st = TUIStore.getData(StoreName.CALL, NAME.CALL_STATUS)
if (st === CALL_STATUS_CALLING || st === CALL_STATUS_CONNECTED) {
try {
localCallRecorder.beginStopNow()
await finalizeLocalRecordingAndAttach()
await TUICallKitServer.hangup()
} catch (err) {
console.warn('挂断通话时出错:', err)
}
}
}
if (diagnosisId.value != null) {
await reportCallEnded()
}
try {
// 登出 Chat
// await logout()
@@ -1139,11 +1397,24 @@ defineExpose({ open })
user-select: none;
flex-shrink: 0;
.call-kit-drag-handle-left {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.drag-handle-text {
font-size: 13px;
color: #fff;
}
.call-local-recording-hint {
font-size: 11px;
color: #f89898;
white-space: nowrap;
}
.call-kit-drag-handle-actions {
display: flex;
align-items: center;
+480
View File
@@ -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>
+14 -3
View File
@@ -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'
}
})
+117
View File
@@ -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);
}
+363
View File
@@ -0,0 +1,363 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>本地录制测试工具</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 1200px;
margin: 20px auto;
padding: 20px;
}
.section {
margin: 20px 0;
padding: 15px;
border: 1px solid #ddd;
border-radius: 5px;
}
.section h2 {
margin-top: 0;
}
button {
padding: 10px 20px;
margin: 5px;
cursor: pointer;
background: #409eff;
color: white;
border: none;
border-radius: 4px;
}
button:hover {
background: #66b1ff;
}
button:disabled {
background: #ccc;
cursor: not-allowed;
}
#videoContainer {
display: flex;
gap: 10px;
margin: 10px 0;
}
video {
width: 320px;
height: 240px;
background: #000;
border: 2px solid #409eff;
}
#log {
background: #f5f5f5;
padding: 10px;
height: 300px;
overflow-y: auto;
font-family: monospace;
font-size: 12px;
white-space: pre-wrap;
}
.log-entry {
margin: 2px 0;
}
.log-info { color: #409eff; }
.log-success { color: #67c23a; }
.log-warning { color: #e6a23c; }
.log-error { color: #f56c6c; }
.status {
padding: 5px 10px;
border-radius: 3px;
display: inline-block;
margin: 5px 0;
}
.status.idle { background: #e4e7ed; }
.status.recording { background: #f56c6c; color: white; }
.status.stopped { background: #67c23a; color: white; }
</style>
</head>
<body>
<h1>本地录制测试工具</h1>
<div class="section">
<h2>1. 浏览器支持检测</h2>
<div id="browserSupport"></div>
</div>
<div class="section">
<h2>2. 视频流测试</h2>
<button id="startCamera">启动摄像头</button>
<button id="stopCamera" disabled>停止摄像头</button>
<div id="videoContainer"></div>
</div>
<div class="section">
<h2>3. 录制测试</h2>
<div>
状态: <span class="status idle" id="recordStatus">未开始</span>
</div>
<button id="startRecord" disabled>开始录制</button>
<button id="stopRecord" disabled>停止录制</button>
<button id="playRecord" disabled>播放录制</button>
<button id="downloadRecord" disabled>下载录制</button>
<div id="recordInfo"></div>
</div>
<div class="section">
<h2>4. 日志</h2>
<button id="clearLog">清空日志</button>
<div id="log"></div>
</div>
<script>
const log = (message, type = 'info') => {
const logDiv = document.getElementById('log');
const entry = document.createElement('div');
entry.className = `log-entry log-${type}`;
entry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
logDiv.appendChild(entry);
logDiv.scrollTop = logDiv.scrollHeight;
console.log(message);
};
// 1. 检测浏览器支持
const checkBrowserSupport = () => {
const support = document.getElementById('browserSupport');
const checks = [];
// MediaRecorder
if (typeof MediaRecorder !== 'undefined') {
checks.push('✅ MediaRecorder API 支持');
// MIME 类型
const mimes = [
'video/webm;codecs=vp9,opus',
'video/webm;codecs=vp8,opus',
'video/webm;codecs=vp9',
'video/webm'
];
mimes.forEach(mime => {
const supported = MediaRecorder.isTypeSupported(mime);
checks.push(`${supported ? '✅' : '❌'} ${mime}`);
if (supported) {
log(`支持 MIME: ${mime}`, 'success');
}
});
} else {
checks.push('❌ MediaRecorder API 不支持');
log('MediaRecorder API 不支持', 'error');
}
// getUserMedia
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
checks.push('✅ getUserMedia 支持');
} else {
checks.push('❌ getUserMedia 不支持');
log('getUserMedia 不支持', 'error');
}
// AudioContext
const AudioCtx = window.AudioContext || window.webkitAudioContext;
if (AudioCtx) {
checks.push('✅ AudioContext 支持');
} else {
checks.push('❌ AudioContext 不支持');
log('AudioContext 不支持', 'warning');
}
support.innerHTML = checks.join('<br>');
};
// 2. 视频流
let localStream = null;
const videoContainer = document.getElementById('videoContainer');
document.getElementById('startCamera').onclick = async () => {
try {
log('请求摄像头权限...', 'info');
localStream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true
});
const video = document.createElement('video');
video.srcObject = localStream;
video.autoplay = true;
video.muted = true;
videoContainer.appendChild(video);
log(`摄像头启动成功,轨道数: 视频=${localStream.getVideoTracks().length}, 音频=${localStream.getAudioTracks().length}`, 'success');
document.getElementById('startCamera').disabled = true;
document.getElementById('stopCamera').disabled = false;
document.getElementById('startRecord').disabled = false;
} catch (err) {
log(`摄像头启动失败: ${err.message}`, 'error');
}
};
document.getElementById('stopCamera').onclick = () => {
if (localStream) {
localStream.getTracks().forEach(track => track.stop());
videoContainer.innerHTML = '';
localStream = null;
log('摄像头已停止', 'info');
document.getElementById('startCamera').disabled = false;
document.getElementById('stopCamera').disabled = true;
document.getElementById('startRecord').disabled = true;
}
};
// 3. 录制
let mediaRecorder = null;
let recordedChunks = [];
let recordedBlob = null;
const updateRecordStatus = (status, text) => {
const statusEl = document.getElementById('recordStatus');
statusEl.className = `status ${status}`;
statusEl.textContent = text;
};
document.getElementById('startRecord').onclick = () => {
if (!localStream) {
log('请先启动摄像头', 'warning');
return;
}
try {
recordedChunks = [];
recordedBlob = null;
// 尝试不同的 MIME 类型
const mimes = [
'video/webm;codecs=vp9,opus',
'video/webm;codecs=vp8,opus',
'video/webm;codecs=vp9',
'video/webm'
];
let selectedMime = '';
for (const mime of mimes) {
if (MediaRecorder.isTypeSupported(mime)) {
selectedMime = mime;
break;
}
}
if (!selectedMime) {
log('没有找到支持的 MIME 类型', 'error');
return;
}
log(`使用 MIME 类型: ${selectedMime}`, 'info');
mediaRecorder = new MediaRecorder(localStream, { mimeType: selectedMime });
mediaRecorder.ondataavailable = (e) => {
if (e.data && e.data.size > 0) {
recordedChunks.push(e.data);
log(`收到数据块: ${e.data.size} bytes (总计: ${recordedChunks.length} 块)`, 'info');
}
};
mediaRecorder.onstart = () => {
log('录制已开始', 'success');
updateRecordStatus('recording', '录制中');
};
mediaRecorder.onstop = () => {
log(`录制已停止,共 ${recordedChunks.length} 个数据块`, 'success');
updateRecordStatus('stopped', '已停止');
if (recordedChunks.length > 0) {
recordedBlob = new Blob(recordedChunks, { type: selectedMime });
log(`生成 Blob: ${recordedBlob.size} bytes`, 'success');
document.getElementById('playRecord').disabled = false;
document.getElementById('downloadRecord').disabled = false;
const info = document.getElementById('recordInfo');
info.innerHTML = `
<p>录制完成!</p>
<p>大小: ${(recordedBlob.size / 1024 / 1024).toFixed(2)} MB</p>
<p>类型: ${recordedBlob.type}</p>
<p>数据块: ${recordedChunks.length}</p>
`;
} else {
log('警告: 没有收到任何数据块!', 'error');
}
};
mediaRecorder.onerror = (e) => {
log(`录制错误: ${e.error}`, 'error');
};
mediaRecorder.start(250); // 每 250ms 一个数据块
log('MediaRecorder.start(250) 调用成功', 'info');
document.getElementById('startRecord').disabled = true;
document.getElementById('stopRecord').disabled = false;
} catch (err) {
log(`录制启动失败: ${err.message}`, 'error');
}
};
document.getElementById('stopRecord').onclick = () => {
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
log('请求停止录制...', 'info');
// 模拟实际代码的停止流程
if (mediaRecorder.requestData) {
mediaRecorder.requestData();
log('调用 requestData()', 'info');
}
setTimeout(() => {
log(`延迟后数据块数: ${recordedChunks.length}`, 'info');
mediaRecorder.stop();
}, 300);
document.getElementById('startRecord').disabled = false;
document.getElementById('stopRecord').disabled = true;
}
};
document.getElementById('playRecord').onclick = () => {
if (recordedBlob) {
const url = URL.createObjectURL(recordedBlob);
const video = document.createElement('video');
video.src = url;
video.controls = true;
video.style.width = '640px';
const info = document.getElementById('recordInfo');
info.innerHTML = '';
info.appendChild(video);
log('播放录制视频', 'info');
}
};
document.getElementById('downloadRecord').onclick = () => {
if (recordedBlob) {
const url = URL.createObjectURL(recordedBlob);
const a = document.createElement('a');
a.href = url;
a.download = `recording-${Date.now()}.webm`;
a.click();
URL.revokeObjectURL(url);
log('下载录制文件', 'success');
}
};
document.getElementById('clearLog').onclick = () => {
document.getElementById('log').innerHTML = '';
};
// 初始化
checkBrowserSupport();
log('测试工具已加载', 'success');
</script>
</body>
</html>
File diff suppressed because one or more lines are too long