新增功能
This commit is contained in:
@@ -50,6 +50,10 @@ export function tcmDiagnosisAssign(params: any) {
|
||||
return request.post({ url: '/tcm.diagnosis/assign', params })
|
||||
}
|
||||
|
||||
export function tcmDiagnosisAssignLogList(params: { id: number }) {
|
||||
return request.get({ url: '/tcm.diagnosis/assignLogList', params })
|
||||
}
|
||||
|
||||
// 获取医助列表
|
||||
export function getAssistants() {
|
||||
return request.get({ url: '/tcm.diagnosis/getAssistants' })
|
||||
@@ -224,6 +228,11 @@ export function prescriptionVoid(params: { id: number }) {
|
||||
return request.post({ url: '/tcm.prescription/void', params })
|
||||
}
|
||||
|
||||
/** 处方审核:action approve | reject(驳回同时作废处方) */
|
||||
export function prescriptionAudit(params: { id: number; action: 'approve' | 'reject'; remark?: string }) {
|
||||
return request.post({ url: '/tcm.prescription/audit', params })
|
||||
}
|
||||
|
||||
// ========== 处方库 ==========
|
||||
|
||||
// 处方库列表
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<div v-if="showFriendlyCustom" class="chat-uikit-custom-msg">
|
||||
<span v-if="friendlyCustom!.tag" class="chat-uikit-custom-msg__tag">{{ friendlyCustom!.tag }}</span>
|
||||
<div class="chat-uikit-custom-msg__main">{{ friendlyCustom!.main }}</div>
|
||||
<div v-if="friendlyCustom!.sub" class="chat-uikit-custom-msg__sub">{{ friendlyCustom!.sub }}</div>
|
||||
</div>
|
||||
<div v-else-if="isCustomType && customDescription" class="chat-uikit-custom-msg chat-uikit-custom-msg--plain">
|
||||
<div class="chat-uikit-custom-msg__main">{{ customDescription }}</div>
|
||||
</div>
|
||||
<div v-else-if="isCustomType" class="chat-uikit-custom-msg chat-uikit-custom-msg--plain">
|
||||
<div class="chat-uikit-custom-msg__main">系统消息</div>
|
||||
<div v-if="customDataPreview" class="chat-uikit-custom-msg__sub">{{ customDataPreview }}</div>
|
||||
</div>
|
||||
<Message v-else v-bind="attrs" :message="message" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, useAttrs } from 'vue'
|
||||
import { Message, MessageType } from '@tencentcloud/chat-uikit-vue3'
|
||||
import type { MessageModel } from '@tencentcloud/chat-uikit-vue3'
|
||||
import { parseImBusinessPayload } from '@/utils/im-business-message-parse'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const props = defineProps<{
|
||||
message: MessageModel
|
||||
}>()
|
||||
|
||||
const attrs = useAttrs()
|
||||
|
||||
function payloadDataString(payload: unknown): string | null {
|
||||
if (!payload || typeof payload !== 'object') return null
|
||||
const p = payload as Record<string, unknown>
|
||||
const d = p.data
|
||||
if (typeof d === 'string') return d
|
||||
if (d && typeof d === 'object') return JSON.stringify(d)
|
||||
const ext = p.extension
|
||||
if (typeof ext === 'string' && ext.trim().startsWith('{')) return ext
|
||||
return null
|
||||
}
|
||||
|
||||
const isCustomType = computed(() => {
|
||||
const t = props.message?.type as unknown
|
||||
if (t === MessageType.CUSTOM) return true
|
||||
if (typeof t === 'string' && /custom/i.test(t)) return true
|
||||
return false
|
||||
})
|
||||
|
||||
const friendlyCustom = computed(() => {
|
||||
if (!isCustomType.value) return null
|
||||
const raw = payloadDataString(props.message.payload)?.trim()
|
||||
if (!raw) return null
|
||||
return parseImBusinessPayload(raw)
|
||||
})
|
||||
|
||||
const showFriendlyCustom = computed(() => isCustomType.value && !!friendlyCustom.value)
|
||||
|
||||
const customDescription = computed(() => {
|
||||
const p = props.message.payload as Record<string, unknown> | undefined
|
||||
const d = p?.description
|
||||
return typeof d === 'string' && d.trim() ? d.trim() : ''
|
||||
})
|
||||
|
||||
/** 无法识别结构时短预览,避免空白 */
|
||||
const customDataPreview = computed(() => {
|
||||
const raw = payloadDataString(props.message.payload)?.trim()
|
||||
if (!raw || raw.length < 2) return ''
|
||||
if (raw.length <= 120) return raw
|
||||
return `${raw.slice(0, 117)}…`
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.chat-uikit-custom-msg {
|
||||
max-width: 85%;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
background: var(--el-fill-color-light, #f4f4f5);
|
||||
color: var(--el-text-color-primary, #303133);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
border: 1px solid var(--el-border-color-lighter, #ebeef5);
|
||||
}
|
||||
|
||||
.chat-uikit-custom-msg--plain {
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.chat-uikit-custom-msg__tag {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
padding: 0 6px;
|
||||
border-radius: 4px;
|
||||
background: var(--el-color-info-light-9, #f4f4f5);
|
||||
color: var(--el-color-info, #909399);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.chat-uikit-custom-msg__main {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.chat-uikit-custom-msg__sub {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary, #909399);
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
@@ -4,18 +4,34 @@
|
||||
v-show="visible"
|
||||
ref="chatWindowRef"
|
||||
class="chat-floating-window"
|
||||
:class="{ 'chat-floating-window--minimized': isMinimized }"
|
||||
:style="windowStyle"
|
||||
>
|
||||
<div
|
||||
class="chat-window-header"
|
||||
@mousedown="onHeaderMouseDown"
|
||||
>
|
||||
<span class="chat-window-title">与 {{ patientName }} 聊天</span>
|
||||
<el-button type="danger" link class="chat-close-btn" @click="handleClose">
|
||||
<el-icon><Close /></el-icon>
|
||||
</el-button>
|
||||
<span class="chat-window-title" :title="patientName">与 {{ patientName }} 聊天</span>
|
||||
<div class="chat-header-actions" @mousedown.stop>
|
||||
<el-button type="danger" link class="chat-close-btn" @click="handleClose">
|
||||
<el-icon><Close /></el-icon>
|
||||
</el-button>
|
||||
<el-tooltip v-if="!isMinimized" content="缩小为标题条,不关闭会话" placement="top">
|
||||
<el-button type="primary" link class="chat-minimize-btn" @click="isMinimized = true">
|
||||
<el-icon><Fold /></el-icon>
|
||||
<span class="chat-header-btn-text">缩小</span>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip v-else content="展开聊天窗口" placement="top">
|
||||
<el-button type="primary" link class="chat-expand-btn" @click="isMinimized = false">
|
||||
<el-icon><Expand /></el-icon>
|
||||
<span class="chat-header-btn-text">展开</span>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-container">
|
||||
<div v-show="!isMinimized" class="chat-container">
|
||||
<div v-if="!isReady" class="loading-container">
|
||||
<el-icon class="is-loading"><Loading /></el-icon>
|
||||
<span>{{ loadingText }}</span>
|
||||
@@ -28,7 +44,7 @@
|
||||
<div class="chat-layout">
|
||||
<!-- 嵌入式问诊窗口仅与当前患者会话,不展示全局会话列表 -->
|
||||
<Chat class="chat-area">
|
||||
<MessageList :conversationID="conversationId" />
|
||||
<MessageList :conversationID="conversationId" :Message="ChatMessageItem" />
|
||||
<MessageInput>
|
||||
<template #headerToolbar>
|
||||
<div class="message-toolbar">
|
||||
@@ -37,6 +53,7 @@
|
||||
<FilePicker />
|
||||
<!-- 仅在 TUICallKit 初始化成功后展示音视频入口,避免“初始化登录未完成”错误 -->
|
||||
<AudioCallPicker v-if="isCallReady" />
|
||||
<!-- 视频接通后 doBindCallRoom → 后端 bindCallRoom 会调 CreateCloudRecording,RecordParams.RecordMode=2(混流/合流),见 TrtcCloudRecordingService -->
|
||||
<VideoCallPicker v-if="isCallReady" />
|
||||
<!-- 群组视频通话(基于 TUICallKitServer.calls,多人通话入口) -->
|
||||
<el-button
|
||||
@@ -76,8 +93,9 @@
|
||||
link
|
||||
class="call-kit-screenshot-btn"
|
||||
:loading="captureUploading"
|
||||
:disabled="captureUploading"
|
||||
@mousedown.stop
|
||||
@click.stop="handleCallKitScreenshot"
|
||||
@click.stop.prevent="handleCallKitScreenshot"
|
||||
>
|
||||
<el-icon><Camera /></el-icon>
|
||||
<span class="call-kit-screenshot-text">截屏</span>
|
||||
@@ -108,7 +126,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick, watch, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { Loading, Close, Rank, Camera } from '@element-plus/icons-vue'
|
||||
import { Loading, Close, Rank, Camera, Fold, Expand } from '@element-plus/icons-vue'
|
||||
import { uploadImageBlob, uploadVideoBlob } from '@/api/file'
|
||||
import {
|
||||
attachLocalCallRecording,
|
||||
@@ -121,6 +139,10 @@ import {
|
||||
} from '@/api/tcm'
|
||||
import { CallLocalRecorder } from '@/utils/call-local-recorder'
|
||||
import { captureVideoFrameFromElement } from '@/utils/call-video-screenshot'
|
||||
import {
|
||||
DIAGNOSIS_TONGUE_IMAGES_UPDATED,
|
||||
type DiagnosisTongueImagesUpdatedDetail
|
||||
} from '@/utils/diagnosis-sync-events'
|
||||
import feedback from '@/utils/feedback'
|
||||
import {
|
||||
formatTUICallUserError,
|
||||
@@ -141,7 +163,10 @@ import {
|
||||
AudioCallPicker,
|
||||
VideoCallPicker
|
||||
} from '@tencentcloud/chat-uikit-vue3'
|
||||
import ChatMessageItem from './ChatMessageItem.vue'
|
||||
import TencentCloudChat from '@tencentcloud/chat'
|
||||
import TUIChatEngine from '@tencentcloud/chat-uikit-engine'
|
||||
import { imCustomDataIndicatesVideoHangup } from '@/utils/im-call-hangup-detect'
|
||||
import '@/assets/chat-uikit.css'
|
||||
import {
|
||||
TUICallKit,
|
||||
@@ -153,6 +178,8 @@ import {
|
||||
} from '@tencentcloud/call-uikit-vue'
|
||||
import { TUICallEvent } from '@tencentcloud/call-engine-js'
|
||||
const assistant_ids=ref('');
|
||||
/** 缩小为仅标题条,便于医生操作后台其他页面(不登出、不挂断) */
|
||||
const isMinimized = ref(false)
|
||||
const visible = ref(false)
|
||||
const isReady = ref(false)
|
||||
const error = ref('')
|
||||
@@ -450,6 +477,84 @@ function installTrtcCloudPreStopHook() {
|
||||
|
||||
let engineRoomEventHandlers: Array<{ event: TUICallEvent; fn: (e: unknown) => void }> = []
|
||||
|
||||
/** IM MESSAGE_RECEIVED:对端挂断等信令走 TIM,与引擎事件双通道;需在 reportCallEnded 之后绑定 */
|
||||
let imHangupMessageHandler: ((event: { data?: unknown }) => void) | null = null
|
||||
let lastHangupCloudStopAt = 0
|
||||
const HANGUP_CLOUD_STOP_DEBOUNCE_MS = 1500
|
||||
|
||||
/** 将 tcm_call_record 置结束并 DeleteCloudRecording(合流停录) */
|
||||
async function reportCallEnded() {
|
||||
const id = recordingDiagnosisIdSnapshot ?? diagnosisId.value
|
||||
if (id == null) {
|
||||
console.warn('[chat-dialog] endCall 跳过:无诊单 ID(快照与当前均为空)')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await endCall({ diagnosis_id: id })
|
||||
console.log('[chat-dialog] endCall 已同步', { diagnosis_id: id })
|
||||
} catch (e) {
|
||||
console.warn('[chat-dialog] endCall 失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
/** 引擎 CALL_END / IM 挂断信令共用防抖,避免重复 DeleteCloudRecording */
|
||||
function debouncedStopRecordingAndEndCloud(source: string) {
|
||||
const now = Date.now()
|
||||
if (now - lastHangupCloudStopAt < HANGUP_CLOUD_STOP_DEBOUNCE_MS) return
|
||||
lastHangupCloudStopAt = now
|
||||
console.log('[chat-dialog] 挂断停合流:', source)
|
||||
if (localCallRecorder.isRecording()) {
|
||||
localCallRecorder.beginStopNow()
|
||||
}
|
||||
void reportCallEnded()
|
||||
}
|
||||
|
||||
function bindImHangupMessageListener() {
|
||||
unbindImHangupMessageListener()
|
||||
try {
|
||||
const chat = TUIChatEngine?.chat as { on?: (ev: string, fn: (e: { data?: unknown }) => void) => void; off?: (ev: string, fn: (e: { data?: unknown }) => void) => void } | undefined
|
||||
if (!chat?.on) {
|
||||
console.warn('[chat-dialog] TUIChatEngine.chat 不可用,无法监听 IM 视频挂断信令')
|
||||
return
|
||||
}
|
||||
imHangupMessageHandler = (event: { data?: unknown }) => {
|
||||
if (!visible.value) return
|
||||
const pu = patientUserId.value
|
||||
if (!pu) return
|
||||
const expectConv = pu.startsWith('C2C') ? pu : `C2C${pu}`
|
||||
const list = Array.isArray(event.data) ? event.data : []
|
||||
for (const m of list as Array<Record<string, unknown>>) {
|
||||
if (!m || m.conversationID !== expectConv) continue
|
||||
const t = m.type as string | number | undefined
|
||||
const isCustom =
|
||||
t === TencentCloudChat.TYPES.MSG_CUSTOM ||
|
||||
t === 'TIMCustomElem' ||
|
||||
(typeof t === 'string' && /custom/i.test(t))
|
||||
if (!isCustom) continue
|
||||
const payload = m.payload as Record<string, unknown> | undefined
|
||||
const raw = typeof payload?.data === 'string' ? payload.data : ''
|
||||
if (!raw || !imCustomDataIndicatesVideoHangup(raw)) continue
|
||||
debouncedStopRecordingAndEndCloud('IM')
|
||||
break
|
||||
}
|
||||
}
|
||||
chat.on(TencentCloudChat.EVENT.MESSAGE_RECEIVED, imHangupMessageHandler)
|
||||
} catch (e) {
|
||||
console.warn('[chat-dialog] 绑定 IM 挂断监听失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
function unbindImHangupMessageListener() {
|
||||
if (!imHangupMessageHandler) return
|
||||
try {
|
||||
const chat = TUIChatEngine?.chat as { off?: (ev: string, fn: (e: { data?: unknown }) => void) => void } | undefined
|
||||
chat?.off?.(TencentCloudChat.EVENT.MESSAGE_RECEIVED, imHangupMessageHandler)
|
||||
} catch {
|
||||
//
|
||||
}
|
||||
imHangupMessageHandler = null
|
||||
}
|
||||
|
||||
function bindEngineRoomEvents() {
|
||||
try {
|
||||
type EngineWithEvents = Record<string, unknown> & {
|
||||
@@ -469,11 +574,7 @@ function bindEngineRoomEvents() {
|
||||
void tryBindRoomIfReady()
|
||||
}
|
||||
const onEarlyStopRecording = () => {
|
||||
console.log('[chat-dialog] 检测到通话结束事件,立即停止录制')
|
||||
if (localCallRecorder.isRecording()) {
|
||||
console.log('[chat-dialog] 录制进行中,触发 beginStopNow')
|
||||
localCallRecorder.beginStopNow()
|
||||
}
|
||||
debouncedStopRecordingAndEndCloud('TUICallEngine')
|
||||
}
|
||||
const earlyStopEvents = [
|
||||
TUICallEvent.CALL_END,
|
||||
@@ -549,9 +650,18 @@ async function doBindCallRoom(rid: string) {
|
||||
}
|
||||
lastBoundRoomKey.value = key
|
||||
try {
|
||||
await bindCallRoom({ diagnosis_id: diagnosisId.value, room_id: rid })
|
||||
const bindRes = (await bindCallRoom({
|
||||
diagnosis_id: diagnosisId.value,
|
||||
room_id: rid
|
||||
})) as { cloud_recording?: { started?: boolean; task_id?: string; message?: string } } | undefined
|
||||
clearBindRoomRetry()
|
||||
console.log('[chat-dialog] bindCallRoom 成功', { room_id: rid })
|
||||
console.log('[chat-dialog] bindCallRoom 成功', { room_id: rid, bindRes })
|
||||
const cr = bindRes?.cloud_recording
|
||||
if (cr && cr.started === false && cr.message) {
|
||||
feedback.msgWarning(
|
||||
`云端合流录制未启动(关闭控制台全局录制后仅依赖 API):${cr.message}`
|
||||
)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[chat-dialog] bindCallRoom 失败', e)
|
||||
lastBoundRoomKey.value = ''
|
||||
@@ -590,17 +700,6 @@ 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 =
|
||||
@@ -616,9 +715,9 @@ function setupCallRoomBinding() {
|
||||
localRecordingBadge.value = false
|
||||
if (previousCallStatus === CALL_STATUS_CALLING || previousCallStatus === CALL_STATUS_CONNECTED) {
|
||||
void (async () => {
|
||||
await finalizeLocalRecordingAndAttach()
|
||||
showCallKitWindow.value = false
|
||||
await reportCallEnded()
|
||||
showCallKitWindow.value = false
|
||||
await finalizeLocalRecordingAndAttach()
|
||||
})()
|
||||
}
|
||||
clearBindRoomRetry()
|
||||
@@ -842,6 +941,7 @@ onUnmounted(() => {
|
||||
localCallRecorder.reset()
|
||||
tearDownCallRoomBinding?.()
|
||||
unbindEngineRoomEvents()
|
||||
unbindImHangupMessageListener()
|
||||
unhookConsoleErrorForPackageHint()
|
||||
unbindEnginePackageErrorListener()
|
||||
window.removeEventListener('unhandledrejection', onTuiCallUnhandledRejection)
|
||||
@@ -916,10 +1016,10 @@ watch(() => activeConversation.value, async (newConversation) => {
|
||||
|
||||
const open = async (data: { patientId: number; patientName: string; diagnosisId?: number }) => {
|
||||
visible.value = true
|
||||
isMinimized.value = false
|
||||
posX.value = 100
|
||||
posY.value = 80
|
||||
callKitX.value = Math.max(0, (window.innerWidth - 375) / 2)
|
||||
callKitY.value = Math.max(0, (window.innerHeight - 667) / 2)
|
||||
resetCallKitPositionToLeft()
|
||||
patientName.value = data.patientName
|
||||
patientId.value = data.patientId
|
||||
diagnosisId.value = data.diagnosisId || null
|
||||
@@ -959,6 +1059,8 @@ const open = async (data: { patientId: number; patientName: string; diagnosisId?
|
||||
userSig: res.userSig,
|
||||
useUploadPlugin: true
|
||||
})
|
||||
// IM 自定义信令:对端挂断等 → 立即 endCall 停合流(不依赖小程序上报)
|
||||
bindImHangupMessageListener()
|
||||
const sdkAPPid = Number(res.sdkAppId)
|
||||
|
||||
// 初始化 TUICallKit(聊天内音视频通话依赖此初始化);只需全局成功一次
|
||||
@@ -989,17 +1091,18 @@ const open = async (data: { patientId: number; patientName: string; diagnosisId?
|
||||
localRecordingBadge.value = false
|
||||
localRecordingRound++
|
||||
recordingDiagnosisIdSnapshot = diagnosisId.value
|
||||
resetCallKitPositionToLeft()
|
||||
showCallKitWindow.value = true
|
||||
pendingCallRecordStart = ensureCallRecordStarted()
|
||||
void pendingCallRecordStart
|
||||
},
|
||||
// 同步停录须早于 SDK 内部退房;再 finalize 上传并关窗
|
||||
// 须先 endCall(DeleteCloudRecording),再 await 本地上传;否则上传 WebM 耗时数分钟会拖住云端停录,控制台房间长期「尚未结束」
|
||||
afterCalling: () => {
|
||||
localCallRecorder.beginStopNow()
|
||||
void (async () => {
|
||||
await finalizeLocalRecordingAndAttach()
|
||||
showCallKitWindow.value = false
|
||||
await reportCallEnded()
|
||||
showCallKitWindow.value = false
|
||||
await finalizeLocalRecordingAndAttach()
|
||||
})()
|
||||
}
|
||||
})
|
||||
@@ -1120,8 +1223,22 @@ const posStartY = ref(0)
|
||||
const callKitWrapperRef = ref<HTMLElement>()
|
||||
const callKitContentRef = ref<HTMLElement>()
|
||||
const captureUploading = ref(false)
|
||||
/** 与 .chat-dialog-call-kit-wrapper 尺寸一致 */
|
||||
const CALL_KIT_FLOAT_W = 375
|
||||
const CALL_KIT_FLOAT_H = 667
|
||||
const CALL_KIT_EDGE = 16
|
||||
|
||||
const callKitX = ref(0)
|
||||
const callKitY = ref(0)
|
||||
/** 视频浮窗默认靠左、垂直居中(每次发起通话也会重置,避免沿用上次拖动位置) */
|
||||
function resetCallKitPositionToLeft() {
|
||||
callKitX.value = CALL_KIT_EDGE
|
||||
callKitY.value = Math.max(
|
||||
CALL_KIT_EDGE,
|
||||
Math.floor((window.innerHeight - CALL_KIT_FLOAT_H) / 2)
|
||||
)
|
||||
}
|
||||
|
||||
const isCallKitDragging = ref(false)
|
||||
const callKitDragStartX = ref(0)
|
||||
const callKitDragStartY = ref(0)
|
||||
@@ -1157,15 +1274,21 @@ const onCallKitDragEnd = () => {
|
||||
|
||||
/** 截取当前视频画面并上传,同步到对应患者诊单的「舌苔照片」 */
|
||||
const handleCallKitScreenshot = async () => {
|
||||
if (patientId.value == null) {
|
||||
feedback.msgWarning('缺少患者信息,无法同步舌苔照片')
|
||||
return
|
||||
}
|
||||
const el = callKitContentRef.value
|
||||
if (!el) return
|
||||
// 同步防重入:避免连点触发两次上传、诊单里出现两张图
|
||||
if (captureUploading.value) return
|
||||
captureUploading.value = true
|
||||
try {
|
||||
const blob = await captureVideoFrameFromElement(el)
|
||||
if (patientId.value == null) {
|
||||
feedback.msgWarning('缺少患者信息,无法同步舌苔照片')
|
||||
return
|
||||
}
|
||||
const wrap = callKitWrapperRef.value
|
||||
const content = callKitContentRef.value
|
||||
const root = wrap || content
|
||||
if (!root) return
|
||||
const blob = await captureVideoFrameFromElement(root, {
|
||||
extraRoots: [content].filter(Boolean) as HTMLElement[]
|
||||
})
|
||||
if (!blob) {
|
||||
feedback.msgWarning('截屏失败,请确认视频画面已加载')
|
||||
return
|
||||
@@ -1195,6 +1318,14 @@ const handleCallKitScreenshot = async () => {
|
||||
payload.tongue_images = next
|
||||
await tcmDiagnosisEdit(payload)
|
||||
feedback.msgSuccess('舌苔照片已同步到诊单')
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<DiagnosisTongueImagesUpdatedDetail>(DIAGNOSIS_TONGUE_IMAGES_UPDATED, {
|
||||
detail: {
|
||||
diagnosisId: diagnosisId.value,
|
||||
tongue_images: next
|
||||
}
|
||||
})
|
||||
)
|
||||
} catch (e: unknown) {
|
||||
const err = e as Error
|
||||
console.error(err)
|
||||
@@ -1224,7 +1355,8 @@ const windowStyle = computed(() => ({
|
||||
}))
|
||||
|
||||
const onHeaderMouseDown = (e: MouseEvent) => {
|
||||
if ((e.target as HTMLElement).closest('.chat-close-btn')) return
|
||||
const t = e.target as HTMLElement
|
||||
if (t.closest('.chat-close-btn') || t.closest('.chat-header-actions')) return
|
||||
isDragging.value = true
|
||||
dragStartX.value = e.clientX
|
||||
dragStartY.value = e.clientY
|
||||
@@ -1247,6 +1379,7 @@ const onHeaderMouseUp = () => {
|
||||
}
|
||||
|
||||
const handleClose = async () => {
|
||||
unbindImHangupMessageListener()
|
||||
// 关闭时如有通话中/拨通中,先结束本地录制再挂断(不依赖悬浮窗是否显示)
|
||||
if (isCallReady.value) {
|
||||
const st = TUIStore.getData(StoreName.CALL, NAME.CALL_STATUS)
|
||||
@@ -1287,7 +1420,7 @@ defineExpose({ open })
|
||||
/* 浮动窗口:无遮罩,不遮挡页面,可任意拖动 */
|
||||
.chat-floating-window {
|
||||
position: fixed;
|
||||
width: 900px;
|
||||
width: 600px;
|
||||
height: 75vh;
|
||||
min-height: 400px;
|
||||
max-height: 90vh;
|
||||
@@ -1298,6 +1431,14 @@ defineExpose({ open })
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
&--minimized {
|
||||
width: min(360px, 92vw);
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
max-height: none;
|
||||
box-shadow: 0 2px 16px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
}
|
||||
|
||||
.chat-window-header {
|
||||
@@ -1314,9 +1455,28 @@ defineExpose({ open })
|
||||
.chat-window-title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.chat-close-btn {
|
||||
.chat-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-header-btn-text {
|
||||
margin-left: 2px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.chat-close-btn,
|
||||
.chat-minimize-btn,
|
||||
.chat-expand-btn {
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<el-drawer
|
||||
v-model="visible"
|
||||
title="中医处方单"
|
||||
:title="drawerTitle"
|
||||
size="1200px"
|
||||
:close-on-click-modal="false"
|
||||
@close="handleClose"
|
||||
@@ -264,7 +264,19 @@
|
||||
<h2 class="prescription-title">{{ templateConfig.stationName }}处方笺</h2>
|
||||
<div class="prescription-type">
|
||||
<el-tag v-if="savedPrescription.void_status === 1" type="danger" size="small">已作废</el-tag>
|
||||
<template v-else>
|
||||
<el-tag
|
||||
v-else-if="approvedPreviewOnly"
|
||||
type="success"
|
||||
size="small"
|
||||
class="mr-1"
|
||||
>已通过</el-tag>
|
||||
<el-tag
|
||||
v-else-if="Number(savedPrescription.audit_status) === 0"
|
||||
type="warning"
|
||||
size="small"
|
||||
class="mr-1"
|
||||
>待审核</el-tag>
|
||||
<template v-if="savedPrescription.void_status !== 1">
|
||||
<div>普通处方</div>
|
||||
<div class="text-sm">当日有效</div>
|
||||
</template>
|
||||
@@ -460,7 +472,7 @@
|
||||
下载PDF
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="savedPrescription.void_status !== 1"
|
||||
v-if="savedPrescription.void_status !== 1 && !approvedPreviewOnly"
|
||||
type="danger"
|
||||
plain
|
||||
:loading="voiding"
|
||||
@@ -468,7 +480,7 @@
|
||||
>
|
||||
作废处方
|
||||
</el-button>
|
||||
<el-button @click="handleNewPrescription">新建处方</el-button>
|
||||
<el-button v-if="!approvedPreviewOnly" @click="handleNewPrescription">新建处方</el-button>
|
||||
<el-button @click="handleClose">关闭</el-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -609,6 +621,15 @@ const signatureCanvasRef = ref<HTMLCanvasElement>()
|
||||
const isDrawing = ref(false)
|
||||
const savedPrescription = ref<any>(null)
|
||||
|
||||
/** 已通过且未作废:与消费者处方「查看」一致,仅预览,不可作废/再开新方 */
|
||||
const approvedPreviewOnly = computed(() => {
|
||||
const s = savedPrescription.value
|
||||
if (!s) return false
|
||||
return Number(s.audit_status) === 1 && Number(s.void_status) !== 1
|
||||
})
|
||||
|
||||
const drawerTitle = computed(() => (approvedPreviewOnly.value ? '查看处方' : '中医处方单'))
|
||||
|
||||
// 处方库相关
|
||||
const showLibraryDialog = ref(false)
|
||||
const libraryLoading = ref(false)
|
||||
|
||||
@@ -4,7 +4,11 @@ const config = {
|
||||
version: '1.9.4', //版本号
|
||||
baseUrl: `${import.meta.env.VITE_APP_BASE_URL || ''}/`, //请求接口域名
|
||||
urlPrefix: 'adminapi', //请求默认前缀
|
||||
timeout: 10 * 1000 //请求超时时长
|
||||
// 默认 30s;可在 .env 设置 VITE_APP_REQUEST_TIMEOUT=60000(毫秒)覆盖
|
||||
timeout:
|
||||
Number(import.meta.env.VITE_APP_REQUEST_TIMEOUT) > 0
|
||||
? Number(import.meta.env.VITE_APP_REQUEST_TIMEOUT)
|
||||
: 30 * 1000
|
||||
}
|
||||
|
||||
export default config
|
||||
|
||||
@@ -38,8 +38,9 @@
|
||||
--el-fill-color-dark: #ebedf0;
|
||||
--el-fill-color-darker: #e6e8eb;
|
||||
--el-fill-color-blank: #ffffff;
|
||||
--el-mask-color: rgba(255, 255, 255, 0.9);
|
||||
--el-mask-color-extra-light: rgba(255, 255, 255, 0.3);
|
||||
/* 过亮会盖住抽屉/弹窗下的内容;Element Loading 与部分蒙层共用此变量 */
|
||||
--el-mask-color: rgba(255, 255, 255, 0.5);
|
||||
--el-mask-color-extra-light: rgba(255, 255, 255, 0.22);
|
||||
-el-box-shadow: 0px 12px 32px 4px rgba(0, 0, 0, 0.04), 0px 8px 20px rgba(0, 0, 0, 0.08);
|
||||
--el-box-shadow-light: 0px 0px 12px rgba(0, 0, 0, 0.12);
|
||||
--el-box-shadow-lighter: 0px 0px 6px rgba(0, 0, 0, 0.12);
|
||||
|
||||
@@ -1,20 +1,178 @@
|
||||
/** 从视频通话区域截取当前最大的一路 video 帧(WebRTC 画面) */
|
||||
export async function captureVideoFrameFromElement(
|
||||
container: HTMLElement
|
||||
): Promise<Blob | null> {
|
||||
const videos = Array.from(container.querySelectorAll('video')) as HTMLVideoElement[]
|
||||
let best: HTMLVideoElement | null = null
|
||||
let bestArea = 0
|
||||
/** 视频 track 是否在播(排除已结束轨道) */
|
||||
function hasLiveVideoTrack(video: HTMLVideoElement): boolean {
|
||||
const so = video.srcObject
|
||||
if (!(so instanceof MediaStream)) return false
|
||||
return so.getVideoTracks().some((t) => t.readyState === 'live')
|
||||
}
|
||||
|
||||
/** 页面上实际占位面积(主画面远大于本地小窗) */
|
||||
function layoutArea(v: HTMLVideoElement): number {
|
||||
const r = v.getBoundingClientRect()
|
||||
return Math.max(0, r.width) * Math.max(0, r.height)
|
||||
}
|
||||
|
||||
function videoPixelArea(v: HTMLVideoElement): number {
|
||||
const vw = v.videoWidth || v.clientWidth
|
||||
const vh = v.videoHeight || v.clientHeight
|
||||
return vw * vh
|
||||
}
|
||||
|
||||
/** 同一路 MediaStream 可能挂多个 video,只保留占位最大的一路,避免重复逻辑与歧义 */
|
||||
function dedupeVideosByStream(videos: HTMLVideoElement[]): HTMLVideoElement[] {
|
||||
const byKey = new Map<string, HTMLVideoElement>()
|
||||
const areaByKey = new Map<string, number>()
|
||||
for (const v of videos) {
|
||||
if (v.readyState < 2) continue
|
||||
const vw = v.videoWidth || v.clientWidth
|
||||
const vh = v.videoHeight || v.clientHeight
|
||||
const area = vw * vh
|
||||
if (area > bestArea) {
|
||||
bestArea = area
|
||||
best = v
|
||||
const so = v.srcObject
|
||||
if (!(so instanceof MediaStream)) continue
|
||||
const tracks = so.getVideoTracks()
|
||||
const key =
|
||||
(so.id && String(so.id)) ||
|
||||
tracks
|
||||
.map((t) => t.id)
|
||||
.filter(Boolean)
|
||||
.join('|') ||
|
||||
`${tracks.length}`
|
||||
const la = layoutArea(v)
|
||||
const prev = areaByKey.get(key) ?? 0
|
||||
if (la >= prev) {
|
||||
byKey.set(key, v)
|
||||
areaByKey.set(key, la)
|
||||
}
|
||||
}
|
||||
if (byKey.size === 0) return videos
|
||||
return Array.from(byKey.values())
|
||||
}
|
||||
|
||||
/**
|
||||
* 1v1 常见:远端全屏 + 本地小窗。去掉占位明显偏小的一路(画中画),只留主画面候选。
|
||||
*/
|
||||
function dropObviousPip(videos: HTMLVideoElement[]): HTMLVideoElement[] {
|
||||
if (videos.length < 2) return videos
|
||||
let maxL = 0
|
||||
for (const v of videos) maxL = Math.max(maxL, layoutArea(v))
|
||||
if (maxL <= 1) return videos
|
||||
const minL = Math.min(...videos.map((v) => layoutArea(v)))
|
||||
if (minL / maxL >= 0.25) return videos
|
||||
return videos.filter((v) => layoutArea(v) >= maxL * 0.25)
|
||||
}
|
||||
|
||||
/** 本地预览常见:镜像(scaleX(-1)) */
|
||||
function hasMirrorTransform(el: HTMLElement): boolean {
|
||||
let p: HTMLElement | null = el
|
||||
for (let i = 0; i < 12 && p; i++) {
|
||||
const t = getComputedStyle(p).transform
|
||||
if (t && t !== 'none') {
|
||||
if (/matrix\(\s*-1\s*,/.test(t) || /scaleX\(\s*-1/.test(t)) return true
|
||||
}
|
||||
p = p.parentElement
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 DOM 判断是否为「本方/本地」预览(腾讯云 TUICall / TRTC 常见 class 及通用命名)
|
||||
* 注意:不要用泛化的 "player",否则本地/远端容器类名都会命中,导致无法区分。
|
||||
*/
|
||||
function isLikelyLocalPreviewVideo(video: HTMLVideoElement): boolean {
|
||||
const localHints =
|
||||
/local|self|pusher|mini[_-]?stream|preview[_-]?self|own[_-]?camera|tui-local|tencent-local|stream-local|publisher|send|pip|picture[_-]?in[_-]?picture/i
|
||||
const remoteHints =
|
||||
/remote|subscriber|peer|other|tui-remote|stream-remote|big-stream|play[_-]?stream|receiver|pull|substream/i
|
||||
|
||||
let el: HTMLElement | null = video
|
||||
for (let depth = 0; depth < 20 && el; depth++) {
|
||||
const s = `${el.className || ''} ${el.id || ''} ${el.getAttribute('data-type') || ''}`
|
||||
if (remoteHints.test(s)) return false
|
||||
if (localHints.test(s)) return true
|
||||
el = el.parentElement
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 从通话区域截取一帧 JPEG。
|
||||
* 默认优先截取「对方/远端」画面:去重 MediaStream、去掉画中画小窗、排除本地预览与镜像节点。
|
||||
*/
|
||||
export async function captureVideoFrameFromElement(
|
||||
searchRoot: HTMLElement | null,
|
||||
options?: { preferRemote?: boolean; extraRoots?: (HTMLElement | null)[] }
|
||||
): Promise<Blob | null> {
|
||||
const preferRemote = options?.preferRemote !== false
|
||||
const roots = [searchRoot, ...(options?.extraRoots || [])].filter((r): r is HTMLElement => !!r)
|
||||
|
||||
const videoSet = new Set<HTMLVideoElement>()
|
||||
for (const r of roots) {
|
||||
for (const v of r.querySelectorAll('video')) {
|
||||
if (v instanceof HTMLVideoElement) videoSet.add(v)
|
||||
}
|
||||
}
|
||||
|
||||
if (videoSet.size === 0) {
|
||||
const wrap = document.querySelector('.chat-dialog-call-kit-wrapper')
|
||||
if (wrap) {
|
||||
for (const v of wrap.querySelectorAll('video')) {
|
||||
if (v instanceof HTMLVideoElement) videoSet.add(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (videoSet.size === 0) {
|
||||
document.body.querySelectorAll('video').forEach((v) => {
|
||||
if (v instanceof HTMLVideoElement && hasLiveVideoTrack(v)) videoSet.add(v)
|
||||
})
|
||||
}
|
||||
|
||||
let candidates = Array.from(videoSet).filter(
|
||||
(v) => v.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA && hasLiveVideoTrack(v)
|
||||
)
|
||||
if (candidates.length === 0) {
|
||||
candidates = Array.from(videoSet).filter((v) => v.readyState >= 2)
|
||||
}
|
||||
if (candidates.length === 0) return null
|
||||
|
||||
candidates = dedupeVideosByStream(candidates)
|
||||
|
||||
let best: HTMLVideoElement | null = null
|
||||
let bestLayout = 0
|
||||
let bestPixel = 0
|
||||
|
||||
if (preferRemote) {
|
||||
const scored = candidates.map((v) => ({
|
||||
v,
|
||||
layout: layoutArea(v),
|
||||
pixel: videoPixelArea(v),
|
||||
localClass: isLikelyLocalPreviewVideo(v),
|
||||
mirror: hasMirrorTransform(v)
|
||||
}))
|
||||
|
||||
let pool = scored.filter((x) => !x.localClass && !x.mirror)
|
||||
if (pool.length === 0) pool = scored.filter((x) => !x.localClass)
|
||||
if (pool.length === 0) pool = scored.filter((x) => !x.mirror)
|
||||
if (pool.length === 0) pool = scored
|
||||
|
||||
let videos = pool.map((x) => x.v)
|
||||
videos = dropObviousPip(videos)
|
||||
pool = pool.filter((x) => videos.includes(x.v))
|
||||
|
||||
for (const x of pool) {
|
||||
if (x.layout > bestLayout || (x.layout === bestLayout && x.pixel > bestPixel)) {
|
||||
bestLayout = x.layout
|
||||
bestPixel = x.pixel
|
||||
best = x.v
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const v of candidates) {
|
||||
const la = layoutArea(v)
|
||||
const pa = videoPixelArea(v)
|
||||
if (la > bestLayout || (la === bestLayout && pa > bestPixel)) {
|
||||
bestLayout = la
|
||||
bestPixel = pa
|
||||
best = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!best) return null
|
||||
const vw = best.videoWidth || best.clientWidth
|
||||
const vh = best.videoHeight || best.clientHeight
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* 跨组件同步诊单数据(如视频截屏写入舌苔照后刷新编辑抽屉)
|
||||
*/
|
||||
export const DIAGNOSIS_TONGUE_IMAGES_UPDATED = 'zyt:diagnosis-tongue-images-updated'
|
||||
|
||||
export type DiagnosisTongueImagesUpdatedDetail = {
|
||||
diagnosisId: number
|
||||
tongue_images: string[]
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
export type FriendlyParse = { main: string; sub?: string; tag?: string }
|
||||
|
||||
/** 业务时间:支持秒级时间戳或毫秒(字符串数字) */
|
||||
export function formatBizTime(t: unknown): string | undefined {
|
||||
if (t == null || t === '') return undefined
|
||||
const n =
|
||||
typeof t === 'string' && /^\d+$/.test(t.trim())
|
||||
? parseInt(t.trim(), 10)
|
||||
: Number(t)
|
||||
if (!Number.isFinite(n) || n <= 0) return undefined
|
||||
return n > 1e12
|
||||
? dayjs(n).format('YYYY-MM-DD HH:mm:ss')
|
||||
: dayjs.unix(n).format('YYYY-MM-DD HH:mm:ss')
|
||||
}
|
||||
|
||||
export function parseRtcInner(inner: Record<string, unknown>): FriendlyParse {
|
||||
const callType = inner.call_type
|
||||
const callTypeLabel =
|
||||
callType === 2 ? '视频通话' : callType === 1 ? '语音通话' : '通话'
|
||||
const cmd = String(inner.cmd || '')
|
||||
const cmdMap: Record<string, string> = {
|
||||
hangup: '已结束',
|
||||
invite: '发起通话',
|
||||
linebusy: '对方忙线',
|
||||
cancel: '已取消',
|
||||
reject: '已拒绝',
|
||||
accept: '已接听',
|
||||
timeout: '无人接听'
|
||||
}
|
||||
const action = cmdMap[cmd] || (cmd ? `状态:${cmd}` : '')
|
||||
const main = action ? `${callTypeLabel} · ${action}` : callTypeLabel
|
||||
const parts: string[] = []
|
||||
if (inner.inviter) parts.push(`发起方 ${inner.inviter}`)
|
||||
if (inner.invitee) parts.push(`对方 ${inner.invitee}`)
|
||||
if (inner.groupID) parts.push(`群组 ${inner.groupID}`)
|
||||
return {
|
||||
main,
|
||||
sub: parts.length ? parts.join(',') : undefined,
|
||||
tag: '通话'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 IM 自定义 / 信令 JSON 解析为可读文案(医生进诊室、音视频通话等)。
|
||||
* 与后台 IM 漫游、小程序 TUIKit 约定字段一致。
|
||||
*/
|
||||
export function parseImBusinessPayload(raw: string): FriendlyParse | null {
|
||||
const s = raw.trim()
|
||||
if (!s.startsWith('{')) return null
|
||||
let o: Record<string, unknown>
|
||||
try {
|
||||
o = JSON.parse(s) as Record<string, unknown>
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!('businessID' in o) && !('cmd' in o)) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (o.businessID === 'doctor_entered_consult_room') {
|
||||
const t = formatBizTime(o.time)
|
||||
return { main: '医生已进入诊室', sub: t, tag: '诊室' }
|
||||
}
|
||||
|
||||
if (o.businessID === 'patient_opened_chat') {
|
||||
const parts: string[] = []
|
||||
if (o.patientName) parts.push(`患者:${String(o.patientName)}`)
|
||||
if (o.patientId != null && o.patientId !== '') parts.push(`患者 ID:${String(o.patientId)}`)
|
||||
if (o.doctorId != null && o.doctorId !== '') parts.push(`关联医生:${String(o.doctorId)}`)
|
||||
const t = formatBizTime(o.time)
|
||||
if (t) parts.push(t)
|
||||
return { main: '患者进入聊天', sub: parts.length ? parts.join(' · ') : undefined, tag: '诊室' }
|
||||
}
|
||||
|
||||
if (o.businessID === 'user_typing_status') {
|
||||
return { main: '对方正在输入…', tag: '状态' }
|
||||
}
|
||||
|
||||
if (o.businessID === 'consultation_complete') {
|
||||
return { main: '问诊已完成', sub: formatBizTime(o.time), tag: '诊室' }
|
||||
}
|
||||
|
||||
if (o.businessID === 1 || o.businessID === '1') {
|
||||
let inner: unknown = o.data
|
||||
if (typeof inner === 'string') {
|
||||
try {
|
||||
inner = JSON.parse(inner)
|
||||
} catch {
|
||||
return { main: '通话信令', sub: '内层数据解析失败', tag: '通话' }
|
||||
}
|
||||
}
|
||||
if (inner && typeof inner === 'object') {
|
||||
return parseRtcInner(inner as Record<string, unknown>)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
if (o.businessID === 'rtc_call' || o.cmd) {
|
||||
return parseRtcInner(o as Record<string, unknown>)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* 判断 TIM 自定义消息 payload.data 是否表示视频通话结束/挂断/未接通,
|
||||
* 用于 PC 管理端在 IM 收信后立即 endCall → DeleteCloudRecording,不依赖小程序上报。
|
||||
*/
|
||||
|
||||
function parseInnerData(data: unknown): Record<string, unknown> | null {
|
||||
if (data == null) return null
|
||||
if (typeof data === 'object' && !Array.isArray(data)) return data as Record<string, unknown>
|
||||
if (typeof data !== 'string') return null
|
||||
const t = data.trim()
|
||||
if (!t.startsWith('{')) return null
|
||||
try {
|
||||
return JSON.parse(t) as Record<string, unknown>
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function rtcCmdIndicatesHangup(cmd: string): boolean {
|
||||
const c = cmd.toLowerCase()
|
||||
return ['hangup', 'cancel', 'reject', 'timeout', 'linebusy', 'end'].includes(c)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param raw TIM 自定义消息外层 JSON 字符串(payload.data)
|
||||
*/
|
||||
export function imCustomDataIndicatesVideoHangup(raw: string): boolean {
|
||||
const s = raw.trim()
|
||||
if (!s) return false
|
||||
|
||||
// 部分信令不落标准 businessID,整段字符串匹配(如含 call_end / call_engine)
|
||||
if (/\bcall_end\b/i.test(s) && (/call_engine|call_record|rtc/i.test(s) || /"reason"\s*:\s*\d/.test(s))) {
|
||||
return true
|
||||
}
|
||||
if (/call_engine_srv\.[a-z_]*call[a-z_]*/i.test(s) && /hangup|end|leave|reject|cancel/i.test(s)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!s.startsWith('{')) return false
|
||||
|
||||
let o: Record<string, unknown>
|
||||
try {
|
||||
o = JSON.parse(s) as Record<string, unknown>
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
|
||||
// 标准音视频 businessID: 1
|
||||
if (o.businessID === 1 || o.businessID === '1') {
|
||||
const inner = parseInnerData(o.data)
|
||||
if (inner) {
|
||||
const cmd = String(inner.cmd ?? '')
|
||||
if (rtcCmdIndicatesHangup(cmd)) return true
|
||||
}
|
||||
}
|
||||
|
||||
if (o.businessID === 'rtc_call' || typeof o.cmd === 'string') {
|
||||
if (rtcCmdIndicatesHangup(String(o.cmd ?? ''))) return true
|
||||
}
|
||||
|
||||
const nested = JSON.stringify(o)
|
||||
if (nested.includes('call_engine_srv') && /hangup|call_end|CALL_END|reject|cancel/i.test(nested)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -99,6 +99,33 @@
|
||||
<span class="font-semibold text-blue-600">{{ row.total_count }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="diagnosis_count" label="诊单数" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip
|
||||
content="统计期内该医生挂号涉及的去重诊单数,用于计算成交率"
|
||||
placement="top"
|
||||
>
|
||||
<span class="text-gray-700">{{ row.diagnosis_count ?? 0 }}</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="deal_count" label="成交单" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip content="上述诊单中已开有效处方(未删除、未作废)的数量" placement="top">
|
||||
<el-tag type="primary" size="small">{{ row.deal_count ?? 0 }}</el-tag>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="deal_rate" label="成交率" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<span :class="getCompletionRateClass(Number(row.deal_rate ?? 0))">
|
||||
{{ row.deal_rate ?? 0 }}%
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="registered_count" label="已挂号" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
|
||||
@@ -331,7 +331,9 @@ const formData = reactive({
|
||||
dietary_taboo: [] as string[],
|
||||
usage_notes: '',
|
||||
doctor_name: '',
|
||||
is_shared: 0
|
||||
is_shared: 0,
|
||||
/** 预约开方直接生效,不走消费者处方「待审核」 */
|
||||
audit_status: 1
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
@@ -399,6 +401,7 @@ const resetForm = () => {
|
||||
formData.usage_notes = ''
|
||||
formData.doctor_name = ''
|
||||
formData.is_shared = 0
|
||||
formData.audit_status = 0
|
||||
}
|
||||
|
||||
// 打开抽屉
|
||||
|
||||
@@ -106,17 +106,15 @@
|
||||
:data="pager.lists"
|
||||
size="default"
|
||||
class="appointment-table"
|
||||
stripe
|
||||
:row-class-name="getRowClassName"
|
||||
>
|
||||
<el-table-column label="ID" prop="id" width="72" align="center" />
|
||||
|
||||
<el-table-column label="患者信息" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<div class="patient-cell">
|
||||
<div class="patient-avatar">
|
||||
<!-- <div class="patient-avatar">
|
||||
{{ (row.patient_name || '患').charAt(0) }}
|
||||
</div>
|
||||
</div> -->
|
||||
<div class="patient-info">
|
||||
<div class="patient-name">{{ row.patient_name }}</div>
|
||||
<div class="patient-phone">{{ row.patient_phone }}</div>
|
||||
@@ -139,9 +137,14 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
|
||||
<el-table-column label="助理" prop="assistant_name" width="100" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
{{ row.assistant_name || '—' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="预约类型" prop="appointment_type_desc" width="100" show-overflow-tooltip />
|
||||
|
||||
<!-- <el-table-column label="预约类型" prop="appointment_type_desc" width="100" show-overflow-tooltip /> -->
|
||||
|
||||
<el-table-column label="确认诊单" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
@@ -172,8 +175,7 @@
|
||||
|
||||
<el-table-column label="备注" prop="remark" min-width="120" show-overflow-tooltip />
|
||||
|
||||
|
||||
|
||||
|
||||
<el-table-column label="操作" width="380" fixed="right" align="left">
|
||||
<template #default="{ row }">
|
||||
<div class="action-btns">
|
||||
@@ -232,7 +234,7 @@
|
||||
size="small"
|
||||
@click="handlePrescription(row)"
|
||||
>
|
||||
开方
|
||||
{{ prescriptionActionLabel(row) }}
|
||||
</el-button>
|
||||
|
||||
<el-dropdown trigger="click" @command="(cmd) => handleAction(cmd, row)" placement="bottom-end">
|
||||
@@ -456,7 +458,7 @@
|
||||
</el-dialog>
|
||||
|
||||
<!-- 编辑患者弹窗 -->
|
||||
<edit-popup ref="editRef" @success="loadData" />
|
||||
<edit-popup ref="editRef" @success="() => loadData({ silent: true })" />
|
||||
|
||||
<!-- 中医处方单 -->
|
||||
<tcm-prescription ref="prescriptionRef" @success="onPrescriptionSuccess" />
|
||||
@@ -506,7 +508,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { usePaging } from '@/hooks/usePaging'
|
||||
import { defineAsyncComponent, onMounted, watch } from 'vue'
|
||||
import { defineAsyncComponent, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { appointmentLists, cancelAppointment, completeAppointment, appointmentDetail } from '@/api/doctor'
|
||||
import { getCallSignature, generateMiniProgramQrcode, tcmDiagnosisDetail, prescriptionGetByAppointment } from '@/api/tcm'
|
||||
import { getDictData } from '@/api/app'
|
||||
@@ -598,11 +600,6 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
// 待接诊行高亮
|
||||
const getRowClassName = ({ row }: { row: any }) => {
|
||||
return row.status === 1 ? 'row-pending' : ''
|
||||
}
|
||||
|
||||
// 切换选项卡
|
||||
const handleTabChange = (tabName: string | number) => {
|
||||
if (tabName === 'all') {
|
||||
@@ -613,11 +610,16 @@ const handleTabChange = (tabName: string | number) => {
|
||||
resetPage()
|
||||
}
|
||||
|
||||
/** 列表静默轮询间隔(毫秒) */
|
||||
const LIST_POLL_MS = 20_000
|
||||
let listPollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
// 列表 + Tab 角标:一次请求(后端 extend.status_count),翻页等仅 getLists 不重复统计
|
||||
const loadData = async () => {
|
||||
const loadData = async (opts?: { silent?: boolean }) => {
|
||||
const silent = opts?.silent === true
|
||||
formData.include_status_counts = 1
|
||||
try {
|
||||
await getLists()
|
||||
await getLists({ silent })
|
||||
const ext = pager.extend as { status_count?: Record<number | string, number> }
|
||||
const sc = ext?.status_count
|
||||
if (sc) {
|
||||
@@ -628,6 +630,11 @@ const loadData = async () => {
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
/* 定时静默刷新失败不向外抛,避免未处理的 rejection;手动刷新仍交给上层/拦截器 */
|
||||
if (!silent) {
|
||||
throw e
|
||||
}
|
||||
} finally {
|
||||
formData.include_status_counts = 0
|
||||
}
|
||||
@@ -929,7 +936,18 @@ const onPrescriptionSuccess = () => {
|
||||
editRef.value?.refreshCaseList?.()
|
||||
}
|
||||
|
||||
// 开处方
|
||||
/** 与本预约关联的处方:已通过且未作废 → 与消费者处方「查看」同为只读预览 */
|
||||
function prescriptionActionLabel(row: any) {
|
||||
if (
|
||||
row?.prescription_audit_status === 1 &&
|
||||
Number(row?.prescription_void_status) !== 1
|
||||
) {
|
||||
return '查看'
|
||||
}
|
||||
return '开方'
|
||||
}
|
||||
|
||||
// 开处方 / 查看已通过处方(只读)
|
||||
const handlePrescription = (row: any) => {
|
||||
if (!row.diagnosis_id && !row.patient_id) {
|
||||
feedback.msgWarning('该预约缺少诊单信息,请先编辑患者')
|
||||
@@ -972,6 +990,16 @@ formData.status = 1
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
listPollTimer = setInterval(() => {
|
||||
loadData({ silent: true })
|
||||
}, LIST_POLL_MS)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (listPollTimer !== null) {
|
||||
clearInterval(listPollTimer)
|
||||
listPollTimer = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1113,9 +1141,10 @@ onMounted(() => {
|
||||
|
||||
.action-btns {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 0 8px;
|
||||
row-gap: 6px;
|
||||
column-gap: 8px;
|
||||
|
||||
.action-qrcode {
|
||||
color: var(--el-color-warning) !important;
|
||||
@@ -1135,11 +1164,12 @@ onMounted(() => {
|
||||
:deep(.el-table__row) {
|
||||
cursor: default;
|
||||
}
|
||||
:deep(.el-table__row.row-pending) {
|
||||
background: linear-gradient(90deg, rgba(var(--el-color-success-rgb), 0.06) 0%, transparent 100%) !important;
|
||||
/* 数据行纯白底(不使用 stripe / 行高亮色) */
|
||||
:deep(.el-table__body tr > td) {
|
||||
background-color: #ffffff !important;
|
||||
}
|
||||
:deep(.el-table__row.row-pending:hover > td) {
|
||||
background: linear-gradient(90deg, rgba(var(--el-color-success-rgb), 0.1) 0%, transparent 100%) !important;
|
||||
:deep(.el-table__body tr:hover > td) {
|
||||
background-color: var(--el-fill-color-light) !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -188,6 +188,17 @@
|
||||
<el-radio :label="0">禁用</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="在用药物" prop="current_medications">
|
||||
<el-input
|
||||
v-model="formData.current_medications"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请填写患者当前正在使用的药物(如降压药、降糖药等),多种请换行或顿号分隔"
|
||||
maxlength="2000"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<!-- 主诉 -->
|
||||
@@ -549,6 +560,7 @@ const formData = ref({
|
||||
prescription: '',
|
||||
doctor_advice: '',
|
||||
remark: '',
|
||||
current_medications: '',
|
||||
status: 1
|
||||
})
|
||||
|
||||
@@ -578,7 +590,6 @@ const formRules = {
|
||||
phone: [{ required: true, validator: validatePhone, trigger: 'blur' }],
|
||||
gender: [{ required: true, message: '请选择性别', trigger: 'change' }],
|
||||
age: [{ required: true, message: '请输入年龄', trigger: 'blur' }],
|
||||
diagnosis_date: [{ required: true, message: '请选择诊断日期', trigger: 'change' }],
|
||||
diagnosis_type: [{ required: true, message: '请选择诊断类型', trigger: 'change' }],
|
||||
syndrome_type: [{ required: true, message: '请选择证型', trigger: 'change' }],
|
||||
diabetes_type: [{ required: true, message: '请选择糖尿病期数', trigger: 'change' }]
|
||||
|
||||
@@ -8,6 +8,14 @@
|
||||
:z-index="2000"
|
||||
>
|
||||
<div v-loading="loading">
|
||||
<el-alert
|
||||
v-if="todayHasBlockingAppointment"
|
||||
:type="isSelectingToday ? 'warning' : 'info'"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="today-block-alert"
|
||||
:title="todayBlockingAlertText"
|
||||
/>
|
||||
<el-form :model="form" label-width="100px" class="appointment-form">
|
||||
<!-- 上次就诊 -->
|
||||
<el-form-item label="上次就诊:">
|
||||
@@ -208,6 +216,8 @@ const lastVisit = ref('')
|
||||
const selectedDoctorId = ref(0)
|
||||
const doctorRosterDates = ref<string[]>([]) // 医生有排班的日期列表
|
||||
const channelOptions = ref<{ name: string; value: string }[]>([])
|
||||
/** 今日是否已有「已预约(1)/已过号(4)」挂号;仅在选择「今天」再预约时禁止提交,其他日期可正常挂 */
|
||||
const todayHasBlockingAppointment = ref(false)
|
||||
|
||||
const patientInfo = reactive({
|
||||
id: 0,
|
||||
@@ -286,9 +296,28 @@ const filteredTimeSlots = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
const todayYmd = () => dayjs().format('YYYY-MM-DD')
|
||||
|
||||
/** 当前选中的预约日是否为今天(与今日冲突检测联动) */
|
||||
const isSelectingToday = computed(() => form.date === todayYmd())
|
||||
|
||||
/** 今日已有占用号时:选今天给警告文案,选他日给提示文案 */
|
||||
const todayBlockingAlertText = computed(() => {
|
||||
if (!todayHasBlockingAppointment.value) return ''
|
||||
return isSelectingToday.value
|
||||
? '该患者今日已有挂号(已预约或已过号),不能重复预约今天,请改选其他日期。'
|
||||
: '该患者今日已有挂号记录;可为患者预约其他日期的时段。'
|
||||
})
|
||||
|
||||
/** 仅当「选今天 + 今日已有占用号」时禁止提交 */
|
||||
const blockedByTodayDuplicate = computed(
|
||||
() => todayHasBlockingAppointment.value && isSelectingToday.value
|
||||
)
|
||||
|
||||
// 是否可以提交
|
||||
const canSubmit = computed(() => {
|
||||
return (
|
||||
!blockedByTodayDuplicate.value &&
|
||||
selectedDoctorId.value &&
|
||||
form.date &&
|
||||
form.appointmentTime &&
|
||||
@@ -318,6 +347,7 @@ const open = async (patient: any) => {
|
||||
timeSlots.value = []
|
||||
doctorRosterDates.value = []
|
||||
lastVisit.value = ''
|
||||
todayHasBlockingAppointment.value = false
|
||||
|
||||
await loadChannelOptions()
|
||||
// 加载医生列表
|
||||
@@ -325,6 +355,34 @@ const open = async (patient: any) => {
|
||||
|
||||
// 查询该患者的最近一次就诊记录
|
||||
await loadLastVisit()
|
||||
await loadTodayAppointmentBlock()
|
||||
}
|
||||
|
||||
/** 今日是否已有状态为已预约(1)或已过号(4)的挂号(仅禁止再挂「今天」,不禁止挂其他天) */
|
||||
const loadTodayAppointmentBlock = async () => {
|
||||
todayHasBlockingAppointment.value = false
|
||||
const pid = patientInfo.id
|
||||
if (!pid) {
|
||||
return
|
||||
}
|
||||
const today = dayjs().format('YYYY-MM-DD')
|
||||
try {
|
||||
const res = await appointmentLists({
|
||||
patient_id: pid,
|
||||
start_date: today,
|
||||
end_date: today,
|
||||
page_no: 1,
|
||||
page_size: 50
|
||||
})
|
||||
const rows = res?.lists || []
|
||||
const blocking = rows.find((r: any) => {
|
||||
const s = Number(r.status)
|
||||
return s === 1 || s === 4
|
||||
})
|
||||
todayHasBlockingAppointment.value = !!blocking
|
||||
} catch (e) {
|
||||
console.error('检查今日挂号状态失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载患者最近一次就诊记录(从预约表查询已完成的预约)
|
||||
@@ -432,9 +490,14 @@ const loadDoctorRoster = async () => {
|
||||
await nextTick()
|
||||
if (doctorRosterDates.value.length > 0) {
|
||||
const today = dayjs().format('YYYY-MM-DD')
|
||||
const defaultDate = doctorRosterDates.value.includes(today)
|
||||
? today
|
||||
let defaultDate = doctorRosterDates.value.includes(today)
|
||||
? today
|
||||
: doctorRosterDates.value[0]
|
||||
// 今日已有占用号时,默认不要落在「今天」,避免一进来确定按钮灰掉
|
||||
if (todayHasBlockingAppointment.value && defaultDate === today) {
|
||||
const later = doctorRosterDates.value.find((d) => d > today)
|
||||
defaultDate = later ?? doctorRosterDates.value.find((d) => d !== today) ?? defaultDate
|
||||
}
|
||||
selectDate(defaultDate)
|
||||
}
|
||||
} else {
|
||||
@@ -549,6 +612,12 @@ const handleConfirm = async () => {
|
||||
feedback.msgWarning('请选择渠道来源')
|
||||
return
|
||||
}
|
||||
if (blockedByTodayDuplicate.value) {
|
||||
feedback.msgWarning(
|
||||
'该患者今日已有挂号(已预约或已过号),不能重复预约今天,请改选其他日期。'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
submitting.value = true
|
||||
@@ -584,6 +653,10 @@ defineExpose({ open })
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.today-block-alert {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.appointment-form {
|
||||
:deep(.el-form-item__label) {
|
||||
font-weight: 500;
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
<template>
|
||||
<div class="appointment-record-panel">
|
||||
<el-table v-loading="loading" :data="rows" border stripe empty-text="暂无挂号记录">
|
||||
<el-table-column label="ID" prop="id" width="72" align="center" />
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
:type="
|
||||
row.status === 1
|
||||
? 'success'
|
||||
: row.status === 2
|
||||
? 'info'
|
||||
: row.status === 3
|
||||
? 'primary'
|
||||
: 'danger'
|
||||
"
|
||||
size="small"
|
||||
effect="light"
|
||||
>
|
||||
{{ row.status_desc || '—' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="患者(挂号人)" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<div class="cell-stack">
|
||||
<span class="font-medium">{{ row.patient_name || '—' }}</span>
|
||||
<span class="text-gray-500 text-sm">{{ row.patient_phone || '' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="挂号医生" prop="doctor_name" width="110" show-overflow-tooltip />
|
||||
<el-table-column label="挂号助理" width="110" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
{{ row.assistant_name || '—' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="预约时间" min-width="130">
|
||||
<template #default="{ row }">
|
||||
<div class="cell-stack">
|
||||
<span>{{ row.appointment_date || '—' }}</span>
|
||||
<span class="text-gray-500 text-sm">{{ formatTime(row.appointment_time) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="时段" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ periodText(row.period) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="类型" width="100" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
{{ row.appointment_type_desc || '—' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="渠道" width="110" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
{{ channelLabel(row) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="确认诊单" width="92" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.diagnosis_confirmed" type="success" size="small" effect="plain">已确认</el-tag>
|
||||
<el-tag v-else type="warning" size="small" effect="plain">未确认</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="开方" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.has_prescription" type="success" size="small" effect="plain">已开方</el-tag>
|
||||
<el-tag v-else type="info" size="small" effect="plain">未开方</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="备注" prop="remark" min-width="100" show-overflow-tooltip />
|
||||
<el-table-column label="创建时间" width="165" prop="create_time" />
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { appointmentLists } from '@/api/doctor'
|
||||
import { getDictData } from '@/api/app'
|
||||
|
||||
const props = defineProps<{
|
||||
diagnosisId: number
|
||||
}>()
|
||||
|
||||
const loading = ref(false)
|
||||
const rows = ref<any[]>([])
|
||||
const channelMap = ref<Record<string, string>>({})
|
||||
|
||||
function formatTime(t: string | undefined) {
|
||||
if (!t) return ''
|
||||
return String(t).length >= 8 ? String(t).slice(0, 5) : t
|
||||
}
|
||||
|
||||
function periodText(p: string | undefined) {
|
||||
if (p === 'morning') return '上午'
|
||||
if (p === 'afternoon') return '下午'
|
||||
if (p === 'all') return '全天'
|
||||
return p || '—'
|
||||
}
|
||||
|
||||
function channelLabel(row: Record<string, any>) {
|
||||
const raw = row.channel_source ?? row.channels ?? ''
|
||||
if (raw === '' || raw === null || raw === undefined) return '—'
|
||||
const key = String(raw)
|
||||
return channelMap.value[key] || key
|
||||
}
|
||||
|
||||
function sortRows(list: any[]) {
|
||||
return [...list].sort((a, b) => {
|
||||
const da = String(a.appointment_date || '')
|
||||
const db = String(b.appointment_date || '')
|
||||
if (da !== db) return db.localeCompare(da)
|
||||
const ta = String(a.appointment_time || '')
|
||||
const tb = String(b.appointment_time || '')
|
||||
if (ta !== tb) return tb.localeCompare(ta)
|
||||
return Number(b.id || 0) - Number(a.id || 0)
|
||||
})
|
||||
}
|
||||
|
||||
const loadChannels = async () => {
|
||||
try {
|
||||
const data = await getDictData({ type: 'channels' })
|
||||
const opts = (data?.channels || []).filter((row: any) => row.status !== 0)
|
||||
const map: Record<string, string> = {}
|
||||
for (const row of opts) {
|
||||
if (row.value != null) map[String(row.value)] = row.name || String(row.value)
|
||||
}
|
||||
channelMap.value = map
|
||||
} catch {
|
||||
channelMap.value = {}
|
||||
}
|
||||
}
|
||||
|
||||
const load = async () => {
|
||||
if (!props.diagnosisId) return
|
||||
loading.value = true
|
||||
try {
|
||||
await loadChannels()
|
||||
const res = await appointmentLists({
|
||||
patient_id: props.diagnosisId,
|
||||
page_no: 1,
|
||||
page_size: 500
|
||||
})
|
||||
const list = res?.lists || []
|
||||
rows.value = sortRows(list)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
rows.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.diagnosisId,
|
||||
() => {
|
||||
load()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
defineExpose({ refresh: load })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cell-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div class="assign-log-panel">
|
||||
<el-table v-loading="loading" :data="rows" border stripe empty-text="暂无指派记录">
|
||||
<el-table-column label="操作时间" width="175" prop="create_time_text" />
|
||||
<el-table-column label="原医助" min-width="120" prop="from_assistant_name" />
|
||||
<el-table-column label="新医助" min-width="120" prop="to_assistant_name" />
|
||||
<el-table-column label="操作人" width="110" prop="operator_name" />
|
||||
<el-table-column label="操作账号" width="120" prop="operator_account" show-overflow-tooltip />
|
||||
<el-table-column label="IP" width="130" prop="ip" show-overflow-tooltip />
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { tcmDiagnosisAssignLogList } from '@/api/tcm'
|
||||
|
||||
const props = defineProps<{
|
||||
diagnosisId: number
|
||||
}>()
|
||||
|
||||
const loading = ref(false)
|
||||
const rows = ref<any[]>([])
|
||||
|
||||
const load = async () => {
|
||||
if (!props.diagnosisId) return
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await tcmDiagnosisAssignLogList({ id: props.diagnosisId })
|
||||
rows.value = Array.isArray(data) ? data : []
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
rows.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.diagnosisId,
|
||||
() => {
|
||||
load()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
defineExpose({ refresh: load })
|
||||
</script>
|
||||
@@ -72,6 +72,8 @@ import { ref, watch, computed } from 'vue'
|
||||
import dayjs from 'dayjs'
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import { getImChatMessages } from '@/api/tcm'
|
||||
import type { FriendlyParse } from '@/utils/im-business-message-parse'
|
||||
import { parseImBusinessPayload } from '@/utils/im-business-message-parse'
|
||||
|
||||
const props = defineProps<{
|
||||
diagnosisId: number
|
||||
@@ -90,109 +92,6 @@ function formatTime(ts: number | undefined) {
|
||||
return dayjs.unix(sec).format('YYYY-MM-DD HH:mm:ss')
|
||||
}
|
||||
|
||||
type FriendlyParse = { main: string; sub?: string; tag?: string }
|
||||
|
||||
/** 业务时间:支持秒级时间戳或毫秒(字符串数字) */
|
||||
function formatBizTime(t: unknown): string | undefined {
|
||||
if (t == null || t === '') return undefined
|
||||
const n =
|
||||
typeof t === 'string' && /^\d+$/.test(t.trim())
|
||||
? parseInt(t.trim(), 10)
|
||||
: Number(t)
|
||||
if (!Number.isFinite(n) || n <= 0) return undefined
|
||||
return n > 1e12
|
||||
? dayjs(n).format('YYYY-MM-DD HH:mm:ss')
|
||||
: dayjs.unix(n).format('YYYY-MM-DD HH:mm:ss')
|
||||
}
|
||||
|
||||
/** 将 IM 自定义 / 信令 JSON 解析为可读文案(医生进诊室、音视频通话等) */
|
||||
function parseImBusinessPayload(raw: string): FriendlyParse | null {
|
||||
const s = raw.trim()
|
||||
if (!s.startsWith('{')) return null
|
||||
let o: any
|
||||
try {
|
||||
o = JSON.parse(s)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!('businessID' in o) && !('cmd' in o)) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (o.businessID === 'doctor_entered_consult_room') {
|
||||
const t = formatBizTime(o.time)
|
||||
return { main: '医生已进入诊室', sub: t, tag: '诊室' }
|
||||
}
|
||||
|
||||
/** 小程序端:患者打开与医生的会话时发送(与 TUIKit/chat.vue 一致) */
|
||||
if (o.businessID === 'patient_opened_chat') {
|
||||
const parts: string[] = []
|
||||
if (o.patientName) parts.push(`患者:${o.patientName}`)
|
||||
if (o.patientId != null && o.patientId !== '') parts.push(`患者 ID:${o.patientId}`)
|
||||
if (o.doctorId != null && o.doctorId !== '') parts.push(`关联医生:${o.doctorId}`)
|
||||
const t = formatBizTime(o.time)
|
||||
if (t) parts.push(t)
|
||||
return { main: '患者进入聊天', sub: parts.length ? parts.join(' · ') : undefined, tag: '诊室' }
|
||||
}
|
||||
|
||||
if (o.businessID === 'user_typing_status') {
|
||||
return { main: '对方正在输入…', tag: '状态' }
|
||||
}
|
||||
|
||||
if (o.businessID === 'consultation_complete') {
|
||||
return { main: '问诊已完成', sub: formatBizTime(o.time), tag: '诊室' }
|
||||
}
|
||||
|
||||
if (o.businessID === 1 || o.businessID === '1') {
|
||||
let inner: any = o.data
|
||||
if (typeof inner === 'string') {
|
||||
try {
|
||||
inner = JSON.parse(inner)
|
||||
} catch {
|
||||
return { main: '通话信令', sub: '内层数据解析失败', tag: '通话' }
|
||||
}
|
||||
}
|
||||
if (inner && typeof inner === 'object') {
|
||||
return parseRtcInner(inner)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
if (o.businessID === 'rtc_call' || o.cmd) {
|
||||
return parseRtcInner(o)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function parseRtcInner(inner: any): FriendlyParse {
|
||||
const callType = inner.call_type
|
||||
const callTypeLabel =
|
||||
callType === 2 ? '视频通话' : callType === 1 ? '语音通话' : '通话'
|
||||
const cmd = String(inner.cmd || '')
|
||||
const cmdMap: Record<string, string> = {
|
||||
hangup: '已结束',
|
||||
invite: '发起通话',
|
||||
linebusy: '对方忙线',
|
||||
cancel: '已取消',
|
||||
reject: '已拒绝',
|
||||
accept: '已接听',
|
||||
timeout: '无人接听'
|
||||
}
|
||||
const action = cmdMap[cmd] || (cmd ? `状态:${cmd}` : '')
|
||||
const main = action ? `${callTypeLabel} · ${action}` : callTypeLabel
|
||||
const parts: string[] = []
|
||||
if (inner.inviter) parts.push(`发起方 ${inner.inviter}`)
|
||||
if (inner.invitee) parts.push(`对方 ${inner.invitee}`)
|
||||
if (inner.groupID) parts.push(`群组 ${inner.groupID}`)
|
||||
return {
|
||||
main,
|
||||
sub: parts.length ? parts.join(',') : undefined,
|
||||
tag: '通话'
|
||||
}
|
||||
}
|
||||
|
||||
function parseImFriendly(row: any): FriendlyParse | null {
|
||||
const raw = (row.text || '').trim()
|
||||
if (!raw) return null
|
||||
|
||||
@@ -100,6 +100,9 @@
|
||||
<el-descriptions-item label="治则" :span="2">
|
||||
{{ detail.treatment_principle || '无' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="在用药物" :span="2">
|
||||
<div style="white-space: pre-wrap">{{ detail.current_medications || '无' }}</div>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="处方" :span="2">
|
||||
<div style="white-space: pre-wrap">{{ detail.prescription || '无' }}</div>
|
||||
</el-descriptions-item>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<el-drawer
|
||||
v-model="visible"
|
||||
:title="drawerTitle"
|
||||
size="70%"
|
||||
size="60%"
|
||||
:before-close="handleClose"
|
||||
:z-index="1500"
|
||||
:modal="true"
|
||||
@@ -198,7 +198,6 @@
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20">
|
||||
|
||||
<el-col :span="12">
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-radio-group v-model="formData.status">
|
||||
@@ -207,6 +206,20 @@
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="在用药物" prop="current_medications">
|
||||
<el-input
|
||||
v-model="formData.current_medications"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请填写患者当前正在使用的药物(如降压药、降糖药等),多种请换行或顿号分隔"
|
||||
maxlength="2000"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-divider content-position="left">主诉</el-divider>
|
||||
|
||||
@@ -475,51 +488,7 @@
|
||||
</el-form-item>
|
||||
|
||||
|
||||
<!-- <el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="舌苔" prop="tongue_coating">
|
||||
<el-input
|
||||
v-model="formData.tongue_coating"
|
||||
placeholder="请输入舌苔情况"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="脉象" prop="pulse">
|
||||
<el-input
|
||||
v-model="formData.pulse"
|
||||
placeholder="请输入脉象"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item label="治则" prop="treatment_principle">
|
||||
<el-input
|
||||
v-model="formData.treatment_principle"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请输入治则"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="处方" prop="prescription">
|
||||
<el-input
|
||||
v-model="formData.prescription"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请输入处方"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="医嘱" prop="doctor_advice">
|
||||
<el-input
|
||||
v-model="formData.doctor_advice"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请输入医嘱"
|
||||
/>
|
||||
</el-form-item> -->
|
||||
|
||||
|
||||
<el-form-item label="病史补充" prop="remark">
|
||||
<el-input
|
||||
@@ -616,6 +585,35 @@
|
||||
/>
|
||||
<el-empty v-else description="请先保存诊单后再查看聊天记录" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane
|
||||
v-if="
|
||||
hasPermission(['tcm.diagnosis/assign']) ||
|
||||
hasPermission(['tcm.diagnosis/detail'])
|
||||
"
|
||||
label="指派医助记录"
|
||||
name="assignLog"
|
||||
:disabled="!formData.id"
|
||||
lazy
|
||||
>
|
||||
<assign-log-panel
|
||||
v-if="formData.id"
|
||||
:diagnosis-id="Number(formData.id)"
|
||||
/>
|
||||
<el-empty v-else description="请先保存诊单后再查看指派记录" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane
|
||||
v-if="hasPermission(['doctor.appointment/lists'])"
|
||||
label="挂号记录"
|
||||
name="appointmentRecords"
|
||||
:disabled="!formData.id"
|
||||
lazy
|
||||
>
|
||||
<appointment-record-panel
|
||||
v-if="formData.id"
|
||||
:diagnosis-id="Number(formData.id)"
|
||||
/>
|
||||
<el-empty v-else description="请先保存诊单后再查看挂号记录" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<!-- 处方详情(查看病历) -->
|
||||
@@ -652,7 +650,13 @@ 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 AssignLogPanel from './components/AssignLogPanel.vue'
|
||||
import AppointmentRecordPanel from './components/AppointmentRecordPanel.vue'
|
||||
import TcmPrescription from '@/components/tcm-prescription/index.vue'
|
||||
import {
|
||||
DIAGNOSIS_TONGUE_IMAGES_UPDATED,
|
||||
type DiagnosisTongueImagesUpdatedDetail
|
||||
} from '@/utils/diagnosis-sync-events'
|
||||
|
||||
const emit = defineEmits(['success'])
|
||||
const appStore = useAppStore()
|
||||
@@ -676,6 +680,32 @@ watch([visible, activeTab], () => {
|
||||
if (activeTab.value === 'visit' && !hasPermission(['tcm.diagnosis/huifang'])) {
|
||||
activeTab.value = 'basic'
|
||||
}
|
||||
if (
|
||||
activeTab.value === 'assignLog' &&
|
||||
!hasPermission(['tcm.diagnosis/assign']) &&
|
||||
!hasPermission(['tcm.diagnosis/detail'])
|
||||
) {
|
||||
activeTab.value = 'basic'
|
||||
}
|
||||
if (activeTab.value === 'appointmentRecords' && !hasPermission(['doctor.appointment/lists'])) {
|
||||
activeTab.value = 'basic'
|
||||
}
|
||||
})
|
||||
|
||||
function onTongueImagesSyncedFromCall(e: Event) {
|
||||
if (!visible.value) return
|
||||
const d = (e as CustomEvent<DiagnosisTongueImagesUpdatedDetail>).detail
|
||||
if (!d?.diagnosisId || !Array.isArray(d.tongue_images)) return
|
||||
if (Number(formData.value.id) !== Number(d.diagnosisId)) return
|
||||
formData.value.tongue_images = [...d.tongue_images]
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener(DIAGNOSIS_TONGUE_IMAGES_UPDATED, onTongueImagesSyncedFromCall as EventListener)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener(DIAGNOSIS_TONGUE_IMAGES_UPDATED, onTongueImagesSyncedFromCall as EventListener)
|
||||
})
|
||||
|
||||
const formData = ref({
|
||||
@@ -720,7 +750,7 @@ const formData = ref({
|
||||
allergy_history: 0,
|
||||
family_history: 0,
|
||||
pregnancy_history: 0,
|
||||
tongue_images: [],
|
||||
tongue_images: [] as string[],
|
||||
report_files: [],
|
||||
symptoms: '',
|
||||
tongue_coating: '',
|
||||
@@ -729,6 +759,7 @@ const formData = ref({
|
||||
prescription: '',
|
||||
doctor_advice: '',
|
||||
remark: '',
|
||||
current_medications: '',
|
||||
status: 1,
|
||||
external_userid: ''
|
||||
})
|
||||
@@ -761,11 +792,7 @@ const formRules = {
|
||||
gender: [{ required: true, message: '请选择性别', trigger: 'change' }],
|
||||
age: [{ required: true, message: '请输入年龄', trigger: 'blur' }],
|
||||
fasting_blood_sugar: [{ required: true, message: '请输入空腹血糖', trigger: 'blur' }],
|
||||
diagnosis_date: [{ required: true, message: '请选择诊断日期', trigger: 'change' }],
|
||||
diagnosis_type: [{ required: true, message: '请选择诊断类型', trigger: 'change' }],
|
||||
diabetes_discovery_year: [{ required: true, message: '请输入发现糖尿病病患病史年数', trigger: 'blur' }],
|
||||
local_hospital_diagnosis: [{ required: true, type: 'array', min: 1, message: '请选择当地医院诊断结果', trigger: 'change' }],
|
||||
local_hospital_name: [{ required: true, message: '请输入当地就诊医院名称', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
// 获取字典选项
|
||||
@@ -1015,7 +1042,7 @@ const handleClose = () => {
|
||||
allergy_history: 0,
|
||||
family_history: 0,
|
||||
pregnancy_history: 0,
|
||||
tongue_images: [],
|
||||
tongue_images: [] as string[],
|
||||
report_files: [],
|
||||
symptoms: '',
|
||||
tongue_coating: '',
|
||||
@@ -1024,6 +1051,7 @@ const handleClose = () => {
|
||||
prescription: '',
|
||||
doctor_advice: '',
|
||||
remark: '',
|
||||
current_medications: '',
|
||||
status: 1,
|
||||
external_userid: ''
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
<el-table-column label="患者" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<div class="patient-cell">
|
||||
<div class="patient-avatar">{{ (row.patient_name || '患').charAt(0) }}</div>
|
||||
<!-- <div class="patient-avatar">{{ (row.patient_name || '患').charAt(0) }}</div> -->
|
||||
<div class="patient-info">
|
||||
<div class="patient-name">{{ row.patient_name }}</div>
|
||||
<div class="patient-meta">{{ row.id }} · {{ row.gender_desc || '-' }} · {{ row.age ?? '-' }}岁</div>
|
||||
@@ -99,9 +99,12 @@
|
||||
</el-table-column>
|
||||
<el-table-column label="挂号" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<div class="appointment-cell" :class="{ 'has-apt': row.has_appointment }">
|
||||
<div
|
||||
class="appointment-cell"
|
||||
:class="appointmentCellClasses(row)"
|
||||
>
|
||||
<template v-if="row.has_appointment">
|
||||
<div class="apt-badge">已挂号</div>
|
||||
<div class="apt-badge">{{ appointmentStatusLabel(row) }}</div>
|
||||
<div class="apt-doctor">{{ row.appointment_doctor_name || '-' }}</div>
|
||||
<div class="apt-time">{{ row.appointment_time_text || '-' }}</div>
|
||||
</template>
|
||||
@@ -183,9 +186,9 @@
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item v-if="hasPermission(['tcm.diagnosis/assign'])" command="assign"><el-icon><User /></el-icon>指派</el-dropdown-item>
|
||||
<el-dropdown-item v-if="row.has_appointment && hasPermission(['tcm.diagnosis/videoQr'])" command="videoQr"><el-icon><Picture /></el-icon>视频二维码</el-dropdown-item>
|
||||
<el-dropdown-item v-if="hasPermission(['tcm.diagnosis/guahao']) && row.has_appointment" command="confirmQr"><el-icon><Picture /></el-icon>二维码</el-dropdown-item>
|
||||
<el-dropdown-item v-if="row.has_appointment && hasPermission(['tcm.diagnosis/guahao'])" command="cancelApt"><el-icon><CircleClose /></el-icon>取消挂号</el-dropdown-item>
|
||||
<el-dropdown-item v-if="isAppointmentActiveForVideo(row) && hasPermission(['tcm.diagnosis/videoQr'])" command="videoQr"><el-icon><Picture /></el-icon>视频二维码</el-dropdown-item>
|
||||
<el-dropdown-item v-if="hasPermission(['tcm.diagnosis/guahao']) && isAppointmentActiveForVideo(row)" command="confirmQr"><el-icon><Picture /></el-icon>二维码</el-dropdown-item>
|
||||
<el-dropdown-item v-if="canCancelAppointmentRow(row) && hasPermission(['tcm.diagnosis/guahao'])" command="cancelApt"><el-icon><CircleClose /></el-icon>取消挂号</el-dropdown-item>
|
||||
<el-dropdown-item command="order" v-if="hasPermission(['tcm.diagnosis/order'])"><el-icon><Document /></el-icon>创建订单</el-dropdown-item>
|
||||
<el-dropdown-item v-if="hasPermission(['tcm.diagnosis/delete'])" command="delete" divided><el-icon><Delete /></el-icon><span class="text-danger">删除</span></el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
@@ -201,9 +204,9 @@
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<edit-popup ref="editRef" @success="getLists" />
|
||||
<edit-popup ref="editRef" @success="refreshListAfterPopupSave" />
|
||||
<detail-popup ref="detailRef" />
|
||||
<appointment-popup ref="appointmentRef" @success="getLists" />
|
||||
<appointment-popup ref="appointmentRef" @success="refreshListAfterPopupSave" />
|
||||
<assistant-watch-call-dialog
|
||||
v-model="watchCallVisible"
|
||||
:diagnosis-id="watchCallDiagnosisId"
|
||||
@@ -698,6 +701,8 @@ const { pager, getLists, resetParams, resetPage } = usePaging({
|
||||
params: formData
|
||||
})
|
||||
|
||||
/** 弹窗内保存后刷新列表:静默请求,避免 el-table v-loading 白蒙层盖住仍打开的抽屉 */
|
||||
const refreshListAfterPopupSave = () => getLists({ silent: true })
|
||||
|
||||
// 重置
|
||||
const handleReset = () => {
|
||||
@@ -995,8 +1000,8 @@ const handleRowAction = (cmd: string, row: any) => {
|
||||
switch (cmd) {
|
||||
case 'assign': handleSingleAssign(row); break
|
||||
case 'videoQr':
|
||||
if (!row.has_appointment) {
|
||||
feedback.msgWarning('未挂号患者无法生成视频二维码,请先预约医生')
|
||||
if (!isAppointmentActiveForVideo(row)) {
|
||||
feedback.msgWarning('仅「已预约」状态可生成视频二维码')
|
||||
return
|
||||
}
|
||||
handleVideoQRCode(row)
|
||||
@@ -1008,10 +1013,42 @@ const handleRowAction = (cmd: string, row: any) => {
|
||||
}
|
||||
}
|
||||
|
||||
/** 挂号状态:1=已预约 3=已完成 4=已过号(与后端一致) */
|
||||
const appointmentStatusLabel = (row: any) => {
|
||||
const s = Number(row.appointment_status)
|
||||
if (s === 3) return '已完成'
|
||||
if (s === 4) return '已过号'
|
||||
return '已挂号'
|
||||
}
|
||||
|
||||
const appointmentCellClasses = (row: any) => {
|
||||
const s = Number(row.appointment_status)
|
||||
return {
|
||||
'has-apt': row.has_appointment,
|
||||
'apt-row-done': row.has_appointment && s === 3,
|
||||
'apt-row-missed': row.has_appointment && s === 4
|
||||
}
|
||||
}
|
||||
|
||||
/** 仅已预约(1)可进视频/小程序码 */
|
||||
const isAppointmentActiveForVideo = (row: any) =>
|
||||
row.has_appointment && Number(row.appointment_status) === 1
|
||||
|
||||
/** 已预约、已过号可取消(后端同步限制) */
|
||||
const canCancelAppointmentRow = (row: any) => {
|
||||
const s = Number(row.appointment_status)
|
||||
return !!(row.has_appointment && row.appointment_id && (s === 1 || s === 4))
|
||||
}
|
||||
|
||||
// 取消挂号
|
||||
const handleCancelAppointment = async (row: any) => {
|
||||
if (!row.has_appointment || !row.appointment_id) {
|
||||
feedback.msgWarning('该诊单未挂号或挂号信息异常')
|
||||
if (!canCancelAppointmentRow(row)) {
|
||||
const s = Number(row.appointment_status)
|
||||
if (row.has_appointment && s === 3) {
|
||||
feedback.msgWarning('已完成就诊,无法取消挂号')
|
||||
} else {
|
||||
feedback.msgWarning('该诊单未挂号或挂号信息异常')
|
||||
}
|
||||
return
|
||||
}
|
||||
try {
|
||||
@@ -1081,10 +1118,10 @@ const submitFillIdCard = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 生成视频二维码(跳转登录页)- 仅已挂号患者可生成
|
||||
// 生成视频二维码(跳转登录页)- 仅已预约(1)可生成
|
||||
const handleVideoQRCode = async (row: any) => {
|
||||
if (!row.has_appointment) {
|
||||
feedback.msgWarning('未挂号患者无法生成视频二维码,请先预约医生')
|
||||
if (!isAppointmentActiveForVideo(row)) {
|
||||
feedback.msgWarning('仅「已预约」状态可生成视频二维码')
|
||||
return
|
||||
}
|
||||
if (!row.patient_id) {
|
||||
@@ -1125,6 +1162,10 @@ const handleVideoQRCode = async (row: any) => {
|
||||
|
||||
// 生成确认诊单二维码
|
||||
const handleMiniProgramQRCode = async (row: any) => {
|
||||
if (!isAppointmentActiveForVideo(row)) {
|
||||
feedback.msgWarning('仅「已预约」状态可使用诊单二维码')
|
||||
return
|
||||
}
|
||||
if (!row.patient_id) {
|
||||
feedback.msgWarning('患者信息不完整')
|
||||
return
|
||||
@@ -1532,6 +1573,34 @@ onUnmounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
&.apt-row-missed {
|
||||
background: linear-gradient(135deg, rgba(var(--el-color-warning-rgb), 0.12), rgba(var(--el-color-warning-rgb), 0.05));
|
||||
border-color: rgba(var(--el-color-warning-rgb), 0.35);
|
||||
|
||||
.apt-badge {
|
||||
color: var(--el-color-warning-dark-2);
|
||||
background: var(--el-color-warning-light-7);
|
||||
}
|
||||
|
||||
.apt-time {
|
||||
color: var(--el-color-warning);
|
||||
}
|
||||
}
|
||||
|
||||
&.apt-row-done {
|
||||
background: linear-gradient(135deg, rgba(var(--el-color-info-rgb), 0.1), rgba(var(--el-color-info-rgb), 0.04));
|
||||
border-color: rgba(var(--el-color-info-rgb), 0.28);
|
||||
|
||||
.apt-badge {
|
||||
color: var(--el-color-info-dark-2);
|
||||
background: var(--el-color-info-light-7);
|
||||
}
|
||||
|
||||
.apt-time {
|
||||
color: var(--el-color-info);
|
||||
}
|
||||
}
|
||||
|
||||
.apt-none {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user