Files
zyt/admin/src/components/tcm-prescription/index.vue
T
2026-03-18 14:53:09 +08:00

873 lines
30 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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="12">
<el-form-item label="数量规格" prop="dose_count">
<div class="flex gap-2 items-center">
<el-input-number v-model="formData.dose_count" :min="1" :max="999" class="!w-28" />
<el-select v-model="formData.dose_unit" placeholder="单位" class="!w-24">
<el-option v-for="u in doseUnitOptions" :key="u" :label="u" :value="u" />
</el-select>
</div>
</el-form-item>
</el-col>
<el-col :span="12">
<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">
<el-tag v-if="savedPrescription.void_status === 1" type="danger" size="small">已作废</el-tag>
<template v-else>
<div>普通处方</div>
<div class="text-sm">当日有效</div>
</template>
</div>
</div>
<div v-if="savedPrescription.void_status === 1" class="prescription-void-info">
作废人{{ savedPrescription.void_by_name || '—' }}作废时间{{ formatVoidTime(savedPrescription.void_time) }}
</div>
<div class="prescription-info">
<div class="info-row">
<span class="info-item"><em>日期</em>{{ savedPrescription.prescription_date }}</span>
<span class="info-item"><em>电话</em>{{ savedPrescription.phone }}</span>
<span class="info-item"><em>门诊号</em>{{ savedPrescription.visit_no }}</span>
</div>
<div class="info-row">
<span class="info-item"><em>姓名</em>{{ savedPrescription.patient_name }}</span>
<span class="info-item"><em>性别</em>{{ savedPrescription.gender_desc }}</span>
<span class="info-item"><em>年龄</em>{{ savedPrescription.age != null ? savedPrescription.age + '岁' : '—' }}</span>
</div>
<div class="info-row">
<span class="info-item" v-if="savedPrescription.pulse"><em>面象</em>{{ savedPrescription.pulse || '—' }}</span>
<span class="info-item" v-if="savedPrescription.tongue"><em>舌象</em>{{ savedPrescription.tongue || '—' }}</span>
</div>
<div class="info-row info-row-full">
<span class="info-item"><em>临床诊断</em>{{ savedPrescription.clinical_diagnosis }}</span>
</div>
</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.dose_unit || '剂' }}, {{ 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
v-if="savedPrescription.void_status !== 1"
type="danger"
plain
:loading="voiding"
@click="handleVoid"
>
作废处方
</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, prescriptionVoid } 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
dose_unit: string
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 voiding = 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,
dose_unit: '剂',
usage_instruction: '水煎服一日二次',
amount: 0,
doctor_name: '',
doctor_signature: ''
})
const userStore = useUserStore()
const doseUnitOptions = ['剂', '丸', '袋', '盒', '瓶', '膏', '贴', '包', '片', '粒']
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.dose_unit = '剂'
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,
dose_unit: formData.dose_unit || '剂',
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 formatVoidTime = (ts: number | null | undefined) => {
if (!ts) return '—'
const d = new Date(ts * 1000)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`
}
const handleVoid = async () => {
const id = savedPrescription.value?.id
if (!id) return
try {
await feedback.confirm('确定要作废该处方吗?作废后不可恢复。')
voiding.value = true
await prescriptionVoid({ id })
feedback.msgSuccess('作废成功')
const detail = await prescriptionDetail({ id })
savedPrescription.value = detail
emit('success')
} catch (e: any) {
if (e !== 'cancel' && e?.message !== 'cancel') feedback.msgError(e?.msg || '作废失败')
} finally {
voiding.value = false
}
}
const handleNewPrescription = () => {
const saved = savedPrescription.value
if (saved) {
// 将患者信息、诊断信息填入表单,新建处方时保留
formData.diagnosis_id = saved.diagnosis_id ?? formData.diagnosis_id
formData.appointment_id = saved.appointment_id ?? formData.appointment_id
formData.patient_name = saved.patient_name || ''
formData.gender = saved.gender ?? 0
formData.gender_desc = saved.gender === 1 ? '男' : '女'
formData.age = saved.age ?? 0
formData.phone = saved.phone || ''
formData.visit_no = saved.visit_no || ''
formData.prescription_date = new Date().toISOString().slice(0, 10)
formData.pulse = saved.pulse || ''
formData.tongue = saved.tongue || ''
formData.clinical_diagnosis = saved.clinical_diagnosis || ''
formData.dose_count = 1
formData.dose_unit = saved.dose_unit || '剂'
formData.usage_instruction = '水煎服一日二次'
formData.amount = 0
formData.doctor_name = userStore.userInfo?.name || saved.doctor_name || ''
formData.doctor_signature = ''
formData.herbs = []
setTimeout(() => initSignatureCanvas(), 100)
}
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: 12px;
padding-bottom: 12px;
border-bottom: 1px solid #ddd;
}
.prescription-void-info {
font-size: 13px;
color: var(--el-color-danger);
margin-bottom: 12px;
padding: 8px 12px;
background: #fef0f0;
border-radius: 4px;
.prescription-title {
font-size: 20px;
font-weight: 600;
margin: 0;
}
.prescription-type {
text-align: right;
font-size: 14px;
}
}
.prescription-info {
margin-bottom: 16px;
padding: 12px 0;
border-bottom: 1px solid #eee;
}
.info-row {
display: flex;
margin-bottom: 10px;
&:last-child {
margin-bottom: 0;
}
.info-item {
flex: 1;
min-width: 0;
}
}
.info-row-full {
.info-item {
flex: 1;
min-width: 100%;
}
}
.info-item {
display: inline-flex;
align-items: baseline;
gap: 6px;
font-size: 14px;
em {
font-style: normal;
color: #909399;
min-width: 56px;
}
}
.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>