新增功能
This commit is contained in:
@@ -1,15 +1,21 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
:title="`与 ${patientName} 聊天`"
|
||||
width="900px"
|
||||
top="10vh"
|
||||
:close-on-click-modal="false"
|
||||
@close="handleClose"
|
||||
:destroy-on-close="true"
|
||||
class="chat-dialog"
|
||||
>
|
||||
<div class="chat-container">
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-show="visible"
|
||||
ref="chatWindowRef"
|
||||
class="chat-floating-window"
|
||||
: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>
|
||||
</div>
|
||||
<div class="chat-container">
|
||||
<div v-if="!isReady" class="loading-container">
|
||||
<el-icon class="is-loading"><Loading /></el-icon>
|
||||
<span>{{ loadingText }}</span>
|
||||
@@ -49,20 +55,35 @@
|
||||
</UIKitProvider>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 挂载 TUICallKit 组件(固定居中显示) -->
|
||||
<TUICallKit
|
||||
v-if="isCallReady"
|
||||
:allowedMinimized="true"
|
||||
:allowedFullScreen="true"
|
||||
class="chat-dialog-call-kit"
|
||||
/>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 挂载 TUICallKit 组件(可拖动,仅在通话时显示) -->
|
||||
<div
|
||||
v-if="isCallReady && showCallKitWindow"
|
||||
ref="callKitWrapperRef"
|
||||
class="chat-dialog-call-kit-wrapper"
|
||||
:style="callKitStyle"
|
||||
>
|
||||
<div
|
||||
class="call-kit-drag-handle"
|
||||
@mousedown="onCallKitDragStart"
|
||||
>
|
||||
<span class="drag-handle-text">视频通话</span>
|
||||
<el-icon class="drag-handle-icon"><Rank /></el-icon>
|
||||
</div>
|
||||
<div class="call-kit-content">
|
||||
<TUICallKit
|
||||
:allowedMinimized="true"
|
||||
:allowedFullScreen="true"
|
||||
class="chat-dialog-call-kit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick, watch } from 'vue'
|
||||
import { Loading } from '@element-plus/icons-vue'
|
||||
import { ref, nextTick, watch, computed } from 'vue'
|
||||
import { Loading, Close, Rank } from '@element-plus/icons-vue'
|
||||
import { getCallSignature } from '@/api/tcm'
|
||||
import feedback from '@/utils/feedback'
|
||||
import useUserStore from '@/stores/modules/user'
|
||||
@@ -94,6 +115,7 @@ const loadingText = ref('正在初始化...')
|
||||
const patientUserId = ref('')
|
||||
const conversationId = ref('')
|
||||
const isCallReady = ref(false)
|
||||
const showCallKitWindow = ref(false) // 仅在通话中显示视频窗口
|
||||
const { login, logout } = useLoginState()
|
||||
const { setActiveConversation, createC2CConversation, activeConversation } = useConversationListState()
|
||||
const userStore = useUserStore()
|
||||
@@ -166,12 +188,17 @@ watch(() => activeConversation.value, async (newConversation) => {
|
||||
|
||||
const open = async (data: { patientId: number; patientName: string; diagnosisId?: number }) => {
|
||||
visible.value = true
|
||||
posX.value = 100
|
||||
posY.value = 80
|
||||
callKitX.value = Math.max(0, (window.innerWidth - 600) / 2)
|
||||
callKitY.value = Math.max(0, (window.innerHeight - 436) / 2)
|
||||
patientName.value = data.patientName
|
||||
patientId.value = data.patientId
|
||||
diagnosisId.value = data.diagnosisId || null
|
||||
error.value = ''
|
||||
isReady.value = false
|
||||
isCallReady.value = false
|
||||
showCallKitWindow.value = false
|
||||
loadingText.value = '正在获取签名...'
|
||||
|
||||
try {
|
||||
@@ -220,6 +247,13 @@ const open = async (data: { patientId: number; patientName: string; diagnosisId?
|
||||
}
|
||||
}
|
||||
isCallReady.value = !!(window as any).__TUICallKitInited__
|
||||
if (isCallReady.value) {
|
||||
// 仅在通话时显示视频窗口,无通话时隐藏
|
||||
TUICallKitServer.setCallback({
|
||||
beforeCalling: () => { showCallKitWindow.value = true },
|
||||
afterCalling: () => { showCallKitWindow.value = false }
|
||||
})
|
||||
}
|
||||
console.log('Chat UIKit 登录成功,患者用户ID:', patientUserId.value)
|
||||
|
||||
loadingText.value = '加载聊天界面...'
|
||||
@@ -308,7 +342,90 @@ const startGroupVideoCall = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 浮动窗口位置与拖动
|
||||
const chatWindowRef = ref<HTMLElement>()
|
||||
const posX = ref(100)
|
||||
const posY = ref(80)
|
||||
const isDragging = ref(false)
|
||||
const dragStartX = ref(0)
|
||||
const dragStartY = ref(0)
|
||||
const posStartX = ref(0)
|
||||
const posStartY = ref(0)
|
||||
|
||||
// TUICallKit 视频窗口拖动
|
||||
const callKitWrapperRef = ref<HTMLElement>()
|
||||
const callKitX = ref(0)
|
||||
const callKitY = ref(0)
|
||||
const isCallKitDragging = ref(false)
|
||||
const callKitDragStartX = ref(0)
|
||||
const callKitDragStartY = ref(0)
|
||||
const callKitPosStartX = ref(0)
|
||||
const callKitPosStartY = ref(0)
|
||||
|
||||
const callKitStyle = computed(() => ({
|
||||
left: `${callKitX.value}px`,
|
||||
top: `${callKitY.value}px`
|
||||
}))
|
||||
|
||||
const onCallKitDragStart = (e: MouseEvent) => {
|
||||
isCallKitDragging.value = true
|
||||
callKitDragStartX.value = e.clientX
|
||||
callKitDragStartY.value = e.clientY
|
||||
callKitPosStartX.value = callKitX.value
|
||||
callKitPosStartY.value = callKitY.value
|
||||
document.addEventListener('mousemove', onCallKitDragMove)
|
||||
document.addEventListener('mouseup', onCallKitDragEnd)
|
||||
}
|
||||
|
||||
const onCallKitDragMove = (e: MouseEvent) => {
|
||||
if (!isCallKitDragging.value) return
|
||||
callKitX.value = Math.max(0, callKitPosStartX.value + e.clientX - callKitDragStartX.value)
|
||||
callKitY.value = Math.max(0, callKitPosStartY.value + e.clientY - callKitDragStartY.value)
|
||||
}
|
||||
|
||||
const onCallKitDragEnd = () => {
|
||||
isCallKitDragging.value = false
|
||||
document.removeEventListener('mousemove', onCallKitDragMove)
|
||||
document.removeEventListener('mouseup', onCallKitDragEnd)
|
||||
}
|
||||
|
||||
const windowStyle = computed(() => ({
|
||||
left: `${posX.value}px`,
|
||||
top: `${posY.value}px`
|
||||
}))
|
||||
|
||||
const onHeaderMouseDown = (e: MouseEvent) => {
|
||||
if ((e.target as HTMLElement).closest('.chat-close-btn')) return
|
||||
isDragging.value = true
|
||||
dragStartX.value = e.clientX
|
||||
dragStartY.value = e.clientY
|
||||
posStartX.value = posX.value
|
||||
posStartY.value = posY.value
|
||||
document.addEventListener('mousemove', onHeaderMouseMove)
|
||||
document.addEventListener('mouseup', onHeaderMouseUp)
|
||||
}
|
||||
|
||||
const onHeaderMouseMove = (e: MouseEvent) => {
|
||||
if (!isDragging.value) return
|
||||
posX.value = Math.max(0, posStartX.value + e.clientX - dragStartX.value)
|
||||
posY.value = Math.max(0, posStartY.value + e.clientY - dragStartY.value)
|
||||
}
|
||||
|
||||
const onHeaderMouseUp = () => {
|
||||
isDragging.value = false
|
||||
document.removeEventListener('mousemove', onHeaderMouseMove)
|
||||
document.removeEventListener('mouseup', onHeaderMouseUp)
|
||||
}
|
||||
|
||||
const handleClose = async () => {
|
||||
// 关闭时如有通话中/拨通中,自动挂断
|
||||
if (isCallReady.value && showCallKitWindow.value) {
|
||||
try {
|
||||
await TUICallKitServer.hangup()
|
||||
} catch (err) {
|
||||
console.warn('挂断通话时出错:', err)
|
||||
}
|
||||
}
|
||||
try {
|
||||
// 登出 Chat
|
||||
// await logout()
|
||||
@@ -317,6 +434,7 @@ const handleClose = async () => {
|
||||
}
|
||||
|
||||
visible.value = false
|
||||
showCallKitWindow.value = false
|
||||
// isReady.value = false
|
||||
// error.value = ''
|
||||
// patientId.value = null
|
||||
@@ -329,31 +447,47 @@ defineExpose({ open })
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.chat-dialog {
|
||||
:deep(.el-dialog__body) {
|
||||
padding: 0;
|
||||
height: 75vh;
|
||||
overflow: visible;
|
||||
/* 浮动窗口:无遮罩,不遮挡页面,可任意拖动 */
|
||||
.chat-floating-window {
|
||||
position: fixed;
|
||||
width: 900px;
|
||||
height: 75vh;
|
||||
min-height: 400px;
|
||||
max-height: 90vh;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.15);
|
||||
z-index: 2501;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-window-header {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
background: #fff;
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
|
||||
.chat-window-title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:deep(.el-dialog__header) {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
// 确保对话框有合适的 z-index
|
||||
:deep(.el-overlay) {
|
||||
z-index: 2500;
|
||||
}
|
||||
|
||||
// 确保对话框本身的 z-index 低于 TUICallKit
|
||||
:deep(.el-dialog) {
|
||||
z-index: 2501;
|
||||
|
||||
.chat-close-btn {
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-container {
|
||||
height: 75vh;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -415,18 +549,61 @@ defineExpose({ open })
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* TUICallKit 容器样式 - 限制在对话框内拖动 */
|
||||
/* TUICallKit 可拖动容器 */
|
||||
.chat-dialog-call-kit-wrapper {
|
||||
position: fixed;
|
||||
width: 600px;
|
||||
height: 436px;
|
||||
max-width: 90vw;
|
||||
z-index: 300000;
|
||||
background: #1a1a1a;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.call-kit-drag-handle {
|
||||
height: 36px;
|
||||
padding: 0 16px;
|
||||
background: #2a2a2a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
|
||||
.drag-handle-text {
|
||||
font-size: 14px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.drag-handle-icon {
|
||||
color: #999;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.call-kit-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chat-dialog-call-kit {
|
||||
position: fixed !important;
|
||||
top: 50% !important;
|
||||
left: 50% !important;
|
||||
transform: translate(-50%, -50%) !important;
|
||||
width: 600px !important;
|
||||
height: 400px !important;
|
||||
max-width: 90% !important;
|
||||
max-height: 80% !important;
|
||||
z-index: 300000 !important;
|
||||
pointer-events: auto !important;
|
||||
position: absolute !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
bottom: 0 !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
transform: none !important;
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
border-radius: 0 0 12px 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -444,8 +621,8 @@ body > div[data-reka-focus-guard] {
|
||||
z-index: 9999 !important;
|
||||
}
|
||||
|
||||
/* 确保聊天对话框内的弹出层也能正确显示 */
|
||||
.chat-dialog [data-reka-popper-content-wrapper] {
|
||||
/* 确保聊天窗口内的弹出层也能正确显示 */
|
||||
.chat-floating-window [data-reka-popper-content-wrapper] {
|
||||
z-index: 9999 !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,756 @@
|
||||
<template>
|
||||
<el-drawer
|
||||
v-model="visible"
|
||||
title="中医处方单"
|
||||
size="900px"
|
||||
:close-on-click-modal="false"
|
||||
@close="handleClose"
|
||||
>
|
||||
<div v-if="!savedPrescription" class="prescription-edit">
|
||||
<el-form ref="formRef" :model="formData" label-width="100px" class="prescription-form">
|
||||
<div class="info-section">
|
||||
<div class="section-title">患者信息</div>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="8">
|
||||
<el-form-item label="姓名">
|
||||
<span>{{ formData.patient_name }}</span>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="性别">
|
||||
<span>{{ formData.gender_desc }}</span>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="年龄">
|
||||
<span>{{ formData.age }}</span>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="电话">
|
||||
<span>{{ formData.phone }}</span>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="门诊号">
|
||||
<el-input v-model="formData.visit_no" placeholder="门诊号" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<div class="info-section">
|
||||
<div class="section-title">诊断信息</div>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="脉象" prop="pulse">
|
||||
<el-input v-model="formData.pulse" placeholder="脉象" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="舌象" prop="tongue">
|
||||
<el-input v-model="formData.tongue" placeholder="舌象" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="临床诊断" prop="clinical_diagnosis">
|
||||
<el-input
|
||||
v-model="formData.clinical_diagnosis"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="请输入临床诊断"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<div class="info-section">
|
||||
<div class="section-title">
|
||||
中药处方 (RP)
|
||||
<el-button type="primary" size="small" @click="handleAddHerb">添加中药</el-button>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<el-form-item label="处方价格" prop="amount" class="!mb-0">
|
||||
<el-input-number
|
||||
v-model="formData.amount"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
placeholder="请输入处方总价(元)"
|
||||
class="!w-40"
|
||||
/>
|
||||
<span class="ml-2 text-gray-500">元</span>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div v-for="(herb, index) in formData.herbs" :key="index" class="herb-item">
|
||||
<el-form-item :label="`中药${index + 1}`">
|
||||
<div class="flex gap-2 items-center flex-wrap">
|
||||
<el-input
|
||||
v-model="herb.name"
|
||||
placeholder="药品名称"
|
||||
style="width: 160px"
|
||||
/>
|
||||
<el-input-number
|
||||
v-model="herb.dosage"
|
||||
:min="0.1"
|
||||
:step="1"
|
||||
:precision="1"
|
||||
placeholder="剂量(克)"
|
||||
style="width: 120px"
|
||||
/>
|
||||
<span class="text-gray-500">克</span>
|
||||
<el-button type="danger" size="small" link @click="handleRemoveHerb(index)">
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-section">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="8">
|
||||
<el-form-item label="剂数" prop="dose_count">
|
||||
<el-input-number v-model="formData.dose_count" :min="1" :max="30" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="16">
|
||||
<el-form-item label="用法" prop="usage_instruction">
|
||||
<el-input v-model="formData.usage_instruction" placeholder="如:水煎服一日二次" />
|
||||
</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="24">
|
||||
<el-form-item label="医师签名" prop="doctor_signature" required>
|
||||
<div class="signature-pad-wrap">
|
||||
<canvas
|
||||
ref="signatureCanvasRef"
|
||||
class="signature-canvas"
|
||||
width="280"
|
||||
height="120"
|
||||
@mousedown="onSignatureStart"
|
||||
@mousemove="onSignatureMove"
|
||||
@mouseup="onSignatureEnd"
|
||||
@mouseleave="onSignatureEnd"
|
||||
@touchstart.prevent="onSignatureStart"
|
||||
@touchmove.prevent="onSignatureMove"
|
||||
@touchend.prevent="onSignatureEnd"
|
||||
/>
|
||||
<div class="signature-actions">
|
||||
<el-button size="small" @click="clearSignature">清空签名</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-amber-600 mt-1">* 必填,请在框内手写医师签名</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
</div>
|
||||
|
||||
<div v-else class="prescription-preview">
|
||||
<div ref="prescriptionPrintRef" class="prescription-print">
|
||||
<div class="prescription-header">
|
||||
<h2 class="prescription-title">{{ templateConfig.stationName }}处方笺</h2>
|
||||
<div class="prescription-type">
|
||||
<div>普通处方</div>
|
||||
<div class="text-sm">当日有效</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="prescription-meta">
|
||||
<span>日期: {{ savedPrescription.prescription_date }}</span>
|
||||
<span class="ml-4">门诊号: {{ savedPrescription.visit_no }}</span>
|
||||
</div>
|
||||
<div class="prescription-patient">
|
||||
<span>姓名: {{ savedPrescription.patient_name }}</span>
|
||||
<span>性别: {{ savedPrescription.gender_desc }}</span>
|
||||
<span>年龄: {{ savedPrescription.age }}</span>
|
||||
<span>电话: {{ savedPrescription.phone }}</span>
|
||||
</div>
|
||||
<div class="prescription-patient">
|
||||
<span>脉象: {{ savedPrescription.pulse || '—' }}</span>
|
||||
<span>舌象: {{ savedPrescription.tongue || '—' }}</span>
|
||||
</div>
|
||||
<div class="prescription-patient">
|
||||
<span>临床诊断: {{ savedPrescription.clinical_diagnosis }}</span>
|
||||
</div>
|
||||
<div class="prescription-rp">
|
||||
<div class="rp-label">RP</div>
|
||||
<div class="herbs-grid">
|
||||
<div
|
||||
v-for="(h, i) in savedPrescription.herbs"
|
||||
:key="i"
|
||||
class="herb-cell"
|
||||
>
|
||||
{{ h.name }} {{ h.dosage }}克
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="prescription-disclaimer" v-if="templateConfig.showDisclaimer">
|
||||
{{ templateConfig.disclaimer }}
|
||||
</div>
|
||||
<div class="prescription-footer">
|
||||
<div class="footer-left">
|
||||
<div class="footer-dosage">
|
||||
共 {{ savedPrescription.dose_count }} 剂, {{ savedPrescription.usage_instruction }}
|
||||
</div>
|
||||
<div class="footer-amount">金额 {{ savedPrescription.amount }}</div>
|
||||
</div>
|
||||
<div class="footer-right footer-sign">
|
||||
<span class="signature-cell">
|
||||
医师
|
||||
<img
|
||||
v-if="savedPrescription.doctor_signature"
|
||||
:src="savedPrescription.doctor_signature"
|
||||
alt="医师签名"
|
||||
class="doctor-signature-img"
|
||||
/>
|
||||
<span v-else>{{ savedPrescription.doctor_name || '医师' }}</span>
|
||||
</span>
|
||||
<span>核对发药</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 mt-4">
|
||||
<el-button @click="handleDownloadImage">
|
||||
<el-icon><Download /></el-icon>
|
||||
下载图片
|
||||
</el-button>
|
||||
<el-button type="primary" @click="handleDownloadPdf">
|
||||
<el-icon><Document /></el-icon>
|
||||
下载PDF
|
||||
</el-button>
|
||||
<el-button @click="handleNewPrescription">新建处方</el-button>
|
||||
<el-button @click="handleClose">关闭</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="!savedPrescription" #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button type="primary" @click="handleSave" :loading="saving">保存处方</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import feedback from '@/utils/feedback'
|
||||
import { prescriptionAdd, prescriptionDetail, prescriptionGetByAppointment } from '@/api/tcm'
|
||||
import { getDictData } from '@/api/app'
|
||||
import html2canvas from 'html2canvas'
|
||||
import { jsPDF } from 'jspdf'
|
||||
import useUserStore from '@/stores/modules/user'
|
||||
import { Download, Document } from '@element-plus/icons-vue'
|
||||
|
||||
interface Herb {
|
||||
name: string
|
||||
dosage: number
|
||||
}
|
||||
|
||||
interface PrescriptionForm {
|
||||
diagnosis_id: number
|
||||
appointment_id: number
|
||||
patient_name: string
|
||||
gender: number
|
||||
gender_desc: string
|
||||
age: number
|
||||
phone: string
|
||||
visit_no: string
|
||||
prescription_date: string
|
||||
pulse: string
|
||||
tongue: string
|
||||
clinical_diagnosis: string
|
||||
herbs: Herb[]
|
||||
dose_count: number
|
||||
usage_instruction: string
|
||||
amount: number
|
||||
doctor_name: string
|
||||
doctor_signature: string
|
||||
}
|
||||
|
||||
const emit = defineEmits(['success'])
|
||||
|
||||
const visible = ref(false)
|
||||
const formRef = ref()
|
||||
const saving = ref(false)
|
||||
const prescriptionPrintRef = ref<HTMLElement>()
|
||||
const signatureCanvasRef = ref<HTMLCanvasElement>()
|
||||
const isDrawing = ref(false)
|
||||
const savedPrescription = ref<any>(null)
|
||||
|
||||
const templateConfig = reactive({
|
||||
stationName: '卫生服务站',
|
||||
showDisclaimer: true,
|
||||
disclaimer: ''
|
||||
})
|
||||
|
||||
const formData = reactive<PrescriptionForm>({
|
||||
diagnosis_id: 0,
|
||||
appointment_id: 0,
|
||||
patient_name: '',
|
||||
gender: 0,
|
||||
gender_desc: '女',
|
||||
age: 0,
|
||||
phone: '',
|
||||
visit_no: '',
|
||||
prescription_date: '',
|
||||
pulse: '',
|
||||
tongue: '',
|
||||
clinical_diagnosis: '',
|
||||
herbs: [],
|
||||
dose_count: 1,
|
||||
usage_instruction: '水煎服一日二次',
|
||||
amount: 0,
|
||||
doctor_name: '',
|
||||
doctor_signature: ''
|
||||
})
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
const diagnosisTypeOptions = ref<any[]>([])
|
||||
const syndromeTypeOptions = ref<any[]>([])
|
||||
|
||||
const getDictLabel = (options: any[], value: string) => {
|
||||
if (!value) return ''
|
||||
const item = options.find((opt: any) => opt.value === value)
|
||||
return item ? item.name : value
|
||||
}
|
||||
|
||||
const loadDictOptions = async () => {
|
||||
try {
|
||||
const [dt, st] = await Promise.all([
|
||||
getDictData({ type: 'diagnosis_type' }),
|
||||
getDictData({ type: 'syndrome_type' })
|
||||
])
|
||||
diagnosisTypeOptions.value = dt?.diagnosis_type || []
|
||||
syndromeTypeOptions.value = st?.syndrome_type || []
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
const buildClinicalDiagnosis = (diagnosis: any) => {
|
||||
if (diagnosis?.symptoms?.trim()) return diagnosis.symptoms.trim()
|
||||
const dtLabel = getDictLabel(diagnosisTypeOptions.value, diagnosis?.diagnosis_type)
|
||||
const stLabel = getDictLabel(syndromeTypeOptions.value, diagnosis?.syndrome_type)
|
||||
const parts = [dtLabel, stLabel].filter(Boolean)
|
||||
return parts.length ? parts.join(' ') : ''
|
||||
}
|
||||
|
||||
const open = async (data: any, options?: { templateId?: number; stationName?: string }) => {
|
||||
if (options?.stationName) templateConfig.stationName = options.stationName
|
||||
|
||||
const diagnosis = data.diagnosis || data
|
||||
const diagnosisId = data.diagnosis_id ?? diagnosis?.id ?? data.id
|
||||
if (!diagnosisId) {
|
||||
feedback.msgWarning('缺少诊单信息')
|
||||
return
|
||||
}
|
||||
|
||||
await loadDictOptions()
|
||||
|
||||
const appointmentId = Number(data.id ?? data.appointment_id ?? 0)
|
||||
if (appointmentId) {
|
||||
try {
|
||||
const existing = await prescriptionGetByAppointment({ appointment_id: appointmentId })
|
||||
if (existing?.id) {
|
||||
const detail = await prescriptionDetail({ id: existing.id })
|
||||
savedPrescription.value = detail
|
||||
visible.value = true
|
||||
return
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
formData.diagnosis_id = Number(diagnosisId)
|
||||
formData.appointment_id = appointmentId
|
||||
formData.patient_name = data.patient_name || diagnosis.patient_name || ''
|
||||
formData.gender = data.patient_gender ?? diagnosis.gender ?? 0
|
||||
formData.gender_desc = formData.gender === 1 ? '男' : '女'
|
||||
formData.age = data.patient_age ?? diagnosis.age ?? 0
|
||||
formData.phone = data.patient_phone || diagnosis.phone || ''
|
||||
formData.visit_no = data.id ? `1K${String(data.id).padStart(8, '0')}` : ''
|
||||
formData.prescription_date = new Date().toISOString().slice(0, 10)
|
||||
formData.pulse = diagnosis.pulse || ''
|
||||
formData.tongue = diagnosis.tongue_coating || diagnosis.tongue || ''
|
||||
formData.clinical_diagnosis = buildClinicalDiagnosis(diagnosis)
|
||||
formData.herbs = []
|
||||
formData.dose_count = 1
|
||||
formData.usage_instruction = '水煎服一日二次'
|
||||
formData.amount = 0
|
||||
formData.doctor_name = userStore.userInfo?.name || ''
|
||||
formData.doctor_signature = ''
|
||||
|
||||
savedPrescription.value = null
|
||||
visible.value = true
|
||||
setTimeout(() => initSignatureCanvas(), 100)
|
||||
}
|
||||
|
||||
const openById = async (prescriptionId: number) => {
|
||||
try {
|
||||
const res = await prescriptionDetail({ id: prescriptionId })
|
||||
savedPrescription.value = res
|
||||
visible.value = true
|
||||
} catch (e) {
|
||||
feedback.msgError('加载处方失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
visible.value = false
|
||||
savedPrescription.value = null
|
||||
}
|
||||
|
||||
const getSignatureCoords = (e: MouseEvent | TouchEvent) => {
|
||||
const canvas = signatureCanvasRef.value
|
||||
if (!canvas) return { x: 0, y: 0 }
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const scaleX = canvas.width / rect.width
|
||||
const scaleY = canvas.height / rect.height
|
||||
if (e instanceof TouchEvent) {
|
||||
const touch = e.touches[0] || e.changedTouches[0]
|
||||
return {
|
||||
x: (touch.clientX - rect.left) * scaleX,
|
||||
y: (touch.clientY - rect.top) * scaleY
|
||||
}
|
||||
}
|
||||
return {
|
||||
x: (e.clientX - rect.left) * scaleX,
|
||||
y: (e.clientY - rect.top) * scaleY
|
||||
}
|
||||
}
|
||||
|
||||
const onSignatureStart = (e: MouseEvent | TouchEvent) => {
|
||||
isDrawing.value = true
|
||||
const ctx = signatureCanvasRef.value?.getContext('2d')
|
||||
if (!ctx) return
|
||||
const { x, y } = getSignatureCoords(e)
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x, y)
|
||||
}
|
||||
|
||||
const onSignatureMove = (e: MouseEvent | TouchEvent) => {
|
||||
if (!isDrawing.value) return
|
||||
const ctx = signatureCanvasRef.value?.getContext('2d')
|
||||
if (!ctx) return
|
||||
const { x, y } = getSignatureCoords(e)
|
||||
ctx.lineTo(x, y)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
const onSignatureEnd = () => {
|
||||
isDrawing.value = false
|
||||
const canvas = signatureCanvasRef.value
|
||||
if (canvas) {
|
||||
formData.doctor_signature = canvas.toDataURL('image/png')
|
||||
}
|
||||
}
|
||||
|
||||
const clearSignature = () => {
|
||||
const canvas = signatureCanvasRef.value
|
||||
if (canvas) {
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (ctx) {
|
||||
ctx.fillStyle = '#fff'
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
}
|
||||
formData.doctor_signature = ''
|
||||
}
|
||||
}
|
||||
|
||||
const initSignatureCanvas = () => {
|
||||
const canvas = signatureCanvasRef.value
|
||||
if (canvas) {
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (ctx) {
|
||||
ctx.fillStyle = '#fff'
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
ctx.strokeStyle = '#333'
|
||||
ctx.lineWidth = 2
|
||||
ctx.lineCap = 'round'
|
||||
ctx.lineJoin = 'round'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddHerb = () => {
|
||||
formData.herbs.push({ name: '', dosage: 6 })
|
||||
}
|
||||
|
||||
const handleRemoveHerb = (index: number) => {
|
||||
formData.herbs.splice(index, 1)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formData.clinical_diagnosis?.trim()) {
|
||||
feedback.msgWarning('请输入临床诊断')
|
||||
return
|
||||
}
|
||||
if (formData.herbs.length === 0) {
|
||||
feedback.msgWarning('请至少添加一味中药')
|
||||
return
|
||||
}
|
||||
for (const h of formData.herbs) {
|
||||
if (!h.name?.trim()) {
|
||||
feedback.msgWarning('请填写中药名称')
|
||||
return
|
||||
}
|
||||
if (!h.dosage || h.dosage <= 0) {
|
||||
feedback.msgWarning(`请填写「${h.name}」的剂量`)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (!formData.doctor_signature?.trim()) {
|
||||
feedback.msgWarning('请使用电子签名板手写医师签名')
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const res = await prescriptionAdd({
|
||||
diagnosis_id: formData.diagnosis_id,
|
||||
appointment_id: formData.appointment_id,
|
||||
patient_name: formData.patient_name,
|
||||
gender: formData.gender,
|
||||
age: formData.age,
|
||||
phone: formData.phone,
|
||||
visit_no: formData.visit_no,
|
||||
prescription_date: formData.prescription_date,
|
||||
pulse: formData.pulse,
|
||||
tongue: formData.tongue,
|
||||
clinical_diagnosis: formData.clinical_diagnosis,
|
||||
herbs: formData.herbs,
|
||||
dose_count: formData.dose_count,
|
||||
usage_instruction: formData.usage_instruction,
|
||||
amount: formData.amount,
|
||||
doctor_name: formData.doctor_name,
|
||||
doctor_signature: formData.doctor_signature
|
||||
})
|
||||
const id = res?.id
|
||||
if (id) {
|
||||
const detail = await prescriptionDetail({ id })
|
||||
savedPrescription.value = detail
|
||||
emit('success')
|
||||
} else {
|
||||
feedback.msgError('保存失败')
|
||||
}
|
||||
} catch (e: any) {
|
||||
feedback.msgError(e?.msg || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleNewPrescription = () => {
|
||||
savedPrescription.value = null
|
||||
}
|
||||
|
||||
const captureElement = async (): Promise<HTMLCanvasElement> => {
|
||||
const el = prescriptionPrintRef.value
|
||||
if (!el) throw new Error('未找到处方内容')
|
||||
return html2canvas(el, {
|
||||
scale: 2,
|
||||
useCORS: true,
|
||||
logging: false,
|
||||
backgroundColor: '#ffffff'
|
||||
})
|
||||
}
|
||||
|
||||
const handleDownloadImage = async () => {
|
||||
try {
|
||||
const canvas = await captureElement()
|
||||
const link = document.createElement('a')
|
||||
link.download = `处方_${savedPrescription.value?.patient_name || '未命名'}_${Date.now()}.png`
|
||||
link.href = canvas.toDataURL('image/png')
|
||||
link.click()
|
||||
feedback.msgSuccess('图片已下载')
|
||||
} catch (e) {
|
||||
feedback.msgError('下载图片失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownloadPdf = async () => {
|
||||
try {
|
||||
const canvas = await captureElement()
|
||||
const imgData = canvas.toDataURL('image/png')
|
||||
const pdf = new jsPDF({
|
||||
orientation: 'portrait',
|
||||
unit: 'mm',
|
||||
format: 'a5'
|
||||
})
|
||||
const pageW = pdf.internal.pageSize.getWidth()
|
||||
const pageH = pdf.internal.pageSize.getHeight()
|
||||
const scale = Math.min(pageW / canvas.width, pageH / canvas.height)
|
||||
const imgW = canvas.width * scale
|
||||
const imgH = canvas.height * scale
|
||||
pdf.addImage(imgData, 'PNG', 0, 0, imgW, imgH)
|
||||
pdf.save(`处方_${savedPrescription.value?.patient_name || '未命名'}_${Date.now()}.pdf`)
|
||||
feedback.msgSuccess('PDF已下载')
|
||||
} catch (e) {
|
||||
feedback.msgError('下载PDF失败')
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
open,
|
||||
openById
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.prescription-form {
|
||||
.info-section {
|
||||
margin-bottom: 20px;
|
||||
padding: 16px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 6px;
|
||||
|
||||
.section-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
color: #303133;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.herb-item {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.signature-pad-wrap {
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
|
||||
.signature-canvas {
|
||||
display: block;
|
||||
cursor: crosshair;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.signature-actions {
|
||||
padding: 8px;
|
||||
border-top: 1px solid #ebeef5;
|
||||
}
|
||||
}
|
||||
|
||||
.prescription-print {
|
||||
width: 148mm;
|
||||
min-height: 210mm;
|
||||
padding: 20px;
|
||||
background: #fff;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
margin: 0 auto;
|
||||
box-sizing: border-box;
|
||||
|
||||
.prescription-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #ddd;
|
||||
|
||||
.prescription-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.prescription-type {
|
||||
text-align: right;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.prescription-meta {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.prescription-patient {
|
||||
margin-bottom: 6px;
|
||||
|
||||
span {
|
||||
margin-right: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.prescription-rp {
|
||||
margin: 16px 0;
|
||||
padding: 12px;
|
||||
border: 1px solid #eee;
|
||||
|
||||
.rp-label {
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.herbs-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px 16px;
|
||||
}
|
||||
|
||||
.herb-cell {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.prescription-disclaimer {
|
||||
text-align: center;
|
||||
color: #e6a23c;
|
||||
font-size: 12px;
|
||||
margin: 12px 0;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.prescription-footer {
|
||||
margin-top: 16px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px dashed #ddd;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 24px;
|
||||
|
||||
.footer-left {
|
||||
.footer-dosage,
|
||||
.footer-amount {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.footer-right.footer-sign {
|
||||
text-align: right;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.signature-cell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.doctor-signature-img {
|
||||
height: 28px;
|
||||
max-width: 100px;
|
||||
object-fit: contain;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user