Files
zyt/admin/src/views/consumer/prescription/index.vue
T
2026-04-30 09:27:42 +08:00

3984 lines
149 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.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">
<div
v-for="(h, i) in slipHerbsList"
:key="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>
</div>
</div>
<!-- 服法 / 医嘱 / 备注 / 药房备注 / 出丸 -->
<div class="rx-text">
<p>服法{{ rxUsageText }}</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-stack"><div class="rx-bot-label">审方</div></div>
<div class="rx-bot-cell rx-bot-stack"><div class="rx-bot-label">配药</div></div>
<div class="rx-bot-cell rx-bot-stack"><div class="rx-bot-label">复核</div></div>
<div class="rx-bot-cell rx-bot-stack"><div class="rx-bot-label">发药</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 class="rx-bot-row rx-bot-row-2">
<div class="rx-bot-cell rx-fee">
<span class="rx-fee-key">医药费</span>
<span class="rx-fee-val">{{ rxFee.medicine }}</span>
</div>
<div class="rx-bot-cell rx-fee">
<span class="rx-fee-key">诊金:</span>
<span class="rx-fee-val">{{ rxFee.consult }}</span>
</div>
<div class="rx-bot-cell rx-fee">
<span class="rx-fee-key">制作费:</span>
<span class="rx-fee-val">{{ rxFee.making }}</span>
</div>
<div class="rx-bot-cell rx-fee">
<span class="rx-fee-key">运费:</span>
<span class="rx-fee-val">{{ rxFee.shipping }}</span>
</div>
<div class="rx-bot-cell rx-fee">
<span class="rx-fee-key">优惠:</span>
<span class="rx-fee-val">{{ rxFee.discount }}</span>
</div>
<div class="rx-bot-cell rx-fee rx-fee-total">
<span class="rx-fee-key">合计:</span>
<span class="rx-fee-val">{{ rxFee.total }}</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="请输入患者姓名" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="门诊号" prop="visit_no">
<el-input v-model="editForm.visit_no" placeholder="自动生成或手动输入" />
</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">
<el-button type="primary" size="small" @click="addHerb">
添加药材
</el-button>
<el-button type="success" size="small" class="ml-2" @click="openLibraryDialog">
从处方库导入
</el-button>
<el-table :data="editForm.herbs" class="mt-2" border>
<el-table-column label="序号" type="index" width="60" />
<el-table-column label="药材名称" min-width="220">
<template #default="{ row }">
<MedicineNameSelect v-model="row.name" class="w-full" />
</template>
</el-table-column>
<el-table-column label="剂量(克)" min-width="120">
<template #default="{ row, $index }">
<el-input-number
v-model="row.dosage"
:min="0"
:precision="1"
:step="0.5"
placeholder="剂量"
class="w-full"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="80">
<template #default="{ $index }">
<el-button
type="danger"
link
@click="removeHerb($index)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</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-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="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>
<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_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-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 label="备注">
<el-input
v-model="createOrderForm.remark_extra"
type="textarea"
:rows="3"
placeholder="选填,对内说明"
maxlength="200"
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>
<!-- 从处方库导入(与诊间 tcm-prescription 一致,按当前处方开方医师 creator_id 筛选) -->
<el-dialog
v-model="showLibraryDialog"
title="从处方库导入"
width="800px"
:close-on-click-modal="false"
>
<div class="mb-4">
<el-input
v-model="librarySearchName"
placeholder="请输入处方名称搜索"
clearable
@keyup.enter="searchLibrary"
@clear="searchLibrary"
>
<template #append>
<el-button :icon="Search" @click="searchLibrary" />
</template>
</el-input>
</div>
<el-table
v-loading="libraryLoading"
:data="libraryList"
height="400"
style="cursor: pointer"
@row-click="handleSelectLibraryRow"
>
<el-table-column label="处方名称" prop="prescription_name" min-width="150" show-overflow-tooltip />
<el-table-column label="药材数量" width="100">
<template #default="{ row }">
{{ row.herbs?.length || 0 }}味
</template>
</el-table-column>
<el-table-column label="药材明细" min-width="300" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.herbs && row.herbs.length > 0">
{{ row.herbs.map((h: any) => `${h.name} ${h.dosage}g`).join('、') }}
</span>
<span v-else class="text-gray-400">暂无药材</span>
</template>
</el-table-column>
<el-table-column label="创建人" prop="creator_name" width="100" />
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click.stop="handleImportLibrary(row)">导入</el-button>
</template>
</el-table-column>
</el-table>
<div class="flex justify-end mt-4">
<el-pagination
v-model:current-page="libraryPage"
v-model:page-size="libraryPageSize"
:total="libraryTotal"
:page-sizes="[10, 15, 20, 50]"
layout="total, sizes, prev, pager, next"
@current-change="loadLibraryList"
@size-change="loadLibraryList"
/>
</div>
</el-dialog>
<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,
prescriptionDelete,
prescriptionAudit,
prescriptionDetail,
prescriptionOrderCreate,
prescriptionOrderPaidPayOrders,
getDoctors,
prescriptionLibraryLists
} from '@/api/tcm'
import { searchPatients as searchPatientsAPI } from '@/api/order'
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 } 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'))
/** 与 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 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: '',
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 s = herbs.map((h) => `${h.name} ${h.dosage}g`).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.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 || ''
}
// 添加省市区字段
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 : []
})
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
const times = Number(v.times_per_day) > 0 ? Number(v.times_per_day) : 3
const bags = Number(v.bags_per_dose) > 0 ? Number(v.bags_per_dose) : 1
const amount = v.dosage_amount != null ? Number(v.dosage_amount) : 10
const unit = v.dosage_unit || 'g'
const usageWay = v.usage_way || '温水送服'
const usageTime = v.usage_time || ''
const seg: string[] = []
seg.push(`每天${times}次`)
seg.push(`一次${bags}包(约${amount}${unit})`)
seg.push(`每包${amount}${unit}`)
seg.push(usageWay)
if (usageTime) seg.push(usageTime)
return seg.join(', ')
})
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 ''
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',
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 Array<{ name: string; dosage: number }>,
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
})
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.need_decoction = false
editForm.bags_per_dose = 1
} else if (newType === '饮片') {
editForm.dosage_unit = 'ml'
editForm.dosage_amount = 50
editForm.bags_per_dose = 1
// need_decoction 保持用户选择
} else {
editForm.dosage_unit = 'g'
editForm.dosage_amount = undefined
editForm.need_decoction = false
editForm.bags_per_dose = 1
}
})
// 表单验证规则
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 normalizeSlipHerbs(raw: unknown): Array<{ name: string; dosage: number }> {
if (!raw) return []
if (Array.isArray(raw)) {
return raw.map((x: any) => ({
name: String(x?.name ?? '').trim(),
dosage: Number(x?.dosage) || 0
}))
}
return []
}
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 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,
is_public: '',
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 imported = JSON.parse(JSON.stringify(row.herbs)) as Array<{ name: string; dosage: number }>
editForm.herbs = imported
if (findDuplicateHerbNamesLocal().length) {
feedback.msgWarning('导入的处方中存在重复药名,请合并剂量或删除多余行')
}
feedback.msgSuccess(`已导入处方「${row.prescription_name}」,共${imported.length}味药材`)
showLibraryDialog.value = false
}
watch(showLibraryDialog, (open) => {
if (open) {
librarySearchName.value = ''
libraryPage.value = 1
loadLibraryList()
}
})
// 添加药材
const addHerb = () => {
editForm.herbs.push({
name: '',
dosage: 0
})
}
// 删除药材
const removeHerb = (index: number) => {
editForm.herbs.splice(index, 1)
}
// 重置表单
const resetForm = () => {
editForm.id = 0
editForm.prescription_type = '浓缩水丸'
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 = ''
}
// 新增处方
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.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 ? JSON.parse(JSON.stringify(src.herbs)) : []
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 || '')
// 数据加载完成,重新启用watch
// 使用 setTimeout 确保所有数据都已经渲染完成
setTimeout(() => {
isLoadingData.value = false
}, 0)
showEdit.value = true
nextTick(() => {
formRef.value?.clearValidate()
scheduleEditSignatureInit()
})
}
// 提交表单
const handleSubmit = async () => {
if (!formRef.value) return
await formRef.value.validate()
// 验证药材
if (!editForm.herbs || editForm.herbs.length === 0) {
feedback.msgError('请至少添加一味药材')
return
}
for (let i = 0; i < editForm.herbs.length; i++) {
const herb = editForm.herbs[i]
if (!herb.name || !herb.name.trim()) {
feedback.msgError(`第${i + 1}味药材名称不能为空`)
return
}
if (!herb.dosage || herb.dosage <= 0) {
feedback.msgError(`第${i + 1}味药材剂量必须大于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-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>