Files
zyt/admin/src/components/tcm-prescription/index.vue
T
2026-05-08 13:57:09 +08:00

1877 lines
79 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="drawerTitle"
size="1200px"
: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="12">
<el-form-item label="舌象详情" prop="tongue_image">
<el-input v-model="formData.tongue_image" placeholder="舌象详情" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="脉象详情" prop="pulse_condition">
<el-input v-model="formData.pulse_condition" placeholder="脉象详情" />
</el-form-item>
</el-col> -->
<el-col :span="24">
<el-form-item label="临床诊断" prop="clinical_diagnosis">
<el-input
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>
<el-button type="success" size="small" @click="showLibraryDialog = true">从处方库导入</el-button>
</div>
<div class="mb-3">
<el-form-item label="处方价格" prop="amount" class="!mb-0">
<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 class="herbs-editor-grid">
<div
v-for="(herb, index) in formData.herbs"
:key="'herb-' + index"
class="herb-editor-card"
:class="{ 'herb-editor-card--dup': isDuplicateHerbName(index) }"
>
<div class="herb-editor-card__title">中药 {{ index + 1 }}</div>
<MedicineNameSelect v-model="herb.name" class="herb-editor-card__select" />
<div
v-if="isDuplicateHerbName(index)"
class="herb-editor-card__dup-hint text-amber-600 text-xs mb-1"
>
该药名与其他行重复请合并剂量或删除多余行
</div>
<div class="herb-editor-card__row">
<el-input-number
v-model="herb.dosage"
:min="0.1"
:step="1"
:precision="1"
placeholder="剂量(克)"
class="herb-editor-card__dose"
/>
<span class="text-gray-500 shrink-0"></span>
<el-button type="danger" size="small" link @click="handleRemoveHerb(index)">
删除
</el-button>
</div>
</div>
</div>
</div>
<div class="info-section">
<el-row :gutter="16">
<el-col :span="8">
<el-form-item label="处方类型" prop="prescription_type">
<el-select v-model="formData.prescription_type" placeholder="请选择处方类型" class="!w-full">
<el-option label="浓缩水丸" value="浓缩水丸" />
<el-option label="饮片" value="饮片" />
<el-option label="颗粒" value="颗粒" />
<el-option label="丸剂" value="丸剂" />
<el-option label="散剂" value="散剂" />
<el-option label="膏方" value="膏方" />
<el-option label="汤剂" value="汤剂" />
</el-select>
</el-form-item>
</el-col>
<!-- 用量字段 -->
<el-col :span="8">
<el-form-item label="用量" prop="dosage_amount">
<!-- 浓缩水丸1-10g 下拉选择 -->
<el-select
v-if="formData.prescription_type === '浓缩水丸'"
v-model="formData.dosage_amount"
placeholder="请选择用量"
class="!w-full"
>
<el-option :label="1 + 'g'" :value="1" />
<el-option :label="2 + 'g'" :value="2" />
<el-option :label="3 + 'g'" :value="3" />
<el-option :label="4 + 'g'" :value="4" />
<el-option :label="5 + 'g'" :value="5" />
<el-option :label="6 + 'g'" :value="6" />
<el-option :label="7 + 'g'" :value="7" />
<el-option :label="8 + 'g'" :value="8" />
<el-option :label="9 + 'g'" :value="9" />
<el-option :label="10 + 'g'" :value="10" />
</el-select>
<!-- 饮片50-250ml 下拉选择步进50 -->
<el-select
v-else-if="formData.prescription_type === '饮片'"
v-model="formData.dosage_amount"
placeholder="请选择用量"
class="!w-full"
>
<el-option label="50ml" :value="50" />
<el-option label="100ml" :value="100" />
<el-option label="120ml" :value="120" />
<el-option label="150ml" :value="150" />
<el-option label="180ml" :value="180" />
<el-option label="200ml" :value="200" />
<el-option label="250ml" :value="250" />
</el-select>
<!-- 其他类型自由输入 -->
<el-input-number
v-else
v-model="formData.dosage_amount"
:min="0"
:precision="2"
class="!w-full"
/>
<span v-if="formData.prescription_type !== '浓缩水丸' && formData.prescription_type !== '饮片'" class="ml-2">{{ formData.dosage_unit }}</span>
</el-form-item>
</el-col>
<el-col v-if="formData.prescription_type === '浓缩水丸'" :span="8">
<el-form-item label=" " prop="dosage_bag_count" label-width="12px">
<el-select v-model="formData.dosage_bag_count" placeholder="请选择袋数" class="!w-[120px]">
<el-option label="1袋" :value="1" />
<el-option label="2袋" :value="2" />
<el-option label="3袋" :value="3" />
<el-option label="4袋" :value="4" />
<el-option label="5袋" :value="5" />
</el-select>
</el-form-item>
</el-col>
<!-- 饮片每贴出包数 -->
<el-col v-if="formData.prescription_type === '饮片'" :span="8">
<el-form-item label="每贴出包数" prop="bags_per_dose">
<el-select v-model="formData.bags_per_dose" placeholder="请选择" class="!w-full">
<el-option label="1包" :value="1" />
<el-option label="2包" :value="2" />
<el-option label="3包" :value="3" />
<el-option label="4包" :value="4" />
<el-option label="5包" :value="5" />
<el-option label="6包" :value="6" />
<el-option label="7包" :value="7" />
<el-option label="8包" :value="8" />
<el-option label="9包" :value="9" />
</el-select>
</el-form-item>
</el-col>
<!-- 饮片代煎选项 -->
<el-col v-if="formData.prescription_type === '饮片'" :span="8">
<el-form-item label="是否代煎" prop="need_decoction">
<el-radio-group v-model="formData.need_decoction">
<el-radio :label="true">代煎</el-radio>
<el-radio :label="false">不代煎</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="服用天数" prop="usage_days">
<el-input-number v-model="formData.usage_days" :min="1" :max="365" class="!w-full" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="每天几次" prop="times_per_day">
<el-input-number v-model="formData.times_per_day" :min="1" :max="6" class="!w-full" placeholder="每天服用次数" />
</el-form-item>
</el-col>
<!-- <el-col :span="8">
<el-form-item label="剂数" prop="dose_count">
<el-input-number v-model="formData.dose_count" :min="1" :max="999" class="!w-full" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="剂量单位" prop="dose_unit">
<el-select v-model="formData.dose_unit" placeholder="请选择" class="!w-full">
<el-option label="剂" value="剂" />
<el-option label="丸" value="丸" />
<el-option label="袋" value="袋" />
<el-option label="盒" value="盒" />
<el-option label="瓶" value="瓶" />
<el-option label="膏" value="膏" />
<el-option label="贴" value="贴" />
</el-select>
</el-form-item>
</el-col> -->
<el-col :span="24">
<el-form-item label="用法" prop="usage_instruction">
<el-input v-model="formData.usage_instruction" placeholder="" maxlength="200" show-word-limit />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="服用时间" prop="usage_time">
<el-select v-model="formData.usage_time" placeholder="请选择服用时间" class="!w-full">
<el-option label="饭前" value="饭前" />
<el-option label="饭后" value="饭后" />
<el-option label="饭中" value="饭中" />
<el-option label="空腹" value="空腹" />
<el-option label="睡前" value="睡前" />
<el-option label="晨起" value="晨起" />
<el-option label="随时" value="随时" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="服用方式" prop="usage_way">
<el-select v-model="formData.usage_way" placeholder="请选择服用方式" class="!w-full">
<el-option label="温水送服" value="温水送服" />
<el-option label="开水冲服" value="开水冲服" />
<el-option label="黄酒送服" value="黄酒送服" />
<el-option label="淡盐水送服" value="淡盐水送服" />
<el-option label="米汤送服" value="米汤送服" />
<el-option label="嚼服" value="嚼服" />
<el-option label="含化" value="含化" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="忌口" prop="dietary_taboo">
<el-select v-model="formData.dietary_taboo" multiple placeholder="请选择忌口内容(可多选)" class="!w-full">
<el-option label="辛辣食物" value="辛辣食物" />
<el-option label="生冷食物" value="生冷食物" />
<el-option label="油腻食物" value="油腻食物" />
<el-option label="海鲜" value="海鲜" />
<el-option label="牛羊肉" value="牛羊肉" />
<el-option label="鸡蛋" value="鸡蛋" />
<el-option label="豆制品" value="豆制品" />
<el-option label="酒类" value="酒类" />
<el-option label="浓茶" value="浓茶" />
<el-option label="咖啡" value="咖啡" />
<el-option label="烟草" value="烟草" />
<el-option label="萝卜" value="萝卜" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="其他说明" prop="usage_notes">
<el-input v-model="formData.usage_notes" type="textarea" :rows="2" placeholder="其他服用注意事项" maxlength="200" show-word-limit />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="医师" prop="doctor_name">
<el-input v-model="formData.doctor_name" placeholder="医师姓名" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="是否共享" prop="is_shared">
<el-switch
v-model="formData.is_shared"
:active-value="1"
:inactive-value="0"
active-text="所有人可见"
inactive-text="仅自己可见"
/>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="医师签名" prop="doctor_signature" required>
<div class="signature-pad-wrap">
<canvas
ref="signatureCanvasRef"
class="signature-canvas"
@pointerdown.prevent="onSignaturePointerDown"
@pointermove.prevent="onSignaturePointerMove"
@pointerup.prevent="onSignaturePointerUp"
@pointercancel.prevent="onSignaturePointerUp"
/>
<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>
<el-tag
v-else-if="approvedPreviewOnly"
type="success"
size="small"
class="mr-1"
>已通过</el-tag>
<el-tag
v-else-if="Number(savedPrescription.audit_status) === 0"
type="warning"
size="small"
class="mr-1"
>待审核</el-tag>
<template v-if="savedPrescription.void_status !== 1">
<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.usage_days || savedPrescription.dose_count }}{{ savedPrescription.times_per_day ? ',每天' + savedPrescription.times_per_day + '次' : '' }}{{ savedDosageDescText ? '' + savedDosageDescText : '' }}, {{ savedPrescription.prescription_type }}
{{ savedPrescription.usage_instruction }}
</div>
<div class="footer-dosage">
服用时间: {{ savedPrescription.usage_time ?' ' + savedPrescription.usage_time : '' }}
<text style="margin-left: 20px;"></text>服用方式: {{ savedPrescription.usage_way ? ' ' + savedPrescription.usage_way : '' }}
</div>
<div v-if="savedPrescription.dietary_taboo && savedPrescription.dietary_taboo.length" class="footer-dietary">
忌口{{ Array.isArray(savedPrescription.dietary_taboo) ? savedPrescription.dietary_taboo.join('、') : savedPrescription.dietary_taboo }}
</div>
<div v-if="savedPrescription.usage_notes" class="footer-notes">
{{ savedPrescription.usage_notes }}
</div>
<!-- <div class="footer-amount">金额 {{ savedPrescription.amount }}</div> -->
</div>
<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.gender_desc }}</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>{{ formatDiabetesDiscoveryDisplay(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-tooltip
v-if="savedPrescription.void_status !== 1 && !approvedPreviewOnly"
:disabled="!hasPrescriptionOrderBlockVoid"
content="该处方已存在业务订单,无法作废,请前往已开处方里修改"
placement="top"
>
<span class="inline-block">
<el-button
type="danger"
plain
:disabled="hasPrescriptionOrderBlockVoid"
:loading="voiding"
@click="handleVoid"
>
作废处方
</el-button>
</span>
</el-tooltip>
<el-button v-if="!approvedPreviewOnly" @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>
<!-- 处方库选择弹窗 -->
<el-dialog
v-model="showLibraryDialog"
title="从处方库导入"
width="800px"
:close-on-click-modal="false"
>
<div class="mb-4">
<el-input
v-model="librarySearchName"
placeholder="请输入处方名称搜索"
clearable
@keyup.enter="searchLibrary"
@clear="searchLibrary"
>
<template #append>
<el-button :icon="Search" @click="searchLibrary" />
</template>
</el-input>
</div>
<el-table
v-loading="libraryLoading"
:data="libraryList"
height="400"
@row-click="handleSelectLibrary"
style="cursor: pointer"
>
<el-table-column label="处方名称" prop="prescription_name" min-width="150" show-overflow-tooltip />
<el-table-column label="药材数量" width="100">
<template #default="{ row }">
{{ row.herbs?.length || 0 }}
</template>
</el-table-column>
<el-table-column label="药材明细" min-width="300" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.herbs && row.herbs.length > 0">
{{ row.herbs.map((h: any) => `${h.name} ${h.dosage}g`).join('、') }}
</span>
<span v-else class="text-gray-400">暂无药材</span>
</template>
</el-table-column>
<el-table-column label="创建人" prop="creator_name" width="100" />
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click.stop="handleImportLibrary(row)">
导入
</el-button>
</template>
</el-table-column>
</el-table>
<div class="flex justify-end mt-4">
<el-pagination
v-model:current-page="libraryPage"
v-model:page-size="libraryPageSize"
:total="libraryTotal"
:page-sizes="[10, 15, 20, 50]"
layout="total, sizes, prev, pager, next"
@current-change="loadLibraryList"
@size-change="loadLibraryList"
/>
</div>
</el-dialog>
</template>
<script setup lang="ts">
import MedicineNameSelect from '@/components/medicine-name-select/index.vue'
import feedback from '@/utils/feedback'
import { formatDiabetesDiscoveryDisplay } from '@/utils/diabetes-discovery-display'
import { prescriptionAdd, prescriptionDetail, prescriptionGetByAppointment, prescriptionVoid, tcmDiagnosisDetail, prescriptionLibraryLists } from '@/api/tcm'
import { getDictData } from '@/api/app'
import html2canvas from 'html2canvas'
import { jsPDF } from 'jspdf'
import useUserStore from '@/stores/modules/user'
import useAppStore from '@/stores/modules/app'
import { Download, Document } from '@element-plus/icons-vue'
import { Search } from '@element-plus/icons-vue'
interface Herb {
name: string
dosage: number
}
interface PrescriptionForm {
diagnosis_id: number
appointment_id: number
prescription_name: string
prescription_type: string
dosage_amount: number | undefined // 用量数值
dosage_unit: string // 用量单位 (g 或 ml)
dosage_bag_count: number // 用量袋数(浓缩水丸,1-5袋)
need_decoction: boolean // 是否代煎(仅饮片)
bags_per_dose: number // 每贴出包数(仅饮片,1-9包)
patient_name: string
gender: number
gender_desc: string
age: number
phone: string
visit_no: string
prescription_date: string
pulse: string
tongue: string
tongue_image: string
pulse_condition: string
clinical_diagnosis: string
case_record: any
herbs: Herb[]
dose_count: number
dose_unit: string
usage_days: number
times_per_day: number
usage_instruction: string
usage_time: string
usage_way: string
dietary_taboo: string[]
usage_notes: string
amount: number
doctor_name: string
doctor_signature: string
is_shared: number
}
const emit = defineEmits(['success'])
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>()
/** 当前一笔的 pointerId(配合 setPointerCapture,移出画布仍跟手) */
const signatureActivePointerId = ref<number | null>(null)
/** 签名板 CSS 像素尺寸(内部分辨率按 DPR 放大,触摸更跟手) */
const SIGNATURE_CSS_W = 560
const SIGNATURE_CSS_H = 168
const savedPrescription = ref<any>(null)
/** 已通过且未作废:与消费者处方「查看」一致,仅预览,不可作废/再开新方 */
const approvedPreviewOnly = computed(() => {
const s = savedPrescription.value
if (!s) return false
return Number(s.audit_status) === 1 && Number(s.void_status) !== 1
})
/** 已有关联业务订单(与列表 has_prescription_order 一致)则禁止作废 */
const hasPrescriptionOrderBlockVoid = computed(() => {
const s = savedPrescription.value
if (!s) return false
return Number(s.has_prescription_order) === 1
})
const drawerTitle = computed(() => (approvedPreviewOnly.value ? '查看处方' : '中医处方单'))
/** 处方笺底部"用量/袋数"展示文案:浓缩水丸显示"一次X袋(每袋Yg)",其它类型显示"一次Yg" */
const savedDosageDescText = computed(() => {
const v = savedPrescription.value
if (!v) return ''
if (v.dosage_amount == null || v.dosage_amount === '') return ''
const amount = Number(v.dosage_amount)
const unit = v.dosage_unit || 'g'
if ((v.prescription_type || '浓缩水丸') === '浓缩水丸') {
const bags = Number(v.dosage_bag_count) > 0 ? Number(v.dosage_bag_count) : 1
return `一次${bags}袋(每袋${amount}${unit}`
}
return `一次${amount}${unit}`
})
// 处方库相关
const showLibraryDialog = ref(false)
const libraryLoading = ref(false)
const libraryList = ref<any[]>([])
const librarySearchName = ref('')
// 防止重复打开
const isOpening = ref(false)
const libraryPage = ref(1)
const libraryPageSize = ref(15)
const libraryTotal = ref(0)
const templateConfig = reactive({
stationName: '成都双流甄养堂互联网医院',
showDisclaimer: true,
disclaimer: ''
})
const formData = reactive<PrescriptionForm>({
diagnosis_id: 0,
appointment_id: 0,
prescription_name: '',
prescription_type: '浓缩水丸',
dosage_amount: 10,
dosage_unit: 'g',
dosage_bag_count: 1,
need_decoction: false,
bags_per_dose: 1,
patient_name: '',
gender: 0,
gender_desc: '女',
age: 0,
phone: '',
visit_no: '',
prescription_date: '',
pulse: '',
tongue: '',
tongue_image: '',
pulse_condition: '',
clinical_diagnosis: '',
case_record: null as any,
herbs: [],
dose_count: 1,
dose_unit: '剂',
usage_days: 7,
times_per_day: 2,
usage_instruction: '',
usage_time: '饭后',
usage_way: '温水送服',
dietary_taboo: [],
usage_notes: '',
amount: 0,
doctor_name: '',
doctor_signature: '',
is_shared: 0
})
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 (isOpening.value) {
console.warn('处方正在打开中,请勿重复点击')
return
}
isOpening.value = true
try {
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 })
console.log('-----------',existing)
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.prescription_name = ''
formData.prescription_type = '浓缩水丸'
formData.dosage_unit = 'g'
formData.dosage_amount = 10
formData.dosage_bag_count = 1
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.tongue_image = ''
formData.pulse_condition = ''
formData.clinical_diagnosis = buildClinicalDiagnosis(diagnosis)
formData.case_record = caseRecord
formData.herbs = []
formData.dose_count = 1
formData.dose_unit = '剂'
formData.usage_days = 7
formData.usage_instruction = ''
formData.usage_time = '饭后'
formData.usage_way = '温水送服'
formData.dietary_taboo = []
formData.usage_notes = ''
formData.amount = 0
formData.doctor_name = userStore.userInfo?.name || ''
formData.doctor_signature = ''
formData.is_shared = 0
formData.need_decoction = false
formData.bags_per_dose = 1
savedPrescription.value = null
visible.value = true
setTimeout(() => initSignatureCanvas(), 100)
} finally {
// 延迟重置,避免快速连续点击
setTimeout(() => {
isOpening.value = false
}, 500)
}
}
const openById = async (prescriptionId: number) => {
// 防止重复打开
if (isOpening.value) {
console.warn('处方正在打开中,请勿重复点击')
return
}
isOpening.value = true
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('加载处方失败')
} finally {
// 延迟重置,避免快速连续点击
setTimeout(() => {
isOpening.value = false
}, 500)
}
}
const handleClose = () => {
visible.value = false
savedPrescription.value = null
}
/**
* 签名坐标:与 initSignatureCanvas 里 ctx.scale(dpr) 后的「逻辑像素」一致(0…SIGNATURE_CSS_W/H),
* 不能用 canvas.width/rect.width(会把 dpr 乘两次,笔画和鼠标严重错位)。
*/
const getSignatureLocalPoint = (e: PointerEvent) => {
const canvas = signatureCanvasRef.value
if (!canvas) return { x: 0, y: 0 }
const rect = canvas.getBoundingClientRect()
if (rect.width <= 0 || rect.height <= 0) return { x: 0, y: 0 }
const x = ((e.clientX - rect.left) / rect.width) * SIGNATURE_CSS_W
const y = ((e.clientY - rect.top) / rect.height) * SIGNATURE_CSS_H
return {
x: Math.max(0, Math.min(SIGNATURE_CSS_W, x)),
y: Math.max(0, Math.min(SIGNATURE_CSS_H, y))
}
}
const commitSignatureImage = () => {
const canvas = signatureCanvasRef.value
if (canvas) {
formData.doctor_signature = canvas.toDataURL('image/png')
}
}
const onSignaturePointerDown = (e: PointerEvent) => {
if (e.pointerType === 'mouse' && e.button !== 0) return
const canvas = signatureCanvasRef.value
const ctx = canvas?.getContext('2d')
if (!canvas || !ctx) return
canvas.setPointerCapture(e.pointerId)
signatureActivePointerId.value = e.pointerId
const { x, y } = getSignatureLocalPoint(e)
ctx.beginPath()
ctx.moveTo(x, y)
}
const onSignaturePointerMove = (e: PointerEvent) => {
if (signatureActivePointerId.value !== e.pointerId) return
const ctx = signatureCanvasRef.value?.getContext('2d')
if (!ctx) return
const { x, y } = getSignatureLocalPoint(e)
ctx.lineTo(x, y)
ctx.stroke()
ctx.beginPath()
ctx.moveTo(x, y)
}
const onSignaturePointerUp = (e: PointerEvent) => {
if (signatureActivePointerId.value !== e.pointerId) return
signatureActivePointerId.value = null
const canvas = signatureCanvasRef.value
if (canvas) {
try {
canvas.releasePointerCapture(e.pointerId)
} catch {
/* 已释放或非当前捕获 */
}
}
commitSignatureImage()
}
const clearSignature = () => {
initSignatureCanvas()
formData.doctor_signature = ''
}
const initSignatureCanvas = () => {
const canvas = signatureCanvasRef.value
if (!canvas) return
const dpr = Math.min(typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1, 2.5)
canvas.style.width = `${SIGNATURE_CSS_W}px`
canvas.style.height = `${SIGNATURE_CSS_H}px`
canvas.width = Math.round(SIGNATURE_CSS_W * dpr)
canvas.height = Math.round(SIGNATURE_CSS_H * dpr)
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.setTransform(1, 0, 0, 1, 0, 0)
ctx.scale(dpr, dpr)
ctx.fillStyle = '#fff'
ctx.fillRect(0, 0, SIGNATURE_CSS_W, SIGNATURE_CSS_H)
ctx.strokeStyle = '#333'
ctx.lineWidth = 2.5
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
}
/** 药名去首尾空格后比较,避免「 黄芪 」与「黄芪」判成不同 */
function normalizeHerbName(name: string | undefined): string {
return (name ?? '').trim()
}
/** 当前行药名是否与其他行重复(非空且出现次数大于 1) */
function isDuplicateHerbName(index: number): boolean {
const herbs = formData.herbs
const key = normalizeHerbName(herbs[index]?.name)
if (!key) return false
let count = 0
for (let i = 0; i < herbs.length; i++) {
if (normalizeHerbName(herbs[i]?.name) === key) count++
}
return count > 1
}
function findDuplicateHerbNames(): string[] {
const map = new Map<string, number>()
for (const h of formData.herbs) {
const key = normalizeHerbName(h?.name)
if (!key) continue
map.set(key, (map.get(key) ?? 0) + 1)
}
return [...map.entries()].filter(([, c]) => c > 1).map(([name]) => name)
}
const handleAddHerb = () => {
formData.herbs.push({ name: '', dosage: 6 })
}
const handleRemoveHerb = (index: number) => {
formData.herbs.splice(index, 1)
}
// 处方库相关方法
const loadLibraryList = async () => {
libraryLoading.value = true
try {
const res = await prescriptionLibraryLists({
page_no: libraryPage.value,
page_size: libraryPageSize.value,
prescription_name: librarySearchName.value,
is_public: ''
})
libraryList.value = res.lists || []
libraryTotal.value = res.count || 0
} catch (error) {
console.error('加载处方库失败:', error)
feedback.msgError('加载处方库失败')
} finally {
libraryLoading.value = false
}
}
const searchLibrary = () => {
libraryPage.value = 1
loadLibraryList()
}
const handleSelectLibrary = (row: any) => {
// 点击行也可以导入
handleImportLibrary(row)
}
const handleImportLibrary = (row: any) => {
if (!row.herbs || row.herbs.length === 0) {
feedback.msgWarning('该处方没有药材信息')
return
}
// 深拷贝药材数据并导入
const importedHerbs = JSON.parse(JSON.stringify(row.herbs))
formData.herbs = importedHerbs
if (findDuplicateHerbNames().length) {
feedback.msgWarning('导入的处方中存在重复药名,请合并剂量或删除多余行')
}
feedback.msgSuccess(`已导入处方「${row.prescription_name}」,共${importedHerbs.length}味药材`)
showLibraryDialog.value = false
}
// 监听处方库弹窗打开,自动加载数据
watch(showLibraryDialog, (newVal) => {
if (newVal) {
librarySearchName.value = ''
libraryPage.value = 1
loadLibraryList()
}
})
// 监听处方类型变化,自动调整用量单位和默认值
watch(() => formData.prescription_type, (newType) => {
if (newType === '浓缩水丸') {
formData.dosage_unit = 'g'
formData.dosage_amount = 10
formData.dosage_bag_count = 1
formData.need_decoction = false
formData.bags_per_dose = 1
} else if (newType === '饮片') {
formData.dosage_unit = 'ml'
formData.dosage_amount = 50
formData.dosage_bag_count = 1
formData.bags_per_dose = 1
// need_decoction 保持用户选择
} else {
formData.dosage_unit = 'g'
formData.dosage_amount = undefined
formData.dosage_bag_count = 1
formData.need_decoction = false
formData.bags_per_dose = 1
}
})
const handleSave = async () => {
if (!formData.clinical_diagnosis?.trim()) {
feedback.msgWarning('请输入临床诊断')
return
}
if (formData.herbs.length === 0) {
feedback.msgWarning('请至少添加一味中药')
return
}
const dupNames = findDuplicateHerbNames()
if (dupNames.length) {
feedback.msgWarning(`存在重复药名:${dupNames.join('、')},请合并剂量或删除多余行后再保存`)
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,
prescription_name: formData.prescription_name,
prescription_type: formData.prescription_type,
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,
tongue_image: formData.tongue_image,
pulse_condition: formData.pulse_condition,
clinical_diagnosis: formData.clinical_diagnosis,
case_record: formData.case_record,
herbs: formData.herbs,
dose_count: formData.dose_count,
dose_unit: formData.dose_unit,
dosage_amount: formData.dosage_amount,
dosage_unit: formData.dosage_unit,
dosage_bag_count: formData.dosage_bag_count,
need_decoction: formData.need_decoction,
bags_per_dose: formData.bags_per_dose,
usage_days: formData.usage_days,
times_per_day: formData.times_per_day,
usage_instruction: formData.usage_instruction,
usage_time: formData.usage_time,
usage_way: formData.usage_way,
dietary_taboo: formData.dietary_taboo,
usage_notes: formData.usage_notes,
amount: formData.amount,
doctor_name: formData.doctor_name,
doctor_signature: formData.doctor_signature,
is_shared: formData.is_shared
})
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
if (Number(savedPrescription.value?.has_prescription_order) === 1) {
feedback.msgWarning('该处方已存在业务订单,无法作废')
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.prescription_name = ''
formData.prescription_type = saved.prescription_type || '浓缩水丸'
if (formData.prescription_type === '浓缩水丸') {
formData.dosage_unit = 'g'
formData.dosage_amount = 10
formData.dosage_bag_count = 1
} else if (formData.prescription_type === '饮片') {
formData.dosage_unit = 'ml'
formData.dosage_amount = 50
formData.dosage_bag_count = 1
} else {
formData.dosage_unit = 'g'
formData.dosage_amount = undefined
formData.dosage_bag_count = 1
}
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.tongue_image = ''
formData.pulse_condition = ''
formData.clinical_diagnosis = saved.clinical_diagnosis || ''
formData.dose_count = 1
formData.dose_unit = saved.dose_unit || '剂'
formData.usage_days = 7
formData.usage_instruction = ''
formData.usage_time = '饭后'
formData.usage_way = '温水送服'
formData.dietary_taboo = []
formData.usage_notes = ''
formData.amount = 0
formData.doctor_name = userStore.userInfo?.name || saved.doctor_name || ''
formData.doctor_signature = ''
formData.is_shared = 0
formData.herbs = []
// 新建处方时重新获取病历(旧处方可能没有 case_record
formData.case_record = saved.case_record && Object.keys(saved.case_record).length > 0
? 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;
}
.herbs-editor-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.herb-editor-card {
min-width: 0;
padding: 10px;
background: #fff;
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
}
.herb-editor-card--dup {
border-color: var(--el-color-warning);
background: var(--el-color-warning-light-9);
}
.herb-editor-card__title {
font-size: 12px;
color: #909399;
margin-bottom: 6px;
}
.herb-editor-card__select {
width: 100%;
margin-bottom: 8px;
}
.herb-editor-card__row {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.herb-editor-card__dose {
flex: 1;
min-width: 88px;
}
}
}
.signature-pad-wrap {
width: 100%;
max-width: 600px;
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>