Files
zyt/admin/src/views/consumer/prescription/index.vue
T
2026-06-04 15:11:16 +08:00

4858 lines
186 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>
<div class="prescription-list">
<el-card class="filter-toolbar" shadow="never" :body-style="{ padding: '10px 14px' }">
<div class="pl-toolbar">
<div class="pl-toolbar__line">
<span class="pl-toolbar__k" title="按处方创建时间">创建时间</span>
<el-radio-group
v-model="dateQuickTab"
size="small"
class="pl-toolbar__rg"
@change="onDateQuickTabChange"
>
<el-radio-button label="all">全部</el-radio-button>
<el-radio-button label="today">今日</el-radio-button>
<el-radio-button label="yesterday">昨日</el-radio-button>
<el-radio-button label="before_yesterday">前天</el-radio-button>
</el-radio-group>
<DaterangePicker
class="pl-toolbar__range"
v-model:startTime="formData.start_time"
v-model:endTime="formData.end_time"
/>
<span class="pl-toolbar__sep" aria-hidden="true" />
<span
class="pl-toolbar__k"
title="「未通过」含待审核与已驳回"
>审核</span>
<el-radio-group
v-model="formData.audit_filter"
size="small"
class="pl-toolbar__rg pl-toolbar__rg--audit"
@change="onAuditFilterChange"
>
<el-radio-button label="pending" class="pl-audit-pending-btn">待审核</el-radio-button>
<el-radio-button label="all">全部</el-radio-button>
<el-radio-button label="passed">已通过</el-radio-button>
<el-radio-button label="not_passed">未通过</el-radio-button>
<el-radio-button label="rejected">驳回</el-radio-button>
</el-radio-group>
<span class="pl-toolbar__sep" aria-hidden="true" />
<span class="pl-toolbar__k" title="与列表「来源」列一致:系统代开 / 手工">来源</span>
<el-radio-group
v-model="formData.source_filter"
size="small"
class="pl-toolbar__rg pl-toolbar__rg--source"
@change="onSourceFilterChange"
>
<el-radio-button label="all">全部</el-radio-button>
<el-radio-button label="manual">手工</el-radio-button>
<el-radio-button label="system">系统代开</el-radio-button>
</el-radio-group>
</div>
<div class="pl-toolbar__line pl-toolbar__line--second">
<el-form :model="formData" inline class="pl-toolbar__form">
<el-form-item label="处方编号" class="!mb-0">
<el-input
v-model="formData.sn"
placeholder="编号"
clearable
class="pl-toolbar__input"
@keyup.enter="resetPage"
/>
</el-form-item>
<el-form-item label="患者" class="!mb-0">
<el-input
v-model="formData.patient_name"
placeholder="姓名"
clearable
class="pl-toolbar__input"
@keyup.enter="resetPage"
/>
</el-form-item>
<el-form-item label="医师" class="!mb-0">
<el-select
v-model="formData.creator_ids"
multiple
collapse-tags
collapse-tags-tooltip
filterable
clearable
placeholder="全部医师"
class="pl-toolbar__select pl-toolbar__select--doctors"
>
<el-option
v-for="d in doctorOptions"
:key="d.id"
:label="d.name"
:value="Number(d.id)"
/>
</el-select>
</el-form-item>
</el-form>
<div class="pl-toolbar__actions">
<el-button type="primary" @click="resetPage">查询</el-button>
<el-button @click="resetParams">重置</el-button>
</div>
</div>
</div>
</el-card>
<el-card v-loading="pager.loading" class="mt-3 table-card" shadow="never">
<div class="pl-table-head">
<span class="pl-table-head__title">处方列表</span>
<div class="flex items-center gap-2">
<el-button
v-perms="['tcm.prescriptionOrder/lists']"
@click="goBusinessOrderList"
>
业务订单
</el-button>
<el-button type="primary" @click="handleAdd" v-perms="['cf.prescription/add']">
<template #icon>
<icon name="el-icon-Plus" />
</template>
新增处方
</el-button>
</div>
</div>
<div class="mt-3">
<el-table :data="pager.lists" size="default" stripe>
<el-table-column label="处方编号" min-width="160">
<template #default="{ row }">
<span class="font-medium">{{ row.sn || '—' }}</span>
<div class="text-xs text-gray-400 mt-0.5">ID: {{ row.id }}</div>
<div
v-for="(warn, idx) in listRxOrderWarnings(row)"
:key="`rx-order-warn-${row.id}-${idx}`"
class="text-xs text-red-600 mt-0.5 leading-snug"
>
{{ warn }}
</div>
</template>
</el-table-column>
<el-table-column label="处方类型" min-width="100">
<template #default="{ row }">
<el-tag type="info" size="small">{{ row.prescription_type || '—' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="来源" min-width="92" align="center">
<template #default="{ row }">
<el-tag
v-if="Number(row.is_system_auto) === 1"
type="warning"
size="small"
effect="plain"
>
系统代开
</el-tag>
<span v-else class="text-gray-400 text-sm">手工</span>
</template>
</el-table-column>
<el-table-column label="患者信息" min-width="110">
<template #default="{ row }">
<div class="font-medium">{{ row.patient_name || '—' }}</div>
<div class="text-xs text-gray-500 mt-0.5">
{{ row.gender === 1 ? '男' : (row.gender === 0 ? '女' : '未知') }} · {{ row.age ? row.age + '岁' : '—' }}
</div>
</template>
</el-table-column>
<el-table-column label="审核状态" min-width="200">
<template #default="{ row }">
<div class="flex flex-col gap-1">
<div class="flex flex-wrap items-center gap-1">
<el-tag :type="listAuditStatusTagType(row)">
{{ listAuditStatusLabel(row) }}
</el-tag>
<span
v-if="Number(row.business_prescription_audit_rejected) === 1"
class="text-xs text-gray-500"
>
业务订单审核
</span>
</div>
<template v-if="listRejectReasonLines(row).length">
<div
v-for="(line, idx) in listRejectReasonLines(row)"
:key="idx"
class="text-xs text-red-600 leading-snug line-clamp-3"
:title="line"
>
{{ line }}
</div>
</template>
</div>
</template>
</el-table-column>
<el-table-column label="作废" min-width="80" align="center">
<template #default="{ row }">
<el-tag v-if="row.void_status === 1" type="danger" size="small">作废</el-tag>
<span v-else class="text-gray-400"></span>
</template>
</el-table-column>
<el-table-column label="医师信息" min-width="120">
<template #default="{ row }">
<div class="font-medium">{{ row.doctor_name || '—' }}</div>
<div class="text-xs text-gray-500 mt-0.5">{{ row.prescription_date || '—' }}</div>
</template>
</el-table-column>
<el-table-column label="医助" min-width="90">
<template #default="{ row }">
<div class="text-sm">{{ row.assistant_name || '—' }}</div>
</template>
</el-table-column>
<el-table-column label="创建时间" min-width="160">
<template #default="{ row }">
{{ formatListCreateTime(row.create_time) }}
</template>
</el-table-column>
<el-table-column label="操作" width="300" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="handleView(row)" v-perms="['cf.prescription/read']">
查看
</el-button>
<el-button
v-if="Number(row.void_status) !== 1"
type="primary"
link
v-perms="['tcm.prescription/patchPatient']"
@click="openPatchPatientDialog(row)"
>
改姓名手机
</el-button>
<el-button
v-if="!Number(row.has_prescription_order) && Number(row.void_status) !== 1"
type="success"
link
v-perms="['tcm.prescriptionOrder/create']"
@click="openCreateOrderFromPrescription(row)"
>
创建订单
</el-button>
<el-button
v-if="!isApprovedActivePrescription(row)"
type="primary"
link
@click="handleEdit(row)"
v-perms="['cf.prescription/edit']"
>
编辑
</el-button>
<el-button
v-if="row.audit_status === 0 && row.void_status !== 1"
type="warning"
link
v-perms="['cf.prescription/audit']"
@click="openAuditDialog(row)"
>
审核
</el-button>
<el-button
v-if="!isApprovedActivePrescription(row)"
type="danger"
link
@click="handleDelete(row.id)"
v-perms="['cf.prescription/del']"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="flex mt-2 justify-end">
<pagination v-model="pager" @change="getLists" />
</div>
</el-card>
<!-- 新增/编辑/查看抽屉 -->
<el-drawer
v-model="showEdit"
:title="editMode === 'add' ? '新增处方' : editMode === 'edit' ? '编辑处方' : '查看处方'"
:size="editMode === 'view' ? '920px' : '860px'"
class="cf-prescription-drawer"
:close-on-click-modal="false"
destroy-on-close
@closed="onEditDialogClosed"
>
<!-- 查看A4 处方笺药房联样式 -->
<div v-if="editMode === 'view'" v-loading="viewDetailLoading" class="rx-wrap">
<!-- 顶部工具栏不打印 / 不下载 -->
<div class="rx-toolbar">
<el-tag
v-if="slipView && Number(slipView.is_system_auto) === 1"
type="warning"
size="small"
effect="plain"
>系统代开</el-tag>
<el-tag v-if="slipView && Number(slipView.void_status) === 1" type="danger" size="small">已作废</el-tag>
<el-tag
v-else-if="slipView && Number(slipView.business_prescription_audit_rejected) === 1"
type="danger"
size="small"
>业务订单已驳回</el-tag>
<el-tag
v-else-if="slipView && Number(slipView.audit_status) === 1"
type="success"
size="small"
>已通过</el-tag>
<el-tag
v-else-if="slipView && Number(slipView.audit_status) === 0"
type="warning"
size="small"
>待审核</el-tag>
<el-tag v-else-if="slipView" type="danger" size="small">已驳回</el-tag>
<div class="flex-1" />
<el-button :icon="Printer" size="small" @click="handlePrintSlip">打印</el-button>
<el-button
type="primary"
:icon="Download"
size="small"
:loading="downloadingSlip"
@click="handleDownloadSlip"
>
下载 PDF (A4)
</el-button>
</div>
<!-- 状态提示不进入打印区 -->
<div v-if="slipView && Number(slipView.void_status) === 1" class="rx-status rx-status-danger">
作废人{{ slipView.void_by_name || '—' }}作废时间{{ formatVoidTime(slipView.void_time) }}
</div>
<div
v-else-if="slipView && Number(slipView.business_prescription_audit_rejected) === 1"
class="rx-status rx-status-danger"
>
<div class="font-medium">审核状态已驳回业务订单·处方审核</div>
<div v-if="slipView.business_prescription_audit_remark" class="mt-1">
驳回意见{{ slipView.business_prescription_audit_remark }}
</div>
</div>
<div
v-else-if="slipView && Number(slipView.audit_status) === 2"
class="rx-status rx-status-danger"
>
<div class="font-medium">消费者处方审核已驳回</div>
<div v-if="slipView.audit_remark" class="mt-1">驳回意见{{ slipView.audit_remark }}</div>
</div>
<!-- A4 处方纸 -->
<div v-if="slipView" ref="rxPaperRef" class="rx-paper">
<!-- 顶部告知栏 -->
<div class="rx-notice">
<span class="rx-notice-text">
服药前请核对姓名电话医生等信息以及服法医嘱等要点
</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">{{ slipView.patient_name || '—' }}</span>
</div>
<div class="rx-info-cell">
<span class="rx-key">性别</span>
<span class="rx-val">{{ slipGenderLabel(slipView.gender) }}</span>
</div>
<div class="rx-info-cell">
<span class="rx-key">年龄</span>
<span class="rx-val">{{ slipAgeText(slipView.age) }}</span>
</div>
<div class="rx-info-cell">
<span class="rx-key">电话</span>
<span class="rx-val">{{ slipView.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">{{ rxRecipientText }}</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">{{ slipView.clinical_diagnosis || '—' }}</span>
</div>
</div>
</div>
<!-- Rp 区域带水印 -->
<div class="rx-rp">
<div class="rx-watermark">药房联</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>总量</span>
</div>
<div class="rx-rp-col-head">
<span>用药 (单剂)</span>
<span>总量</span>
</div>
</div>
</div>
<div class="rx-herbs">
<template v-if="slipMainHerbs.length">
<div class="rx-herb-section-label">主方</div>
<div
v-for="(h, i) in slipMainHerbs"
: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="slipAuxHerbs.length">
<div class="rx-herb-section-label rx-herb-section-label--aux">辅方</div>
<div
v-for="(h, i) in slipAuxHerbs"
: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>
</div>
</div>
<!-- 服法 / 医嘱 / 备注 / 药房备注 / 出丸 -->
<div class="rx-text">
<p>主方服法{{ rxUsageText }}</p>
<p v-if="rxAuxUsageText">辅方服法{{ rxAuxUsageText }}</p>
<p v-if="rxAdviceText">医嘱{{ rxAdviceText }}</p>
<p v-if="rxRemarkText">备注{{ rxRemarkText }}</p>
<p v-if="rxPharmacyRemarkText" class="rx-text-warn">
药房备注{{ rxPharmacyRemarkText }}
</p>
<p v-if="rxOutPelletText" class="rx-text-warn">
出丸{{ rxOutPelletText }}
</p>
</div>
<!-- 底部表医师/审方/配药/复核/发药 + 类型/剂数/单剂量 -->
<div class="rx-bottom">
<div class="rx-bot-row rx-bot-row-1">
<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="slipView.doctor_signature"
:src="slipView.doctor_signature"
alt="医师签名"
class="rx-bot-sign-img"
/>
<div class="rx-bot-doctor-name">{{ slipView.doctor_name || '—' }}</div>
</div>
</div>
<div 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">{{ slipView.dose_count || '—' }}</span>
</div>
<div 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>
</div>
<!-- 审核痕迹不进入打印区 -->
<div
v-if="slipView && (slipView.audit_by_name || slipView.audit_remark)"
class="rx-audit"
>
<div v-if="slipView.audit_by_name">
审核人{{ slipView.audit_by_name }}
<span v-if="slipView.audit_time" class="ml-2 text-gray-400">
{{ formatAuditTime(slipView.audit_time) }}
</span>
</div>
<div v-if="slipView.audit_remark" class="mt-1">审核意见{{ slipView.audit_remark }}</div>
</div>
</div>
<el-form
v-else
ref="formRef"
:model="editForm"
:rules="rules"
label-width="120px"
>
<el-alert
v-if="editMode === 'edit' && Number(editForm.is_system_auto) === 1"
type="warning"
:closable="false"
show-icon
class="mb-4"
title="本处方为系统自动生成(如挂号完成),默认无药材配方,请补充药材与诊断等信息后再保存。"
/>
<el-alert
v-if="editMode === 'edit'"
type="info"
:closable="false"
show-icon
class="mb-4"
title="保存修改后,该处方将重新进入「待审核」,需审核人员再次通过后方可生效。"
/>
<el-alert
v-if="editMode === 'edit' && Number(editForm.audit_status) === 2"
type="error"
:closable="false"
show-icon
class="mb-4"
>
<template #title>当前消费者处方为已驳回</template>
<div class="text-sm mt-1">
保存修改后将清除驳回并变为待审核」。{{
editForm.audit_remark ? `上次意见:${editForm.audit_remark}` : ''
}}
</div>
</el-alert>
<el-alert
v-if="
editMode === 'edit' &&
Number(editForm.business_prescription_audit_rejected) === 1 &&
Number(editForm.audit_status) === 1
"
type="warning"
:closable="false"
show-icon
class="mb-4"
>
<template #title>业务订单侧「处方审核」已驳回</template>
<div class="text-sm mt-1">
消费者处方仍为「已通过」;您修改并保存后,将重置关联业务订单的处方/支付单审核为「待审」,请在「处方业务订单」中继续处理。
</div>
<div
v-if="String(editForm.business_prescription_audit_remark || '').trim()"
class="text-sm mt-2 text-gray-800 font-medium"
>
驳回意见:{{ editForm.business_prescription_audit_remark }}
</div>
</el-alert>
<el-alert
v-if="editMode === 'edit' && editReviveHint"
type="warning"
:closable="false"
show-icon
class="mb-4"
:title="editReviveHint"
/>
<div
v-if="Number(editForm.diagnosis_id) > 0"
class="mb-3 flex flex-wrap items-center gap-2 pl-[120px]"
>
<el-button type="primary" size="small" @click="openDiagnosisPatientDetail">
查看患者诊单详情
</el-button>
<span class="text-xs text-gray-400">关联诊单 #{{ editForm.diagnosis_id }}</span>
</div>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="患者姓名" prop="patient_name">
<el-input
v-model="editForm.patient_name"
placeholder="请输入患者姓名"
:disabled="editMode === 'edit'"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="门诊号" prop="visit_no">
<el-input
v-model="editForm.visit_no"
placeholder="自动生成或手动输入"
:disabled="editMode === 'edit'"
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="8">
<el-form-item label="性别" prop="gender">
<el-radio-group v-model="editForm.gender">
<el-radio :label="1">男</el-radio>
<el-radio :label="0">女</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="年龄" prop="age">
<el-input-number
v-model="editForm.age"
:min="0"
:max="150"
placeholder="请输入年龄"
class="w-full"
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="处方日期" prop="prescription_date">
<el-date-picker
v-model="editForm.prescription_date"
type="date"
placeholder="选择日期"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
class="w-full"
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="面象" prop="tongue">
<el-input v-model="editForm.tongue" placeholder="请输入面象" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="舌象" prop="tongue_image">
<el-input v-model="editForm.tongue_image" placeholder="请输入舌象" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="脉象" prop="pulse">
<el-input v-model="editForm.pulse" placeholder="请输入脉象" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="脉象详情" prop="pulse_condition">
<el-input v-model="editForm.pulse_condition" placeholder="请输入脉象详情" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="临床诊断" prop="clinical_diagnosis">
<el-input
v-model="editForm.clinical_diagnosis"
type="textarea"
:rows="3"
placeholder="请输入临床诊断"
maxlength="500"
show-word-limit
/>
</el-form-item>
<el-form-item label="药材配方" prop="herbs" required>
<div class="w-full">
<div class="flex flex-wrap items-center gap-2">
<el-button
type="success"
size="small"
v-perms="['cf.prescription/add', 'cf.prescription/edit', 'tcm.prescriptionLibrary/lists']"
@click="openLibraryDialog"
>
从处方库导入
</el-button>
<el-button type="warning" size="small" plain :icon="EditPen" @click="openPasteRecipeDialog">
导入药方
</el-button>
<span class="text-xs text-gray-500">
粘贴文本解析药名与剂量;须与药品库名称完全一致才录入(导入至主方)
</span>
</div>
<div v-if="herbsLocked" class="flex items-center gap-2 flex-wrap mt-2 text-xs text-[var(--el-color-danger)]">
<el-tag type="danger" size="small" effect="plain">禁用修改</el-tag>
含「禁用修改」处方库模板,药材已锁定,不可增删改,如需调整请重新「从处方库导入」覆盖
</div>
<div class="herb-formula-block mt-3">
<div class="herb-formula-block__head">
<el-tag type="primary" size="small">主方</el-tag>
<el-button type="primary" size="small" :disabled="herbsLocked" @click="addHerb('主方')">添加主方药材</el-button>
</div>
<el-table :data="mainHerbRows" class="mt-2" border empty-text="暂无主方药材">
<el-table-column label="序号" type="index" width="60" />
<el-table-column label="药材名称" min-width="220">
<template #default="{ row }">
<MedicineNameSelect v-model="editForm.herbs[row.index].name" :disabled="herbsLocked" class="w-full" />
</template>
</el-table-column>
<el-table-column label="剂量(克)" min-width="120">
<template #default="{ row }">
<el-input-number
v-model="editForm.herbs[row.index].dosage"
:min="0"
:precision="1"
:step="0.5"
:disabled="herbsLocked"
placeholder="剂量"
class="w-full"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="80">
<template #default="{ row }">
<el-button v-if="!herbsLocked" type="danger" link @click="removeHerb(row.index)">删除</el-button>
<span v-else class="text-gray-400 text-xs">已锁定</span>
</template>
</el-table-column>
</el-table>
</div>
<div class="herb-formula-block mt-4">
<div class="herb-formula-block__head">
<el-tag type="warning" size="small">辅方</el-tag>
<el-button type="primary" size="small" plain :disabled="herbsLocked" @click="addHerb('辅方')">
添加辅方药材
</el-button>
</div>
<el-table :data="auxHerbRows" class="mt-2" border empty-text="暂无辅方药材">
<el-table-column label="序号" type="index" width="60" />
<el-table-column label="药材名称" min-width="220">
<template #default="{ row }">
<MedicineNameSelect v-model="editForm.herbs[row.index].name" :disabled="herbsLocked" class="w-full" />
</template>
</el-table-column>
<el-table-column label="剂量(克)" min-width="120">
<template #default="{ row }">
<el-input-number
v-model="editForm.herbs[row.index].dosage"
:min="0"
:precision="1"
:step="0.5"
:disabled="herbsLocked"
placeholder="剂量"
class="w-full"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="80">
<template #default="{ row }">
<el-button v-if="!herbsLocked" type="danger" link @click="removeHerb(row.index)">删除</el-button>
<span v-else class="text-gray-400 text-xs">已锁定</span>
</template>
</el-table-column>
</el-table>
</div>
<el-alert
v-if="editMode === 'edit'"
type="warning"
:closable="false"
show-icon
class="mt-3"
v-loading="rxLinkedOrderHint.loading"
>
<template #title>业务订单要求(请与本处方保持一致)</template>
<div class="text-sm mt-1 leading-relaxed">
<template
v-if="
rxLinkedOrderHint.orderNo ||
rxLinkedOrderHint.medicationDays != null ||
rxLinkedOrderHint.remarkAssistant
"
>
<p v-if="rxLinkedOrderHint.orderNo" class="mb-1.5">
最近关联订单(按创建倒序):
<span class="font-mono text-primary">{{ rxLinkedOrderHint.orderNo }}</span>
</p>
<p class="mb-1.5">
<span class="font-semibold">服用天数(订单)</span>
{{
rxLinkedOrderHint.medicationDays != null
? `${rxLinkedOrderHint.medicationDays} 天`
: '— 订单未填写'
}}
</p>
<p>
<span class="font-semibold">医助备注(订单)</span>
<span class="whitespace-pre-wrap">{{
rxLinkedOrderHint.remarkAssistant || '— 无'
}}</span>
</p>
</template>
<p v-else-if="!rxLinkedOrderHint.loading" class="text-gray-600">
当前处方暂无关联业务订单,或您无订单列表权限;若有订单,将在此处显示订单内
<span class="font-semibold">服用天数</span>与
<span class="font-semibold">医助备注</span>。
</p>
</div>
</el-alert>
</div>
</el-form-item>
<el-row :gutter="20">
<el-col :span="8">
<el-form-item label="剂数" prop="dose_count">
<el-input-number
v-model="editForm.dose_count"
:min="1"
placeholder="剂数"
class="w-full"
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="剂量单位" prop="dose_unit">
<el-select v-model="editForm.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="8">
<el-form-item label="处方类型" prop="prescription_type">
<el-select v-model="editForm.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-row>
<div class="usage-formula-title usage-formula-title--main">主方用法</div>
<el-row :gutter="20">
<el-col :span="8">
<el-form-item label="用量" prop="dosage_amount">
<!-- 浓缩水丸:1-10g 下拉选择 -->
<el-select
v-if="editForm.prescription_type === '浓缩水丸'"
v-model="editForm.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="editForm.prescription_type === '饮片'"
v-model="editForm.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="editForm.dosage_amount"
:min="0"
:precision="2"
class="w-full"
/>
</el-form-item>
</el-col>
<el-col v-if="editForm.prescription_type === '浓缩水丸'" :span="8">
<el-form-item label=" " prop="dosage_bag_count" label-width="12px">
<el-select v-model="editForm.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="editForm.prescription_type === '饮片'" :span="8">
<el-form-item label="是否代煎" prop="need_decoction">
<el-radio-group v-model="editForm.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="editForm.prescription_type === '饮片'" :span="8">
<el-form-item label="每贴出包数" prop="bags_per_dose">
<el-select v-model="editForm.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 :span="8">
<el-form-item label="每天几次" prop="times_per_day">
<el-input-number v-model="editForm.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="usage_days">
<el-input-number
v-model="editForm.usage_days"
:min="1"
:max="365"
placeholder="服用天数"
class="w-full"
/>
</el-form-item>
</el-col>
</el-row>
<template v-if="auxHerbRows.length > 0">
<div class="usage-formula-title usage-formula-title--aux">辅方用法</div>
<el-row :gutter="20">
<el-col :span="8">
<el-form-item label="用量">
<el-select
v-if="editForm.prescription_type === '浓缩水丸'"
v-model="editForm.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="editForm.prescription_type === '饮片'"
v-model="editForm.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="editForm.aux_usage.dosage_amount"
:min="0"
:precision="2"
class="w-full"
/>
</el-form-item>
</el-col>
<el-col v-if="editForm.prescription_type === '浓缩水丸'" :span="8">
<el-form-item label=" " label-width="12px">
<el-select
v-model="editForm.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="editForm.prescription_type === '饮片'" :span="8">
<el-form-item label="是否代煎">
<el-radio-group v-model="editForm.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="editForm.prescription_type === '饮片'" :span="8">
<el-form-item label="每贴出包数">
<el-select v-model="editForm.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="editForm.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="editForm.aux_usage.usage_days"
:min="1"
:max="365"
placeholder="服用天数"
class="w-full"
/>
</el-form-item>
</el-col>
</el-row>
</template>
<el-form-item label="用法" prop="usage_instruction">
<el-input
v-model="editForm.usage_instruction"
placeholder="例如:水煎服,一日二次"
maxlength="200"
show-word-limit
/>
</el-form-item>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="服用时间" prop="usage_time">
<el-select v-model="editForm.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="editForm.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-row>
<el-form-item label="忌口" prop="dietary_taboo">
<el-select
v-model="editForm.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-form-item label="其他说明" prop="usage_notes">
<el-input
v-model="editForm.usage_notes"
type="textarea"
:rows="2"
placeholder="其他服用注意事项"
maxlength="200"
show-word-limit
/>
</el-form-item>
<el-form-item label="医师姓名" prop="doctor_name">
<el-input v-model="editForm.doctor_name" placeholder="请输入医师姓名" />
</el-form-item>
<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-form>
<template #footer>
<template v-if="editMode === 'view'">
<el-button @click="showEdit = false">关闭</el-button>
</template>
<template v-else>
<el-button @click="showEdit = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitLoading">
确定
</el-button>
</template>
</template>
</el-drawer>
<!-- 修正处方笺姓名 / 手机号(zyt_tcm_prescription,写入订单日志) -->
<el-dialog
v-model="patchPatientVisible"
title="修正姓名、性别与手机号"
width="440px"
:close-on-click-modal="false"
destroy-on-close
@closed="resetPatchPatientForm"
>
<el-form
ref="patchPatientFormRef"
:model="patchPatientForm"
:rules="patchPatientRules"
label-width="88px"
>
<el-form-item label="处方编号">
<span class="text-gray-700">#{{ patchPatientForm.id || '—' }}</span>
</el-form-item>
<el-form-item label="患者姓名" prop="patient_name">
<el-input v-model="patchPatientForm.patient_name" maxlength="50" show-word-limit placeholder="处方笺展示姓名" />
</el-form-item>
<el-form-item label="性别" prop="gender">
<el-radio-group v-model="patchPatientForm.gender">
<el-radio :label="1">男</el-radio>
<el-radio :label="0">女</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="手机号" prop="phone">
<el-input v-model="patchPatientForm.phone" maxlength="20" placeholder="处方笺展示手机号" />
</el-form-item>
</el-form>
<p class="text-xs text-gray-500 -mt-2 mb-2">
仅更新处方表 patient_name、gender、phone,不改变审核状态;记录写入业务订单操作日志(有关联订单时可在此订单日志中查看)。
</p>
<template #footer>
<el-button @click="patchPatientVisible = false">取消</el-button>
<el-button type="primary" :loading="patchPatientSubmitLoading" @click="submitPatchPatient">
保存
</el-button>
</template>
</el-dialog>
<!-- 从消费者处方创建业务订单(zyt_tcm_prescription_order,与支付单 zyt_order 分离) -->
<el-dialog
v-model="createOrderVisible"
title="创建业务订单"
width="940px"
top="4vh"
:close-on-click-modal="false"
@closed="resetCreateOrderForm"
class="create-order-dialog"
>
<div class="create-order-dialog__inner">
<!-- 关联处方:卡片式信息区,层次更清晰 -->
<div v-if="createOrderPrescription" class="create-order-rx-card">
<div class="create-order-rx-card__head">
<span class="create-order-rx-card__badge">关联处方</span>
<div class="create-order-rx-card__meta">
<span class="create-order-rx-card__id">#{{ createOrderPrescription.id }}</span>
<span v-if="createOrderPrescription.sn" class="create-order-rx-card__sn">{{ createOrderPrescription.sn }}</span>
<span class="create-order-rx-card__patient">{{ createOrderPrescription.patient_name || '—' }}</span>
</div>
</div>
<!-- <el-tooltip :content="createOrderHerbSummary" placement="bottom" :show-after="300">
<div class="create-order-rx-card__herbs">{{ createOrderHerbSummary }}</div>
</el-tooltip> -->
</div>
<el-steps
:active="createOrderStep"
finish-status="success"
align-center
class="create-order-steps"
>
<el-step title="患者与收货" description="诊单与物流地址" />
<el-step title="服务与支付单" description="套餐与关联收款" />
<el-step title="金额与确认" description="费用与备注" />
</el-steps>
<p class="create-order-step-lead">{{ createOrderStepLead }}</p>
<el-form
ref="createOrderFormRef"
:model="createOrderForm"
:rules="createOrderRules"
label-width="108px"
class="create-order-form"
>
<div v-show="createOrderStep === 0" class="create-order-step-panel">
<el-row :gutter="20">
<el-col :span="24">
<el-form-item label="诊单患者" prop="patient_id">
<el-select
v-model="createOrderForm.patient_id"
class="w-full"
placeholder="搜索姓名/手机/身份证"
filterable
remote
:remote-method="searchPatientsForOrder"
:loading="createOrderPatientLoading"
@change="onCreateOrderPatientChange"
>
<el-option
v-for="item in createOrderPatientList"
:key="item.id"
:label="`${item.patient_name} (${item.phone || '无手机'})`"
:value="item.id"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="收货人" prop="recipient_name">
<el-input v-model="createOrderForm.recipient_name" placeholder="姓名" maxlength="50" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="收货手机" prop="recipient_phone">
<el-input v-model="createOrderForm.recipient_phone" placeholder="物流联系" maxlength="20" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="省市区" prop="region">
<el-cascader
v-model="createOrderForm.region"
:options="regionOptions"
:props="{ value: 'name', label: 'name', children: 'children', emitPath: true, checkStrictly: false }"
placeholder="省 / 市 / 区"
clearable
filterable
class="w-full"
@change="handleRegionChange"
/>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="详细地址" prop="shipping_address">
<el-input
v-model="createOrderForm.shipping_address"
type="textarea"
:rows="3"
placeholder="街道、小区、门牌号等详细地址"
maxlength="200"
show-word-limit
/>
</el-form-item>
</el-col>
</el-row>
</div>
<div v-show="createOrderStep === 1" class="create-order-step-panel">
<el-row :gutter="20">
<el-col :xs="24" :sm="8">
<el-form-item label="复诊">
<el-switch v-model="createOrderForm.is_follow_up" :active-value="1" :inactive-value="0" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8">
<el-form-item label="服用天数" class="create-order-form-item--inline-num">
<div class="flex items-center gap-1 w-full">
<el-input-number
v-model="createOrderForm.medication_days"
:min="1"
:max="999"
:step="1"
controls-position="right"
class="flex-1 min-w-0"
/>
<span class="create-order-unit-suffix">天</span>
</div>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8">
<el-form-item label="上次医生">
<el-input v-model="createOrderForm.prev_staff" placeholder="可选" maxlength="100" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="服务渠道">
<el-input v-model="createOrderForm.service_channel" placeholder="可选" maxlength="100" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="服务套餐">
<el-select
v-model="createOrderForm.service_package"
multiple
collapse-tags
collapse-tags-tooltip
placeholder="请选择服务套餐"
clearable
class="w-full"
>
<el-option
v-for="item in servicePackageOptions"
:key="item.value"
:label="item.name"
:value="item.value"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="关联支付单" prop="pay_order_ids">
<el-select
v-model="createOrderForm.pay_order_ids"
class="w-full"
multiple
collapse-tags
collapse-tags-tooltip
:placeholder="
createOrderDepositMin > 0
? '请至少选择一笔本诊单支付单(已开启门槛)'
: '多选本诊单支付单(可选)'
"
:loading="createOrderPaidOrdersLoading"
>
<el-option
v-for="o in createOrderPaidOrders"
:key="o.id"
:label="`${o.order_no} · ¥${o.amount} · ${formatPoCategory(o.order_type)}`"
:value="o.id"
class="!h-auto py-1.5"
>
<div class="flex flex-col gap-0.5" style="line-height: normal;">
<div class="flex items-center justify-between">
<span class="font-medium text-[13px] text-gray-800">{{ o.order_no }}</span>
<span class="text-orange-500 font-medium ml-4">¥{{ o.amount }}</span>
</div>
<div class="flex items-center justify-between text-xs text-gray-400 mt-1">
<span class="truncate pr-2">{{ formatPoCategory(o.order_type) }}<template v-if="o.remark"> · {{ o.remark }}</template></span>
<span class="shrink-0">{{ String(o.create_time || '').substring(0, 16) }}</span>
</div>
</div>
</el-option>
</el-select>
<div class="create-order-pay-hint text-gray-500">
<template v-if="createOrderDepositMin > 0">
<span class="text-amber-600 font-medium">当前已开启关联支付门槛(¥{{ formatPoDepositMoney(createOrderDepositMin) }}),须至少选择一笔支付单。</span>
<span class="mx-1"> </span>
</template>
主管/本诊单医助可见全部支付单;否则仅本人收款单。已被占用的支付单不列出。
<el-tooltip
content="主管或本诊单医助可见该诊单全部支付单(待支付/已支付),否则仅本人创建的收款单。已被其他有效业务订单占用的支付单不会出现在此列表。"
placement="top"
>
<span class="text-primary cursor-help ml-0.5">详情</span>
</el-tooltip>
</div>
</el-form-item>
</el-col>
</el-row>
</div>
<div v-show="createOrderStep === 2" class="create-order-step-panel">
<!-- 横向概览,一屏内更省高度 -->
<div
class="create-order-deposit-bar"
:class="createOrderLinkedPaidTotal >= createOrderDepositMin ? 'is-ok' : 'is-warn'"
>
<div class="create-order-deposit-bar__cell">
<span class="create-order-deposit-bar__label">定金门槛</span>
<strong>¥{{ formatPoDepositMoney(createOrderDepositMin) }}</strong>
<span class="create-order-deposit-bar__sub">{{ createOrderDepositMin > 0 ? '须 ≥ 此值' : '未设置' }}</span>
</div>
<div class="create-order-deposit-bar__sep" />
<div class="create-order-deposit-bar__cell">
<span class="create-order-deposit-bar__label">已选收款</span>
<strong>¥{{ formatPoDepositMoney(createOrderLinkedPaidTotal) }}</strong>
<span class="create-order-deposit-bar__sub">
{{ createOrderLinkedPaidTotal >= createOrderDepositMin ? '已达标' : '未达标' }}
</span>
</div>
</div>
<el-row :gutter="20" class="create-order-step-panel__row-after-bar">
<el-col :span="12">
<el-form-item label="费用类别" prop="fee_type">
<el-select v-model="createOrderForm.fee_type" placeholder="请选择" class="w-full">
<el-option label="挂号费" :value="1" />
<el-option label="问诊费" :value="2" />
<el-option label="药品费用" :value="3" />
<el-option label="首付费用" :value="4" />
<el-option label="尾款费用" :value="5" />
<el-option label="其他费用" :value="6" />
<el-option label="全部费用" :value="7" />
<el-option label="驼奶费用" :value="8" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="总金额" prop="amount">
<el-input-number
v-model="createOrderForm.amount"
:min="createOrderAmountInputMin"
:step="0.01"
:precision="2"
class="w-full"
controls-position="right"
placeholder="元"
/>
</el-form-item>
</el-col>
<el-col v-perms="['finance.account_log/lists']" :span="12">
<el-form-item label="内部成本">
<el-input-number
v-model="createOrderForm.internal_cost"
:min="0"
:step="0.01"
:precision="2"
class="w-full"
controls-position="right"
placeholder="财务可见"
/>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item v-perms="['tcm.prescriptionOrder/editRemarkExtra']" label="药房备注">
<el-input
v-model="createOrderForm.remark_extra"
type="textarea"
:rows="3"
placeholder="选填,对内说明"
maxlength="200"
show-word-limit
/>
</el-form-item>
<el-form-item label="医助备注">
<el-input
v-model="createOrderForm.remark_assistant"
type="textarea"
:rows="3"
placeholder="选填,医助对内说明"
maxlength="500"
show-word-limit
/>
</el-form-item>
</el-col>
</el-row>
</div>
</el-form>
</div>
<template #footer>
<div class="create-order-footer">
<div class="create-order-footer__hint">
<span class="create-order-footer__step">第 {{ createOrderStep + 1 }} / 3 步</span>
<span v-if="createOrderStep === 2 && createOrderDepositMin > 0" class="create-order-footer__warn">
订单金额与关联支付单合计须 ≥ ¥{{ formatPoDepositMoney(createOrderDepositMin) }}
</span>
</div>
<div class="create-order-footer__actions">
<el-button size="large" @click="createOrderVisible = false">取消</el-button>
<el-button v-if="createOrderStep > 0" size="large" @click="goCreateOrderPrevStep">上一步</el-button>
<el-button
v-if="createOrderStep < 2"
type="primary"
size="large"
@click="goCreateOrderNextStep"
>
下一步
</el-button>
<el-button
v-if="createOrderStep === 2"
type="primary"
size="large"
:loading="createOrderSubmitLoading"
@click="submitCreateOrderFromPrescription"
>
创建业务订单
</el-button>
</div>
</div>
</template>
</el-dialog>
<!-- 从处方库导入:当前开方医师的全部模板 + 所有人可见的公共模板 -->
<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"
style="cursor: pointer"
@row-click="handleSelectLibraryRow"
>
<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>
<!-- 粘贴药方文本导入(与诊间 tcm-prescription 逻辑一致:药品库完全同名才录入) -->
<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>
</div>
<template #footer>
<el-button @click="showPasteRecipeDialog = false">取消</el-button>
<el-button type="primary" :loading="pasteRecipeImportLoading" @click="handlePasteRecipeImport">
识别并导入
</el-button>
</template>
</el-dialog>
<el-dialog v-model="auditDialogVisible" title="处方审核" width="480px" destroy-on-close @closed="auditRemark = ''">
<p class="text-sm text-gray-600 mb-3">
通过:处方保持有效。驳回:将同时作废此处方(与诊间「驳回/作废处方」一致)。
</p>
<el-form label-width="80px">
<el-form-item label="审核意见">
<el-input
v-model="auditRemark"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
placeholder="通过可简要说明;驳回必填"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="auditDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="auditLoading" @click="submitAudit('approve')">审核通过</el-button>
<el-button type="danger" :loading="auditLoading" @click="submitAudit('reject')">驳回处方</el-button>
</template>
</el-dialog>
<!-- 与 tcm/diagnosis/edit 同一套诊单界面(只读 + 全部分页签) -->
<TcmDiagnosisEditView ref="diagnosisViewRef" />
</div>
</template>
<script lang="ts" setup name="prescriptionList">
import {
prescriptionLists,
prescriptionAdd,
prescriptionEdit,
prescriptionPatchPatient,
prescriptionDelete,
prescriptionAudit,
prescriptionDetail,
prescriptionOrderCreate,
prescriptionOrderPaidPayOrders,
prescriptionOrderLists,
getDoctors,
prescriptionLibraryLists
} from '@/api/tcm'
import { medicineLists } from '@/api/medicine'
import { getDictData } from '@/api/app'
import { roleAll } from '@/api/perms/role'
import { usePaging } from '@/hooks/usePaging'
import useUserStore from '@/stores/modules/user'
import feedback from '@/utils/feedback'
import type { FormInstance, FormRules } from 'element-plus'
import DaterangePicker from '@/components/daterange-picker/index.vue'
import MedicineNameSelect from '@/components/medicine-name-select/index.vue'
import { Search, Download, Printer, EditPen } from '@element-plus/icons-vue'
import { computed, onMounted, reactive, ref, watch, nextTick, defineAsyncComponent } from 'vue'
import { useRouter } from 'vue-router'
import html2canvas from 'html2canvas'
import jsPDF from 'jspdf'
const TcmDiagnosisEditView = defineAsyncComponent(() => import('@/views/tcm/diagnosis/edit.vue'))
type FormulaType = '主方' | '辅方'
type HerbRow = { 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
/** 从处方库导入辅方时写入的模板名称,供药房联展示 */
prescription_name?: string
}
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,
prescription_name: String(o.prescription_name ?? '').trim()
}
}
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(', ')
}
function normalizeFormulaType(v: unknown): FormulaType {
return v === '辅方' ? '辅方' : '主方'
}
function normalizeHerbRow(raw: any): HerbRow {
const row: HerbRow = {
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 normalizeSlipHerbs(raw: unknown): HerbRow[] {
if (!raw) return []
if (Array.isArray(raw)) {
return raw.map((x: any) => normalizeHerbRow(x))
}
return []
}
/** 与 server/config/project.php prescription_audit_roles 保持一致 */
const PRESCRIPTION_AUDIT_ROLE_IDS = [0, 3]
/** 处方笺抬头/页脚(与诊间打印一致) */
const SLIP_HOSPITAL_NAME = '成都双流甄养堂互联网医院'
const SLIP_COMPANY_LINE = '成都双流甄养堂互联网医院有限公司 联系方式:4001667339'
const SLIP_ADDRESS_LINE = '地址:四川省成都市双流区黄甲街道黄龙大道二段280号'
const userStore = useUserStore()
const router = useRouter()
function goBusinessOrderList() {
const hit = router.getRoutes().find((r) => r.meta?.perms === 'tcm.prescriptionOrder/lists')
if (hit?.path) {
router.push(hit.path)
} else {
feedback.msgWarning('未配置「处方业务订单」菜单,请在权限管理中执行菜单 SQL 或手动添加路由')
}
}
// 表单数据(与列表接口查询参数一致)
const formData = reactive({
/** 处方编号(后台 sn,模糊匹配) */
sn: '',
patient_name: '',
/** 处方创建人(医师账号 ID),多选 */
creator_ids: [] as number[],
/** all | passed | not_passed | pending | rejectedall 表示不限,接口传参会转为空) */
audit_filter: 'all' as string,
/** all | manual | system — 与 is_system_auto:手工(0) / 系统代开(1) */
source_filter: 'all' as string,
start_time: '',
end_time: ''
})
const doctorOptions = ref<Array<{ id: number; name: string; account?: string }>>([])
/** 创建时间快捷:all | today | yesterday | before_yesterday | custom | '' */
const dateQuickTab = ref<string>('all')
const showEdit = ref(false)
const editMode = ref<'add' | 'edit' | 'view'>('add')
const submitLoading = ref(false)
const formRef = ref<FormInstance>()
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 slipView = ref<Record<string, any> | null>(null)
const viewDetailLoading = ref(false)
const roleOptions = ref<Array<{ id: number; name: string }>>([])
const auditDialogVisible = ref(false)
const auditTargetId = ref(0)
const auditRemark = ref('')
const auditLoading = ref(false)
/** 修正处方笺显示用姓名 / 手机号 */
const patchPatientVisible = ref(false)
const patchPatientSubmitLoading = ref(false)
const patchPatientFormRef = ref<FormInstance>()
const patchPatientForm = reactive({
id: 0,
patient_name: '',
gender: 1 as 0 | 1,
phone: ''
})
const patchPatientRules: FormRules = {
patient_name: [{ required: true, message: '请输入患者姓名', trigger: 'blur' }],
gender: [{ required: true, message: '请选择性别', trigger: 'change' }],
phone: [{ required: true, message: '请输入手机号', trigger: 'blur' }]
}
function openPatchPatientDialog(row: Record<string, unknown>) {
patchPatientForm.id = Number(row.id) || 0
patchPatientForm.patient_name = String(row.patient_name ?? '').trim()
const g = Number(row.gender)
patchPatientForm.gender = g === 0 || g === 1 ? (g as 0 | 1) : 1
patchPatientForm.phone = String(row.phone ?? '').trim()
patchPatientVisible.value = true
}
function resetPatchPatientForm() {
patchPatientForm.id = 0
patchPatientForm.patient_name = ''
patchPatientForm.gender = 1
patchPatientForm.phone = ''
patchPatientFormRef.value?.clearValidate()
}
async function submitPatchPatient() {
const form = patchPatientFormRef.value
if (!form) return
try {
await form.validate()
} catch {
return
}
patchPatientSubmitLoading.value = true
try {
await prescriptionPatchPatient({
id: patchPatientForm.id,
patient_name: patchPatientForm.patient_name.trim(),
phone: patchPatientForm.phone.trim(),
gender: patchPatientForm.gender
})
feedback.msgSuccess('已保存')
patchPatientVisible.value = false
getLists()
} catch (e: unknown) {
const msg = e && typeof e === 'object' && 'msg' in e ? String((e as { msg?: string }).msg) : ''
feedback.msgError(msg || '保存失败')
} finally {
patchPatientSubmitLoading.value = false
}
}
/** 从消费者处方创建订单 */
const createOrderVisible = ref(false)
/** 创建订单分步向导:0 患者与收货 / 1 服务与支付单 / 2 金额与确认 */
const createOrderStep = ref(0)
const createOrderStepLead = computed(() => {
const texts = [
'请核对诊单患者并填写完整收货信息,便于发货与物流联系。',
'按需填写服务相关信息,并选择本诊单下需要关联的支付单。',
'确认费用类别与订单金额;提交前请对照定金门槛是否已满足。'
]
return texts[createOrderStep.value] ?? ''
})
/** 第 1 步:患者与收货(分步校验) */
const CREATE_ORDER_STEP0_FIELDS = ['patient_id', 'recipient_name', 'recipient_phone', 'region', 'shipping_address'] as const
const createOrderPrescription = ref<any>(null)
const createOrderFormRef = ref<FormInstance>()
const createOrderSubmitLoading = ref(false)
const createOrderPatientLoading = ref(false)
const createOrderPatientList = ref<any[]>([])
const createOrderPaidOrders = ref<Array<{ id: number; order_no?: string; amount?: number | string; order_type?: number | string; remark?: string; create_time?: string }>>([])
const createOrderPaidOrdersLoading = ref(false)
const createOrderDepositMin = ref(0)
function formatPoDepositMoney(v: unknown) {
const n = Number(v)
if (Number.isNaN(n)) return '—'
return n.toFixed(2)
}
function formatPoCategory(t: unknown) {
const m: Record<number, string> = {
1: '挂号费',
2: '问诊费',
3: '药品费用',
4: '首付',
5: '尾款',
6: '其他'
}
return m[Number(t)] || '未知'
}
const createOrderForm = reactive({
patient_id: '' as number | string,
recipient_name: '',
recipient_phone: '',
region: [] as string[],
shipping_address: '',
is_follow_up: 0,
medication_days: undefined as number | undefined,
prev_staff: '',
service_channel: '',
service_package: [] as string[],
express_company: 'auto',
tracking_number: '',
fee_type: 3,
amount: 0,
internal_cost: undefined as number | undefined,
remark_extra: '',
remark_assistant: '',
pay_order_ids: [] as number[]
})
// 省市区数据(简化版,实际项目中应该从 API 获取或使用完整的省市区数据)
const regionOptions = ref([])
/** 服务套餐(字典 server_order,与业务订单编辑一致) */
const servicePackageOptions = ref<Array<{ name: string; value: string }>>([])
// 转换省市区数据格式
const transformRegionData = (data: any[]) => {
return data.map((province: any) => ({
name: province.province,
code: province.code,
children: province.citys?.map((city: any) => ({
name: city.city,
code: city.code,
children: city.areas?.map((area: any) => ({
name: area.area,
code: area.code
})) || []
})) || []
}))
}
// 加载省市区数据
const loadRegionData = async () => {
try {
console.log('开始加载省市区数据...')
// 开发环境使用代理,生产环境使用环境变量中的基础URL
const apiUrl = import.meta.env.DEV
? '/api-proxy/area/ChinaCitys.json'
: `https://cos.zhenyangtang.com.cn/area/ChinaCitys.json`
const response = await fetch(apiUrl)
console.log('响应状态:', response.status)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const data = await response.json()
console.log('加载的数据条数:', data?.length)
// 转换数据格式
regionOptions.value = transformRegionData(data)
console.log('regionOptions 已设置,条数:', regionOptions.value.length)
} catch (error) {
console.error('加载省市区数据失败:', error)
// 如果加载失败,使用基本的省市数据作为后备
regionOptions.value = [
{
name: '四川省',
children: [
{
name: '成都市',
children: [
{ name: '锦江区' },
{ name: '青羊区' },
{ name: '金牛区' },
{ name: '武侯区' },
{ name: '成华区' },
{ name: '龙泉驿区' },
{ name: '青白江区' },
{ name: '新都区' },
{ name: '温江区' },
{ name: '双流区' }
]
},
{
name: '绵阳市',
children: [
{ name: '涪城区' },
{ name: '游仙区' },
{ name: '安州区' }
]
}
]
},
{
name: '北京市',
children: [
{
name: '北京市',
children: [
{ name: '东城区' },
{ name: '西城区' },
{ name: '朝阳区' },
{ name: '丰台区' },
{ name: '海淀区' }
]
}
]
},
{
name: '上海市',
children: [
{
name: '上海市',
children: [
{ name: '黄浦区' },
{ name: '徐汇区' },
{ name: '长宁区' },
{ name: '静安区' },
{ name: '普陀区' }
]
}
]
},
{
name: '广东省',
children: [
{
name: '广州市',
children: [
{ name: '荔湾区' },
{ name: '越秀区' },
{ name: '海珠区' },
{ name: '天河区' },
{ name: '白云区' }
]
},
{
name: '深圳市',
children: [
{ name: '罗湖区' },
{ name: '福田区' },
{ name: '南山区' },
{ name: '宝安区' }
]
}
]
}
]
console.log('使用后备数据,条数:', regionOptions.value.length)
}
}
const loadServicePackageOptions = async () => {
try {
const data = await getDictData({ type: 'server_order' })
servicePackageOptions.value = (data?.server_order || []).filter((item: { status?: number }) => item.status !== 0)
} catch {
servicePackageOptions.value = []
}
}
// 处理省市区选择变化
const handleRegionChange = (value: string[]) => {
console.log('选择的省市区:', value)
// value is an array like ['四川省', '成都市', '锦江区']
// We'll extract these in the submit function
}
const createOrderLinkedPaidTotal = computed(() => {
const ids = new Set(createOrderForm.pay_order_ids.map((x) => Number(x)))
let s = 0
for (const o of createOrderPaidOrders.value) {
if (ids.has(Number(o.id))) {
s += Number(o.amount) || 0
}
}
return Math.round(s * 100) / 100
})
const createOrderAmountInputMin = computed(() => {
const m = createOrderDepositMin.value
if (m > 0) {
return Math.round(m * 100) / 100
}
return 0
})
const createOrderRules = computed<FormRules>(() => {
const rules: FormRules = {
patient_id: [{ required: true, message: '请选择诊单患者', trigger: 'change' }],
recipient_name: [{ required: true, message: '请输入收货人', trigger: 'blur' }],
recipient_phone: [{ required: true, message: '请输入收货手机', trigger: 'blur' }],
region: [{ required: true, message: '请选择省/市/区', trigger: 'change', type: 'array' }],
shipping_address: [{ required: true, message: '请输入收货地址', trigger: 'blur' }],
fee_type: [{ required: true, message: '请选择费用类别', trigger: 'change' }],
amount: [
{ required: true, message: '请输入订单金额', trigger: 'blur' },
{
validator: (_rule, v, cb) => {
const min = createOrderDepositMin.value
const num = Number(v)
if (min > 0 && (!Number.isFinite(num) || num < min)) {
cb(new Error(`订单金额须大于等于定金门槛 ¥${formatPoDepositMoney(min)}`))
return
}
cb()
},
trigger: 'blur'
}
]
}
// 与 .env PRESCRIPTION_ORDER_LINK_PAY_MIN_AMOUNT 一致(接口 deposit_min_amount
// 勿在 el-form-item 上设 required,否则会多出一条默认英文「pay_order_ids is required」
if (createOrderDepositMin.value > 0) {
const depMsg = `已开启关联支付门槛(¥${formatPoDepositMoney(createOrderDepositMin.value)}),请至少选择一笔支付单`
rules.pay_order_ids = [
{
type: 'array',
required: true,
min: 1,
message: depMsg,
trigger: 'change'
}
]
}
return rules
})
const createOrderHerbSummary = computed(() => {
const row = createOrderPrescription.value
const herbs = normalizeSlipHerbs(row?.herbs)
if (!herbs.length) return '暂无药材明细'
const main = herbs.filter((h) => normalizeFormulaType(h.formula_type) === '主方')
const aux = herbs.filter((h) => normalizeFormulaType(h.formula_type) === '辅方')
const fmt = (list: HerbRow[]) => list.map((h) => `${h.name} ${h.dosage}g`).join('、')
const parts: string[] = []
if (main.length) parts.push(`主方:${fmt(main)}`)
if (aux.length) parts.push(`辅方:${fmt(aux)}`)
const s = parts.join('')
return s.length > 120 ? `${s.slice(0, 120)}…` : s
})
function resetCreateOrderForm() {
createOrderStep.value = 0
createOrderForm.patient_id = ''
createOrderForm.recipient_name = ''
createOrderForm.recipient_phone = ''
createOrderForm.shipping_address = ''
createOrderForm.is_follow_up = 0
createOrderForm.medication_days = undefined
createOrderForm.prev_staff = ''
createOrderForm.service_channel = ''
createOrderForm.service_package = []
createOrderForm.express_company = 'auto'
createOrderForm.tracking_number = ''
createOrderForm.fee_type = 3
createOrderForm.amount = 0
createOrderForm.internal_cost = undefined
createOrderForm.remark_extra = ''
createOrderForm.remark_assistant = ''
createOrderForm.pay_order_ids = []
createOrderPatientList.value = []
createOrderPaidOrders.value = []
createOrderDepositMin.value = 0
createOrderPrescription.value = null
createOrderFormRef.value?.clearValidate()
}
/** 仅按定金门槛抬升「总金额」,不跟已选支付单合计联动(由用户自填) */
function syncCreateOrderAmountFromRules() {
const min = createOrderDepositMin.value
if (min <= 0) return
const need = Math.round(min * 100) / 100
if (Number(createOrderForm.amount) < need) {
createOrderForm.amount = need
}
}
async function loadCreateOrderPaidOrders() {
const did = Number(createOrderForm.patient_id)
if (!did) {
createOrderPaidOrders.value = []
createOrderDepositMin.value = 0
return
}
try {
createOrderPaidOrdersLoading.value = true
const res: any = await prescriptionOrderPaidPayOrders({
diagnosis_id: did
})
const lists = res?.lists ?? res?.data?.lists ?? []
createOrderPaidOrders.value = Array.isArray(lists) ? lists : []
const dep = Number(res?.deposit_min_amount ?? res?.data?.deposit_min_amount) || 0
createOrderDepositMin.value = dep
syncCreateOrderAmountFromRules()
} catch {
createOrderPaidOrders.value = []
createOrderDepositMin.value = 0
} finally {
createOrderPaidOrdersLoading.value = false
}
}
watch(
() => createOrderForm.patient_id,
() => {
createOrderForm.pay_order_ids = []
void loadCreateOrderPaidOrders()
}
)
async function openCreateOrderFromPrescription(row: any) {
if (Number(row?.void_status) === 1) {
feedback.msgWarning('已作废处方不可创建业务订单')
return
}
resetCreateOrderForm()
createOrderPrescription.value = row
const did = Number(row.diagnosis_id) || 0
if (did > 0) {
createOrderForm.patient_id = did
createOrderForm.recipient_name = row.patient_name || ''
createOrderForm.recipient_phone = String(row.phone || '').trim()
createOrderPatientList.value = [
{
id: did,
patient_name: row.patient_name || '',
phone: String(row.phone || '').trim()
}
]
}
const ud = Number(row.usage_days)
if (ud > 0) {
createOrderForm.medication_days = ud
}
createOrderVisible.value = true
void loadCreateOrderPaidOrders()
const rxId = row.id
if (rxId && !createOrderForm.recipient_phone) {
try {
const d = (await prescriptionDetail({ id: rxId })) as Record<string, unknown> | null
const phone = d && typeof d.phone === 'string' ? d.phone.trim() : ''
if (phone && createOrderPrescription.value?.id === rxId) {
createOrderForm.recipient_phone = phone
const first = createOrderPatientList.value[0]
if (first && Number(first.id) === did) {
first.phone = phone
}
}
} catch {
/* 列表无电话时尽力补全,失败忽略 */
}
}
}
async function searchPatientsForOrder(query: string) {
if (!query) {
createOrderPatientList.value = []
return
}
try {
createOrderPatientLoading.value = true
const res: any = await searchPatientsAPI({
keyword: query,
page_no: 1,
page_size: 10
})
createOrderPatientList.value = res?.lists || []
} catch {
createOrderPatientList.value = []
} finally {
createOrderPatientLoading.value = false
}
}
function onCreateOrderPatientChange(id: number | string) {
const p = createOrderPatientList.value.find((x) => Number(x.id) === Number(id))
if (p) {
createOrderForm.recipient_name = p.patient_name || ''
createOrderForm.recipient_phone = p.phone || ''
}
}
function goCreateOrderPrevStep() {
if (createOrderStep.value > 0) {
createOrderStep.value -= 1
}
}
async function goCreateOrderNextStep() {
const form = createOrderFormRef.value
if (!form) return
try {
if (createOrderStep.value === 0) {
await form.validateField([...CREATE_ORDER_STEP0_FIELDS])
} else if (createOrderStep.value === 1 && createOrderDepositMin.value > 0) {
await form.validateField(['pay_order_ids'])
}
if (createOrderStep.value < 2) {
createOrderStep.value += 1
}
} catch {
/* 校验未通过,留在当前步 */
}
}
async function submitCreateOrderFromPrescription() {
if (!createOrderFormRef.value || !createOrderPrescription.value) return
if (createOrderStep.value !== 2) return
try {
await createOrderFormRef.value.validate()
createOrderSubmitLoading.value = true
const diagnosisId = Number(createOrderForm.patient_id)
const payload: Record<string, unknown> = {
prescription_id: createOrderPrescription.value.id,
diagnosis_id: diagnosisId,
recipient_name: createOrderForm.recipient_name,
recipient_phone: createOrderForm.recipient_phone,
shipping_address: createOrderForm.shipping_address,
is_follow_up: createOrderForm.is_follow_up,
prev_staff: createOrderForm.prev_staff || '',
service_channel: createOrderForm.service_channel || '',
service_package:
Array.isArray(createOrderForm.service_package) && createOrderForm.service_package.length > 0
? createOrderForm.service_package.join(',')
: '',
express_company: createOrderForm.express_company || 'auto',
tracking_number: createOrderForm.tracking_number || '',
fee_type: createOrderForm.fee_type,
amount: createOrderForm.amount,
remark_extra: createOrderForm.remark_extra || '',
remark_assistant: createOrderForm.remark_assistant || ''
}
// 添加省市区字段
if (createOrderForm.region && createOrderForm.region.length >= 3) {
payload.shipping_province = createOrderForm.region[0]
payload.shipping_city = createOrderForm.region[1]
payload.shipping_district = createOrderForm.region[2]
}
if (createOrderForm.medication_days != null && createOrderForm.medication_days > 0) {
payload.medication_days = createOrderForm.medication_days
}
if (createOrderForm.internal_cost != null && createOrderForm.internal_cost > 0) {
payload.internal_cost = createOrderForm.internal_cost
}
if (createOrderForm.pay_order_ids.length) {
payload.pay_order_ids = [...createOrderForm.pay_order_ids]
}
const res: any = await prescriptionOrderCreate(payload)
const row = res?.data ?? res
const orderNo = row?.order_no
feedback.msgSuccess(orderNo ? `业务订单已创建,单号:${orderNo}` : '业务订单已创建')
createOrderVisible.value = false
getLists()
} catch (e) {
console.error(e)
} finally {
createOrderSubmitLoading.value = false
}
}
/** 正在编辑的这条是否原为已作废 / 已驳回(用于提示) */
const editingWasVoid = ref(false)
const editingWasRejected = ref(false)
const editReviveHint = computed(() => {
const v = editingWasVoid.value
const r = editingWasRejected.value
if (!v && !r) return ''
if (v && r) return '当前处方已作废且已驳回。保存后将取消作废、清除驳回,并重新进入待审核。'
if (v) return '当前为已作废处方。保存后将取消作废,并重新进入待审核。'
return '当前为已驳回处方。保存后将清除驳回,并重新进入待审核。'
})
const slipHerbsList = computed(() => {
const h = slipView.value?.herbs
return Array.isArray(h) ? (h as HerbRow[]) : []
})
const slipMainHerbs = computed(() =>
slipHerbsList.value.filter((h) => normalizeFormulaType(h.formula_type) === '主方')
)
const slipAuxHerbs = computed(() =>
slipHerbsList.value.filter((h) => normalizeFormulaType(h.formula_type) === '辅方')
)
const mainHerbRows = computed(() =>
editForm.herbs
.map((herb, index) => ({ herb, index }))
.filter(({ herb }) => normalizeFormulaType(herb.formula_type) === '主方')
)
const auxHerbRows = computed(() =>
editForm.herbs
.map((herb, index) => ({ herb, index }))
.filter(({ herb }) => normalizeFormulaType(herb.formula_type) === '辅方')
)
/**
* 整张处方是否被锁定:只要任一药材来自「禁用修改」处方库模板即锁定。
* 锁定后不可增删改任意药材,只能再次「从处方库导入」覆盖。
*/
const herbsLocked = computed(() => editForm.herbs.some((h) => (h as HerbRow).locked === true))
const slipDietaryText = computed(() => {
const d = slipView.value?.dietary_taboo
if (Array.isArray(d)) return d.filter(Boolean).join('、')
if (typeof d === 'string' && d.trim()) return d.trim()
return ''
})
/* ============================================================
* A4 处方笺(药房联)相关计算
* ============================================================ */
const rxPaperRef = ref<HTMLElement | null>(null)
const downloadingSlip = ref(false)
const padN = (n: number) => String(n).padStart(2, '0')
const rxDateText = computed(() => {
const v = slipView.value
if (!v) return '—'
const ts = (v as any).create_time ?? (v as any).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()}-${padN(d.getMonth() + 1)}-${padN(d.getDate())} ${padN(d.getHours())}:${padN(d.getMinutes())}`
}
}
return v.prescription_date || '—'
})
const rxSerialText = computed(() => {
const v = slipView.value as any
if (!v) return '—'
return (
v.order_no ||
v.serial_no ||
v.serial_number ||
v.no ||
v.prescription_no ||
v.visit_no ||
(v.id ? `G${v.id}` : '—')
)
})
const rxRecipientText = computed(() => {
const v = slipView.value as any
if (!v) return '—'
if (v.recipient_text) return v.recipient_text
const parts: string[] = []
if (v.recipient_name || v.patient_name) parts.push(v.recipient_name || v.patient_name)
if (v.recipient_phone || v.phone) parts.push(v.recipient_phone || v.phone)
let region = v.region
if (Array.isArray(region)) region = region.filter(Boolean).join('')
if (region) parts.push(String(region))
if (v.shipping_address) parts.push(String(v.shipping_address))
else if (v.address) parts.push(String(v.address))
return parts.length ? parts.join('') : '—'
})
const rxDoseCount = computed(() => {
const n = Number(slipView.value?.dose_count)
return Number.isFinite(n) && n > 0 ? n : 1
})
const rxPerDoseAmount = computed(() => {
const list = slipHerbsList.value
const sum = list.reduce((acc: number, h: any) => acc + (Number(h?.dosage) || 0), 0)
return formatNum(sum)
})
const rxHerbTotal = (dosage: number | string) => {
const d = Number(dosage) || 0
return formatNum(d * rxDoseCount.value)
}
const rxTypeText = computed(() => {
const v = slipView.value as any
if (!v) return '—'
const t = v.prescription_type || '浓缩水丸'
return /丸|散|膏|片/.test(t) ? `浓缩丸-${t}` : t
})
const rxUsageText = computed(() => {
const v = slipView.value as any
if (!v) return '—'
if (v.usage_text) return v.usage_text
return buildUsageSegmentText(v)
})
const rxAuxUsageText = computed(() => {
const v = slipView.value as any
if (!v || !slipAuxHerbs.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 rxAdviceText = computed(() => {
const v = slipView.value as any
if (!v) return ''
/** 出丸克数已经在底部以红色独立行展示,这里不重复 */
return v.medical_advice || ''
})
const rxRemarkText = computed(() => {
const v = slipView.value as any
if (!v) return ''
const parts: string[] = []
parts.push(`共${slipHerbsList.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 = slipView.value as any
if (!v) return ''
return v.pharmacy_remark || v.pharmacy_note || ''
})
const rxOutPelletText = computed(() => {
const v = slipView.value as any
if (!v) return ''
const out = computeOutPellet(v)
return out ? `${out}克` : ''
})
function computeOutPellet(v: any): string {
if (!v) return ''
if ((v.prescription_type || '浓缩水丸') === '浓缩水丸') {
const days = Number(v.medication_days ?? v.usage_days ?? v.dose_count)
const times = Number(v.times_per_day)
const doseG = Number(v.dosage_amount)
const bags = Number(v.dosage_bag_count) > 0 ? Number(v.dosage_bag_count) : 1
if (
Number.isFinite(days) &&
Number.isFinite(times) &&
Number.isFinite(doseG) &&
days > 0 &&
times > 0 &&
doseG > 0
) {
return formatNum(days * times * doseG * bags)
}
}
const explicit = v.out_pellet || v.total_weight
if (explicit && Number(explicit) > 0) return formatNum(Number(explicit))
const list = slipHerbsList.value
if (!list.length) return ''
const perDose = list.reduce((acc: number, h: any) => acc + (Number(h?.dosage) || 0), 0)
return formatNum(perDose * rxDoseCount.value)
}
function formatNum(n: number): string {
if (!isFinite(n)) return '0'
return Number.isInteger(n) ? String(n) : n.toFixed(2).replace(/\.?0+$/, '')
}
const rxFee = computed(() => {
const v = slipView.value as any
const medicine = pickFee(v, ['medicine_fee', 'medicine_amount', 'drug_fee', 'pay_amount']) || 0
const consult = pickFee(v, ['consult_fee', 'consultation_fee']) || 0
const making = pickFee(v, ['making_fee', 'process_fee', 'manufacturing_fee']) || 0
const shipping = pickFee(v, ['shipping_fee', 'freight_fee', 'delivery_fee']) || 0
const discount = pickFee(v, ['discount_amount', 'discount_fee', 'discount']) || 0
/** 业务订单合计金额(来自后端 detail 注入的 order_amount */
const orderAmount = pickFee(v, ['order_amount']) || 0
let total = pickFee(v, ['total_amount', 'grand_total', 'amount_total']) || 0
if (!total) total = orderAmount
if (!total) total = Math.max(0, medicine + consult + making + shipping - discount)
return {
medicine: formatYuan(medicine),
consult: formatYuan(consult),
making: formatYuan(making),
shipping: formatYuan(shipping),
discount: formatYuan(discount),
total: formatYuan(total)
}
})
function pickFee(v: any, keys: string[]): number {
if (!v) return 0
for (const k of keys) {
const val = v[k]
if (val == null || val === '') continue
const n = Number(val)
if (!isNaN(n)) return n
}
return 0
}
function formatYuan(n: number): string {
if (!isFinite(n)) return '0'
return n.toFixed(2).replace(/\.?0+$/, '') || '0'
}
/* === 打印 === */
function handlePrintSlip() {
if (!slipView.value) return
nextTick(() => {
try {
window.print()
} catch (e) {
console.error(e)
}
})
}
/* === 下载 PDFA4 === */
async function handleDownloadSlip() {
const node = rxPaperRef.value
if (!node) {
feedback.msgWarning('处方笺尚未渲染完成')
return
}
downloadingSlip.value = true
try {
await nextTick()
/** 等一帧让浏览器完成最后的布局/字体回流 */
await new Promise((r) => requestAnimationFrame(() => r(null)))
await new Promise((r) => setTimeout(r, 250))
/** 走 html2canvas 默认(DOM 克隆 + stylesheet 复制)模式:
* - 它会把页面 <style>/<link> 表全部复制到 iframe 渲染上下文,
* Vue scoped 样式与 Tailwind 工具类都能生效;
* - 不要开 foreignObjectRendering:它不携带外部样式表,会导致空白。
*/
const canvas = await html2canvas(node, {
scale: 2,
backgroundColor: '#ffffff',
useCORS: true,
allowTaint: false,
logging: false,
/** 锁定渲染尺寸 = 元素自身尺寸,按 A4 真实宽度排版 */
windowWidth: node.scrollWidth,
windowHeight: node.scrollHeight,
width: node.scrollWidth,
height: node.scrollHeight
})
const imgData = canvas.toDataURL('image/jpeg', 0.95)
const pdf = new jsPDF({ unit: 'mm', format: 'a4', orientation: 'portrait' })
const pageW = 210
const pageH = 297
const imgW = pageW
const imgH = (canvas.height * imgW) / canvas.width
if (imgH <= pageH) {
pdf.addImage(imgData, 'JPEG', 0, 0, imgW, imgH)
} else {
let leftHeight = imgH
let position = 0
while (leftHeight > 0) {
pdf.addImage(imgData, 'JPEG', 0, position, imgW, imgH)
leftHeight -= pageH
position -= pageH
if (leftHeight > 0) pdf.addPage()
}
}
const name = (slipView.value as any)?.patient_name || '处方'
const id = (slipView.value as any)?.id || ''
const date = (slipView.value as any)?.prescription_date || ''
pdf.save(`处方-${name}${date ? '-' + date : ''}${id ? '-' + id : ''}.pdf`)
} catch (e: any) {
console.error('下载处方 PDF 失败:', e)
feedback.msgError(e?.message || '下载失败,请重试')
} finally {
downloadingSlip.value = false
}
}
// 编辑表单
const editForm = reactive({
id: 0,
prescription_type: '浓缩水丸',
dosage_amount: 1 as number | undefined,
dosage_unit: 'g',
dosage_bag_count: 1,
need_decoction: false,
bags_per_dose: 1,
patient_name: '',
gender: 1,
age: 0,
visit_no: '',
prescription_date: '',
tongue: '',
tongue_image: '',
pulse: '',
pulse_condition: '',
clinical_diagnosis: '',
herbs: [] as HerbRow[],
dose_count: 7,
dose_unit: '剂',
usage_days: 7,
usage_instruction: '',
usage_time: '饭后',
usage_way: '温水送服',
dietary_taboo: [] as string[],
usage_notes: '',
doctor_name: '',
/** 医师电子签名 PNG data URL(与诊间处方一致) */
doctor_signature: '',
is_shared: 0,
visible_role_ids: [] as number[],
audit_status: 1,
audit_time: null as number | null,
audit_by_name: '',
audit_remark: '',
diagnosis_id: 0,
/** 开方医师 admin id(处方库导入按此医生筛选) */
creator_id: 0,
/** 1=系统自动生成(如挂号完成) */
is_system_auto: 0,
/** 列表带入:业务订单「处方审核」驳回(消费者处方本身可能仍为已通过) */
business_prescription_audit_rejected: 0,
business_prescription_audit_remark: '',
times_per_day: 2,
aux_usage: defaultAuxUsage('浓缩水丸')
})
/** 编辑页:关联业务订单上的服用天数、医助备注(与处方提示一致) */
const rxLinkedOrderHint = reactive({
loading: false,
orderNo: '',
medicationDays: null as number | null,
remarkAssistant: ''
})
function clearRxLinkedOrderHint() {
rxLinkedOrderHint.loading = false
rxLinkedOrderHint.orderNo = ''
rxLinkedOrderHint.medicationDays = null
rxLinkedOrderHint.remarkAssistant = ''
}
async function loadRxLinkedOrderHintForEdit(prescriptionId: number) {
rxLinkedOrderHint.orderNo = ''
rxLinkedOrderHint.medicationDays = null
rxLinkedOrderHint.remarkAssistant = ''
if (!prescriptionId) {
rxLinkedOrderHint.loading = false
return
}
rxLinkedOrderHint.loading = true
try {
const data: any = await prescriptionOrderLists({
page_no: 1,
page_size: 5,
prescription_id: prescriptionId
})
const lists = data?.lists
const row = Array.isArray(lists) && lists.length ? lists[0] : null
if (row) {
rxLinkedOrderHint.orderNo = String(row.order_no || '').trim()
const md = row.medication_days
const mdn = md !== null && md !== undefined && String(md).trim() !== '' ? Number(md) : NaN
rxLinkedOrderHint.medicationDays = Number.isFinite(mdn) && mdn > 0 ? mdn : null
rxLinkedOrderHint.remarkAssistant = String(row.remark_assistant || '').trim()
}
} catch {
/* 无列表权限或网络错误:保持空 */
} finally {
rxLinkedOrderHint.loading = false
}
}
const diagnosisViewRef = ref<{
openViewOnly: (id: number) => Promise<void>
} | null>(null)
function openDiagnosisPatientDetail() {
const id = Number(editForm.diagnosis_id)
if (!id) {
feedback.msgWarning('本处方未关联诊单,无法查看诊单患者信息')
return
}
void diagnosisViewRef.value?.openViewOnly(id)
}
// 标志:是否正在加载数据(防止watch函数覆盖已加载的值)
const isLoadingData = ref(false)
// 监听处方类型变化,自动调整用量单位和默认值
watch(() => editForm.prescription_type, (newType) => {
// 如果正在加载数据,不要自动重置值
if (isLoadingData.value) {
return
}
if (newType === '浓缩水丸') {
editForm.dosage_unit = 'g'
editForm.dosage_amount = 1
editForm.dosage_bag_count = 1
editForm.need_decoction = false
editForm.bags_per_dose = 1
} else if (newType === '饮片') {
editForm.dosage_unit = 'ml'
editForm.dosage_amount = 50
editForm.dosage_bag_count = 1
editForm.bags_per_dose = 1
// need_decoction 保持用户选择
} else {
editForm.dosage_unit = 'g'
editForm.dosage_amount = undefined
editForm.dosage_bag_count = 1
editForm.need_decoction = false
editForm.bags_per_dose = 1
}
Object.assign(editForm.aux_usage, defaultAuxUsage(newType))
})
// 表单验证规则
const rules: FormRules = {
patient_name: [
{ required: true, message: '请输入患者姓名', trigger: 'blur' }
],
gender: [
{ required: true, message: '请选择性别', trigger: 'change' }
],
prescription_date: [
{ required: true, message: '请选择处方日期', trigger: 'change' }
],
clinical_diagnosis: [
{ required: true, message: '请输入临床诊断', trigger: 'blur' }
],
dose_count: [
{ required: true, message: '请输入剂数', trigger: 'blur' }
],
doctor_name: [
{ required: true, message: '请输入医师姓名', trigger: 'blur' }
],
doctor_signature: [
{
required: true,
validator: (_rule, v, cb) => {
if (!v || !String(v).trim()) {
cb(new Error('请使用电子签名板手写医师签名'))
} else {
cb()
}
},
trigger: 'change'
}
]
}
async function fetchPrescriptionLists(params: Record<string, unknown>) {
const { audit_filter, source_filter, creator_ids, ...rest } = params
const payload: Record<string, unknown> = {
...rest,
audit_filter: audit_filter === 'all' || audit_filter === '' ? '' : audit_filter,
source_filter:
source_filter === 'all' || source_filter === '' || source_filter === undefined ? '' : source_filter
}
if (Array.isArray(creator_ids) && creator_ids.length > 0) {
payload.creator_ids = creator_ids
}
return prescriptionLists(payload)
}
const { pager, getLists, resetPage } = usePaging({
fetchFun: fetchPrescriptionLists,
params: formData
})
function pad2(n: number) {
return String(n).padStart(2, '0')
}
/** 本地日历日的 00:00:00 ~ 23:59:59 字符串(与 daterange-picker 一致) */
function dayRangeStrings(d: Date) {
const y = d.getFullYear()
const m = pad2(d.getMonth() + 1)
const day = pad2(d.getDate())
const base = `${y}-${m}-${day}`
return { start: `${base} 00:00:00`, end: `${base} 23:59:59` }
}
function onDateQuickTabChange(val: string | number | boolean | undefined) {
const tab = String(val ?? 'all')
if (tab === 'all') {
formData.start_time = ''
formData.end_time = ''
} else {
const now = new Date()
const t = new Date(now.getFullYear(), now.getMonth(), now.getDate())
if (tab === 'yesterday') {
t.setDate(t.getDate() - 1)
} else if (tab === 'before_yesterday') {
t.setDate(t.getDate() - 2)
}
const { start, end } = dayRangeStrings(t)
formData.start_time = start
formData.end_time = end
}
resetPage()
}
function onAuditFilterChange() {
resetPage()
}
function onSourceFilterChange() {
resetPage()
}
let syncingDateTab = false
watch(
() => [formData.start_time, formData.end_time],
() => {
if (syncingDateTab) return
const s = formData.start_time
const e = formData.end_time
if (!s && !e) {
dateQuickTab.value = 'all'
return
}
if (!s || !e) {
dateQuickTab.value = ''
return
}
const now = new Date()
const matchOffset = (offset: number) => {
const d = new Date(now.getFullYear(), now.getMonth(), now.getDate() - offset)
const { start, end } = dayRangeStrings(d)
return s === start && e === end
}
if (matchOffset(0)) dateQuickTab.value = 'today'
else if (matchOffset(1)) dateQuickTab.value = 'yesterday'
else if (matchOffset(2)) dateQuickTab.value = 'before_yesterday'
else dateQuickTab.value = ''
}
)
function formatListCreateTime(v: unknown) {
if (v === null || v === undefined || v === '') return '—'
if (typeof v === 'number' && v >= 1e9 && v < 1e11) {
return formatAuditTime(v)
}
const s = String(v).trim()
if (/^\d{10}$/.test(s)) {
return formatAuditTime(parseInt(s, 10))
}
return s
}
function resetParams() {
syncingDateTab = true
dateQuickTab.value = 'all'
formData.sn = ''
formData.patient_name = ''
formData.creator_ids = []
formData.audit_filter = 'all'
formData.source_filter = 'all'
formData.start_time = ''
formData.end_time = ''
syncingDateTab = false
resetPage()
}
function userCanAudit() {
const u = userStore.userInfo
if (!u) return false
if (Number(u.root) === 1) return true
const raw = u.role_ids ?? u.role_id
const ids = Array.isArray(raw) ? raw.map((n: unknown) => Number(n)) : []
return PRESCRIPTION_AUDIT_ROLE_IDS.some((rid) => ids.includes(rid))
}
function auditStatusLabel(v: number | undefined) {
if (v === 0) return '待审核'
if (v === 2) return '已驳回'
return '已通过'
}
function auditStatusTagType(v: number | undefined) {
if (v === 0) return 'warning'
if (v === 2) return 'danger'
return 'success'
}
/** 业务订单处方审核驳回时,列表「审核状态」主标签显示为已驳回 */
function listAuditStatusLabel(row: { audit_status?: number; business_prescription_audit_rejected?: number }) {
if (Number(row.business_prescription_audit_rejected) === 1) return '已驳回'
return auditStatusLabel(row.audit_status)
}
function listAuditStatusTagType(row: { audit_status?: number; business_prescription_audit_rejected?: number }) {
if (Number(row.business_prescription_audit_rejected) === 1) return 'danger'
return auditStatusTagType(row.audit_status)
}
function listRejectReasonLines(row: {
audit_status?: number
audit_remark?: string
business_prescription_audit_rejected?: number
business_prescription_audit_remark?: string
}): string[] {
const out: string[] = []
if (Number(row.business_prescription_audit_rejected) === 1) {
const t = String(row.business_prescription_audit_remark || '').trim()
out.push(t ? `业务订单审核驳回意见:${t}` : '业务订单审核驳回意见:—')
}
if (Number(row.audit_status) === 2) {
const t = String(row.audit_remark || '').trim()
out.push(t ? `消费者处方审核驳回意见:${t}` : '消费者处方审核驳回意见:—')
}
return out
}
/** 已通过审核且未作废:仅可查看,不可编辑/删除(与预约列表「查看」一致) */
function isApprovedActivePrescription(row: { audit_status?: number; void_status?: number }) {
return Number(row.audit_status) === 1 && Number(row.void_status) !== 1
}
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')}`
}
function formatVoidTime(ts: number | null | undefined) {
if (!ts) return '—'
return formatAuditTime(ts)
}
function slipGenderLabel(g: unknown) {
if (g === 1 || g === '1') return '男'
if (g === 0 || g === '0') return '女'
return '—'
}
function slipAgeText(age: unknown) {
if (age === null || age === undefined || age === '') return '—'
return `${age}岁`
}
function herbValidationLabel(globalIndex: number): string {
const herb = editForm.herbs[globalIndex]
if (!herb) return `第${globalIndex + 1}味`
const ft = normalizeFormulaType(herb.formula_type)
let n = 0
for (let i = 0; i <= globalIndex; i++) {
if (normalizeFormulaType(editForm.herbs[i]?.formula_type) === ft) n++
}
return `${ft}第${n}味`
}
function listRxOrderWarnings(row: any): string[] {
if (Number(row?.has_prescription_order) !== 1) return []
const herbs = normalizeSlipHerbs(row?.herbs)
const herbNames = herbs
.map((h) => String(h?.name || '').trim())
.filter(Boolean)
const warns: string[] = []
if (!herbNames.length) {
warns.push('请开方,当前处方药材为空白')
return warns
}
const seen = new Set<string>()
const dup = new Set<string>()
for (const n of herbNames) {
const k = n.replace(/\s+/g, '').toLowerCase()
if (!k) continue
if (seen.has(k)) dup.add(n)
else seen.add(k)
}
if (dup.size > 0) {
warns.push(`已有关联业务订单,当前处方存在重复药材:${Array.from(dup).join('、')}`)
}
return warns
}
function rowToSlipView(row: any) {
return {
...row,
herbs: normalizeSlipHerbs(row?.herbs)
}
}
function onEditDialogClosed() {
slipView.value = null
signatureActivePointerId.value = null
}
/**
* 签名坐标:与 initSignatureCanvas 里 ctx.scale(dpr) 后的「逻辑像素」一致(0…SIGNATURE_CSS_W/H),
* 不能用 canvas.width/rect.width(会把 dpr 乘两次,笔画和鼠标严重错位)。
*/
function 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))
}
}
function commitSignatureImage() {
const canvas = signatureCanvasRef.value
if (canvas) {
editForm.doctor_signature = canvas.toDataURL('image/png')
}
nextTick(() => {
formRef.value?.validateField('doctor_signature').catch(() => {})
})
}
function 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)
}
function 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)
}
function 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()
}
function clearSignature() {
initSignatureCanvas()
editForm.doctor_signature = ''
nextTick(() => {
formRef.value?.validateField('doctor_signature').catch(() => {})
})
}
function 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 scheduleEditSignatureInit() {
if (editMode.value === 'view') return
nextTick(() => {
setTimeout(() => {
initSignatureCanvas()
const sig = editForm.doctor_signature
if (sig && String(sig).trim().startsWith('data:image') && signatureCanvasRef.value) {
const img = new Image()
img.onload = () => {
const canvas = signatureCanvasRef.value
const ctx = canvas?.getContext('2d')
if (!canvas || !ctx) return
ctx.drawImage(img, 0, 0, SIGNATURE_CSS_W, SIGNATURE_CSS_H)
commitSignatureImage()
}
img.onerror = () => {
commitSignatureImage()
}
img.src = sig
}
}, 100)
})
}
function formatVisibleRoleNames(ids: number[] | undefined) {
if (!ids || !ids.length) return '—'
const names = ids
.map((id) => roleOptions.value.find((r) => r.id === id)?.name)
.filter(Boolean)
return names.length ? names.join('、') : ids.join('、')
}
function normalizeVisibleRoleIds(row: any): number[] {
const v = row?.visible_role_ids
if (Array.isArray(v)) return v.map((n) => Number(n)).filter((n) => !Number.isNaN(n))
if (typeof v === 'string' && v)
return v
.split(',')
.map((s) => parseInt(s.trim(), 10))
.filter((n) => !Number.isNaN(n))
return []
}
function openAuditDialog(row: any) {
auditTargetId.value = row.id
auditRemark.value = ''
auditDialogVisible.value = true
}
async function submitAudit(action: 'approve' | 'reject') {
if (action === 'reject' && !auditRemark.value.trim()) {
feedback.msgError('驳回时请填写审核意见')
return
}
auditLoading.value = true
try {
const auditData = (await prescriptionAudit({
id: auditTargetId.value,
action,
remark: auditRemark.value.trim()
})) as { wecom_notify_ok?: boolean; wecom_notify_hint?: string } | void
feedback.msgSuccess(action === 'reject' ? '已驳回并作废处方' : '审核通过')
if (auditData && auditData.wecom_notify_ok === false && auditData.wecom_notify_hint) {
feedback.msgWarning(auditData.wecom_notify_hint)
}
auditDialogVisible.value = false
getLists()
} catch {
/* 拦截器已提示 */
} finally {
auditLoading.value = false
}
}
async function loadRoleOptions() {
try {
const list = await roleAll({})
roleOptions.value = Array.isArray(list) ? list : []
} catch {
roleOptions.value = []
}
}
// —— 处方库导入(与 components/tcm-prescription 一致,按 creator_id 拉取该医师库内处方)——
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 libraryPage = ref(1)
const libraryPageSize = ref(15)
const libraryTotal = ref(0)
/** 当前用于检索处方库的开方医师 ID:编辑用处方 creator_id,新增用当前登录账号 */
const libraryDoctorId = computed(() => {
const cid = Number(editForm.creator_id)
if (cid > 0) return cid
const uid = Number(userStore.userInfo?.id)
return uid > 0 ? uid : 0
})
function normalizeHerbNameLocal(s: string) {
return String(s || '')
.trim()
.toLowerCase()
}
function findDuplicateHerbNamesLocal(): string[] {
const map = new Map<string, number>()
for (const h of editForm.herbs) {
const key = normalizeHerbNameLocal(h?.name)
if (!key) continue
map.set(key, (map.get(key) ?? 0) + 1)
}
return [...map.entries()]
.filter(([, c]) => c > 1)
.map(([name]) => name)
}
const loadLibraryList = async () => {
const doctorId = libraryDoctorId.value
if (!doctorId) {
feedback.msgWarning('无法确定开方医师,无法加载处方库')
libraryList.value = []
libraryTotal.value = 0
return
}
libraryLoading.value = true
try {
const res: any = 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 (e) {
console.error(e)
feedback.msgError('加载处方库失败')
} finally {
libraryLoading.value = false
}
}
const searchLibrary = () => {
libraryPage.value = 1
loadLibraryList()
}
const openLibraryDialog = () => {
const doctorId = libraryDoctorId.value
if (!doctorId) {
feedback.msgWarning('无法确定开方医师,无法从处方库导入')
return
}
showLibraryDialog.value = true
}
const handleSelectLibraryRow = (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 = editForm.herbs.filter((h) => normalizeFormulaType(h.formula_type) !== formulaType)
editForm.herbs = [...kept, ...imported]
} else {
editForm.herbs.push(...imported)
}
if (formulaType === '辅方') {
editForm.aux_usage.prescription_name = String(row.prescription_name ?? '').trim()
}
const modeHint = libraryImportMode.value === 'append' ? '(已追加)' : ''
if (findDuplicateHerbNamesLocal().length) {
feedback.msgWarning(
`已导入处方「${row.prescription_name}」${modeHint},共${imported.length}味药材。存在重复药名,请合并剂量或删除多余行`
)
} else {
feedback.msgSuccess(`已导入处方「${row.prescription_name}」${modeHint},共${imported.length}味药材`)
}
showLibraryDialog.value = false
}
watch(showLibraryDialog, (open) => {
if (open) {
librarySearchName.value = ''
librarySearchFormulaType.value = ''
libraryImportMode.value = 'replace'
libraryPage.value = 1
loadLibraryList()
}
})
/** 粘贴药方导入(与 components/tcm-prescription 一致) */
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 normalizeRecipePasteInput(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 stripRecipePasteHeaders(text: string): string {
return text
.replace(/^\s*Rp[::**\s]*/i, '')
.replace(/^\s*处方[:\s]*/, '')
.replace(/^\s*中药处方[:\s]*/, '')
.trimStart()
}
function stripHerbPasteNote(name: string): string {
return name
.replace(/[^]*$/u, '')
.replace(/\([^)]*\)$/u, '')
.trim()
}
function parseHerbPasteToken(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: stripHerbPasteNote(name), dosage: dose }))
}
const m = s.match(/^(.+?)\s*(\d+(?:\.\d+)?)\s*(?:克|g|G|钱)?$/i)
if (m) {
const name = stripHerbPasteNote(m[1].trim())
const dose = parseFloat(m[2])
if (!name || !Number.isFinite(dose) || dose <= 0) return null
return [{ name, dosage: dose }]
}
const nameOnly = stripHerbPasteNote(s)
if (nameOnly.length >= 2 && nameOnly.length <= 16) {
return [{ name: nameOnly, dosage: 6 }]
}
return null
}
function chunkRecipePasteText(text: string): string[] {
const t = stripRecipePasteHeaders(normalizeRecipePasteInput(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) && !parseHerbPasteToken(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 parseRecipePasteToHerbs(text: string): { name: string; dosage: number }[] {
const herbs: { name: string; dosage: number }[] = []
for (const c of chunkRecipePasteText(text)) {
const parsed = parseHerbPasteToken(c)
if (!parsed) continue
herbs.push(...parsed)
}
return herbs
}
async function resolvePasteHerbNameFromLibrary(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 = parseRecipePasteToHerbs(pasteRecipeText.value)
if (!parsed.length) {
feedback.msgWarning('未能从文本中解析出药材,请检查格式(药名 + 剂量,或用顿号/换行分隔)')
return
}
pasteRecipeImportLoading.value = true
try {
const resolved: HerbRow[] = []
const skippedNames: string[] = []
for (const row of parsed) {
const name = await resolvePasteHerbNameFromLibrary(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 = editForm.herbs.filter((h) => normalizeFormulaType(h.formula_type) === '辅方')
editForm.herbs = [...auxKept, ...resolved]
} else {
editForm.herbs.push(...resolved)
}
const dupNames = findDuplicateHerbNamesLocal()
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 addHerb = (formulaType: FormulaType = '主方') => {
if (herbsLocked.value) {
feedback.msgWarning('该处方含「禁用修改」模板,药材已锁定,请重新从处方库导入覆盖')
return
}
editForm.herbs.push({
name: '',
dosage: 0,
formula_type: formulaType
})
}
// 删除药材
const removeHerb = (index: number) => {
if (herbsLocked.value) {
feedback.msgWarning('该处方含「禁用修改」模板,药材已锁定,不可删除')
return
}
editForm.herbs.splice(index, 1)
}
// 重置表单
const resetForm = () => {
editForm.id = 0
editForm.prescription_type = '浓缩水丸'
editForm.dosage_amount = 1
editForm.dosage_unit = 'g'
editForm.dosage_bag_count = 1
editForm.need_decoction = false
editForm.bags_per_dose = 1
editForm.patient_name = ''
editForm.gender = 1
editForm.age = 0
editForm.visit_no = ''
editForm.prescription_date = new Date().toISOString().split('T')[0]
editForm.tongue = ''
editForm.tongue_image = ''
editForm.pulse = ''
editForm.pulse_condition = ''
editForm.clinical_diagnosis = ''
editForm.herbs = []
editForm.dose_count = 7
editForm.dose_unit = '剂'
editForm.usage_days = 7
editForm.usage_instruction = ''
editForm.usage_time = '饭后'
editForm.usage_way = '温水送服'
editForm.dietary_taboo = []
editForm.usage_notes = ''
editForm.doctor_name = ''
editForm.doctor_signature = ''
editForm.is_shared = 0
editForm.visible_role_ids = []
editForm.audit_status = 1
editForm.audit_time = null
editForm.audit_by_name = ''
editForm.audit_remark = ''
editForm.diagnosis_id = 0
editForm.creator_id = Number(userStore.userInfo?.id) || 0
editForm.is_system_auto = 0
editForm.business_prescription_audit_rejected = 0
editForm.business_prescription_audit_remark = ''
editForm.times_per_day = 2
Object.assign(editForm.aux_usage, defaultAuxUsage('浓缩水丸'))
clearRxLinkedOrderHint()
}
// 新增处方
const handleAdd = () => {
resetForm()
editForm.doctor_name = String(userStore.userInfo?.name || '')
editingWasVoid.value = false
editingWasRejected.value = false
editMode.value = 'add'
// 新建时允许watch自动设置默认值
isLoadingData.value = false
showEdit.value = true
nextTick(() => {
formRef.value?.clearValidate()
scheduleEditSignatureInit()
})
}
// 查看处方(处方笺样式,详情接口补全电话、签名等)
const handleView = async (row: any) => {
editingWasVoid.value = false
editingWasRejected.value = false
editMode.value = 'view'
slipView.value = rowToSlipView(row)
showEdit.value = true
viewDetailLoading.value = true
try {
const d = await prescriptionDetail({ id: row.id })
if (d && typeof d === 'object') {
slipView.value = {
...d,
herbs: normalizeSlipHerbs((d as any).herbs)
}
}
} catch (e: any) {
feedback.msgError(e?.msg || '加载处方详情失败')
} finally {
viewDetailLoading.value = false
}
}
// 编辑处方
const handleEdit = async (row: any) => {
editingWasVoid.value = Number(row.void_status) === 1
editingWasRejected.value = Number(row.audit_status) === 2
editMode.value = 'edit'
// 开始加载数据,暂时禁用watch自动重置
isLoadingData.value = true
let src = row
if (row?.id && !row.doctor_signature) {
try {
const d = await prescriptionDetail({ id: row.id })
if (d && typeof d === 'object') {
src = { ...row, ...d }
}
} catch {
/* 保持列表行 */
}
}
// 深拷贝数据到表单
editForm.id = src.id
editForm.prescription_type = src.prescription_type || '浓缩水丸'
// 确保 dosage_amount 是数字类型,以匹配下拉框的 :value
if (src.dosage_amount !== null && src.dosage_amount !== undefined && src.dosage_amount !== '') {
editForm.dosage_amount = Number(src.dosage_amount)
} else {
editForm.dosage_amount = undefined
}
// 如果数据库中没有 dosage_unit,根据 prescription_type 设置默认值
if (src.dosage_unit) {
editForm.dosage_unit = src.dosage_unit
} else {
if (editForm.prescription_type === '饮片') {
editForm.dosage_unit = 'ml'
} else {
editForm.dosage_unit = 'g'
}
}
editForm.dosage_bag_count = src.dosage_bag_count ? Number(src.dosage_bag_count) : 1
editForm.need_decoction = src.need_decoction === 1 || src.need_decoction === true
editForm.bags_per_dose = src.bags_per_dose ? Number(src.bags_per_dose) : 1
editForm.patient_name = src.patient_name || ''
editForm.gender = src.gender ?? 1
editForm.age = src.age ?? 0
editForm.visit_no = src.visit_no || ''
editForm.prescription_date = src.prescription_date || ''
editForm.tongue = src.tongue || ''
editForm.tongue_image = src.tongue_image || ''
editForm.pulse = src.pulse || ''
editForm.pulse_condition = src.pulse_condition || ''
editForm.clinical_diagnosis = src.clinical_diagnosis || ''
editForm.herbs = src.herbs ? (src.herbs as any[]).map((h) => normalizeHerbRow(h)) : []
editForm.dose_count = src.dose_count ?? 7
editForm.dose_unit = src.dose_unit || '剂'
editForm.usage_days = src.usage_days ?? 7
editForm.times_per_day = src.times_per_day ?? 2
editForm.usage_instruction = src.usage_instruction || ''
editForm.usage_time = src.usage_time || '饭后'
editForm.usage_way = src.usage_way || '温水送服'
editForm.dietary_taboo = src.dietary_taboo
? (Array.isArray(src.dietary_taboo) ? src.dietary_taboo : String(src.dietary_taboo).split(',').filter((v: string) => v))
: []
editForm.usage_notes = src.usage_notes || ''
editForm.doctor_name = src.doctor_name || ''
editForm.doctor_signature = String(src.doctor_signature || '')
editForm.is_shared = src.is_shared ?? 0
editForm.visible_role_ids = normalizeVisibleRoleIds(src)
editForm.audit_status = src.audit_status ?? 1
editForm.audit_time = src.audit_time ?? null
editForm.audit_by_name = src.audit_by_name || ''
editForm.audit_remark = src.audit_remark || ''
editForm.diagnosis_id = src.diagnosis_id ?? 0
editForm.creator_id = Number(src.creator_id) > 0 ? Number(src.creator_id) : 0
editForm.is_system_auto = Number(src.is_system_auto) === 1 ? 1 : 0
editForm.business_prescription_audit_rejected = Number(src.business_prescription_audit_rejected) === 1 ? 1 : 0
editForm.business_prescription_audit_remark = String(src.business_prescription_audit_remark || '')
Object.assign(
editForm.aux_usage,
normalizeAuxUsageForm(src.aux_usage, editForm.prescription_type)
)
// 数据加载完成,重新启用watch
// 使用 setTimeout 确保所有数据都已经渲染完成
setTimeout(() => {
isLoadingData.value = false
}, 0)
showEdit.value = true
void loadRxLinkedOrderHintForEdit(Number(editForm.id))
nextTick(() => {
formRef.value?.clearValidate()
scheduleEditSignatureInit()
})
}
// 提交表单
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
} catch {
// Element Plus:校验失败会 reject,表单项已展示错误,勿再向外抛成 Uncaught
return
}
// 验证药材
if (!editForm.herbs || editForm.herbs.length === 0) {
feedback.msgError('请至少添加一味药材')
return
}
for (let i = 0; i < editForm.herbs.length; i++) {
const herb = editForm.herbs[i]
const label = herbValidationLabel(i)
if (!herb.name || !herb.name.trim()) {
feedback.msgError(`${label}药材名称不能为空`)
return
}
if (!herb.dosage || herb.dosage <= 0) {
feedback.msgError(`${label}药材剂量必须大于0`)
return
}
}
submitLoading.value = true
try {
const params: Record<string, unknown> = { ...editForm }
if (editMode.value === 'add') {
params.audit_status = 0
await prescriptionAdd(params)
feedback.msgSuccess('添加成功,已提交审核')
} else {
await prescriptionEdit(params)
feedback.msgSuccess('保存成功,处方已重新进入待审核')
}
showEdit.value = false
getLists()
} catch (error) {
console.error(error)
} finally {
submitLoading.value = false
}
}
// 删除处方
const handleDelete = async (id: number) => {
await feedback.confirm('确定要删除该处方吗?')
await prescriptionDelete({ id })
feedback.msgSuccess('删除成功')
getLists()
}
onMounted(async () => {
await loadRoleOptions()
await loadRegionData()
await loadServicePackageOptions()
try {
const list = await getDoctors()
doctorOptions.value = Array.isArray(list) ? list : []
} catch {
doctorOptions.value = []
}
try {
if (!userStore.userInfo?.id) {
await userStore.getUserInfo()
}
} catch {
/* 已登录页仍可无用户信息,保持默认筛选 */
}
getLists()
})
</script>
<style scoped lang="scss">
.prescription-list {
padding: 12px 16px 20px;
}
/* 紧凑检索条少占纵向空间表格才是主体 */
.filter-toolbar {
border-radius: 8px;
border: 1px solid var(--el-border-color-lighter);
background: var(--el-fill-color-blank);
}
.pl-toolbar {
display: flex;
flex-direction: column;
gap: 8px;
}
.pl-toolbar__line {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 10px;
}
.pl-toolbar__line--second {
padding-top: 2px;
border-top: 1px dashed var(--el-border-color-lighter);
justify-content: flex-start;
}
.pl-toolbar__k {
flex-shrink: 0;
font-size: 12px;
font-weight: 500;
color: var(--el-text-color-secondary);
line-height: 1;
}
.pl-toolbar__sep {
width: 1px;
height: 18px;
background: var(--el-border-color);
opacity: 0.7;
margin: 0 2px;
}
.pl-toolbar__rg {
flex-wrap: wrap;
}
.pl-toolbar__rg :deep(.el-radio-button__inner) {
padding: 5px 10px;
font-size: 12px;
}
/* 审核人员最常用待审核 */
.pl-toolbar__rg--audit :deep(.pl-audit-pending-btn .el-radio-button__inner) {
font-weight: 600;
padding: 6px 14px;
font-size: 13px;
}
.pl-toolbar__rg--audit :deep(.pl-audit-pending-btn:not(.is-active) .el-radio-button__inner) {
color: var(--el-color-primary);
border-color: var(--el-color-primary-light-5);
background: var(--el-color-primary-light-9);
}
.pl-toolbar__rg--audit :deep(.pl-audit-pending-btn.is-active .el-radio-button__inner) {
background: var(--el-color-primary) !important;
border-color: var(--el-color-primary) !important;
color: #fff !important;
box-shadow: none;
}
.pl-toolbar__range {
flex: 1 1 240px;
min-width: 220px;
max-width: 380px;
}
.pl-toolbar__range :deep(.el-date-editor) {
width: 100%;
}
.pl-toolbar__range :deep(.el-date-editor.el-input__wrapper) {
min-height: 30px;
padding: 2px 8px;
}
.pl-toolbar__form {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: 0 4px;
flex: 1;
min-width: 0;
}
.pl-toolbar__form :deep(.el-form-item) {
margin-bottom: 0;
margin-right: 12px;
}
.pl-toolbar__form :deep(.el-form-item__label) {
font-size: 12px;
padding-right: 6px;
}
.pl-toolbar__input {
width: 132px;
}
.pl-toolbar__select {
width: 108px;
}
.pl-toolbar__select--doctors {
width: min(320px, 100%);
min-width: 200px;
}
.pl-toolbar__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-left: auto;
}
.table-card {
border-radius: 8px;
border: 1px solid var(--el-border-color-lighter);
}
.pl-table-head {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.pl-table-head__title {
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
letter-spacing: 0.02em;
}
/* 查看处方互联网医院处方笺对齐诊间 tcm-prescription 打印版式 */
.cf-prescription-drawer :deep(.el-drawer__body) {
padding-top: 8px;
overflow-y: auto;
}
/* ============================================================
* 处方笺A4 药房联样式
* 严格按 A4210mm × 297mmpadding 12mm
* ============================================================ */
.rx-wrap {
min-height: 160px;
background: #f5f6f8;
padding: 12px;
margin: -20px;
}
.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;
}
/* === A4 === */
.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);
/* 防止 html2canvas 把内部表格裁切 */
overflow: visible;
}
/* === 顶部告知 === */
.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;
}
}
/* === Rp 区域 + 水印 === */
.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;
/* 不使用 inset 简写html2canvas inset 解析不稳定
* 会导致水印容器尺寸异常进而影响内层网格对齐 */
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;
}
/* 注意列内 1fr 64px .rx-herb-cell 完全一致
* 配合 .rx-herbs .rx-rp-cols-head 同样的左侧 44px 缩进
* 即可保证用药 (单剂)」与药材名、「总量与总量数字精确对齐 */
.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 {
/* 与 .rx-rp-head 中 Rp. 列(36px) + column-gap(8px) 等宽缩进,
* 这样下方两列的左右边界与表头两列完全对齐。 */
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;
}
.herb-formula-block__head {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 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;
}
.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 {
/* 类型/剂数/单剂量 三个 meta 列适当加宽,
* 避免"浓缩丸-浓缩水丸"被强制折行后把整行挤错位 */
grid-template-columns: 1.4fr 0.6fr 0.6fr 0.6fr 0.6fr 1.7fr 0.95fr 1.25fr;
min-height: 70px;
}
.rx-bot-row-2 {
grid-template-columns: repeat(6, 1fr);
}
/* 默认:单行内容左对齐、上下居中(适配 类型/剂数/单剂量/费用 等格子)。
* 需要"标题置顶 + 签名留白"的格子(医师/审方/配药/复核/发药)请加 .rx-bot-stack。
* 这样无需任何 !important 反向覆盖,html2canvas/原生渲染表现一致。 */
.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-fee {
font-size: 13px;
line-height: 1.4;
padding: 8px 10px;
.rx-fee-key {
color: #1f1f1f;
margin-right: 6px;
white-space: nowrap;
}
.rx-fee-val {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
}
.rx-fee-total {
font-weight: 700;
background: #fafafa;
.rx-fee-val { font-weight: 700; }
}
/* === 审核痕迹(不打印) === */
.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;
}
/* 打印控制规则放到非 scoped 的全局 style 中,参见底部 <style> */
/* 创建业务订单弹窗:宽松留白、默认字号、卡片化信息区 */
.create-order-dialog {
:deep(.el-dialog__header) {
padding: 20px 24px 12px;
margin-right: 0;
}
:deep(.el-dialog__title) {
font-size: 18px;
font-weight: 600;
letter-spacing: 0.02em;
}
:deep(.el-dialog__body) {
padding: 8px 24px 20px;
max-height: calc(100vh - 200px);
overflow: hidden;
}
:deep(.el-dialog__footer) {
padding: 16px 24px 20px;
border-top: 1px solid var(--el-border-color-lighter);
background: var(--el-fill-color-blank);
}
}
.create-order-dialog__inner {
display: flex;
flex-direction: column;
gap: 0;
}
.create-order-rx-card {
border-radius: 10px;
border: 1px solid var(--el-color-primary-light-5);
background: linear-gradient(135deg, var(--el-color-primary-light-9) 0%, var(--el-fill-color-blank) 100%);
padding: 16px 18px;
margin-bottom: 20px;
box-shadow: 0 1px 2px rgb(0 0 0 / 4%);
}
.create-order-rx-card__head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px 14px;
margin-bottom: 10px;
}
.create-order-rx-card__badge {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
color: #fff;
background: var(--el-color-primary);
letter-spacing: 0.04em;
}
.create-order-rx-card__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 12px;
font-size: 14px;
color: var(--el-text-color-regular);
}
.create-order-rx-card__id {
font-weight: 600;
color: var(--el-text-color-primary);
}
.create-order-rx-card__sn {
padding: 2px 8px;
border-radius: 4px;
background: var(--el-fill-color);
font-size: 13px;
color: var(--el-text-color-secondary);
}
.create-order-rx-card__patient {
font-weight: 500;
color: var(--el-text-color-primary);
}
.create-order-rx-card__herbs {
font-size: 13px;
line-height: 1.55;
color: var(--el-text-color-secondary);
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
cursor: default;
padding-top: 2px;
}
.create-order-steps {
margin-bottom: 8px;
padding: 8px 12px 4px;
:deep(.el-step__title) {
font-size: 14px;
font-weight: 500;
line-height: 1.4;
}
:deep(.el-step__description) {
font-size: 12px;
margin-top: 4px;
line-height: 1.35;
padding-right: 6px;
}
:deep(.el-step__head) {
padding-right: 8px;
}
:deep(.el-step.is-horizontal .el-step__line) {
top: 14px;
}
}
.create-order-step-lead {
margin: 0 0 20px;
padding: 12px 16px;
font-size: 13px;
line-height: 1.6;
color: var(--el-text-color-secondary);
background: var(--el-fill-color-light);
border-radius: 8px;
border-left: 3px solid var(--el-color-primary-light-5);
}
.create-order-step-panel {
max-height: min(56vh, 480px);
overflow-y: auto;
overflow-x: hidden;
padding: 4px 8px 12px 4px;
margin-right: -4px;
}
.create-order-step-panel__row-after-bar {
margin-top: 20px;
}
.create-order-unit-suffix {
flex-shrink: 0;
font-size: 13px;
color: var(--el-text-color-secondary);
padding-left: 4px;
}
.create-order-footer {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 16px;
width: 100%;
}
.create-order-footer__hint {
flex: 1;
min-width: 200px;
display: flex;
flex-direction: column;
gap: 6px;
font-size: 13px;
line-height: 1.5;
color: var(--el-text-color-secondary);
}
.create-order-footer__step {
color: var(--el-text-color-placeholder);
}
.create-order-footer__warn {
color: var(--el-color-warning);
font-weight: 500;
}
.create-order-footer__actions {
flex-shrink: 0;
display: flex;
flex-wrap: wrap;
gap: 12px;
justify-content: flex-end;
}
.create-order-deposit-bar {
display: flex;
align-items: stretch;
border-radius: 10px;
border: 1px solid var(--el-border-color-lighter);
overflow: hidden;
font-size: 13px;
box-shadow: 0 1px 2px rgb(0 0 0 / 4%);
}
.create-order-deposit-bar__cell {
flex: 1;
padding: 16px 20px;
display: flex;
flex-direction: column;
gap: 6px;
background: var(--el-fill-color-light);
strong {
font-size: 20px;
font-weight: 600;
color: var(--el-text-color-primary);
letter-spacing: 0.02em;
}
}
.create-order-deposit-bar__label {
font-size: 13px;
color: var(--el-text-color-secondary);
}
.create-order-deposit-bar__sub {
font-size: 12px;
color: var(--el-text-color-placeholder);
}
.create-order-deposit-bar__sep {
width: 1px;
background: var(--el-border-color-lighter);
flex-shrink: 0;
}
.create-order-deposit-bar.is-ok .create-order-deposit-bar__cell:last-child strong {
color: var(--el-color-success);
}
.create-order-deposit-bar.is-warn .create-order-deposit-bar__cell:last-child strong {
color: var(--el-color-danger);
}
.create-order-pay-hint {
font-size: 12px;
line-height: 1.55;
margin-top: 8px;
color: var(--el-text-color-secondary);
}
.create-order-form {
:deep(.el-form-item) {
margin-bottom: 20px;
}
:deep(.el-form-item__label) {
font-size: 14px;
padding-right: 12px;
}
:deep(.el-input__inner),
:deep(.el-textarea__inner) {
font-size: 14px;
}
}
.create-order-form-item--inline-num {
:deep(.el-form-item__content) {
line-height: normal;
}
}
.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;
}
}
</style>
<!-- 全局打印样式不能 scoped否则 body * 不生效 -->
<style>
@media print {
body * { visibility: hidden !important; }
.rx-paper, .rx-paper * { visibility: visible !important; }
.rx-paper {
position: fixed !important;
left: 0 !important;
top: 0 !important;
width: 210mm !important;
height: 297mm !important;
margin: 0 !important;
padding: 8mm 10mm !important;
box-shadow: none !important;
page-break-after: always;
}
@page {
size: A4;
margin: 0;
}
}
</style>