diff --git a/admin/src/components/tcm-prescription/index.vue b/admin/src/components/tcm-prescription/index.vue index 06028b65..ec7ae650 100644 --- a/admin/src/components/tcm-prescription/index.vue +++ b/admin/src/components/tcm-prescription/index.vue @@ -75,12 +75,37 @@ -
-
- 中药处方 (RP) - 添加中药 - 从处方库导入 - 导入药方 +
+
+
+
+
@@ -135,11 +160,11 @@ - + @@ -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)); diff --git a/admin/src/views/consumer/prescription/index.vue b/admin/src/views/consumer/prescription/index.vue index 12c67ac0..99817b09 100644 --- a/admin/src/views/consumer/prescription/index.vue +++ b/admin/src/views/consumer/prescription/index.vue @@ -631,12 +631,22 @@
- - 添加药材 - - - 从处方库导入 - +
+ + 添加药材 + + + 从处方库导入 + +
+
+ + 导入药方 + + + 粘贴文本解析药名与剂量;须与药品库名称完全一致才录入 + +
@@ -1359,6 +1369,40 @@
+ + +

+ 粘贴整张药方文本,系统按「药名 + 剂量」解析;药名须与药品库中名称完全一致才会录入,否则跳过。支持顿号/逗号/换行分隔;单行可用空格分味(如「黄芪15g + 白术10g」);「黄芪、党参各10克」等同剂量写法亦可识别。 +

+ +
+ 导入方式 + + 覆盖现有药材 + 追加到末尾 + +
+ +
+

通过:处方保持有效。驳回:将同时作废此处方(与诊间「驳回/作废处方」一致)。 @@ -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(/[0-9]/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 { + 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({