新增功能

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>
+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);
}