新增功能

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
@@ -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>