This commit is contained in:
Your Name
2026-05-09 15:57:12 +08:00
parent 3f12829204
commit 3330613254
2 changed files with 358 additions and 17 deletions
+135 -9
View File
@@ -75,12 +75,37 @@
</el-row>
</div>
<div class="info-section">
<div class="section-title">
中药处方 (RP)
<el-button type="primary" size="small" @click="handleAddHerb">添加中药</el-button>
<el-button type="success" size="small" @click="showLibraryDialog = true">从处方库导入</el-button>
<el-button type="warning" size="small" @click="openPasteRecipeDialog">导入药方</el-button>
<div class="info-section info-section--rp">
<div class="rp-toolbar" role="region" aria-label="中药处方">
<div class="rp-toolbar__lead">
<span class="rp-toolbar__marker" aria-hidden="true" />
<div class="rp-toolbar__text">
<h3 class="rp-toolbar__heading">
中药处方
<span class="rp-toolbar__abbr">(RP)</span>
</h3>
<p class="rp-toolbar__meta">
<strong>{{ formData.herbs.length }}</strong> 味药材 · 可手动添加从处方库或粘贴药方批量录入药品库同名匹配
</p>
</div>
</div>
<div class="rp-toolbar__actions" role="toolbar" aria-label="处方药材快捷操作">
<el-button type="primary" size="small" :icon="CirclePlus" @click="handleAddHerb">
添加中药
</el-button>
<el-button
type="primary"
plain
size="small"
:icon="CollectionTag"
@click="showLibraryDialog = true"
>
从处方库导入
</el-button>
<el-button type="primary" plain size="small" :icon="EditPen" @click="openPasteRecipeDialog">
导入药方
</el-button>
</div>
</div>
<div class="mb-3">
<el-form-item label="处方价格" prop="amount" class="!mb-0">
@@ -135,11 +160,11 @@
<el-select v-model="formData.prescription_type" placeholder="请选择处方类型" class="!w-full">
<el-option label="浓缩水丸" value="浓缩水丸" />
<el-option label="饮片" value="饮片" />
<el-option label="颗粒" value="颗粒" />
<!-- <el-option label="颗粒" value="颗粒" />
<el-option label="丸剂" value="丸剂" />
<el-option label="散剂" value="散剂" />
<el-option label="膏方" value="膏方" />
<el-option label="汤剂" value="汤剂" />
<el-option label="汤剂" value="汤剂" /> -->
</el-select>
</el-form-item>
</el-col>
@@ -709,7 +734,7 @@ import html2canvas from 'html2canvas'
import { jsPDF } from 'jspdf'
import useUserStore from '@/stores/modules/user'
import useAppStore from '@/stores/modules/app'
import { Download, Document } from '@element-plus/icons-vue'
import { CirclePlus, CollectionTag, Download, Document, EditPen } from '@element-plus/icons-vue'
import { Search } from '@element-plus/icons-vue'
interface Herb {
@@ -1761,6 +1786,107 @@ defineExpose({
align-items: center;
}
&.info-section--rp {
padding-top: 14px;
}
.rp-toolbar {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 12px 20px;
margin-bottom: 14px;
padding: 14px 16px;
background: linear-gradient(
145deg,
var(--el-bg-color) 0%,
var(--el-fill-color-extra-light) 100%
);
border: 1px solid var(--el-border-color-lighter);
border-radius: 10px;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
transition:
border-color 0.2s ease,
box-shadow 0.2s ease;
}
.rp-toolbar:hover {
border-color: var(--el-border-color);
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.07);
}
.rp-toolbar__lead {
display: flex;
align-items: stretch;
gap: 12px;
min-width: 0;
flex: 1;
}
.rp-toolbar__marker {
width: 4px;
min-height: 44px;
border-radius: 4px;
background: linear-gradient(
180deg,
var(--el-color-primary) 0%,
var(--el-color-primary-light-5) 100%
);
flex-shrink: 0;
}
.rp-toolbar__text {
min-width: 0;
}
.rp-toolbar__heading {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
letter-spacing: 0.02em;
line-height: 1.35;
}
.rp-toolbar__abbr {
margin-left: 4px;
font-weight: 500;
font-size: 13px;
color: var(--el-text-color-secondary);
}
.rp-toolbar__meta {
margin: 6px 0 0;
font-size: 12px;
line-height: 1.55;
color: var(--el-text-color-secondary);
}
.rp-toolbar__meta strong {
font-weight: 600;
color: var(--el-text-color-regular);
}
.rp-toolbar__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.rp-toolbar__actions :deep(.el-button + .el-button) {
margin-left: 0;
}
@media (max-width: 960px) {
.rp-toolbar__actions {
width: 100%;
justify-content: flex-start;
}
}
.herbs-editor-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
+218 -3
View File
@@ -631,12 +631,22 @@
<el-form-item label="药材配方" prop="herbs" required>
<div class="w-full">
<div class="flex flex-wrap items-center gap-2">
<el-button type="primary" size="small" @click="addHerb">
添加药材
</el-button>
<el-button type="success" size="small" class="ml-2" @click="openLibraryDialog">
<el-button type="success" size="small" @click="openLibraryDialog">
从处方库导入
</el-button>
</div>
<div class="mt-2">
<el-button type="warning" size="small" plain :icon="EditPen" @click="openPasteRecipeDialog">
导入药方
</el-button>
<span class="text-xs text-gray-500 ml-2 align-middle">
粘贴文本解析药名与剂量;须与药品库名称完全一致才录入
</span>
</div>
<el-table :data="editForm.herbs" class="mt-2" border>
<el-table-column label="序号" type="index" width="60" />
<el-table-column label="药材名称" min-width="220">
@@ -1359,6 +1369,40 @@
</div>
</el-dialog>
<!-- 粘贴药方文本导入(与诊间 tcm-prescription 逻辑一致:药品库完全同名才录入) -->
<el-dialog
v-model="showPasteRecipeDialog"
title="导入药方(识别药材)"
width="560px"
:close-on-click-modal="false"
destroy-on-close
@closed="pasteRecipeText = ''"
>
<p class="text-gray-500 text-sm mb-2 leading-relaxed">
粘贴整张药方文本,系统按「药名 + 剂量」解析;药名须与药品库中名称完全一致才会录入,否则跳过。支持顿号/逗号/换行分隔;单行可用空格分味(如「黄芪15g
白术10g」);「黄芪、党参各10克」等同剂量写法亦可识别。
</p>
<el-input
v-model="pasteRecipeText"
type="textarea"
:rows="10"
placeholder="示例:&#10;黄芪15 党参12 茯苓10&#10;柴胡10g、黄芩10、半夏12克&#10;黄芪、白术、茯苓各15克"
/>
<div class="mt-3 flex flex-wrap items-center gap-x-4 gap-y-2">
<span class="text-sm text-gray-600 shrink-0">导入方式</span>
<el-radio-group v-model="pasteRecipeImportMode">
<el-radio label="replace">覆盖现有药材</el-radio>
<el-radio label="append">追加到末尾</el-radio>
</el-radio-group>
</div>
<template #footer>
<el-button @click="showPasteRecipeDialog = false">取消</el-button>
<el-button type="primary" :loading="pasteRecipeImportLoading" @click="handlePasteRecipeImport">
识别并导入
</el-button>
</template>
</el-dialog>
<el-dialog v-model="auditDialogVisible" title="处方审核" width="480px" destroy-on-close @closed="auditRemark = ''">
<p class="text-sm text-gray-600 mb-3">
通过:处方保持有效。驳回:将同时作废此处方(与诊间「驳回/作废处方」一致)。
@@ -1401,7 +1445,7 @@ import {
getDoctors,
prescriptionLibraryLists
} from '@/api/tcm'
import { searchPatients as searchPatientsAPI } from '@/api/order'
import { medicineLists } from '@/api/medicine'
import { getDictData } from '@/api/app'
import { roleAll } from '@/api/perms/role'
import { usePaging } from '@/hooks/usePaging'
@@ -1410,7 +1454,7 @@ 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 { Search, Download, Printer, EditPen } from '@element-plus/icons-vue'
import { computed, onMounted, reactive, ref, watch, nextTick, defineAsyncComponent } from 'vue'
import { useRouter } from 'vue-router'
import html2canvas from 'html2canvas'
@@ -2971,6 +3015,177 @@ watch(showLibraryDialog, (open) => {
}
})
/** 粘贴药方导入(与 components/tcm-prescription 一致) */
const showPasteRecipeDialog = ref(false)
const pasteRecipeText = ref('')
const pasteRecipeImportMode = ref<'replace' | 'append'>('replace')
const pasteRecipeImportLoading = ref(false)
const PASTE_RECIPE_SKIP_SEGMENT =
/^(用法|用量|医嘱|忌口|水煎服|空腹|饭后|温水|内服|外用|备注|顿服|分服|ml|毫升|[×xX]\s*\d)/
function normalizeRecipePasteInput(raw: string): string {
let s = raw.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
s = s.replace(/[-]/g, (ch) => String.fromCharCode(ch.charCodeAt(0) - 0xff10 + 48))
return s.trim()
}
function stripRecipePasteHeaders(text: string): string {
return text
.replace(/^\s*Rp[::**\s]*/i, '')
.replace(/^\s*处方[:\s]*/, '')
.replace(/^\s*中药处方[:\s]*/, '')
.trimStart()
}
function stripHerbPasteNote(name: string): string {
return name
.replace(/[^]*$/u, '')
.replace(/\([^)]*\)$/u, '')
.trim()
}
function parseHerbPasteToken(seg: string): { name: string; dosage: number }[] | null {
const s = seg.trim()
if (!s || PASTE_RECIPE_SKIP_SEGMENT.test(s)) return null
if (/^\d/.test(s)) return null
if (/^\d+\s*剂$/.test(s)) return null
const ge = s.match(/^(.+)\s*各\s*(\d+(?:\.\d+)?)\s*(?:克|g|G)?$/i)
if (ge) {
const dose = parseFloat(ge[2])
if (!Number.isFinite(dose) || dose <= 0) return null
const names = ge[1]
.split(/[、,,\s]+/)
.map((x) => x.trim())
.filter(Boolean)
if (!names.length) return null
return names.map((name) => ({ name: stripHerbPasteNote(name), dosage: dose }))
}
const m = s.match(/^(.+?)\s*(\d+(?:\.\d+)?)\s*(?:克|g|G|钱)?$/i)
if (m) {
const name = stripHerbPasteNote(m[1].trim())
const dose = parseFloat(m[2])
if (!name || !Number.isFinite(dose) || dose <= 0) return null
return [{ name, dosage: dose }]
}
const nameOnly = stripHerbPasteNote(s)
if (nameOnly.length >= 2 && nameOnly.length <= 16) {
return [{ name: nameOnly, dosage: 6 }]
}
return null
}
function chunkRecipePasteText(text: string): string[] {
const t = stripRecipePasteHeaders(normalizeRecipePasteInput(text))
const out: string[] = []
for (const line of t.split(/\n+/)) {
const ln = line.trim()
if (!ln) continue
for (const piece of ln.split(/[、,,;]/)) {
const p = piece.trim()
if (!p) continue
if (/\s/.test(p) && !parseHerbPasteToken(p)) {
const sub = p.split(/\s+(?=[\u4e00-\u9fff]{2,})/)
for (const x of sub) {
const c = x.trim()
if (c) out.push(c)
}
} else {
out.push(p)
}
}
}
return out
}
function parseRecipePasteToHerbs(text: string): { name: string; dosage: number }[] {
const herbs: { name: string; dosage: number }[] = []
for (const c of chunkRecipePasteText(text)) {
const parsed = parseHerbPasteToken(c)
if (!parsed) continue
herbs.push(...parsed)
}
return herbs
}
async function resolvePasteHerbNameFromLibrary(rawName: string): Promise<string | null> {
const q = rawName.trim()
if (!q) return null
try {
const res = await medicineLists({
name: q,
page_no: 1,
page_size: 200,
status: 1
})
const lists = (res.lists || []) as { id: number; name: string }[]
const exact = lists.find((item) => (item.name ?? '').trim() === q)
return exact ? exact.name.trim() : null
} catch {
return null
}
}
function openPasteRecipeDialog() {
showPasteRecipeDialog.value = true
}
async function handlePasteRecipeImport() {
const parsed = parseRecipePasteToHerbs(pasteRecipeText.value)
if (!parsed.length) {
feedback.msgWarning('未能从文本中解析出药材,请检查格式(药名 + 剂量,或用顿号/换行分隔)')
return
}
pasteRecipeImportLoading.value = true
try {
const resolved: Array<{ name: string; dosage: number }> = []
const skippedNames: string[] = []
for (const row of parsed) {
const name = await resolvePasteHerbNameFromLibrary(row.name)
if (!name) {
skippedNames.push(row.name.trim())
continue
}
resolved.push({ name, dosage: row.dosage })
}
const skippedUnique = [...new Set(skippedNames.filter(Boolean))]
if (resolved.length === 0) {
feedback.msgWarning(
skippedUnique.length
? `药品库中无以下完全同名药材,未录入:${skippedUnique.join('、')}`
: '未能匹配到药品库中的药材'
)
return
}
if (pasteRecipeImportMode.value === 'replace') {
editForm.herbs = resolved
} else {
editForm.herbs.push(...resolved)
}
const dupNames = findDuplicateHerbNamesLocal()
const skipHint =
skippedUnique.length > 0 ? `;药品库未收录已跳过:${skippedUnique.join('、')}` : ''
if (dupNames.length) {
feedback.msgWarning(
`已导入 ${resolved.length} 味药材${skipHint}。存在重复药名:${dupNames.join(
'、'
)},请合并剂量或删除多余行`
)
} else if (skippedUnique.length) {
feedback.msgSuccess(`已导入 ${resolved.length} 味药材${skipHint},请核对剂量`)
} else {
feedback.msgSuccess(`已导入 ${resolved.length} 味药材,请核对药名与剂量`)
}
showPasteRecipeDialog.value = false
pasteRecipeText.value = ''
} finally {
pasteRecipeImportLoading.value = false
}
}
// 添加药材
const addHerb = () => {
editForm.herbs.push({