新增功能
This commit is contained in:
@@ -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