Files
zyt/admin/src/components/tcm-prescription/index.vue
T
2026-03-20 13:56:40 +08:00

1249 lines
50 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 class="prescription-preview-content">
<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 class="prescription-area">
<div >成都双流甄养堂互联网医院有限公司 联系方式4001667339</div>
<div >地址 四川省成都市双流区黄甲街道黄龙大道二段280号</div>
</div>
</div>
<!-- 详细病历 - 单独A3纸张 -->
<div v-if="savedPrescription.case_record" ref="caseRecordPrintRef" class="case-record-a3">
<div class="case-record-a3-inner">
<h2 class="case-record-a3-title">{{ templateConfig.stationName }} 详细病历</h2>
<div class="case-record-a3-body">
<!-- 基本信息 -->
<div class="cr-section">
<div class="cr-section-title">基本信息</div>
<div class="cr-grid">
<div class="cr-item"><span class="cr-label">诊单ID</span>{{ savedPrescription.case_record.patient_id || '—' }}</div>
<div class="cr-item"><span class="cr-label">姓名</span>{{ savedPrescription.case_record.patient_name || '—' }}</div>
<div class="cr-item"><span class="cr-label">身份证号</span>{{ savedPrescription.case_record.id_card || '—' }}</div>
<div class="cr-item"><span class="cr-label">手机号</span>{{ savedPrescription.case_record.phone || '—' }}</div>
<div class="cr-item"><span class="cr-label">性别</span>{{ savedPrescription.case_record.gender === 1 ? '男' : '女' }}</div>
<div class="cr-item"><span class="cr-label">年龄</span>{{ savedPrescription.case_record.age ?? '—' }}</div>
<div class="cr-item"><span class="cr-label">婚姻状态</span>{{ ['未婚','已婚','离异'][savedPrescription.case_record.marital_status] ?? '—' }}</div>
<div class="cr-item"><span class="cr-label">身高</span>{{ savedPrescription.case_record.height ?? '—' }}cm</div>
<div class="cr-item"><span class="cr-label">体重</span>{{ savedPrescription.case_record.weight ?? '—' }}kg</div>
<div class="cr-item"><span class="cr-label">地区</span>{{ savedPrescription.case_record.region || '—' }}</div>
</div>
</div>
<!-- 生命体征 -->
<div class="cr-section">
<div class="cr-section-title">生命体征</div>
<div class="cr-grid">
<div class="cr-item"><span class="cr-label">血压</span>{{ (savedPrescription.case_record.systolic_pressure != null && savedPrescription.case_record.diastolic_pressure != null) ? (savedPrescription.case_record.systolic_pressure + '/' + savedPrescription.case_record.diastolic_pressure + ' mmHg') : '—' }}</div>
<div class="cr-item"><span class="cr-label">空腹血糖</span>{{ savedPrescription.case_record.fasting_blood_sugar != null ? savedPrescription.case_record.fasting_blood_sugar + ' mmol/L' : '—' }}</div>
</div>
</div>
<!-- 主诉 -->
<div class="cr-section">
<div class="cr-section-title">主诉</div>
<div class="cr-grid">
<div class="cr-item"><span class="cr-label">诊断日期</span>{{ savedPrescription.case_record.diagnosis_date || '—' }}</div>
<div class="cr-item"><span class="cr-label">诊断类型</span>{{ getDictLabel(diagnosisTypeOptions, savedPrescription.case_record.diagnosis_type) || '—' }}</div>
<div class="cr-item"><span class="cr-label">证型</span>{{ getDictLabel(syndromeTypeOptions, savedPrescription.case_record.syndrome_type) || '—' }}</div>
<div class="cr-item"><span class="cr-label">糖尿病期数</span>{{ getDictLabel(diabetesTypeOptions, savedPrescription.case_record.diabetes_type) || '—' }}</div>
<div class="cr-item"><span class="cr-label">发现糖尿病患病史</span>{{ savedPrescription.case_record.diabetes_discovery_year != null ? savedPrescription.case_record.diabetes_discovery_year + ' 年' : '—' }}</div>
<div class="cr-item cr-full"><span class="cr-label">当地医院诊断</span>{{ formatLocalDiagnosis(savedPrescription.case_record.local_hospital_diagnosis) }}</div>
<div class="cr-item cr-full"><span class="cr-label">当地就诊医院</span>{{ savedPrescription.case_record.local_hospital_name || '—' }}</div>
</div>
</div>
<!-- 现病史 -->
<div class="cr-section">
<div class="cr-section-title">现病史</div>
<div class="cr-grid">
<div class="cr-item cr-full"><span class="cr-label">口腔感觉</span>{{ getMultiLabels(appetiteOptions, savedPrescription.case_record.appetite) }}</div>
<div class="cr-item cr-full"><span class="cr-label">每日饮水量</span>{{ getDictLabel(waterIntakeOptions, savedPrescription.case_record.water_intake) || '—' }}</div>
<div class="cr-item cr-full"><span class="cr-label">体重变化</span>{{ getDictLabel(weightChangeOptions, savedPrescription.case_record.weight_change) || '—' }}</div>
<div class="cr-item cr-full"><span class="cr-label">脂肪肝程度</span>{{ getDictLabel(fattyLiverDegreeOptions, savedPrescription.case_record.fatty_liver_degree) || '—' }}</div>
<div class="cr-item cr-full"><span class="cr-label">饮食情况</span>{{ getMultiLabels(dietConditionOptions, savedPrescription.case_record.diet_condition) }}</div>
<div class="cr-item cr-full"><span class="cr-label">肢体感觉</span>{{ getMultiLabels(bodyFeelingOptions, savedPrescription.case_record.body_feeling) }}</div>
<div class="cr-item cr-full"><span class="cr-label">睡眠情况</span>{{ getMultiLabels(sleepConditionOptions, savedPrescription.case_record.sleep_condition) }}</div>
<div class="cr-item cr-full"><span class="cr-label">眼睛情况</span>{{ getMultiLabels(eyeConditionOptions, savedPrescription.case_record.eye_condition) }}</div>
<div class="cr-item cr-full"><span class="cr-label">头部感觉</span>{{ getMultiLabels(headFeelingOptions, savedPrescription.case_record.head_feeling) }}</div>
<div class="cr-item cr-full"><span class="cr-label">出汗情况</span>{{ getMultiLabels(sweatConditionOptions, savedPrescription.case_record.sweat_condition) }}</div>
<div class="cr-item cr-full"><span class="cr-label">皮肤情况</span>{{ getMultiLabels(skinConditionOptions, savedPrescription.case_record.skin_condition) }}</div>
<div class="cr-item cr-full"><span class="cr-label">小便情况</span>{{ getMultiLabels(urineConditionOptions, savedPrescription.case_record.urine_condition) }}</div>
<div class="cr-item cr-full"><span class="cr-label">大便情况</span>{{ getMultiLabels(stoolConditionOptions, savedPrescription.case_record.stool_condition) }}</div>
<div class="cr-item cr-full"><span class="cr-label">腰肾情况</span>{{ getMultiLabels(kidneyConditionOptions, savedPrescription.case_record.kidney_condition) }}</div>
<div class="cr-item cr-full"><span class="cr-label">其他补充</span>{{ savedPrescription.case_record.symptoms || '—' }}</div>
</div>
</div>
<!-- 既往史 -->
<div class="cr-section">
<div class="cr-section-title">既往史</div>
<div class="cr-item cr-full"><span class="cr-label"></span>{{ getMultiLabels(pastHistoryOptions, savedPrescription.case_record.past_history) }}</div>
</div>
<!-- 其他病史 -->
<div class="cr-section">
<div class="cr-section-title">其他病史</div>
<div class="cr-grid">
<div class="cr-item"><span class="cr-label">外伤史</span>{{ savedPrescription.case_record.trauma_history === 1 ? '有' : '无' }}</div>
<div class="cr-item"><span class="cr-label">手术史</span>{{ savedPrescription.case_record.surgery_history === 1 ? '有' : '无' }}</div>
<div class="cr-item"><span class="cr-label">过敏史</span>{{ savedPrescription.case_record.allergy_history === 1 ? '有' : '无' }}</div>
<div class="cr-item"><span class="cr-label">家族病史</span>{{ savedPrescription.case_record.family_history === 1 ? '有' : '无' }}</div>
<div class="cr-item"><span class="cr-label">妊娠哺乳史</span>{{ savedPrescription.case_record.pregnancy_history === 1 ? '有' : '无' }}</div>
</div>
</div>
<!-- 诊断信息 -->
<div class="cr-section">
<div class="cr-section-title">诊断信息</div>
<div class="cr-grid" v-if="savedPrescription.case_record.tongue_images?.length">
<div class="cr-item cr-full">
<span class="cr-label">舌苔照片</span>
<div class="cr-images">
<img v-for="(img, idx) in savedPrescription.case_record.tongue_images" :key="idx" :src="getImageUrl(img)" class="cr-img" crossorigin="anonymous" />
</div>
</div>
</div>
<div class="cr-grid">
<div class="cr-item cr-full"><span class="cr-label">舌象</span>{{ savedPrescription.case_record.tongue_coating || '—' }}</div>
</div>
</div>
</div>
</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, tcmDiagnosisDetail } 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'
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
case_record: any
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 caseRecordPrintRef = 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: '',
case_record: null as any,
herbs: [],
dose_count: 1,
dose_unit: '剂',
usage_instruction: '水煎服一日二次',
amount: 0,
doctor_name: '',
doctor_signature: ''
})
const userStore = useUserStore()
const appStore = useAppStore()
const getImageUrl = (url: any) => {
const s = typeof url === 'string' ? url : (url?.url ?? url?.uri ?? url?.path ?? '')
return s ? (String(s).startsWith('http') ? s : appStore.getImageUrl(s)) : ''
}
const doseUnitOptions = ['剂', '丸', '袋', '盒', '瓶', '膏', '贴', '包', '片', '粒']
const diagnosisTypeOptions = ref<any[]>([])
const syndromeTypeOptions = ref<any[]>([])
const diabetesTypeOptions = ref<any[]>([])
const pastHistoryOptions = ref<any[]>([])
const appetiteOptions = ref<any[]>([])
const waterIntakeOptions = ref<any[]>([])
const dietConditionOptions = ref<any[]>([])
const weightChangeOptions = ref<any[]>([])
const bodyFeelingOptions = ref<any[]>([])
const sleepConditionOptions = ref<any[]>([])
const eyeConditionOptions = ref<any[]>([])
const headFeelingOptions = ref<any[]>([])
const sweatConditionOptions = ref<any[]>([])
const skinConditionOptions = ref<any[]>([])
const urineConditionOptions = ref<any[]>([])
const stoolConditionOptions = ref<any[]>([])
const kidneyConditionOptions = ref<any[]>([])
const fattyLiverDegreeOptions = 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, diabetesType, pastHistory, appetite, waterIntake, dietCondition,
weightChange, bodyFeeling, sleepCondition, eyeCondition, headFeeling,
sweatCondition, skinCondition, urineCondition, stoolCondition, kidneyCondition,
fattyLiverDegree
] = await Promise.all([
getDictData({ type: 'diagnosis_type' }),
getDictData({ type: 'syndrome_type' }),
getDictData({ type: 'diabetes_type' }),
getDictData({ type: 'past_history' }),
getDictData({ type: 'appetite' }),
getDictData({ type: 'water_intake' }),
getDictData({ type: 'diet_condition' }),
getDictData({ type: 'weight_change' }),
getDictData({ type: 'body_feeling' }),
getDictData({ type: 'sleep_condition' }),
getDictData({ type: 'eye_condition' }),
getDictData({ type: 'head_feeling' }),
getDictData({ type: 'sweat_condition' }),
getDictData({ type: 'skin_condition' }),
getDictData({ type: 'urine_condition' }),
getDictData({ type: 'stool_condition' }),
getDictData({ type: 'kidney_condition' }),
getDictData({ type: 'fatty_liver_degree' })
])
diagnosisTypeOptions.value = dt?.diagnosis_type || []
syndromeTypeOptions.value = st?.syndrome_type || []
diabetesTypeOptions.value = diabetesType?.diabetes_type || []
pastHistoryOptions.value = pastHistory?.past_history || []
appetiteOptions.value = appetite?.appetite || []
waterIntakeOptions.value = waterIntake?.water_intake || []
dietConditionOptions.value = dietCondition?.diet_condition || []
weightChangeOptions.value = weightChange?.weight_change || []
bodyFeelingOptions.value = bodyFeeling?.body_feeling || []
sleepConditionOptions.value = sleepCondition?.sleep_condition || []
eyeConditionOptions.value = eyeCondition?.eye_condition || []
headFeelingOptions.value = headFeeling?.head_feeling || []
sweatConditionOptions.value = sweatCondition?.sweat_condition || []
skinConditionOptions.value = skinCondition?.skin_condition || []
urineConditionOptions.value = urineCondition?.urine_condition || []
stoolConditionOptions.value = stoolCondition?.stool_condition || []
kidneyConditionOptions.value = kidneyCondition?.kidney_condition || []
fattyLiverDegreeOptions.value = fattyLiverDegree?.fatty_liver_degree || []
} catch (_) {}
}
const getMultiLabels = (options: any[], values: string[] | string | undefined) => {
if (!values || (Array.isArray(values) && values.length === 0)) return '—'
const arr = Array.isArray(values) ? values : (typeof values === 'string' ? values.split(',') : [])
const names = arr.map(v => {
const item = options.find((o: any) => o.value === v)
return item ? item.name : v
}).filter(Boolean)
return names.length ? names.join('、') : '—'
}
const formatLocalDiagnosis = (val: string[] | string | undefined) => {
if (!val || (Array.isArray(val) && val.length === 0)) return '—'
const arr = Array.isArray(val) ? val : (typeof val === 'string' ? val.split(',') : [])
return arr.filter(Boolean).join('、') || '—'
}
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 (_) {}
}
// 获取详细诊单作为病历快照(每次开方保存一份)
let caseRecord = data.case_record && Object.keys(data.case_record).length > 0 ? data.case_record : null
if (!caseRecord && diagnosisId) {
try {
const res = await tcmDiagnosisDetail({ id: Number(diagnosisId) })
caseRecord = res && typeof res === 'object' ? res : null
} catch (e) {
console.warn('获取诊单详情失败:', e)
}
}
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.case_record = caseRecord
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
if (res?.case_record && Object.keys(res.case_record).length > 0) {
await loadDictOptions()
}
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,
case_record: formData.case_record,
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 fetchCaseRecord = async (diagnosisId: number) => {
if (!diagnosisId) return null
try {
const res = await tcmDiagnosisDetail({ id: diagnosisId })
return res && typeof res === 'object' ? res : null
} catch (e) {
console.warn('获取诊单详情失败:', e)
return null
}
}
const handleNewPrescription = async () => {
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 = []
// 新建处方时重新获取病历(旧处方可能没有 case_record
formData.case_record = saved.case_record && Object.keys(saved.case_record).length > 0
? saved.case_record
: await fetchCaseRecord(Number(formData.diagnosis_id))
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'
})
}
/** 将元素内图片转为 base64,确保 html2canvas 能正确绘制 */
const preloadImagesToDataUrl = async (el: HTMLElement): Promise<void> => {
const imgs = el.querySelectorAll<HTMLImageElement>('img[src^="http"]')
const tasks = Array.from(imgs).map(async (img) => {
try {
const res = await fetch(img.src, { mode: 'cors' })
if (!res.ok) return
const blob = await res.blob()
const dataUrl = await new Promise<string>((resolve, reject) => {
const r = new FileReader()
r.onload = () => resolve(r.result as string)
r.onerror = reject
r.readAsDataURL(blob)
})
img.src = dataUrl
} catch {
// 跨域失败时保留原 src,依赖 useCORS
}
})
await Promise.allSettled(tasks)
}
const captureCaseRecordElement = async (): Promise<HTMLCanvasElement | null> => {
const el = caseRecordPrintRef.value
if (!el) return null
el.scrollIntoView({ behavior: 'instant', block: 'nearest' })
await preloadImagesToDataUrl(el)
await new Promise((r) => setTimeout(r, 200))
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)
// 若有详细病历,单独一页A3
const caseCanvas = await captureCaseRecordElement()
if (caseCanvas) {
pdf.addPage('a3', 'p')
const a3W = 297
const a3H = 420
const caseScale = Math.min(a3W / caseCanvas.width, a3H / caseCanvas.height)
const caseImgW = caseCanvas.width * caseScale
const caseImgH = caseCanvas.height * caseScale
pdf.addImage(caseCanvas.toDataURL('image/png'), 'PNG', 0, 0, caseImgW, caseImgH)
}
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;
position: relative;
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid #ddd;
.prescription-title {
flex: 1;
text-align: center;
font-size: 20px;
font-weight: 600;
margin: 0;
}
.prescription-type {
position: absolute;
right: 0;
top: 0;
}
}
.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-area {
border-top: 1px dashed #ddd;
padding-top: 12px;
margin-top: 10px;
}
.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;
}
}
}
.prescription-preview-content {
display: flex;
flex-direction: column;
gap: 24px;
}
/* 详细病历 - 单独A3纸张 */
.case-record-a3 {
width: 297mm;
min-height: 420mm;
padding: 20px;
background: #fff;
font-size: 13px;
color: #333;
margin: 0 auto;
box-sizing: border-box;
border: 1px solid #dcdfe6;
page-break-before: always;
.case-record-a3-inner {
width: 100%;
}
.case-record-a3-title {
font-size: 18px;
font-weight: 600;
text-align: center;
margin: 0 0 20px 0;
padding-bottom: 12px;
border-bottom: 2px solid #333;
}
.case-record-a3-body {
font-size: 12px;
}
.cr-section {
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid #eee;
&:last-child {
border-bottom: none;
}
}
.cr-section-title {
font-weight: 600;
font-size: 14px;
margin-bottom: 10px;
color: #303133;
}
.cr-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px 16px;
}
.cr-item {
display: flex;
align-items: flex-start;
gap: 6px;
line-height: 1.6;
&.cr-full {
grid-column: 1 / -1;
}
}
.cr-label {
flex-shrink: 0;
color: #909399;
min-width: 90px;
}
.cr-images {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
}
.cr-img {
width: 160px;
height: 160px;
object-fit: cover;
border: 1px solid #eee;
border-radius: 4px;
}
}
</style>