新增功能

This commit is contained in:
Your Name
2026-03-14 15:39:34 +08:00
parent 4ddee40675
commit 4a853ba237
27 changed files with 2906 additions and 74 deletions
@@ -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>