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

3182 lines
128 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 info-section--rp">
<div class="rp-toolbar" role="region" aria-label="中药处方">
<div class="rp-toolbar__lead">
<span class="rp-toolbar__marker" aria-hidden="true" />
<div class="rp-toolbar__text">
<h3 class="rp-toolbar__heading">
中药处方
<span class="rp-toolbar__abbr">(RP)</span>
</h3>
<p class="rp-toolbar__meta">
<strong>{{ formData.herbs.length }}</strong> 主方 {{ mainHerbCount }} / 辅方
{{ auxHerbCount }})· 可手动添加从处方库或粘贴药方批量录入
</p>
<p v-if="herbsLocked" class="rp-toolbar__lock-tip">
<el-tag type="danger" size="small" effect="plain">禁用修改</el-tag>
禁用修改处方库模板药材已锁定不可增删改如需调整请重新从处方库导入覆盖
</p>
</div>
</div>
<div class="rp-toolbar__actions" role="toolbar" aria-label="处方药材快捷操作">
<el-button type="primary" size="small" :icon="CirclePlus" :disabled="herbsLocked" @click="handleAddHerb('主方')">
添加主方
</el-button>
<el-button type="primary" plain size="small" :disabled="herbsLocked" @click="handleAddHerb('辅方')">
添加辅方
</el-button>
<el-button
type="primary"
plain
size="small"
:icon="CollectionTag"
@click="openLibraryDialog"
>
从处方库导入
</el-button>
<el-button type="primary" plain size="small" :icon="EditPen" @click="openPasteRecipeDialog">
导入药方
</el-button>
</div>
</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="herb-formula-block">
<div class="herb-formula-block__head">
<el-tag type="primary" size="small">主方</el-tag>
</div>
<div class="herbs-editor-grid">
<div
v-for="row in mainHerbRows"
:key="'main-' + row.index"
class="herb-editor-card"
:class="{ 'herb-editor-card--dup': isDuplicateHerbName(row.index) }"
>
<div class="herb-editor-card__title">主方 {{ row.index + 1 }}</div>
<MedicineNameSelect v-model="formData.herbs[row.index].name" :disabled="herbsLocked" class="herb-editor-card__select" />
<div
v-if="isDuplicateHerbName(row.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="formData.herbs[row.index].dosage"
:min="0.1"
:step="1"
:precision="1"
:disabled="herbsLocked"
placeholder="剂量(克)"
class="herb-editor-card__dose"
/>
<span class="text-gray-500 shrink-0"></span>
<el-button v-if="!herbsLocked" type="danger" size="small" link @click="handleRemoveHerb(row.index)">
删除
</el-button>
</div>
</div>
</div>
</div>
<div class="herb-formula-block mt-4">
<div class="herb-formula-block__head">
<el-tag type="warning" size="small">辅方</el-tag>
</div>
<div class="herbs-editor-grid">
<div
v-for="row in auxHerbRows"
:key="'aux-' + row.index"
class="herb-editor-card"
:class="{ 'herb-editor-card--dup': isDuplicateHerbName(row.index) }"
>
<div class="herb-editor-card__title">辅方</div>
<MedicineNameSelect v-model="formData.herbs[row.index].name" :disabled="herbsLocked" class="herb-editor-card__select" />
<div
v-if="isDuplicateHerbName(row.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="formData.herbs[row.index].dosage"
:min="0.1"
:step="1"
:precision="1"
:disabled="herbsLocked"
placeholder="剂量(克)"
class="herb-editor-card__dose"
/>
<span class="text-gray-500 shrink-0"></span>
<el-button v-if="!herbsLocked" type="danger" size="small" link @click="handleRemoveHerb(row.index)">
删除
</el-button>
</div>
</div>
<p v-if="!auxHerbRows.length" class="text-sm text-gray-400 py-2">暂无辅方药材</p>
</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="24">
<div class="usage-formula-title usage-formula-title--main">主方用法</div>
</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>
<template v-if="auxHerbRows.length > 0">
<el-col :span="24">
<div class="usage-formula-title usage-formula-title--aux">辅方用法</div>
</el-col>
<el-col :span="8">
<el-form-item label="用量">
<el-select
v-if="formData.prescription_type === '浓缩水丸'"
v-model="formData.aux_usage.dosage_amount"
placeholder="请选择用量"
class="!w-full"
>
<el-option v-for="n in 10" :key="n" :label="n + 'g'" :value="n" />
</el-select>
<el-select
v-else-if="formData.prescription_type === '饮片'"
v-model="formData.aux_usage.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.aux_usage.dosage_amount"
:min="0"
:precision="2"
class="!w-full"
/>
</el-form-item>
</el-col>
<el-col v-if="formData.prescription_type === '浓缩水丸'" :span="8">
<el-form-item label=" " label-width="12px">
<el-select
v-model="formData.aux_usage.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="是否代煎">
<el-radio-group v-model="formData.aux_usage.need_decoction">
<el-radio :label="true">代煎</el-radio>
<el-radio :label="false">不代煎</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col v-if="formData.prescription_type === '饮片'" :span="8">
<el-form-item label="每贴出包数">
<el-select v-model="formData.aux_usage.bags_per_dose" placeholder="请选择" class="!w-full">
<el-option v-for="n in 9" :key="n" :label="n + '包'" :value="n" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="每天几次">
<el-input-number
v-model="formData.aux_usage.times_per_day"
:min="1"
:max="6"
class="!w-full"
placeholder="每天服用次数"
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="服用天数">
<el-input-number
v-model="formData.aux_usage.usage_days"
:min="1"
:max="365"
class="!w-full"
/>
</el-form-item>
</el-col>
</template>
<!-- <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="rx-wrap">
<el-tabs
v-model="prescriptionTabType"
class="rx-slip-tabs"
>
<el-tab-pane label="药房联" name="internal" />
<el-tab-pane label="处方联" name="user" />
</el-tabs>
<div class="rx-toolbar">
<el-tag v-if="savedPrescription.void_status === 1" type="danger" size="small">已作废</el-tag>
<el-tag v-else-if="approvedPreviewOnly" type="success" size="small">已通过</el-tag>
<el-tag
v-else-if="Number(savedPrescription.audit_status) === 0"
type="warning"
size="small"
>待审核</el-tag>
<el-tag v-else-if="Number(savedPrescription.audit_status) === 2" type="danger" size="small">已驳回</el-tag>
<div class="flex-1" />
</div>
<div v-if="savedPrescription.void_status === 1" class="rx-status rx-status-danger">
作废人{{ savedPrescription.void_by_name || '—' }}作废时间{{ formatVoidTime(savedPrescription.void_time) }}
</div>
<div
v-else-if="Number(savedPrescription.audit_status) === 2"
class="rx-status rx-status-danger"
>
<div class="font-medium">处方审核已驳回</div>
<div v-if="savedPrescription.audit_remark" class="mt-1">驳回意见{{ savedPrescription.audit_remark }}</div>
</div>
<div class="prescription-preview-content">
<div v-if="savedPrescription" ref="prescriptionPrintRef" class="rx-paper">
<div class="rx-title">{{ prescriptionTabType === 'internal' ? '药房联' : SLIP_TITLE }}</div>
<div class="rx-notice">
<span class="rx-notice-text">
{{
prescriptionTabType === 'user'
? '服药前请核对姓名、电话、医生等信息以及医嘱等要点'
: '服药前请核对姓名、电话、医生等信息以及服法、医嘱等要点'
}}
</span>
<span class="rx-notice-meta">
<span>日期{{ rxDateText }}</span>
<span>编号{{ rxSerialText }}</span>
</span>
</div>
<div class="rx-info">
<div class="rx-info-row">
<div class="rx-info-cell">
<span class="rx-key">姓名</span>
<span class="rx-val">{{ savedPrescription.patient_name || '—' }}</span>
</div>
<div class="rx-info-cell">
<span class="rx-key">性别</span>
<span class="rx-val">{{ slipGenderLabel(savedPrescription.gender) }}</span>
</div>
<div class="rx-info-cell">
<span class="rx-key">年龄</span>
<span class="rx-val">{{ slipAgeText(savedPrescription.age) }}</span>
</div>
<div class="rx-info-cell">
<span class="rx-key">电话</span>
<span class="rx-val">{{ savedPrescription.phone || '—' }}</span>
</div>
</div>
<div class="rx-info-row rx-info-row-full">
<div class="rx-info-cell">
<span class="rx-key">门诊号</span>
<span class="rx-val">{{ savedPrescription.visit_no || '—' }}</span>
</div>
</div>
<div v-if="savedPrescription.pulse || savedPrescription.tongue" class="rx-info-row rx-info-row-full">
<div v-if="savedPrescription.pulse" class="rx-info-cell">
<span class="rx-key">面象</span>
<span class="rx-val">{{ savedPrescription.pulse }}</span>
</div>
<div v-if="savedPrescription.tongue" class="rx-info-cell">
<span class="rx-key">舌象</span>
<span class="rx-val">{{ savedPrescription.tongue }}</span>
</div>
</div>
<div class="rx-info-row rx-info-row-full">
<div class="rx-info-cell">
<span class="rx-key">临床诊断</span>
<span class="rx-val">{{ savedPrescription.clinical_diagnosis || '—' }}</span>
</div>
</div>
</div>
<div class="rx-rp">
<div class="rx-watermark">{{ prescriptionTabType === 'internal' ? '药房联' : '处方联' }}</div>
<div class="rx-rp-head">
<div class="rx-rp-label">Rp.</div>
<div class="rx-rp-cols-head">
<div class="rx-rp-col-head">
<span>用药 (单剂)</span>
<span>{{ prescriptionTabType === 'user' ? '用量' : '总量' }}</span>
</div>
<div class="rx-rp-col-head">
<span>用药 (单剂)</span>
<span>{{ prescriptionTabType === 'user' ? '用量' : '总量' }}</span>
</div>
</div>
</div>
<div class="rx-herbs">
<template v-if="prescriptionTabType === 'internal'">
<template v-if="savedMainHerbs.length">
<div class="rx-herb-section-label">主方</div>
<div
v-for="(h, i) in savedMainHerbs"
:key="'main-' + i"
class="rx-herb-cell"
>
<span class="rx-herb-name">{{ h.name }} ({{ h.dosage }})</span>
<span class="rx-herb-total">{{ rxHerbTotal(h.dosage) }}</span>
</div>
</template>
<template v-if="savedAuxHerbs.length">
<div class="rx-herb-section-label rx-herb-section-label--aux">辅方</div>
<div
v-for="(h, i) in savedAuxHerbs"
:key="'aux-' + i"
class="rx-herb-cell"
>
<span class="rx-herb-name">{{ h.name }} ({{ h.dosage }})</span>
<span class="rx-herb-total">{{ rxHerbTotal(h.dosage) }}</span>
</div>
</template>
</template>
<template v-else>
<template v-if="savedMainHerbs.length">
<div class="rx-herb-section-label">主方</div>
<div
v-for="(h, i) in savedMainHerbs"
:key="'user-main-' + i"
class="rx-herb-cell"
>
<span class="rx-herb-name">{{ h.name }}</span>
<span class="rx-herb-total">{{ h.dosage }}</span>
</div>
</template>
<template v-if="savedAuxHerbs.length">
<div class="rx-herb-section-label rx-herb-section-label--aux">辅方</div>
<div
v-for="(h, i) in savedAuxHerbs"
:key="'user-aux-' + i"
class="rx-herb-cell"
>
<span class="rx-herb-name">{{ h.name }}</span>
<span class="rx-herb-total">{{ h.dosage }}</span>
</div>
</template>
</template>
</div>
</div>
<div class="rx-text">
<template v-if="prescriptionTabType === 'internal'">
<p v-if="savedAuxUsageText">主方服法{{ rxUsageText }}</p>
<p v-else>服法{{ rxUsageText }}</p>
<p v-if="savedAuxUsageText">辅方服法{{ savedAuxUsageText }}</p>
</template>
<p v-if="rxAdviceText">医嘱{{ rxAdviceText }}</p>
<p v-if="prescriptionTabType === 'internal' && rxRemarkText">备注{{ rxRemarkText }}</p>
<p v-if="savedPrescription.dietary_taboo && savedPrescription.dietary_taboo.length">
忌口{{ Array.isArray(savedPrescription.dietary_taboo) ? savedPrescription.dietary_taboo.join('、') : savedPrescription.dietary_taboo }}
</p>
<p v-if="rxPharmacyRemarkText" class="rx-text-warn">药房备注{{ rxPharmacyRemarkText }}</p>
<p
v-if="prescriptionTabType === 'internal' && rxOutPelletText && savedPrescription.prescription_type !== '饮片'"
class="rx-text-warn"
>
出丸{{ rxOutPelletText }}
</p>
</div>
<div class="rx-bottom">
<div
class="rx-bot-row rx-bot-row-1"
:class="{ 'rx-bot-row-1--user': prescriptionTabType === 'user' }"
>
<div class="rx-bot-cell rx-bot-stack rx-bot-doctor">
<div class="rx-bot-label">医师</div>
<div class="rx-bot-doctor-body">
<img
v-if="savedPrescription.doctor_signature"
:src="savedPrescription.doctor_signature"
alt="医师签名"
class="rx-bot-sign-img"
/>
<div v-if="!savedPrescription.doctor_signature" class="rx-bot-doctor-name">
{{ savedPrescription.doctor_name || '—' }}
</div>
</div>
</div>
<div v-if="prescriptionTabType === 'internal'" class="rx-bot-cell rx-bot-meta">
<span class="rx-bot-meta-key">类型:</span>
<span class="rx-bot-meta-val">{{ rxTypeText }}</span>
</div>
<div class="rx-bot-cell rx-bot-meta">
<span class="rx-bot-meta-key">天数:</span>
<span class="rx-bot-meta-val">{{ rxSlipMedicationDaysText }}</span>
</div>
<div v-if="prescriptionTabType === 'internal'" class="rx-bot-cell rx-bot-meta">
<span class="rx-bot-meta-key">剂量:</span>
<span class="rx-bot-meta-val">{{ rxPerDoseAmount }}</span>
</div>
</div>
<div v-if="prescriptionTabType === 'user'" class="rx-bot-row rx-hospital-row">
<div class="rx-hospital-line">成都双流甄养堂互联网医院有限公司 联系方式4001667339</div>
<div class="rx-hospital-line">地址四川省成都市双流区黄甲街道黄龙大道二段280号</div>
</div>
</div>
</div>
<!-- 详细病历 - 单独A3纸张 -->
<div v-if="savedPrescription.case_record && !hideCaseRecordPreview" 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
v-if="savedPrescription && (savedPrescription.audit_by_name || savedPrescription.audit_remark)"
class="rx-audit"
>
<div v-if="savedPrescription.audit_by_name">
审核人{{ savedPrescription.audit_by_name }}
<span v-if="savedPrescription.audit_time" class="ml-2 text-gray-400">
{{ formatAuditTime(savedPrescription.audit_time) }}
</span>
</div>
<div v-if="savedPrescription.audit_remark" class="mt-1">审核意见{{ savedPrescription.audit_remark }}</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 flex gap-3">
<el-input
v-model="librarySearchName"
class="flex-1"
placeholder="请输入处方名称搜索"
clearable
@keyup.enter="searchLibrary"
@clear="searchLibrary"
>
<template #append>
<el-button :icon="Search" @click="searchLibrary" />
</template>
</el-input>
<el-select
v-model="librarySearchFormulaType"
placeholder="处方类型"
clearable
class="w-[140px]"
@change="searchLibrary"
>
<el-option label="全部" value="" />
<el-option label="主方" value="主方" />
<el-option label="辅方" value="辅方" />
</el-select>
</div>
<div class="mb-3 flex flex-wrap items-center gap-x-4 gap-y-2">
<span class="text-sm text-gray-600 shrink-0">导入方式</span>
<el-radio-group v-model="libraryImportMode">
<el-radio label="replace">覆盖同类型药材</el-radio>
<el-radio label="append">追加到末尾</el-radio>
</el-radio-group>
</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="90">
<template #default="{ row }">
<el-tag :type="row.formula_type === '辅方' ? 'warning' : 'primary'" size="small">
{{ row.formula_type || '主方' }}
</el-tag>
</template>
</el-table-column>
<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="是否公开" width="110">
<template #default="{ row }">
<el-tag :type="row.is_public ? 'success' : 'info'" size="small">
{{ row.is_public ? '所有人可见' : '仅自己可见' }}
</el-tag>
</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>
<!-- 粘贴药方文本解析药名与剂量 -->
<el-dialog
v-model="showPasteRecipeDialog"
title="导入药方(识别药材)"
width="560px"
:close-on-click-modal="false"
destroy-on-close
@closed="pasteRecipeText = ''"
>
<p class="text-gray-500 text-sm mb-2 leading-relaxed">
粘贴整张药方文本系统按药名 + 剂量解析药名须与药品库中名称完全一致才会录入否则跳过支持顿号/逗号/换行分隔单行可用空格分味黄芪15g
白术10g」);「黄芪党参各10克等同剂量写法亦可识别
</p>
<el-input
v-model="pasteRecipeText"
type="textarea"
:rows="10"
placeholder="示例:&#10;黄芪15 党参12 茯苓10&#10;柴胡10g、黄芩10、半夏12克&#10;黄芪、白术、茯苓各15克"
/>
<div class="mt-3 flex flex-wrap items-center gap-x-4 gap-y-2">
<span class="text-sm text-gray-600 shrink-0">导入方式</span>
<el-radio-group v-model="pasteRecipeImportMode">
<el-radio label="replace">覆盖主方药材</el-radio>
<el-radio label="append">追加到主方末尾</el-radio>
</el-radio-group>
<span class="text-xs text-gray-500">粘贴导入至主方保留已有辅方</span>
</div>
<template #footer>
<el-button @click="showPasteRecipeDialog = false">取消</el-button>
<el-button type="primary" :loading="pasteRecipeImportLoading" @click="handlePasteRecipeImport">
识别并导入
</el-button>
</template>
</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 { medicineLists } from '@/api/medicine'
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 { CirclePlus, CollectionTag, Download, Document, EditPen } from '@element-plus/icons-vue'
import { Search } from '@element-plus/icons-vue'
type FormulaType = '主方' | '辅方'
interface Herb {
name: string
dosage: number
formula_type?: FormulaType
/** 来自「禁用修改」处方库模板:锁定后不可增删改,只能再次导入覆盖 */
locked?: boolean
}
type AuxUsageForm = {
dosage_amount?: number
dosage_bag_count: number
need_decoction: boolean
bags_per_dose: number
times_per_day: number
usage_days: number
}
function normalizeFormulaType(v: unknown): FormulaType {
return v === '辅方' ? '辅方' : '主方'
}
function normalizeHerbRow(raw: any): Herb {
const row: Herb = {
name: String(raw?.name ?? '').trim(),
dosage: Number(raw?.dosage) || 0,
formula_type: normalizeFormulaType(raw?.formula_type)
}
if (raw?.locked === true || raw?.locked === 1) {
row.locked = true
}
return row
}
function defaultAuxUsage(prescriptionType = '浓缩水丸'): AuxUsageForm {
if (prescriptionType === '饮片') {
return {
dosage_amount: 50,
dosage_bag_count: 1,
need_decoction: false,
bags_per_dose: 1,
times_per_day: 3,
usage_days: 7
}
}
if (prescriptionType === '浓缩水丸') {
return {
dosage_amount: 5,
dosage_bag_count: 1,
need_decoction: false,
bags_per_dose: 1,
times_per_day: 3,
usage_days: 7
}
}
return {
dosage_amount: 1,
dosage_bag_count: 1,
need_decoction: false,
bags_per_dose: 1,
times_per_day: 3,
usage_days: 7
}
}
function normalizeAuxUsageForm(raw: unknown, prescriptionType: string): AuxUsageForm {
const base = defaultAuxUsage(prescriptionType)
if (!raw || typeof raw !== 'object') return { ...base }
const o = raw as Record<string, unknown>
return {
dosage_amount:
o.dosage_amount !== null && o.dosage_amount !== undefined && o.dosage_amount !== ''
? Number(o.dosage_amount)
: base.dosage_amount,
dosage_bag_count: o.dosage_bag_count != null ? Number(o.dosage_bag_count) || 1 : base.dosage_bag_count,
need_decoction: o.need_decoction === 1 || o.need_decoction === true,
bags_per_dose: o.bags_per_dose != null ? Number(o.bags_per_dose) || 1 : base.bags_per_dose,
times_per_day: o.times_per_day != null ? Number(o.times_per_day) || 3 : base.times_per_day,
usage_days: o.usage_days != null ? Number(o.usage_days) || 7 : base.usage_days
}
}
function buildUsageSegmentText(
usage: {
prescription_type?: string
dosage_amount?: number | null
dosage_unit?: string
dosage_bag_count?: number
times_per_day?: number
usage_way?: string
usage_time?: string
},
fallbackWay?: string,
fallbackTime?: string
): string {
const pt = usage.prescription_type || '浓缩水丸'
const times = Number(usage.times_per_day) > 0 ? Number(usage.times_per_day) : 3
const amount = usage.dosage_amount != null ? Number(usage.dosage_amount) : 10
const unit = usage.dosage_unit || (pt === '饮片' ? 'ml' : 'g')
const usageWay = usage.usage_way || fallbackWay || '温水送服'
const usageTime = usage.usage_time || fallbackTime || ''
const seg: string[] = []
seg.push(`每天${times}次`)
if (pt === '浓缩水丸') {
const bags = Number(usage.dosage_bag_count) > 0 ? Number(usage.dosage_bag_count) : 1
seg.push(`一次${bags}袋`)
seg.push(`每袋${amount}${unit}`)
} else {
seg.push(`一次${amount}${unit}`)
}
seg.push(usageWay)
if (usageTime) seg.push(usageTime)
return seg.join(', ')
}
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
aux_usage: AuxUsageForm
}
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 prescriptionTabType = ref<'internal' | 'user'>('internal')
/** 诊单列表「查看」仅展示处方联,不展示详细病历 A3 */
const hideCaseRecordPreview = ref(false)
/** 已通过且未作废:与消费者处方「查看」一致,仅预览,不可作废/再开新方 */
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 ? '查看处方' : '中医处方单'))
const savedMainHerbs = computed(() => {
const list = savedPrescription.value?.herbs
if (!Array.isArray(list)) return []
return list.filter((h: Herb) => normalizeFormulaType(h?.formula_type) === '主方')
})
const savedAuxHerbs = computed(() => {
const list = savedPrescription.value?.herbs
if (!Array.isArray(list)) return []
return list.filter((h: Herb) => normalizeFormulaType(h?.formula_type) === '辅方')
})
const savedAuxUsageText = computed(() => {
const v = savedPrescription.value
if (!v || !savedAuxHerbs.value.length) return ''
const aux = normalizeAuxUsageForm(v.aux_usage, v.prescription_type || '浓缩水丸')
return buildUsageSegmentText(
{
prescription_type: v.prescription_type,
dosage_amount: aux.dosage_amount,
dosage_unit: v.dosage_unit,
dosage_bag_count: aux.dosage_bag_count,
times_per_day: aux.times_per_day,
usage_way: v.usage_way,
usage_time: v.usage_time
},
v.usage_way,
v.usage_time
)
})
const savedSlipHerbsList = computed(() => {
const h = savedPrescription.value?.herbs
return Array.isArray(h) ? h : []
})
const padRxN = (n: number) => String(n).padStart(2, '0')
const rxDateText = computed(() => {
const v = savedPrescription.value as any
if (!v) return '—'
const ts = v.create_time ?? v.update_time ?? null
if (ts) {
const t = String(ts).length === 10 ? Number(ts) * 1000 : Number(ts)
const d = new Date(t)
if (!isNaN(d.getTime())) {
return `${d.getFullYear()}-${padRxN(d.getMonth() + 1)}-${padRxN(d.getDate())} ${padRxN(d.getHours())}:${padRxN(d.getMinutes())}`
}
}
return v.prescription_date || '—'
})
const rxSerialText = computed(() => {
const v = savedPrescription.value as any
if (!v) return '—'
const sn = String(v.sn ?? '').trim()
if (sn) return sn
const vn = String(v.visit_no ?? '').trim()
if (vn) return vn
return v.id ? `G${v.id}` : '—'
})
const rxDoseCount = computed(() => {
const n = Number(savedPrescription.value?.dose_count)
return Number.isFinite(n) && n > 0 ? n : 1
})
const rxPerDoseAmount = computed(() => {
const sum = savedSlipHerbsList.value.reduce((acc: number, h: any) => acc + (Number(h?.dosage) || 0), 0)
return formatRxNum(sum)
})
const rxHerbTotal = (dosage: number | string) => {
const d = Number(dosage) || 0
return formatRxNum(d * rxDoseCount.value)
}
const rxTypeText = computed(() => {
const v = savedPrescription.value as any
if (!v) return '—'
const t = v.prescription_type || '浓缩水丸'
if (t === '饮片') return '饮片'
return /丸|散|膏|片/.test(t) ? `浓缩丸-${t}` : t
})
const rxUsageText = computed(() => {
const v = savedPrescription.value as any
if (!v) return '—'
if (v.usage_text) return v.usage_text
return buildUsageSegmentText(v)
})
const rxAdviceText = computed(() => {
const v = savedPrescription.value as any
if (!v) return ''
return v.medical_advice || ''
})
const rxRemarkText = computed(() => {
const v = savedPrescription.value as any
if (!v) return ''
const parts: string[] = []
parts.push(`共${savedSlipHerbsList.value.length}味药`)
if (v.usage_notes) parts.push(String(v.usage_notes))
if (v.remark) parts.push(String(v.remark))
return parts.join(' ')
})
const rxPharmacyRemarkText = computed(() => {
const v = savedPrescription.value as any
if (!v) return ''
return v.pharmacy_remark || v.pharmacy_note || ''
})
/** 笺面用药天数:与 consumer/prescription/order_list 一致 */
const rxSlipMedicationDaysText = computed(() => {
const v = savedPrescription.value as any
if (!v) return '—'
const md = v.medication_days ?? v.usage_days
if (md != null && String(md).trim() !== '') return `${md} 天`
const dc = v.dose_count
if (dc != null && String(dc).trim() !== '' && Number(dc) > 0) return `${dc} 天`
return '—'
})
/** 主方出丸:用药疗程 medication_days → usage_days → dose_count;出丸 = 每袋用量×袋数×每天次数×服用天数 */
const slipPillGrams = computed(() => {
const d = savedPrescription.value as any
if (!d || (d.prescription_type || '浓缩水丸') !== '浓缩水丸') return null
const days = Number(d.medication_days ?? d.usage_days ?? d.dose_count)
const times = Number(d.times_per_day)
const doseG = Number(d.dosage_amount)
const bags = Number(d.dosage_bag_count) > 0 ? Number(d.dosage_bag_count) : 1
if (!Number.isFinite(days) || !Number.isFinite(times) || !Number.isFinite(doseG)) return null
if (days <= 0 || times <= 0 || doseG <= 0) return null
return days * times * doseG * bags
})
/** 辅方出丸:公式同主方,用 aux_usage 字段 */
const slipAuxPillGrams = computed(() => {
const d = savedPrescription.value as any
if (!d || (d.prescription_type || '浓缩水丸') !== '浓缩水丸') return null
if (!savedAuxHerbs.value.length) return null
const aux = normalizeAuxUsageForm(d.aux_usage, d.prescription_type || '浓缩水丸')
const days = Number(d.medication_days ?? aux.usage_days)
const times = Number(aux.times_per_day)
const doseG = Number(aux.dosage_amount)
const bags = Number(aux.dosage_bag_count) > 0 ? Number(aux.dosage_bag_count) : 1
if (!Number.isFinite(days) || !Number.isFinite(times) || !Number.isFinite(doseG)) return null
if (days <= 0 || times <= 0 || doseG <= 0) return null
return days * times * doseG * bags
})
/** 出丸:优先 slipPillGrams + slipAuxPillGrams,否则 out_pellet / 药材总量×剂数 */
const rxOutPelletGrams = computed(() => {
const main = slipPillGrams.value
const aux = slipAuxPillGrams.value
const mainNum = typeof main === 'number' && isFinite(main) && main > 0 ? main : 0
const auxNum = typeof aux === 'number' && isFinite(aux) && aux > 0 ? aux : 0
const sum = mainNum + auxNum
if (sum > 0) return formatRxNum(sum)
const v = savedPrescription.value as any
if (!v) return ''
const explicit = v.out_pellet || v.total_weight
if (explicit && Number(explicit) > 0) return formatRxNum(Number(explicit))
const list = savedSlipHerbsList.value
if (!list.length) return ''
const perDose = list.reduce((acc: number, h: any) => acc + (Number(h?.dosage) || 0), 0)
return formatRxNum(perDose * rxDoseCount.value)
})
const rxOutPelletText = computed(() => {
const out = rxOutPelletGrams.value
if (!out) return ''
const main = slipPillGrams.value
const aux = slipAuxPillGrams.value
if (
typeof main === 'number' && isFinite(main) && main > 0 &&
typeof aux === 'number' && isFinite(aux) && aux > 0
) {
return `${out}克(主方 ${formatRxNum(main)}克 + 辅方 ${formatRxNum(aux)}克)`
}
return `${out}克`
})
function formatRxNum(n: number): string {
if (!isFinite(n)) return '0'
return Number.isInteger(n) ? String(n) : n.toFixed(2).replace(/\.?0+$/, '')
}
function slipGenderLabel(g: unknown) {
if (g === 1 || g === '1') return '男'
if (g === 0 || g === '0') return '女'
return savedPrescription.value?.gender_desc || '—'
}
function slipAgeText(age: unknown) {
if (age === null || age === undefined || age === '') return '—'
return `${age}岁`
}
function formatAuditTime(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')}`
}
const mainHerbRows = computed(() =>
formData.herbs
.map((herb, index) => ({ herb, index }))
.filter(({ herb }) => normalizeFormulaType(herb.formula_type) === '主方')
)
const auxHerbRows = computed(() =>
formData.herbs
.map((herb, index) => ({ herb, index }))
.filter(({ herb }) => normalizeFormulaType(herb.formula_type) === '辅方')
)
const mainHerbCount = computed(() => mainHerbRows.value.length)
const auxHerbCount = computed(() => auxHerbRows.value.length)
/**
* 整张处方是否被锁定:只要任一药材来自「禁用修改」模板即锁定。
* 锁定后:不可增删改任意药材,仅可再次「从处方库导入」覆盖。
*/
const herbsLocked = computed(() => formData.herbs.some((h) => (h as Herb).locked === true))
const libraryDoctorId = computed(() => {
const uid = Number(userStore.userInfo?.id)
return uid > 0 ? uid : 0
})
// 处方库相关
const showLibraryDialog = ref(false)
const libraryLoading = ref(false)
const libraryList = ref<any[]>([])
const librarySearchName = ref('')
const librarySearchFormulaType = ref('')
const libraryImportMode = ref<'replace' | 'append'>('replace')
// 防止重复打开
const isOpening = ref(false)
const libraryPage = ref(1)
const libraryPageSize = ref(15)
const libraryTotal = ref(0)
/** 粘贴药方导入 */
const showPasteRecipeDialog = ref(false)
const pasteRecipeText = ref('')
const pasteRecipeImportMode = ref<'replace' | 'append'>('replace')
const pasteRecipeImportLoading = ref(false)
const PASTE_RECIPE_SKIP_SEGMENT =
/^(用法|用量|医嘱|忌口|水煎服|空腹|饭后|温水|内服|外用|备注|顿服|分服|ml|毫升|[×xX]\s*\d)/
function normalizeRecipeInput(raw: string): string {
let s = raw.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
s = s.replace(/[-]/g, (ch) => String.fromCharCode(ch.charCodeAt(0) - 0xff10 + 48))
return s.trim()
}
function stripRecipeHeaders(text: string): string {
return text
.replace(/^\s*Rp[::**\s]*/i, '')
.replace(/^\s*处方[:\s]*/, '')
.replace(/^\s*中药处方[:\s]*/, '')
.trimStart()
}
function stripHerbNote(name: string): string {
return name
.replace(/[^]*$/u, '')
.replace(/\([^)]*\)$/u, '')
.trim()
}
/** 单段解析为多味(含「各」),失败返回 null */
function parseHerbToken(seg: string): { name: string; dosage: number }[] | null {
const s = seg.trim()
if (!s || PASTE_RECIPE_SKIP_SEGMENT.test(s)) return null
if (/^\d/.test(s)) return null
if (/^\d+\s*剂$/.test(s)) return null
const ge = s.match(/^(.+)\s*各\s*(\d+(?:\.\d+)?)\s*(?:克|g|G)?$/i)
if (ge) {
const dose = parseFloat(ge[2])
if (!Number.isFinite(dose) || dose <= 0) return null
const names = ge[1]
.split(/[、,,\s]+/)
.map((x) => x.trim())
.filter(Boolean)
if (!names.length) return null
return names.map((name) => ({ name: stripHerbNote(name), dosage: dose }))
}
const m = s.match(/^(.+?)\s*(\d+(?:\.\d+)?)\s*(?:克|g|G|钱)?$/i)
if (m) {
const name = stripHerbNote(m[1].trim())
const dose = parseFloat(m[2])
if (!name || !Number.isFinite(dose) || dose <= 0) return null
return [{ name, dosage: dose }]
}
const nameOnly = stripHerbNote(s)
if (nameOnly.length >= 2 && nameOnly.length <= 16) {
return [{ name: nameOnly, dosage: 6 }]
}
return null
}
function chunkRecipeText(text: string): string[] {
const t = stripRecipeHeaders(normalizeRecipeInput(text))
const out: string[] = []
for (const line of t.split(/\n+/)) {
const ln = line.trim()
if (!ln) continue
for (const piece of ln.split(/[、,,;]/)) {
const p = piece.trim()
if (!p) continue
if (/\s/.test(p) && !parseHerbToken(p)) {
const sub = p.split(/\s+(?=[\u4e00-\u9fff]{2,})/)
for (const x of sub) {
const c = x.trim()
if (c) out.push(c)
}
} else {
out.push(p)
}
}
}
return out
}
function parseRecipeToHerbs(text: string): { name: string; dosage: number }[] {
const herbs: { name: string; dosage: number }[] = []
for (const c of chunkRecipeText(text)) {
const parsed = parseHerbToken(c)
if (!parsed) continue
herbs.push(...parsed)
}
return herbs
}
/** 仅在药品库中存在「完全同名」药材时返回规范药名,否则返回 null(不模糊猜测) */
async function resolveHerbNameFromLibrary(rawName: string): Promise<string | null> {
const q = rawName.trim()
if (!q) return null
try {
const res = await medicineLists({
name: q,
page_no: 1,
page_size: 200,
status: 1
})
const lists = (res.lists || []) as { id: number; name: string }[]
const exact = lists.find((item) => (item.name ?? '').trim() === q)
return exact ? exact.name.trim() : null
} catch {
return null
}
}
function openPasteRecipeDialog() {
showPasteRecipeDialog.value = true
}
async function handlePasteRecipeImport() {
const parsed = parseRecipeToHerbs(pasteRecipeText.value)
if (!parsed.length) {
feedback.msgWarning('未能从文本中解析出药材,请检查格式(药名 + 剂量,或用顿号/换行分隔)')
return
}
pasteRecipeImportLoading.value = true
try {
const resolved: Herb[] = []
const skippedNames: string[] = []
for (const row of parsed) {
const name = await resolveHerbNameFromLibrary(row.name)
if (!name) {
skippedNames.push(row.name.trim())
continue
}
resolved.push({ name, dosage: row.dosage, formula_type: '主方' })
}
const skippedUnique = [...new Set(skippedNames.filter(Boolean))]
if (resolved.length === 0) {
feedback.msgWarning(
skippedUnique.length
? `药品库中无以下完全同名药材,未录入:${skippedUnique.join('、')}`
: '未能匹配到药品库中的药材'
)
return
}
if (pasteRecipeImportMode.value === 'replace') {
const auxKept = formData.herbs.filter((h) => normalizeFormulaType(h.formula_type) === '辅方')
formData.herbs = [...auxKept, ...resolved]
} else {
formData.herbs.push(...resolved)
}
const dupNames = findDuplicateHerbNames()
const skipHint =
skippedUnique.length > 0 ? `;药品库未收录已跳过:${skippedUnique.join('、')}` : ''
if (dupNames.length) {
feedback.msgWarning(
`已导入 ${resolved.length} 味药材${skipHint}。存在重复药名:${dupNames.join(
'、'
)},请合并剂量或删除多余行`
)
} else if (skippedUnique.length) {
feedback.msgSuccess(`已导入 ${resolved.length} 味药材${skipHint},请核对剂量`)
} else {
feedback.msgSuccess(`已导入 ${resolved.length} 味药材,请核对药名与剂量`)
}
showPasteRecipeDialog.value = false
pasteRecipeText.value = ''
} finally {
pasteRecipeImportLoading.value = false
}
}
const SLIP_TITLE = '成都双流甄养堂互联网医院 处方笺'
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,
aux_usage: defaultAuxUsage('浓缩水丸')
})
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
formData.times_per_day = 2
Object.assign(formData.aux_usage, defaultAuxUsage('浓缩水丸'))
savedPrescription.value = null
visible.value = true
setTimeout(() => initSignatureCanvas(), 100)
} finally {
// 延迟重置,避免快速连续点击
setTimeout(() => {
isOpening.value = false
}, 500)
}
}
type OpenByIdOptions = {
/** 默认 internal;诊单列表查看传 user 展示处方联 */
slipType?: 'internal' | 'user'
/** 为 true 时不展示详细病历 A3 */
hideCaseRecord?: boolean
}
const openById = async (prescriptionId: number, options?: OpenByIdOptions) => {
// 防止重复打开
if (isOpening.value) {
console.warn('处方正在打开中,请勿重复点击')
return
}
isOpening.value = true
prescriptionTabType.value = options?.slipType ?? 'internal'
hideCaseRecordPreview.value = options?.hideCaseRecord ?? false
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
prescriptionTabType.value = 'internal'
hideCaseRecordPreview.value = false
}
/**
* 签名坐标:与 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 = (formulaType: FormulaType = '主方') => {
if (herbsLocked.value) {
feedback.msgWarning('该处方含「禁用修改」模板,药材已锁定,请重新从处方库导入覆盖')
return
}
formData.herbs.push({ name: '', dosage: 6, formula_type: formulaType })
}
const handleRemoveHerb = (index: number) => {
if (herbsLocked.value) {
feedback.msgWarning('该处方含「禁用修改」模板,药材已锁定,不可删除')
return
}
formData.herbs.splice(index, 1)
}
// 处方库相关方法
const loadLibraryList = async () => {
const doctorId = libraryDoctorId.value
if (!doctorId) {
feedback.msgWarning('无法确定开方医师,无法加载处方库')
libraryList.value = []
libraryTotal.value = 0
return
}
libraryLoading.value = true
try {
const res = await prescriptionLibraryLists({
page_no: libraryPage.value,
page_size: libraryPageSize.value,
prescription_name: librarySearchName.value,
formula_type: librarySearchFormulaType.value,
prescribing_creator_id: doctorId
})
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 openLibraryDialog = () => {
if (!libraryDoctorId.value) {
feedback.msgWarning('无法确定开方医师,无法从处方库导入')
return
}
showLibraryDialog.value = true
}
const handleSelectLibrary = (row: any) => {
// 点击行也可以导入
handleImportLibrary(row)
}
const handleImportLibrary = (row: any) => {
if (!row.herbs || row.herbs.length === 0) {
feedback.msgWarning('该处方没有药材信息')
return
}
const formulaType = normalizeFormulaType(row.formula_type)
// 「禁用修改」模板:导入的药材打上锁定标记 → 整张处方锁定(不可增删改,只能再次导入覆盖)
const isLockedTemplate = Number(row?.disable_edit) === 1
const imported = (JSON.parse(JSON.stringify(row.herbs)) as any[]).map((h) => {
const herb = normalizeHerbRow(h)
herb.formula_type = formulaType
if (isLockedTemplate) {
herb.locked = true
} else {
delete herb.locked
}
return herb
})
if (libraryImportMode.value === 'replace') {
const kept = formData.herbs.filter((h) => normalizeFormulaType(h.formula_type) !== formulaType)
formData.herbs = [...kept, ...imported]
} else {
formData.herbs.push(...imported)
}
const modeHint = libraryImportMode.value === 'append' ? '(已追加)' : ''
if (findDuplicateHerbNames().length) {
feedback.msgWarning(
`已导入处方「${row.prescription_name}${modeHint},共${imported.length}味药材。存在重复药名,请合并剂量或删除多余行`
)
} else {
feedback.msgSuccess(`已导入处方「${row.prescription_name}${modeHint},共${imported.length}味药材`)
}
showLibraryDialog.value = false
}
// 监听处方库弹窗打开,自动加载数据
watch(showLibraryDialog, (newVal) => {
if (newVal) {
if (!libraryDoctorId.value) {
feedback.msgWarning('无法确定开方医师,无法从处方库导入')
showLibraryDialog.value = false
return
}
librarySearchName.value = ''
librarySearchFormulaType.value = ''
libraryImportMode.value = 'replace'
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
}
Object.assign(formData.aux_usage, defaultAuxUsage(newType))
})
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.map((h) => normalizeHerbRow(h)),
aux_usage: auxHerbRows.value.length > 0 ? { ...formData.aux_usage } : null,
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 = []
Object.assign(formData.aux_usage, defaultAuxUsage(formData.prescription_type || '浓缩水丸'))
// 新建处方时重新获取病历(旧处方可能没有 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;
}
&.info-section--rp {
padding-top: 14px;
}
.rp-toolbar {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 12px 20px;
margin-bottom: 14px;
padding: 14px 16px;
background: linear-gradient(
145deg,
var(--el-bg-color) 0%,
var(--el-fill-color-extra-light) 100%
);
border: 1px solid var(--el-border-color-lighter);
border-radius: 10px;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
transition:
border-color 0.2s ease,
box-shadow 0.2s ease;
}
.rp-toolbar:hover {
border-color: var(--el-border-color);
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.07);
}
.rp-toolbar__lead {
display: flex;
align-items: stretch;
gap: 12px;
min-width: 0;
flex: 1;
}
.rp-toolbar__marker {
width: 4px;
min-height: 44px;
border-radius: 4px;
background: linear-gradient(
180deg,
var(--el-color-primary) 0%,
var(--el-color-primary-light-5) 100%
);
flex-shrink: 0;
}
.rp-toolbar__text {
min-width: 0;
}
.rp-toolbar__heading {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
letter-spacing: 0.02em;
line-height: 1.35;
}
.rp-toolbar__abbr {
margin-left: 4px;
font-weight: 500;
font-size: 13px;
color: var(--el-text-color-secondary);
}
.rp-toolbar__meta {
margin: 6px 0 0;
font-size: 12px;
line-height: 1.55;
color: var(--el-text-color-secondary);
}
.rp-toolbar__meta strong {
font-weight: 600;
color: var(--el-text-color-regular);
}
.rp-toolbar__lock-tip {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
margin: 6px 0 0;
font-size: 12px;
line-height: 1.55;
color: var(--el-color-danger);
}
.rp-toolbar__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.rp-toolbar__actions :deep(.el-button + .el-button) {
margin-left: 0;
}
@media (max-width: 960px) {
.rp-toolbar__actions {
width: 100%;
justify-content: flex-start;
}
}
.herb-formula-block__head {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
}
.usage-formula-title {
font-size: 13px;
font-weight: 600;
margin: 8px 0 4px;
color: #409eff;
}
.usage-formula-title--aux {
color: #e6a23c;
margin-top: 12px;
}
.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;
}
}
/* 处方笺(A4 药房联)— 与 consumer/prescription/index.vue 查看样式一致 */
.rx-wrap {
min-height: 160px;
background: #f5f6f8;
padding: 12px;
margin: -20px;
}
.rx-slip-tabs {
margin-bottom: 0;
z-index: 1;
position: relative;
background: #fff;
padding: 0 4px;
border-radius: 6px 6px 0 0;
}
.rx-toolbar {
display: flex;
align-items: center;
gap: 8px;
background: #fff;
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
padding: 8px 12px;
margin-bottom: 10px;
.flex-1 {
flex: 1;
}
}
.rx-status {
margin-bottom: 10px;
padding: 8px 12px;
border-radius: 4px;
font-size: 13px;
line-height: 1.6;
}
.rx-status-danger {
background: #fef0f0;
color: #b91c1c;
border: 1px solid #fecaca;
}
.rx-paper {
width: 210mm;
min-height: 297mm;
margin: 0 auto;
background: #fff;
box-sizing: border-box;
padding: 8mm 10mm;
color: #1f1f1f;
font-size: 13px;
line-height: 1.5;
font-family: 'Microsoft YaHei', 'PingFang SC', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
box-shadow: 0 0 0 1px #d6d6d6, 0 6px 24px rgba(0, 0, 0, 0.06);
overflow: visible;
}
.rx-title {
text-align: center;
font-size: 28px;
font-weight: 700;
line-height: 1.2;
margin: 10px 0 35px;
letter-spacing: 0.08em;
}
.rx-notice {
display: flex;
align-items: center;
justify-content: space-between;
background: #f3f4f6;
border: 1px solid #e5e7eb;
padding: 6px 10px;
border-radius: 2px;
font-size: 12px;
color: #1f1f1f;
margin-bottom: 6px;
.rx-notice-text {
flex: 1;
}
.rx-notice-meta {
display: inline-flex;
gap: 24px;
white-space: nowrap;
}
}
.rx-info {
border: 1px solid #c8c8c8;
border-bottom: none;
}
.rx-info-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
border-bottom: 1px solid #c8c8c8;
}
.rx-info-row-full {
grid-template-columns: 1fr;
}
.rx-info-cell {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
font-size: 13px;
border-right: 1px solid #c8c8c8;
min-height: 32px;
&:last-child {
border-right: none;
}
.rx-key {
color: #1f1f1f;
flex-shrink: 0;
}
.rx-val {
flex: 1;
min-width: 0;
word-break: break-all;
}
}
.rx-rp {
position: relative;
border-left: 1px solid #c8c8c8;
border-right: 1px solid #c8c8c8;
padding: 8px 10px 16px;
overflow: hidden;
box-sizing: border-box;
width: 100%;
}
.rx-watermark {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 88px;
font-weight: 700;
color: rgba(0, 0, 0, 0.06);
transform: rotate(-22deg);
letter-spacing: 0.18em;
pointer-events: none;
user-select: none;
z-index: 0;
}
.rx-rp-head {
display: grid;
grid-template-columns: 36px 1fr;
align-items: end;
column-gap: 8px;
padding-bottom: 4px;
border-bottom: 1px solid #d4d4d4;
margin-bottom: 6px;
position: relative;
z-index: 1;
width: 100%;
box-sizing: border-box;
}
.rx-rp-label {
font-size: 16px;
font-weight: 700;
color: #1f1f1f;
line-height: 1;
}
.rx-rp-cols-head {
display: grid;
grid-template-columns: 1fr 1fr;
column-gap: 24px;
width: 100%;
box-sizing: border-box;
min-width: 0;
}
.rx-rp-col-head {
display: grid;
grid-template-columns: 1fr 64px;
column-gap: 8px;
font-size: 13px;
color: #1f1f1f;
min-width: 0;
& > span:last-child {
text-align: right;
}
}
.rx-herbs {
padding-left: 44px;
display: grid;
grid-template-columns: 1fr 1fr;
column-gap: 24px;
row-gap: 6px;
position: relative;
z-index: 1;
width: 100%;
box-sizing: border-box;
}
.rx-herb-section-label {
grid-column: 1 / -1;
font-size: 12px;
font-weight: 600;
color: #409eff;
line-height: 1.4;
}
.rx-herb-section-label--aux {
color: #e6a23c;
margin-top: 4px;
}
.rx-herb-cell {
display: grid;
grid-template-columns: 1fr 64px;
column-gap: 8px;
font-size: 13px;
line-height: 1.85;
min-width: 0;
}
.rx-herb-name {
color: #1f1f1f;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rx-herb-total {
color: #1f1f1f;
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.rx-text {
border: 1px solid #c8c8c8;
border-top: none;
padding: 10px 12px;
font-size: 13px;
line-height: 1.85;
p {
margin: 0;
}
.rx-text-warn {
color: #d72424;
font-weight: 600;
}
}
.rx-bottom {
border: 1px solid #c8c8c8;
border-top: none;
}
.rx-bot-row {
display: grid;
border-bottom: 1px solid #c8c8c8;
&:last-child {
border-bottom: none;
}
}
.rx-bot-row-1 {
grid-template-columns: 1.4fr 0.6fr 0.6fr 0.6fr;
min-height: 70px;
}
.rx-bot-row-1--user {
grid-template-columns: 1.4fr 0.6fr;
}
.rx-hospital-row {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
gap: 2px;
padding: 10px 12px;
min-height: 52px;
text-align: left;
}
.rx-hospital-line {
font-size: 12px;
color: #374151;
line-height: 1.6;
}
.rx-bot-cell {
border-right: 1px solid #c8c8c8;
padding: 6px 10px;
font-size: 12px;
color: #1f1f1f;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
min-height: 32px;
box-sizing: border-box;
&:last-child {
border-right: none;
}
}
.rx-bot-stack {
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
}
.rx-bot-label {
font-size: 13px;
color: #1f1f1f;
margin-bottom: 4px;
}
.rx-bot-doctor {
.rx-bot-doctor-body {
flex: 1;
display: flex;
align-items: center;
gap: 6px;
min-height: 36px;
}
.rx-bot-doctor-name {
font-size: 13px;
color: #1f1f1f;
}
.rx-bot-sign-img {
max-height: 40px;
max-width: 110px;
object-fit: contain;
}
}
.rx-bot-meta {
flex-wrap: nowrap;
.rx-bot-meta-key {
color: #1f1f1f;
margin-right: 6px;
white-space: nowrap;
flex-shrink: 0;
line-height: 1.4;
}
.rx-bot-meta-val {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
line-height: 1.4;
}
}
.rx-audit {
margin-top: 12px;
padding: 8px 12px;
background: #fafafa;
border: 1px dashed #d4d4d4;
color: #6b7280;
font-size: 12px;
line-height: 1.6;
border-radius: 4px;
}
.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>