This commit is contained in:
Your Name
2026-03-23 18:02:16 +08:00
parent 18fee2e8f8
commit 250d173c2f
525 changed files with 10659 additions and 793 deletions
+604 -10
View File
@@ -67,9 +67,31 @@
@mousedown="onCallKitDragStart"
>
<span class="drag-handle-text">视频通话</span>
<el-icon class="drag-handle-icon"><Rank /></el-icon>
<div class="call-kit-drag-handle-actions">
<el-button
type="primary"
link
class="call-kit-screenshot-btn"
:loading="captureUploading"
@mousedown.stop
@click.stop="handleCallKitScreenshot"
>
<el-icon><Camera /></el-icon>
<span class="call-kit-screenshot-text">截屏</span>
</el-button>
<el-button
type="danger"
link
class="call-kit-close-btn"
@mousedown.stop
@click.stop="handleCallKitClose"
>
<el-icon><Close /></el-icon>
</el-button>
<el-icon class="drag-handle-icon"><Rank /></el-icon>
</div>
</div>
<div class="call-kit-content">
<div ref="callKitContentRef" class="call-kit-content">
<TUICallKit
:allowedMinimized="true"
:allowedFullScreen="true"
@@ -82,10 +104,23 @@
</template>
<script setup lang="ts">
import { ref, nextTick, watch, computed } from 'vue'
import { Loading, Close, Rank } from '@element-plus/icons-vue'
import { getCallSignature } from '@/api/tcm'
import { ref, nextTick, watch, computed, onMounted, onUnmounted } from 'vue'
import { Loading, Close, Rank, Camera } from '@element-plus/icons-vue'
import { uploadImageBlob } from '@/api/file'
import {
bindCallRoom,
getCallSignature,
startCall,
tcmDiagnosisDetail,
tcmDiagnosisEdit
} from '@/api/tcm'
import { captureVideoFrameFromElement } from '@/utils/call-video-screenshot'
import feedback from '@/utils/feedback'
import {
formatTUICallUserError,
getTUICallPackageArrearsMessage,
isTUICallPackageAbilityError
} from '@/utils/tuicall-error'
import useUserStore from '@/stores/modules/user'
import {
ConversationList,
@@ -103,7 +138,15 @@ import {
} from '@tencentcloud/chat-uikit-vue3'
import TencentCloudChat from '@tencentcloud/chat'
import '@/assets/chat-uikit.css'
import { TUICallKit, TUICallKitServer, TUICallType } from '@tencentcloud/call-uikit-vue'
import {
TUICallKit,
TUICallKitServer,
TUICallType,
TUIStore,
StoreName,
NAME
} from '@tencentcloud/call-uikit-vue'
import { TUICallEvent } from '@tencentcloud/call-engine-js'
const assistant_ids=ref('');
const visible = ref(false)
const isReady = ref(false)
@@ -120,6 +163,445 @@ const { login, logout } = useLoginState()
const { setActiveConversation, createC2CConversation, activeConversation } = useConversationListState()
const userStore = useUserStore()
/** TUICallKit CallStatus(与 @tencentcloud/call-uikit-vue 一致) */
const CALL_STATUS_IDLE = 'idle'
const CALL_STATUS_CALLING = 'calling'
const CALL_STATUS_CONNECTED = 'connected'
/** 通话绑定 TRTC 房间号到诊单(云端录制回调按 room_id 关联) */
const lastBoundRoomKey = ref('')
let tearDownCallRoomBinding: (() => void) | null = null
let bindRoomRetryTimer: ReturnType<typeof setTimeout> | null = null
let bindRoomRetryAttempts = 0
const BIND_ROOM_MAX_ATTEMPTS = 40
const BIND_ROOM_RETRY_MS = 100
/** 从 TUIStore 读取房间号;默认 0 表示尚未分配 */
function readRoomIdFromStore(): string {
const raw = TUIStore.getData(StoreName.CALL, NAME.ROOM_ID)
if (raw === null || raw === undefined) return ''
if (typeof raw === 'number') {
if (raw === 0) return ''
return String(raw)
}
const s = String(raw).trim()
if (!s || s === '0') return ''
return s
}
/** 引擎实例上可能存在房间号(不同版本字段名不一,仅作兜底) */
function readRoomIdFromEngineFallback(): string {
try {
const eng = TUICallKitServer.getTUICallEngineInstance?.() as Record<string, unknown> | null
if (!eng) return ''
const candidates = [eng.roomId, eng.roomID, eng._roomId, eng._roomID, eng.strRoomID]
for (const c of candidates) {
if (c === null || c === undefined || c === '') continue
const s = String(c).trim()
if (s && s !== '0') return s
}
} catch {
//
}
return ''
}
function readAnyRoomId(): string {
return readRoomIdFromStore() || readRoomIdFromEngineFallback()
}
/** 从引擎 calls/call/groupCall 的 Promise 返回值里解析房间号(含 data 嵌套) */
function normalizeRoomPair(roomID: unknown, strRoomID: unknown): string {
if (strRoomID != null && String(strRoomID).trim() !== '' && String(strRoomID) !== '0') {
return String(strRoomID).trim()
}
if (typeof roomID === 'number' && roomID > 0) return String(roomID)
if (typeof roomID === 'string') {
const t = roomID.trim()
if (t && t !== '0') return t
}
return ''
}
function extractRoomFromEngineResult(ret: unknown): string {
if (ret == null || typeof ret !== 'object') return ''
const r = ret as Record<string, unknown>
const direct =
normalizeRoomPair(r.roomID, r.strRoomID) ||
normalizeRoomPair(r.roomId, r.strRoomId)
if (direct) return direct
const d = r.data
if (d && typeof d === 'object') {
const dd = d as Record<string, unknown>
const nested =
normalizeRoomPair(dd.roomID, dd.strRoomID) ||
normalizeRoomPair(dd.roomId, dd.strRoomId)
if (nested) return nested
}
return ''
}
function clearBindRoomRetry() {
if (bindRoomRetryTimer) {
clearTimeout(bindRoomRetryTimer)
bindRoomRetryTimer = null
}
bindRoomRetryAttempts = 0
}
/** 在 calling / connected 阶段轮询房间号(ROOM_ID 可能比 status 晚一拍) */
function scheduleBindRoomRetry() {
clearBindRoomRetry()
const tick = () => {
bindRoomRetryTimer = null
const status = TUIStore.getData(StoreName.CALL, NAME.CALL_STATUS)
if (status === CALL_STATUS_IDLE) return
if (status !== CALL_STATUS_CALLING && status !== CALL_STATUS_CONNECTED) return
const rid = readAnyRoomId()
if (rid) {
void doBindCallRoom(rid)
return
}
bindRoomRetryAttempts++
if (bindRoomRetryAttempts >= BIND_ROOM_MAX_ATTEMPTS) {
console.warn(
'[chat-dialog] 仍未解析到有效房间号(已重试',
BIND_ROOM_MAX_ATTEMPTS,
'次)'
)
return
}
bindRoomRetryTimer = setTimeout(tick, BIND_ROOM_RETRY_MS)
}
bindRoomRetryTimer = setTimeout(tick, 0)
}
/**
* 在 TUICallKitServer.call / calls / groupCall 执行完毕后,引擎已返回 roomId,
* _updateCallStoreAfterCall 会写入 TUIStore;工具栏多走 call() 而非 calls(),必须 hook 这三者。
*/
async function flushAndBindRoomAfterTUICallApi(apiName: string) {
if (patientId.value == null || diagnosisId.value == null) return
await nextTick()
for (let i = 0; i < 40; i++) {
const rid = readAnyRoomId()
if (rid) {
await doBindCallRoom(rid)
return
}
await new Promise((r) => setTimeout(r, 50))
}
console.warn('[chat-dialog] 发起通话后仍未解析到房间号(API:', apiName, ')')
}
/**
* 在 TUICallEngine 上包装 calls/call/groupCall:房间号往往在 Promise 返回值里,
* 仅靠 TUIStore 轮询可能拿不到或晚一拍。
*/
function installEngineRoomCapture() {
const w = window as unknown as Record<string, unknown>
if (w.__zytEngineRoomCapture__) return
const eng = TUICallKitServer.getTUICallEngineInstance?.() as
| (Record<string, unknown> & { __zytEngineRoomCapture__?: boolean })
| null
| undefined
if (!eng || typeof eng.calls !== 'function') return
w.__zytEngineRoomCapture__ = true
eng.__zytEngineRoomCapture__ = true
const wrap = (methodName: 'calls' | 'call' | 'groupCall') => {
const original = eng[methodName]
if (typeof original !== 'function') return
eng[methodName] = async function patched(this: unknown, ...args: unknown[]) {
const ret = await (original as (...a: unknown[]) => Promise<unknown>).apply(eng, args)
const fromRet = extractRoomFromEngineResult(ret)
if (fromRet) {
void doBindCallRoom(fromRet)
} else if (import.meta.env.DEV) {
console.debug('[chat-dialog] engine.' + methodName + ' 返回未含 roomID,将回退读 Store', ret)
}
await flushAndBindRoomAfterTUICallApi('engine.' + methodName)
return ret
}
}
wrap('calls')
wrap('call')
wrap('groupCall')
}
let engineRoomEventHandlers: Array<{ event: TUICallEvent; fn: (e: unknown) => void }> = []
function bindEngineRoomEvents() {
try {
type EngineWithEvents = Record<string, unknown> & {
__zytRoomEvents?: boolean
on?: (event: TUICallEvent | string, fn: (e: unknown) => void) => void
}
const eng = TUICallKitServer.getTUICallEngineInstance?.() as EngineWithEvents | null | undefined
const subscribe = eng?.on
if (!eng || typeof subscribe !== 'function' || eng.__zytRoomEvents) return
eng.__zytRoomEvents = true
const onRoomHint = (ev: unknown) => {
const fromEv = extractRoomFromEngineResult(ev)
if (fromEv) {
void doBindCallRoom(fromEv)
return
}
void tryBindRoomIfReady()
}
const events = [TUICallEvent.USER_ENTER, TUICallEvent.ON_CALL_BEGIN] as const
for (const event of events) {
subscribe.call(eng, event, onRoomHint)
engineRoomEventHandlers.push({ event, fn: onRoomHint })
}
} catch (e) {
console.warn('[chat-dialog] 绑定引擎房间事件失败', e)
}
}
function unbindEngineRoomEvents() {
try {
const eng = TUICallKitServer.getTUICallEngineInstance?.() as
| ({ off?: (e: TUICallEvent, fn: (ev: unknown) => void) => void } & {
__zytRoomEvents?: boolean
})
| null
| undefined
if (!eng?.off) {
engineRoomEventHandlers = []
return
}
for (const { event, fn } of engineRoomEventHandlers) {
eng.off(event, fn)
}
eng.__zytRoomEvents = false
} catch {
//
}
engineRoomEventHandlers = []
}
function installTUICallKitRoomHooks() {
const w = window as unknown as Record<string, unknown>
if (w.__zytTUICallRoomHooks__) return
w.__zytTUICallRoomHooks__ = true
const server = TUICallKitServer as unknown as Record<string, (...args: unknown[]) => Promise<unknown>>
const wrap = (methodName: 'calls' | 'call' | 'groupCall') => {
const original = server[methodName]
if (typeof original !== 'function') return
server[methodName] = async function patched(...args: unknown[]) {
const result = await original.apply(server, args)
await flushAndBindRoomAfterTUICallApi(methodName)
return result
}
}
wrap('calls')
wrap('call')
wrap('groupCall')
}
async function doBindCallRoom(rid: string) {
if (patientId.value == null || diagnosisId.value == null) return
const key = `${diagnosisId.value}:${rid}`
if (key === lastBoundRoomKey.value) {
clearBindRoomRetry()
return
}
lastBoundRoomKey.value = key
try {
await bindCallRoom({ diagnosis_id: diagnosisId.value, room_id: rid })
clearBindRoomRetry()
console.log('[chat-dialog] bindCallRoom 成功', { room_id: rid })
} catch (e) {
console.warn('[chat-dialog] bindCallRoom 失败', e)
lastBoundRoomKey.value = ''
}
}
async function tryBindRoomIfReady() {
if (patientId.value == null || diagnosisId.value == null) return
const status = TUIStore.getData(StoreName.CALL, NAME.CALL_STATUS)
if (status === CALL_STATUS_IDLE) {
clearBindRoomRetry()
lastBoundRoomKey.value = ''
return
}
if (status !== CALL_STATUS_CALLING && status !== CALL_STATUS_CONNECTED) return
await nextTick()
const rid = readAnyRoomId()
if (rid) {
await doBindCallRoom(rid)
return
}
scheduleBindRoomRetry()
}
async function ensureCallRecordStarted() {
if (patientId.value == null || diagnosisId.value == null) return
try {
await startCall({
diagnosis_id: diagnosisId.value,
patient_id: patientId.value,
call_type: 2
})
} catch (e) {
console.warn('[chat-dialog] startCall 记录失败', e)
}
}
function setupCallRoomBinding() {
tearDownCallRoomBinding?.()
const onCallStatus = (newData?: string) => {
if (newData === CALL_STATUS_IDLE) {
clearBindRoomRetry()
lastBoundRoomKey.value = ''
return
}
if (newData === CALL_STATUS_CONNECTED) {
void tryBindRoomIfReady()
}
}
const onRoomId = () => {
void tryBindRoomIfReady()
}
TUIStore.watch(StoreName.CALL, {
[NAME.CALL_STATUS]: onCallStatus,
[NAME.ROOM_ID]: onRoomId
})
tearDownCallRoomBinding = () => {
try {
TUIStore.unwatch(StoreName.CALL, {
[NAME.CALL_STATUS]: onCallStatus,
[NAME.ROOM_ID]: onRoomId
})
} catch {
//
}
clearBindRoomRetry()
tearDownCallRoomBinding = null
}
}
/** 防止 console + 引擎事件 与 unhandledrejection 连续弹多次 */
let lastPackageToastAt = 0
const PACKAGE_TOAST_DEBOUNCE_MS = 2600
function notifyPackageArrearsOnce() {
const now = Date.now()
if (now - lastPackageToastAt < PACKAGE_TOAST_DEBOUNCE_MS) return
lastPackageToastAt = now
feedback.msgError(getTUICallPackageArrearsMessage())
showCallKitWindow.value = false
}
/**
* SDK 在 calls 失败时往往只 console.error(见 CallService),Chat UIKit 工具栏又会吞掉 Promise
* 故需:1) 挂钩 console 识别套餐文案;2) 监听 TUICallEngine 的 error 事件。
*/
let savedConsoleError: typeof console.error | null = null
let consoleErrorHooked = false
let engineErrorHandler: ((e: unknown) => void) | null = null
function hookConsoleErrorForPackageHint() {
if (consoleErrorHooked) return
savedConsoleError = console.error.bind(console)
consoleErrorHooked = true
console.error = (...args: unknown[]) => {
savedConsoleError!(...args)
if (!visible.value || !isCallReady.value) return
const text = args
.map((a) => {
if (typeof a === 'string') return a
if (a instanceof Error) return a.message
try {
return JSON.stringify(a)
} catch {
return String(a)
}
})
.join(' ')
if (isTUICallPackageAbilityError(text)) notifyPackageArrearsOnce()
}
}
function unhookConsoleErrorForPackageHint() {
if (consoleErrorHooked && savedConsoleError) {
console.error = savedConsoleError
}
consoleErrorHooked = false
savedConsoleError = null
}
function bindEnginePackageErrorListener() {
try {
const engine = TUICallKitServer.getTUICallEngineInstance?.()
if (!engine?.on) return
if (engineErrorHandler) {
engine.off?.('error', engineErrorHandler)
}
engineErrorHandler = (payload: unknown) => {
if (!visible.value || !isCallReady.value) return
if (isTUICallPackageAbilityError(payload)) notifyPackageArrearsOnce()
}
engine.on('error', engineErrorHandler)
} catch (e) {
console.warn('绑定 TUICallEngine error 监听失败:', e)
}
}
function unbindEnginePackageErrorListener() {
try {
const engine = TUICallKitServer.getTUICallEngineInstance?.()
if (engine?.off && engineErrorHandler) {
engine.off('error', engineErrorHandler)
}
} catch {
//
}
engineErrorHandler = null
}
/** 医生端进入会话后发给患者的信令:患者端不展示气泡,仅用于更新「已进入诊室」状态 */
async function sendDoctorEnteredConsultRoomSignal(sdkAppId: number, toUserId: string) {
try {
const chat = TencentCloudChat.create({ SDKAppID: sdkAppId })
const payload = {
businessID: 'doctor_entered_consult_room',
time: Date.now()
}
const message = chat.createCustomMessage({
to: toUserId,
conversationType: TencentCloudChat.TYPES.CONV_C2C,
payload: {
data: JSON.stringify(payload)
}
})
await chat.sendMessage(message)
console.log('已发送医生进入诊室信令(患者端聊天列表不展示)')
} catch (e) {
console.warn('发送医生进入诊室信令失败:', e)
}
}
/** 未捕获 Promise(若存在) */
const onTuiCallUnhandledRejection = (e: PromiseRejectionEvent) => {
if (!visible.value || !isCallReady.value) return
if (isTUICallPackageAbilityError(e.reason)) notifyPackageArrearsOnce()
}
onMounted(() => {
hookConsoleErrorForPackageHint()
window.addEventListener('unhandledrejection', onTuiCallUnhandledRejection)
})
onUnmounted(() => {
tearDownCallRoomBinding?.()
unbindEngineRoomEvents()
unhookConsoleErrorForPackageHint()
unbindEnginePackageErrorListener()
window.removeEventListener('unhandledrejection', onTuiCallUnhandledRejection)
})
// 更新用户头像到 IM
const updateUserAvatarToIM = async (avatarUrl: string,appid: number) => {
try {
@@ -244,15 +726,28 @@ const open = async (data: { patientId: number; patientName: string; diagnosisId?
;(window as any).__TUICallKitInited__ = true
} catch (err: any) {
console.warn('TUICallKit 初始化失败,聊天仍可用,音视频通话不可用:', err?.message || err)
if (isTUICallPackageAbilityError(err)) {
notifyPackageArrearsOnce()
}
}
}
isCallReady.value = !!(window as any).__TUICallKitInited__
if (isCallReady.value) {
// 仅在通话时显示视频窗口,无通话时隐藏
// 仅在通话时显示视频窗口,无通话时隐藏;发起通话前落库、接通后绑定房间号(云端录制)
TUICallKitServer.setCallback({
beforeCalling: () => { showCallKitWindow.value = true },
afterCalling: () => { showCallKitWindow.value = false }
beforeCalling: () => {
showCallKitWindow.value = true
void ensureCallRecordStarted()
},
afterCalling: () => {
showCallKitWindow.value = false
}
})
installTUICallKitRoomHooks()
installEngineRoomCapture()
bindEngineRoomEvents()
setupCallRoomBinding()
bindEnginePackageErrorListener()
}
console.log('Chat UIKit 登录成功,患者用户ID:', patientUserId.value)
@@ -285,6 +780,8 @@ const open = async (data: { patientId: number; patientName: string; diagnosisId?
// 激活该会话
setActiveConversation(conversationId.value)
console.log('已激活会话')
await sendDoctorEnteredConsultRoomSignal(sdkAPPid, patientUserId.value)
} catch (err) {
console.error('创建/激活会话失败:', err)
}
@@ -338,7 +835,11 @@ const startGroupVideoCall = async () => {
})
} catch (err: any) {
console.error('发起群视频失败:', err)
feedback.msgError(err?.message || '发起群视频失败')
if (isTUICallPackageAbilityError(err)) {
notifyPackageArrearsOnce()
} else {
feedback.msgError(formatTUICallUserError(err, '发起群视频失败'))
}
}
}
@@ -354,6 +855,8 @@ const posStartY = ref(0)
// TUICallKit 视频窗口拖动
const callKitWrapperRef = ref<HTMLElement>()
const callKitContentRef = ref<HTMLElement>()
const captureUploading = ref(false)
const callKitX = ref(0)
const callKitY = ref(0)
const isCallKitDragging = ref(false)
@@ -389,6 +892,67 @@ const onCallKitDragEnd = () => {
document.removeEventListener('mouseup', onCallKitDragEnd)
}
/** 截取当前视频画面并上传,同步到对应患者诊单的「舌苔照片」 */
const handleCallKitScreenshot = async () => {
if (patientId.value == null) {
feedback.msgWarning('缺少患者信息,无法同步舌苔照片')
return
}
const el = callKitContentRef.value
if (!el) return
captureUploading.value = true
try {
const blob = await captureVideoFrameFromElement(el)
if (!blob) {
feedback.msgWarning('截屏失败,请确认视频画面已加载')
return
}
const data = await uploadImageBlob(blob, `callshot-${Date.now()}.jpg`)
const path = data.url || data.uri
if (!path) {
feedback.msgError('上传未返回图片路径')
return
}
if (diagnosisId.value == null) {
feedback.msgWarning('请先保存诊单后再从视频同步舌苔照片')
return
}
const detail = await tcmDiagnosisDetail({ id: diagnosisId.value })
if (Number(detail.patient_id) !== Number(patientId.value)) {
feedback.msgError('患者与当前诊单不匹配,无法同步')
return
}
const previous: string[] = [...((detail.tongue_images || []) as string[])]
if (previous.length >= 9) {
feedback.msgWarning('舌苔照片已达上限 9 张')
return
}
const next = [...previous, path]
const payload = JSON.parse(JSON.stringify(detail)) as Record<string, unknown>
payload.tongue_images = next
await tcmDiagnosisEdit(payload)
feedback.msgSuccess('舌苔照片已同步到诊单')
} catch (e: unknown) {
const err = e as Error
console.error(err)
feedback.msgError(err?.message || '上传失败')
} finally {
captureUploading.value = false
}
}
/** 关闭视频浮窗并挂断当前通话 */
const handleCallKitClose = async () => {
if (isCallReady.value) {
try {
await TUICallKitServer.hangup()
} catch (err) {
console.warn('关闭视频窗口时挂断失败:', err)
}
}
showCallKitWindow.value = false
}
const windowStyle = computed(() => ({
left: `${posX.value}px`,
top: `${posY.value}px`
@@ -580,6 +1144,36 @@ defineExpose({ open })
color: #fff;
}
.call-kit-drag-handle-actions {
display: flex;
align-items: center;
gap: 4px;
}
.call-kit-screenshot-btn {
padding: 4px 6px;
color: #79bbff;
&:hover {
color: #a0cfff;
}
}
.call-kit-screenshot-text {
margin-left: 2px;
font-size: 12px;
}
.call-kit-close-btn {
cursor: pointer;
padding: 4px;
color: #f56c6c;
&:hover {
color: #f89898;
}
}
.drag-handle-icon {
color: #999;
font-size: 16px;
+305 -22
View File
@@ -2,7 +2,7 @@
<el-drawer
v-model="visible"
title="中医处方单"
size="900px"
size="1200px"
:close-on-click-modal="false"
@close="handleClose"
>
@@ -52,6 +52,16 @@
<el-input v-model="formData.tongue" placeholder="舌象" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="舌象详情" prop="tongue_image">
<el-input v-model="formData.tongue_image" placeholder="舌象详情" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="脉象详情" prop="pulse_condition">
<el-input v-model="formData.pulse_condition" placeholder="脉象详情" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="临床诊断" prop="clinical_diagnosis">
<el-input
@@ -69,6 +79,7 @@
<div class="section-title">
中药处方 (RP)
<el-button type="primary" size="small" @click="handleAddHerb">添加中药</el-button>
<el-button type="success" size="small" @click="showLibraryDialog = true">从处方库导入</el-button>
</div>
<div class="mb-3">
<el-form-item label="处方价格" prop="amount" class="!mb-0">
@@ -109,32 +120,113 @@
<div class="info-section">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="处方类型" prop="prescription_type">
<el-select v-model="formData.prescription_type" placeholder="请选择处方类型" class="!w-full">
<el-option label="浓缩水丸" value="浓缩水丸" />
<el-option label="饮片" value="饮片" />
<el-option label="颗粒" value="颗粒" />
<el-option label="丸剂" value="丸剂" />
<el-option label="散剂" value="散剂" />
<el-option label="膏方" value="膏方" />
<el-option label="汤剂" value="汤剂" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="服用天数" prop="dose_count">
<el-form-item label="服用天数" prop="usage_days">
<el-input-number v-model="formData.usage_days" :min="1" :max="365" class="!w-full" />
</el-form-item>
</el-col>
<!-- <el-col :span="8">
<el-form-item label="剂数" prop="dose_count">
<el-input-number v-model="formData.dose_count" :min="1" :max="999" class="!w-full" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="类型" prop="dose_unit">
<el-select v-model="formData.dose_unit" placeholder="请选择类型" class="!w-full">
<el-option label="浓缩水丸" value="浓缩水丸" />
<el-option label="饮片" value="饮片" />
<el-form-item label="剂量单位" prop="dose_unit">
<el-select v-model="formData.dose_unit" placeholder="请选择" class="!w-full">
<el-option label="" value="" />
<el-option label="" value="" />
<el-option label="袋" value="袋" />
<el-option label="盒" value="盒" />
<el-option label="瓶" value="瓶" />
<el-option label="膏" value="膏" />
<el-option label="贴" value="贴" />
</el-select>
</el-form-item>
</el-col> -->
<el-col :span="24">
<el-form-item label="用法" prop="usage_instruction">
<el-input v-model="formData.usage_instruction" placeholder="例如:水煎服,一日二次" maxlength="200" show-word-limit />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="服用时间" prop="usage_time">
<el-select v-model="formData.usage_time" placeholder="请选择服用时间" class="!w-full">
<el-option label="饭前" value="饭前" />
<el-option label="饭后" value="饭后" />
<el-option label="饭中" value="饭中" />
<el-option label="空腹" value="空腹" />
<el-option label="睡前" value="睡前" />
<el-option label="晨起" value="晨起" />
<el-option label="随时" value="随时" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="服用说明" prop="usage_instruction">
<el-select v-model="formData.usage_instruction" placeholder="请选择服用说明" class="!w-full">
<el-option label="饭前" value="饭前" />
<el-option label="饭后" value="饭后" />
<el-col :span="12">
<el-form-item label="服用方式" prop="usage_way">
<el-select v-model="formData.usage_way" placeholder="请选择服用方式" class="!w-full">
<el-option label="温水送服" value="温水送服" />
<el-option label="开水冲服" value="开水冲服" />
<el-option label="黄酒送服" value="黄酒送服" />
<el-option label="淡盐水送服" value="淡盐水送服" />
<el-option label="米汤送服" value="米汤送服" />
<el-option label="嚼服" value="嚼服" />
<el-option label="含化" value="含化" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="忌口" prop="dietary_taboo">
<el-select v-model="formData.dietary_taboo" multiple placeholder="请选择忌口内容(可多选)" class="!w-full">
<el-option label="辛辣食物" value="辛辣食物" />
<el-option label="生冷食物" value="生冷食物" />
<el-option label="油腻食物" value="油腻食物" />
<el-option label="海鲜" value="海鲜" />
<el-option label="牛羊肉" value="牛羊肉" />
<el-option label="鸡蛋" value="鸡蛋" />
<el-option label="豆制品" value="豆制品" />
<el-option label="酒类" value="酒类" />
<el-option label="浓茶" value="浓茶" />
<el-option label="咖啡" value="咖啡" />
<el-option label="烟草" value="烟草" />
<el-option label="萝卜" value="萝卜" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="其他说明" prop="usage_notes">
<el-input v-model="formData.usage_notes" type="textarea" :rows="2" placeholder="其他服用注意事项" maxlength="200" show-word-limit />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="医师" prop="doctor_name">
<el-input v-model="formData.doctor_name" placeholder="医师姓名" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="是否共享" prop="is_shared">
<el-switch
v-model="formData.is_shared"
:active-value="1"
:inactive-value="0"
active-text="所有人可见"
inactive-text="仅自己可见"
/>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="医师签名" prop="doctor_signature" required>
<div class="signature-pad-wrap">
@@ -219,7 +311,20 @@
<div class="prescription-footer">
<div class="footer-left">
<div class="footer-dosage">
服用{{ savedPrescription.dose_count }}, {{ savedPrescription.dose_unit || '浓缩水丸' }}, {{ savedPrescription.usage_instruction }}
服用{{ savedPrescription.usage_days || savedPrescription.dose_count }}, {{ savedPrescription.prescription_type }}
{{ savedPrescription.usage_instruction }}
</div>
<div class="footer-dosage">
服用时间: {{ savedPrescription.usage_time ?' ' + savedPrescription.usage_time : '' }}
<text style="margin-left: 20px;"></text>服用方式: {{ savedPrescription.usage_way ? ' ' + savedPrescription.usage_way : '' }}
</div>
<div v-if="savedPrescription.dietary_taboo && savedPrescription.dietary_taboo.length" class="footer-dietary">
忌口{{ Array.isArray(savedPrescription.dietary_taboo) ? savedPrescription.dietary_taboo.join('') : savedPrescription.dietary_taboo }}
</div>
<div v-if="savedPrescription.usage_notes" class="footer-notes">
{{ savedPrescription.usage_notes }}
</div>
<!-- <div class="footer-amount">金额 {{ savedPrescription.amount }}</div> -->
</div>
@@ -374,17 +479,83 @@
</div>
</template>
</el-drawer>
<!-- 处方库选择弹窗 -->
<el-dialog
v-model="showLibraryDialog"
title="从处方库导入"
width="800px"
:close-on-click-modal="false"
>
<div class="mb-4">
<el-input
v-model="librarySearchName"
placeholder="请输入处方名称搜索"
clearable
@keyup.enter="searchLibrary"
@clear="searchLibrary"
>
<template #append>
<el-button :icon="Search" @click="searchLibrary" />
</template>
</el-input>
</div>
<el-table
v-loading="libraryLoading"
:data="libraryList"
height="400"
@row-click="handleSelectLibrary"
style="cursor: pointer"
>
<el-table-column label="处方名称" prop="prescription_name" min-width="150" show-overflow-tooltip />
<el-table-column label="药材数量" width="100">
<template #default="{ row }">
{{ row.herbs?.length || 0 }}
</template>
</el-table-column>
<el-table-column label="药材明细" min-width="300" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.herbs && row.herbs.length > 0">
{{ row.herbs.map((h: any) => `${h.name} ${h.dosage}g`).join('、') }}
</span>
<span v-else class="text-gray-400">暂无药材</span>
</template>
</el-table-column>
<el-table-column label="创建人" prop="creator_name" width="100" />
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click.stop="handleImportLibrary(row)">
导入
</el-button>
</template>
</el-table-column>
</el-table>
<div class="flex justify-end mt-4">
<el-pagination
v-model:current-page="libraryPage"
v-model:page-size="libraryPageSize"
:total="libraryTotal"
:page-sizes="[10, 15, 20, 50]"
layout="total, sizes, prev, pager, next"
@current-change="loadLibraryList"
@size-change="loadLibraryList"
/>
</div>
</el-dialog>
</template>
<script setup lang="ts">
import feedback from '@/utils/feedback'
import { prescriptionAdd, prescriptionDetail, prescriptionGetByAppointment, prescriptionVoid, tcmDiagnosisDetail } from '@/api/tcm'
import { prescriptionAdd, prescriptionDetail, prescriptionGetByAppointment, prescriptionVoid, tcmDiagnosisDetail, prescriptionLibraryLists } from '@/api/tcm'
import { getDictData } from '@/api/app'
import html2canvas from 'html2canvas'
import { jsPDF } from 'jspdf'
import useUserStore from '@/stores/modules/user'
import useAppStore from '@/stores/modules/app'
import { Download, Document } from '@element-plus/icons-vue'
import { Search } from '@element-plus/icons-vue'
interface Herb {
name: string
@@ -394,6 +565,8 @@ interface Herb {
interface PrescriptionForm {
diagnosis_id: number
appointment_id: number
prescription_name: string
prescription_type: string
patient_name: string
gender: number
gender_desc: string
@@ -403,15 +576,23 @@ interface PrescriptionForm {
prescription_date: string
pulse: string
tongue: string
tongue_image: string
pulse_condition: string
clinical_diagnosis: string
case_record: any
herbs: Herb[]
dose_count: number
dose_unit: string
usage_days: number
usage_instruction: string
usage_time: string
usage_way: string
dietary_taboo: string[]
usage_notes: string
amount: number
doctor_name: string
doctor_signature: string
is_shared: number
}
const emit = defineEmits(['success'])
@@ -426,6 +607,15 @@ const signatureCanvasRef = ref<HTMLCanvasElement>()
const isDrawing = ref(false)
const savedPrescription = ref<any>(null)
// 处方库相关
const showLibraryDialog = ref(false)
const libraryLoading = ref(false)
const libraryList = ref<any[]>([])
const librarySearchName = ref('')
const libraryPage = ref(1)
const libraryPageSize = ref(15)
const libraryTotal = ref(0)
const templateConfig = reactive({
stationName: '成都双流甄养堂互联网医院',
showDisclaimer: true,
@@ -435,6 +625,8 @@ const templateConfig = reactive({
const formData = reactive<PrescriptionForm>({
diagnosis_id: 0,
appointment_id: 0,
prescription_name: '',
prescription_type: '浓缩水丸',
patient_name: '',
gender: 0,
gender_desc: '女',
@@ -444,15 +636,23 @@ const formData = reactive<PrescriptionForm>({
prescription_date: '',
pulse: '',
tongue: '',
tongue_image: '',
pulse_condition: '',
clinical_diagnosis: '',
case_record: null as any,
herbs: [],
dose_count: 1,
dose_unit: '浓缩水丸',
usage_instruction: '饭前',
dose_unit: '',
usage_days: 7,
usage_instruction: '水煎服,一日二次',
usage_time: '饭前',
usage_way: '温水送服',
dietary_taboo: [],
usage_notes: '',
amount: 0,
doctor_name: '',
doctor_signature: ''
doctor_signature: '',
is_shared: 0
})
const userStore = useUserStore()
@@ -599,6 +799,8 @@ const open = async (data: any, options?: { templateId?: number; stationName?: st
formData.diagnosis_id = Number(diagnosisId)
formData.appointment_id = appointmentId
formData.prescription_name = ''
formData.prescription_type = '浓缩水丸'
formData.patient_name = data.patient_name || diagnosis.patient_name || ''
formData.gender = data.patient_gender ?? diagnosis.gender ?? 0
formData.gender_desc = formData.gender === 1 ? '男' : '女'
@@ -608,15 +810,23 @@ const open = async (data: any, options?: { templateId?: number; stationName?: st
formData.prescription_date = new Date().toISOString().slice(0, 10)
formData.pulse = diagnosis.pulse || ''
formData.tongue = diagnosis.tongue_coating || diagnosis.tongue || ''
formData.tongue_image = ''
formData.pulse_condition = ''
formData.clinical_diagnosis = buildClinicalDiagnosis(diagnosis)
formData.case_record = caseRecord
formData.herbs = []
formData.dose_count = 1
formData.dose_unit = '浓缩水丸'
formData.usage_instruction = '饭前'
formData.dose_unit = ''
formData.usage_days = 7
formData.usage_instruction = '水煎服,一日二次'
formData.usage_time = '饭前'
formData.usage_way = '温水送服'
formData.dietary_taboo = []
formData.usage_notes = ''
formData.amount = 0
formData.doctor_name = userStore.userInfo?.name || ''
formData.doctor_signature = ''
formData.is_shared = 0
savedPrescription.value = null
visible.value = true
@@ -721,6 +931,59 @@ const handleRemoveHerb = (index: number) => {
formData.herbs.splice(index, 1)
}
// 处方库相关方法
const loadLibraryList = async () => {
libraryLoading.value = true
try {
const res = await prescriptionLibraryLists({
page_no: libraryPage.value,
page_size: libraryPageSize.value,
prescription_name: librarySearchName.value,
is_public: ''
})
libraryList.value = res.lists || []
libraryTotal.value = res.count || 0
} catch (error) {
console.error('加载处方库失败:', error)
feedback.msgError('加载处方库失败')
} finally {
libraryLoading.value = false
}
}
const searchLibrary = () => {
libraryPage.value = 1
loadLibraryList()
}
const handleSelectLibrary = (row: any) => {
// 点击行也可以导入
handleImportLibrary(row)
}
const handleImportLibrary = (row: any) => {
if (!row.herbs || row.herbs.length === 0) {
feedback.msgWarning('该处方没有药材信息')
return
}
// 深拷贝药材数据并导入
const importedHerbs = JSON.parse(JSON.stringify(row.herbs))
formData.herbs = importedHerbs
feedback.msgSuccess(`已导入处方「${row.prescription_name}」,共${importedHerbs.length}味药材`)
showLibraryDialog.value = false
}
// 监听处方库弹窗打开,自动加载数据
watch(showLibraryDialog, (newVal) => {
if (newVal) {
librarySearchName.value = ''
libraryPage.value = 1
loadLibraryList()
}
})
const handleSave = async () => {
if (!formData.clinical_diagnosis?.trim()) {
feedback.msgWarning('请输入临床诊断')
@@ -751,6 +1014,8 @@ const handleSave = async () => {
const res = await prescriptionAdd({
diagnosis_id: formData.diagnosis_id,
appointment_id: formData.appointment_id,
prescription_name: formData.prescription_name,
prescription_type: formData.prescription_type,
patient_name: formData.patient_name,
gender: formData.gender,
age: formData.age,
@@ -759,15 +1024,23 @@ const handleSave = async () => {
prescription_date: formData.prescription_date,
pulse: formData.pulse,
tongue: formData.tongue,
tongue_image: formData.tongue_image,
pulse_condition: formData.pulse_condition,
clinical_diagnosis: formData.clinical_diagnosis,
case_record: formData.case_record,
herbs: formData.herbs,
dose_count: formData.dose_count,
dose_unit: formData.dose_unit || '浓缩水丸',
dose_unit: formData.dose_unit,
usage_days: formData.usage_days,
usage_instruction: formData.usage_instruction,
usage_time: formData.usage_time,
usage_way: formData.usage_way,
dietary_taboo: formData.dietary_taboo,
usage_notes: formData.usage_notes,
amount: formData.amount,
doctor_name: formData.doctor_name,
doctor_signature: formData.doctor_signature
doctor_signature: formData.doctor_signature,
is_shared: formData.is_shared
})
const id = res?.id
if (id) {
@@ -825,6 +1098,8 @@ const handleNewPrescription = async () => {
// 将患者信息、诊断信息填入表单,新建处方时保留
formData.diagnosis_id = saved.diagnosis_id ?? formData.diagnosis_id
formData.appointment_id = saved.appointment_id ?? formData.appointment_id
formData.prescription_name = ''
formData.prescription_type = saved.prescription_type || '浓缩水丸'
formData.patient_name = saved.patient_name || ''
formData.gender = saved.gender ?? 0
formData.gender_desc = saved.gender === 1 ? '男' : '女'
@@ -834,13 +1109,21 @@ const handleNewPrescription = async () => {
formData.prescription_date = new Date().toISOString().slice(0, 10)
formData.pulse = saved.pulse || ''
formData.tongue = saved.tongue || ''
formData.tongue_image = ''
formData.pulse_condition = ''
formData.clinical_diagnosis = saved.clinical_diagnosis || ''
formData.dose_count = 1
formData.dose_unit = saved.dose_unit || '浓缩水丸'
formData.usage_instruction = '饭前'
formData.dose_unit = saved.dose_unit || ''
formData.usage_days = 7
formData.usage_instruction = '水煎服,一日二次'
formData.usage_time = '饭前'
formData.usage_way = '温水送服'
formData.dietary_taboo = []
formData.usage_notes = ''
formData.amount = 0
formData.doctor_name = userStore.userInfo?.name || saved.doctor_name || ''
formData.doctor_signature = ''
formData.is_shared = 0
formData.herbs = []
// 新建处方时重新获取病历(旧处方可能没有 case_record
formData.case_record = saved.case_record && Object.keys(saved.case_record).length > 0
+2 -1
View File
@@ -95,6 +95,7 @@ import { ref, onUnmounted, nextTick, computed } from 'vue'
import { VideoCamera, CircleClose, Phone } from '@element-plus/icons-vue'
import { getCallSignature, endCall } from '@/api/tcm'
import feedback from '@/utils/feedback'
import { formatTUICallUserError } from '@/utils/tuicall-error'
import { TUICallKitAPI, TUICallKit, TUICallType, STATUS } from '@trtc/calls-uikit-vue'
interface CallInfo {
@@ -646,7 +647,7 @@ const startCall = async () => {
} catch (error: any) {
console.error('启动通话失败:', error)
const errorMessage = error.message || '启动通话失败,请稍后重试'
const errorMessage = formatTUICallUserError(error, '启动通话失败,请稍后重试')
feedback.msgError(errorMessage)
callRejected.value = true
statusText.value = errorMessage