feat: 金方相关

This commit is contained in:
李琦
2026-08-08 14:53:02 +08:00
parent 7697af2619
commit c4e833a1c4
11 changed files with 872 additions and 109 deletions

View File

@@ -1168,9 +1168,13 @@ function openCommonPrescriptionModal() {
}
/**
* 打开金方导入VIPgolden_formula
* 打开金方导入VIPgolden_formula;仅中药处方可用
*/
function openGoldenFormulaModal() {
if (Number(prescriptionStore.activeCategory) !== 1) {
message.warning('金方仅支持中药处方');
return;
}
if (!canUseGoldenFormula.value) {
message.warning('当前门店未开通金方 VIP 功能');
return;
@@ -1590,7 +1594,7 @@ const cancelSaveCommonPrescription = () => {
选择常用方
</Button>
<Button
v-if="canUseGoldenFormula"
v-if="prescriptionStore.activeCategory === 1 && canUseGoldenFormula"
type="link"
size="small"
@click="openGoldenFormulaModal"

View File

@@ -499,16 +499,24 @@ export async function aiSavePrescriptionDrugSelectionApi(data: {
);
}
/** 金方列表VIPgolden_formula */
/** 金方列表VIPgolden_formula;支持 source_id 筛选 */
export async function goldenFormulaListApi(data: {
keyword?: string;
page?: number;
pageSize?: number;
store_id?: number;
source_id?: number;
}) {
return requestClient.get<any>(`${prefix}golden-formula-list`, { params: data });
}
/** 金方来源下拉(导入弹窗筛选用) */
export async function goldenFormulaSourceOptionApi(data?: { store_id?: number }) {
return requestClient.get<any>(`${prefix}golden-formula-source-option`, {
params: data || {},
});
}
/** 金方详情 */
export async function goldenFormulaDetailApi(data: {
id: number;
@@ -526,3 +534,16 @@ export async function goldenFormulaApplyApi(data: {
}) {
return requestClient.post<any>(`${prefix}golden-formula-apply`, data);
}
/** 保存医生金方匹配选择(改选/确认导入) */
export async function goldenFormulaSaveMatchApi(data: {
id: number;
store_id?: number;
items: Array<{
drug_name: string;
selected_drug_id: number;
selected_drug_name?: string;
}>;
}) {
return requestClient.post<any>(`${prefix}golden-formula-save-match`, data);
}

View File

@@ -2,6 +2,7 @@
/**
* 金方导入弹窗PC 接诊 / 在线复诊共用)
* - VIPgolden_formula
* - 支持来源筛选;异常方标记不可选;方名后展示绑定来源
* - 选方后调用药名对照,确认导入时抛出与 AI 出方相同结构的 rows
*/
import { ref } from 'vue';
@@ -12,6 +13,7 @@ import {
Button,
Empty,
Input,
Select,
Spin,
Tag,
message,
@@ -20,6 +22,8 @@ import {
import {
goldenFormulaApplyApi,
goldenFormulaListApi,
goldenFormulaSaveMatchApi,
goldenFormulaSourceOptionApi,
} from '../api';
const emit = defineEmits<{
@@ -35,6 +39,9 @@ const emit = defineEmits<{
const loading = ref(false);
const applying = ref(false);
const keyword = ref('');
/** 金方来源筛选0=全部) */
const sourceId = ref<number | undefined>(undefined);
const sourceOptions = ref<Array<{ label: string; value: number }>>([]);
const list = ref<any[]>([]);
const total = ref(0);
const page = ref(1);
@@ -57,6 +64,7 @@ const [Modal, modalApi] = useVbenModal({
onOpenChange(isOpen: boolean) {
if (!isOpen) {
keyword.value = '';
sourceId.value = undefined;
list.value = [];
matchResult.value = null;
selectedId.value = 0;
@@ -73,10 +81,29 @@ const [Modal, modalApi] = useVbenModal({
// activeCategory 1=中药 2=西药,与 ProductTypeEnum 一致
prescriptionType.value = Number(data.category || 1) === 2 ? 2 : 1;
page.value = 1;
loadSources();
loadList();
},
});
/**
* 加载金方来源下拉,供顶部筛选
*/
async function loadSources() {
try {
const res = await goldenFormulaSourceOptionApi({
store_id: storeId.value || undefined,
});
const rows = Array.isArray(res) ? res : res?.items || [];
sourceOptions.value = rows.map((r: any) => ({
label: r.name,
value: Number(r.id),
}));
} catch {
sourceOptions.value = [];
}
}
/** 加载金方列表 */
async function loadList() {
loading.value = true;
@@ -88,6 +115,7 @@ async function loadList() {
page: page.value,
pageSize,
store_id: storeId.value || undefined,
source_id: sourceId.value || undefined,
});
list.value = res?.items || [];
total.value = Number(res?.total || 0);
@@ -103,10 +131,34 @@ function onSearch() {
loadList();
}
/** 切换来源筛选后回到第一页重载 */
function onSourceChange() {
page.value = 1;
loadList();
}
/**
* 选中金方并做药名对照
* 列表展示名:方名(来源);无来源则只显示方名
*/
function displayName(item: any): string {
const name = item?.name || '';
const source = item?.source_name || item?.source || '';
return source ? `${name}${source}` : name;
}
/** 是否异常方(不可选) */
function isAbnormal(item: any): boolean {
return Number(item?.is_abnormal ?? 0) === 1 || Number(item?.selectable ?? 1) === 0;
}
/**
* 选中金方并做药名对照;异常方直接提示不可选
*/
async function handleSelect(row: any) {
if (isAbnormal(row)) {
message.warning('该金方为异常方,无法选择导入');
return;
}
selectedId.value = Number(row.id);
applying.value = true;
matchResult.value = null;
@@ -117,7 +169,8 @@ async function handleSelect(row: any) {
register_id: registerId.value || undefined,
prescription_type: prescriptionType.value,
});
matchResult.value = res;
// 规范化:保证每行有 candidates + selected_drug_id便于点选切换
matchResult.value = normalizeMatchPayload(res);
} catch (e: any) {
message.error(e?.message || '对照失败');
} finally {
@@ -126,27 +179,121 @@ async function handleSelect(row: any) {
}
/**
* 确认导入:把 matched 转成 AI 导入同构 rows候选默认取第一项
* 规范化对照结果:缺省 selected_drug_id 时取候选第一项
*/
function handleConfirmImport() {
function normalizeMatchPayload(payload: any) {
if (!payload) return null;
const matched = (payload.matched || []).map((m: any) => {
const candidates = Array.isArray(m.candidates) ? m.candidates : [];
let sid = Number(m.selected_drug_id || 0);
if (!sid && candidates.length) {
sid = Number(candidates[0].drug_id || 0);
}
return { ...m, candidates, selected_drug_id: sid };
});
return { ...payload, matched };
}
/**
* 组装当前对照行的匹配记忆 items供落库
*/
function buildMatchSaveItems(matched: any[]) {
return matched
.map((m: any) => {
const candidates = m.candidates || [];
const sid = Number(m.selected_drug_id || 0);
const picked =
candidates.find((c: any) => Number(c.drug_id) === sid) ||
candidates[0] ||
{};
const drugId = Number(picked.drug_id || sid || 0);
const drugName = String(m.ai_name || '').trim();
if (!drugName || !drugId) return null;
return {
drug_name: drugName,
selected_drug_id: drugId,
selected_drug_name: String(picked.drug_name || ''),
};
})
.filter(Boolean) as Array<{
drug_name: string;
selected_drug_id: number;
selected_drug_name?: string;
}>;
}
/**
* 持久化医生匹配记忆(失败不阻断导入)
*/
async function persistDoctorMatches(items: Array<{
drug_name: string;
selected_drug_id: number;
selected_drug_name?: string;
}>) {
if (!selectedId.value || !items.length) return;
try {
await goldenFormulaSaveMatchApi({
id: selectedId.value,
store_id: storeId.value || undefined,
items,
});
} catch (e) {
console.warn('金方匹配记忆保存失败', e);
}
}
/**
* 点击候选药品:更新该行 selected_drug_id并立即落库记忆
*/
async function onPickCandidate(mi: number, candidate: any) {
const list = matchResult.value?.matched;
if (!list || !list[mi] || !candidate) return;
const drugId = Number(candidate.drug_id || 0);
if (!drugId) return;
if (Number(list[mi].selected_drug_id) === drugId) return;
list[mi].selected_drug_id = drugId;
const drugName = String(list[mi].ai_name || '').trim();
if (!drugName) return;
await persistDoctorMatches([
{
drug_name: drugName,
selected_drug_id: drugId,
selected_drug_name: String(candidate.drug_name || ''),
},
]);
}
/**
* 确认导入:把 matched 转成 AI 导入同构 rows按当前选中候选
* 剂量:直接用后端 apply 已处理好的 m.dose有小数已进位到整数克禁止前端再改
*/
async function handleConfirmImport() {
const matched = matchResult.value?.matched || [];
if (!matched.length) {
message.warning('没有可导入的已匹配药品');
return;
}
// 确认导入前再整批落库,保证下次自动选中
await persistDoctorMatches(buildMatchSaveItems(matched));
const rows = matched.map((m: any) => {
const candidates = m.candidates || [];
const selectedId = Number(m.selected_drug_id || 0);
const selectedDrugId = Number(m.selected_drug_id || 0);
const candidate =
candidates.find((c: any) => Number(c.drug_id) === selectedId) ||
candidates.find((c: any) => Number(c.drug_id) === selectedDrugId) ||
candidates[0] ||
{};
return {
ai_name: m.ai_name,
dose: m.dose,
unit: m.unit,
usage: m.usage,
frequency: m.frequency,
// 以下 || '' 兜底对齐 AI 出方协议AiPrescriptionDrawer.vue onConfirmImport
// 避免 undefined 进入 handleImportAiPrescription 后:
// - dose 为 undefined → parseFloat NaN → 回退 1丢失真实剂量
// - unit 为 undefined → 中药卡片单位显示异常
// - usage 为 undefined → wayId 解析失败
ai_name: m.ai_name || '',
// 后端 enrich 已对原始剂量做小数进位(如 46.88→47
dose: m.dose || '',
unit: m.unit || '',
usage: m.usage || '',
frequency: m.frequency || '',
candidate,
};
});
@@ -173,14 +320,23 @@ defineExpose({
});
</script>
<template>
<Modal title="导入金方">
<div class="flex gap-3" style="min-height: 420px">
<div class="w-[340px] shrink-0 border-r pr-3">
<Modal title="导入金方" class="w-[50%] h-[80%]">
<!-- 颜色一律用主题变量禁止写死亮色底暗色模式可读 -->
<div class="gf-modal flex gap-3 text-foreground" style="min-height: 420px">
<div class="w-[340px] shrink-0 border-r border-border pr-3">
<div class="mb-2 flex gap-2">
<Select
v-model:value="sourceId"
allow-clear
class="w-[120px] shrink-0"
placeholder="来源"
:options="sourceOptions"
@change="onSourceChange"
/>
<Input
v-model:value="keyword"
allow-clear
placeholder="名称/首拼/来源/速览"
placeholder="名称/首拼/全拼/来源/速览"
@press-enter="onSearch"
/>
<Button type="primary" @click="onSearch">搜索</Button>
@@ -189,24 +345,31 @@ defineExpose({
<div v-if="!list.length" class="py-8">
<Empty description="暂无金方" />
</div>
<div v-else class="max-h-[360px] space-y-2 overflow-y-auto">
<div v-else class="max-h-[550px] space-y-2 overflow-y-auto">
<div
v-for="item in list"
:key="item.id"
class="cursor-pointer rounded border p-2 transition hover:border-primary"
:class="selectedId === item.id ? 'border-primary bg-primary/5' : ''"
class="gf-item rounded border border-border bg-card p-2 transition"
:class="{
'gf-item--active': selectedId === item.id,
'gf-item--disabled': isAbnormal(item),
'cursor-pointer hover:border-primary': !isAbnormal(item),
}"
@click="handleSelect(item)"
>
<div class="font-medium">{{ item.name }}</div>
<div class="mt-1 text-xs text-gray-500 line-clamp-2">
{{ item.herb_overview || '—' }}
<div class="font-medium text-foreground">
{{ displayName(item) }}
<Tag v-if="isAbnormal(item)" color="error" class="ml-1">异常</Tag>
</div>
<div class="mt-1 flex flex-wrap gap-1">
<Tag v-if="item.source" color="gold">{{ item.source }}</Tag>
<div class="mt-1 line-clamp-2 text-xs text-muted-foreground">
{{ item.herb_overview || '—' }}
</div>
</div>
</div>
<div v-if="total > pageSize" class="mt-2 text-center text-xs text-gray-400">
<div
v-if="total > pageSize"
class="mt-2 text-center text-xs text-muted-foreground"
>
{{ total }} 当前第 {{ page }}
<Button
v-if="page * pageSize < total"
@@ -225,15 +388,16 @@ defineExpose({
<Empty description="请选择左侧金方进行药名对照" />
</div>
<div v-else>
<div class="mb-2 text-base font-medium">
{{ matchResult.formula?.name }}
<Tag v-if="matchResult.formula?.source" class="ml-2" color="gold">
{{ matchResult.formula.source }}
</Tag>
<div class="mb-2 text-base font-medium text-foreground">
{{
matchResult.formula?.source_name || matchResult.formula?.source
? `${matchResult.formula?.name || ''}${matchResult.formula.source_name || matchResult.formula.source}`
: matchResult.formula?.name
}}
</div>
<div
v-if="matchResult.formula?.indication_translation || matchResult.formula?.indication_original"
class="mb-2 text-xs text-gray-500"
class="mb-2 text-xs text-muted-foreground"
>
主治
{{
@@ -243,31 +407,57 @@ defineExpose({
</div>
<div
v-if="matchResult.formula?.original_formula"
class="mb-2 max-h-24 overflow-y-auto rounded bg-amber-50 px-2 py-1 text-xs text-gray-700 whitespace-pre-wrap"
class="gf-original mb-2 max-h-24 overflow-y-auto rounded px-2 py-1 text-xs whitespace-pre-wrap"
>
<span class="font-medium text-amber-700">原方</span>
<span class="gf-original__label font-medium">原方</span>
{{ matchResult.formula.original_formula }}
</div>
<div class="mb-2 text-sm">
<div class="mb-2 text-sm text-foreground">
已匹配
<Tag color="success">{{ (matchResult.matched || []).length }}</Tag>
未匹配
<Tag color="warning">{{ (matchResult.unmatched || []).length }}</Tag>
</div>
<div class="max-h-[280px] space-y-1 overflow-y-auto text-sm">
<div class="max-h-[280px] space-y-2 overflow-y-auto text-sm">
<!-- AI 出方一致一行一药 + 全部候选 Tag 可点选 -->
<div
v-for="(m, idx) in matchResult.matched || []"
:key="'m' + idx"
class="rounded bg-green-50 px-2 py-1"
v-for="(m, mi) in matchResult.matched || []"
:key="'m' + mi"
class="gf-matched rounded px-2.5 py-2"
>
{{ m.show_dose || m.display_text || `${m.ai_name} ${m.dose || ''}${m.unit || ''}${m.usage ? `${m.usage}` : ''}` }}
{{ (m.candidates && m.candidates[0] && m.candidates[0].drug_name) || '—' }}
<div class="font-medium text-foreground">
{{
m.show_dose ||
m.display_text ||
`${m.ai_name} ${m.dose || ''}${m.unit || ''}${m.usage ? `${m.usage}` : ''}`
}}
</div>
<div class="mt-1.5 flex flex-wrap gap-1">
<Tag
v-for="c in m.candidates || []"
:key="c.drug_id"
class="cursor-pointer !m-0 !text-[11px]"
:color="
Number(m.selected_drug_id) === Number(c.drug_id)
? 'success'
: undefined
"
@click="onPickCandidate(mi, c)"
>
{{ c.drug_name }}
</Tag>
<span
v-if="!(m.candidates || []).length"
class="text-xs text-muted-foreground"
>
无候选
</span>
</div>
</div>
<div
v-for="(u, idx) in matchResult.unmatched || []"
:key="'u' + idx"
class="rounded bg-orange-50 px-2 py-1"
class="gf-unmatched rounded px-2 py-1"
>
{{ u.show_dose || u.display_text || `${u.ai_name} ${u.dose || ''}${u.unit || ''}` }}
未匹配
@@ -288,3 +478,37 @@ defineExpose({
</div>
</Modal>
</template>
<style scoped>
/* 选中项primary 透明度,暗色自动跟主题 */
.gf-item--active {
border-color: hsl(var(--primary)) !important;
background-color: hsl(var(--primary) / 0.1);
}
/* 异常方:灰显 + 禁止指针,表达不可选 */
.gf-item--disabled {
cursor: not-allowed;
opacity: 0.55;
background-color: hsl(var(--muted) / 0.35);
}
/* 原方展示区:用 warning 变量,禁止 bg-amber-50 / text-amber-700 */
.gf-original {
background-color: hsl(var(--warning) / 0.12);
color: hsl(var(--foreground));
border: 1px solid hsl(var(--warning) / 0.35);
}
.gf-original__label {
color: hsl(var(--warning));
}
/* 已匹配success/primary 浅绿底;暗色下半透明 */
.gf-matched {
background-color: hsl(var(--primary) / 0.12);
color: hsl(var(--foreground));
border: 1px solid hsl(var(--primary) / 0.25);
}
/* 未匹配warning 橙底 */
.gf-unmatched {
background-color: hsl(var(--warning) / 0.12);
color: hsl(var(--foreground));
border: 1px solid hsl(var(--warning) / 0.35);
}
</style>

View File

@@ -1337,9 +1337,13 @@ function openCommonPrescriptionModal() {
}
/**
* 打开金方导入弹窗VIPgolden_formula
* 打开金方导入弹窗VIPgolden_formula;仅中药处方可用
*/
function openGoldenFormulaModal() {
if (Number(activeCategory.value) !== 1) {
message.warning('金方仅支持中药处方');
return;
}
if (!canUseGoldenFormula.value) {
message.warning('当前门店未开通金方 VIP 功能');
return;
@@ -3330,7 +3334,7 @@ function onStoreSelectOpenChange(open: boolean) {
选择常用方
</Button>
<Button
v-if="!isSpecialPrescriptionCartLocked && canUseGoldenFormula"
v-if="activeCategory === 1 && !isSpecialPrescriptionCartLocked && canUseGoldenFormula"
type="link"
size="small"
@click="openGoldenFormulaModal"

View File

@@ -3,6 +3,9 @@ import { requestClient } from '#/api/request';
/** 金方库管理 API */
const prefix = 'golden-formula/';
/** 金方来源字典 API */
const sourcePrefix = 'golden-formula-source/';
export async function getGoldenFormulaList(data: any) {
return requestClient.get<any>(`${prefix}list`, { params: data });
}
@@ -23,7 +26,32 @@ export async function deleteGoldenFormula(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}delete`, data);
}
/** Excel 解析后的行批量导入 */
export async function importGoldenFormula(data: { rows: Record<string, any>[] }) {
/** Excel 解析后的行批量导入(带全局金方来源 source_id */
export async function importGoldenFormula(data: {
rows: Record<string, any>[];
source_id?: number;
}) {
return requestClient.post<any>(`${prefix}import`, data);
}
/**
* 药品存在性排查(全局中药饮片 type=1 + 别名表)
* 预览页打开时调用,返回存在的药名 + 不存在的药名
*/
export async function checkGoldenFormulaDrugs(data: { drug_names: string[] }) {
return requestClient.post<{ exists: string[]; missing: string[] }>(
`${prefix}check-drugs`,
data,
);
}
/** 金方来源字典 - 下拉选项 */
export async function getGoldenFormulaSourceOption() {
return requestClient.get<any[]>(`${sourcePrefix}option`);
}
/** 金方来源字典 - 新增来源(预览页输入框新增) */
export async function createGoldenFormulaSource(data: { name: string }) {
return requestClient.post<any>(`${sourcePrefix}create`, data);
}

View File

@@ -0,0 +1,359 @@
<script lang="ts" setup>
/**
* 金方导入预览弹窗
* - 解析后的行在这里表格预览
* - 顶部全局「金方来源」选择器:下拉选已有 + 输入新增,所有行共用同一 source_id
* - 打开后批量排查药品存在性(中药饮片 type=1 + 别名表),表格加「不存在的药品」列红字标记
* - 确认导入时 emit { rows, sourceId }sourceId 为空提示选择来源
*/
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import {
Button,
Input,
Select,
Table,
Tag,
Tooltip,
message,
} from 'ant-design-vue';
import {
checkGoldenFormulaDrugs,
createGoldenFormulaSource,
getGoldenFormulaSourceOption,
} from '../api';
const emit = defineEmits<{
(e: 'confirm', payload: { rows: any[]; sourceId: number }): void;
}>();
// 预览的原始行Excel 解析后)
const rows = ref<any[]>([]);
// 金方来源下拉选项
const sourceOptions = ref<{ label: string; value: number }[]>([]);
// 选中的金方来源 id
const sourceId = ref<number>(0);
// 新增来源输入框文案
const newSourceName = ref('');
// 来源下拉 loading
const sourceLoading = ref(false);
// 新增来源提交中
const creatingSource = ref(false);
// 药品排查:不存在的药名集合
const missingSet = ref<Set<string>>(new Set());
// 排查中
const checking = ref(false);
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
class: 'w-[90%]',
onCancel() {
modalApi.close();
},
onConfirm: async () => {
if (sourceId.value <= 0) {
message.warning('请选择金方来源');
return;
}
if (!rows.value.length) {
message.warning('没有可导入的数据');
return;
}
emit('confirm', { rows: rows.value, sourceId: sourceId.value });
modalApi.close();
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
// 关闭时重置状态
rows.value = [];
sourceId.value = 0;
newSourceName.value = '';
missingSet.value = new Set();
return;
}
const data = modalApi.getData<{ rows?: any[] }>() || {};
rows.value = Array.isArray(data.rows) ? data.rows : [];
await loadSourceOptions();
// 打开后批量排查药品存在性
await runDrugCheck();
},
});
/** 加载金方来源下拉 */
async function loadSourceOptions() {
sourceLoading.value = true;
try {
const list = await getGoldenFormulaSourceOption();
sourceOptions.value = (Array.isArray(list) ? list : []).map((item: any) => ({
label: String(item.name || ''),
value: Number(item.id || 0),
}));
} catch (e: any) {
sourceOptions.value = [];
message.error(e?.message || '加载金方来源失败');
} finally {
sourceLoading.value = false;
}
}
/** 新增来源:成功后刷新下拉并选中 */
async function handleCreateSource() {
const name = newSourceName.value.trim();
if (!name) {
message.warning('请输入来源名称');
return;
}
// 已存在同名则直接选中
const exist = sourceOptions.value.find(
(o) => o.label === name || o.label.includes(name),
);
if (exist) {
sourceId.value = exist.value;
newSourceName.value = '';
message.success('已存在该来源,已自动选中');
return;
}
creatingSource.value = true;
try {
const res = await createGoldenFormulaSource({ name });
const newId = Number(res?.id || 0);
if (newId > 0) {
await loadSourceOptions();
sourceId.value = newId;
newSourceName.value = '';
message.success('来源新增成功');
}
} catch (e: any) {
message.error(e?.message || '新增来源失败');
} finally {
creatingSource.value = false;
}
}
/**
* 从所有行的 drugs_json 收集药名,去重后批量排查存在性
*/
async function runDrugCheck() {
if (!rows.value.length) {
missingSet.value = new Set();
return;
}
const nameSet = new Set<string>();
for (const row of rows.value) {
const raw = row?.drugs_json;
if (!raw) continue;
try {
const arr = typeof raw === 'string' ? JSON.parse(raw) : raw;
if (Array.isArray(arr)) {
for (const item of arr) {
const n = String(item?.name || item?.drug_name || '').trim();
if (n) nameSet.add(n);
}
}
} catch {
// JSON 非法则跳过该行
}
}
const names = Array.from(nameSet);
if (!names.length) {
missingSet.value = new Set();
return;
}
checking.value = true;
try {
const res = await checkGoldenFormulaDrugs({ drug_names: names });
const missing = Array.isArray(res?.missing) ? res.missing : [];
missingSet.value = new Set(missing);
} catch (e: any) {
// 排查失败不阻塞预览
missingSet.value = new Set();
} finally {
checking.value = false;
}
}
/** 计算单行的「不存在的药品」列表(该行药名 ∩ 缺失集合) */
function rowMissingDrugs(row: any): string[] {
const raw = row?.drugs_json;
if (!raw) return [];
let arr: any[] = [];
try {
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
if (Array.isArray(parsed)) arr = parsed;
} catch {
return [];
}
const result: string[] = [];
for (const item of arr) {
const n = String(item?.name || item?.drug_name || '').trim();
if (n && missingSet.value.has(n)) result.push(n);
}
return result;
}
/** 汇总:不存在的药品总数(用于顶部提示) */
const missingSummary = computed(() => Array.from(missingSet.value));
/**
* 判定单行是否异常处方
* - drugs_json 解析后是对象且含 error如 {error:无数据}
* - 或规范化后药味数为 0空数组 / 无法解析出有效药名)
*/
function isAbnormalRow(row: any): boolean {
const raw = row?.drugs_json;
if (raw === null || raw === undefined || raw === '') return true;
let parsed: any;
try {
parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
} catch {
return true;
}
if (!parsed || typeof parsed !== 'object') return true;
// 关联对象含 error典型 {"error":"无数据"}
if (!Array.isArray(parsed) && Object.prototype.hasOwnProperty.call(parsed, 'error')) {
return true;
}
const list = Array.isArray(parsed)
? parsed
: Array.isArray(parsed?.drugs)
? parsed.drugs
: Array.isArray(parsed?.items)
? parsed.items
: [];
// 药味数为 0
const count = list.filter(
(item: any) => String(item?.name || item?.drug_name || '').trim() !== '',
).length;
return count === 0;
}
/** 异常处方数量(顶部提示用) */
const abnormalCount = computed(
() => rows.value.filter((r) => isAbnormalRow(r)).length,
);
</script>
<template>
<Modal title="金方导入预览">
<div class="flex flex-col gap-3">
<!-- 顶部金方来源全局选择器 + 新增 -->
<div class="flex flex-wrap items-center gap-2">
<span class="text-sm font-medium">金方来源</span>
<Select
v-model:value="sourceId"
:options="sourceOptions"
:loading="sourceLoading"
show-search
option-filter-prop="label"
placeholder="选择已有金方来源"
style="min-width: 220px"
/>
<span class="text-xs text-muted-foreground">或新增</span>
<Input
v-model:value="newSourceName"
placeholder="输入新来源名称"
style="width: 200px"
@press-enter="handleCreateSource"
/>
<Button
type="primary"
:loading="creatingSource"
size="small"
@click="handleCreateSource"
>
新增来源
</Button>
</div>
<!-- 异常处方提示仅警告不拦截 -->
<div
v-if="abnormalCount > 0"
class="rounded-lg border border-red-500/35 bg-red-500/10 px-3 py-2 text-xs"
>
<span class="font-medium"> 异常处方</span>
<Tag color="error" class="!mx-1">{{ abnormalCount }}</Tag>
药品JSON含 error 或药味数为 0仍可导入入库后标记为异常
</div>
<!-- 药品存在性排查提示仅警告不拦截 -->
<div
v-if="missingSummary.length"
class="rounded-lg border border-orange-500/35 bg-orange-500/10 px-3 py-2 text-xs"
>
<span class="font-medium"> 药品排查</span>
共发现
<Tag color="orange" class="!mx-1">{{ missingSummary.length }}</Tag>
个不存在的药品仅提示不拦截导入
<span class="text-orange-700">{{ missingSummary.join('、') }}</span>
<span v-if="checking" class="ml-2 text-muted-foreground">排查中</span>
</div>
<!-- 预览表格 -->
<Table
:columns="[
{ title: '序号', width: 60, customRender: ({ index }) => index + 1 },
{ title: '药方名字', dataIndex: 'name', width: 140 },
{ title: '条文编号', dataIndex: 'clause_number', width: 110 },
{ title: '状态', key: 'abnormal', width: 90 },
{
title: '药品JSON',
dataIndex: 'drugs_json',
ellipsis: true,
customRender: ({ text }) => String(text || ''),
},
{ title: '药材速览', dataIndex: 'herb_overview', width: 180, ellipsis: true },
{
title: '药方主治(文言文)',
dataIndex: 'indication_original',
width: 200,
ellipsis: true,
},
{
title: '现代主治(白话文)',
dataIndex: 'indication_translation',
width: 200,
ellipsis: true,
},
{ title: '拼音首拼', dataIndex: 'pinyin_initials', width: 100 },
{ title: '拼音全拼', dataIndex: 'pinyin', width: 120 },
{ title: '不存在的药品', key: 'missing', width: 180 },
]"
:data-source="rows"
:pagination="{ pageSize: 10, showSizeChanger: true }"
size="small"
:scroll="{ x: 1400 }"
:row-class-name="(record) => (isAbnormalRow(record) ? 'gf-abnormal-row' : '')"
row-key="name"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'abnormal'">
<Tag v-if="isAbnormalRow(record)" color="error" class="!m-0">异常</Tag>
<Tag v-else color="success" class="!m-0">正常</Tag>
</template>
<template v-else-if="column.key === 'missing'">
<Tooltip
v-if="rowMissingDrugs(record).length"
:title="rowMissingDrugs(record).join('、')"
>
<span class="text-red-500">
{{ rowMissingDrugs(record).join('、') }}
</span>
</Tooltip>
<Tag v-else color="success" class="!m-0">齐全</Tag>
</template>
</template>
</Table>
</div>
</Modal>
</template>
<style scoped>
/* 异常处方行:浅红底,暗色下用半透明红 */
:deep(.gf-abnormal-row) > td {
background-color: hsl(var(--destructive) / 0.08) !important;
color: hsl(var(--destructive));
}
</style>

View File

@@ -1,6 +1,7 @@
<script lang="ts" setup>
/**
* 金方新增/编辑弹窗
* 打开时拉取金方来源下拉,通过 updateSchema 注入 source_id 选项
*/
import { ref } from 'vue';
@@ -10,7 +11,11 @@ import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { createGoldenFormula, updateGoldenFormula } from '../api';
import {
createGoldenFormula,
getGoldenFormulaSourceOption,
updateGoldenFormula,
} from '../api';
import { modalFormProps } from '../config/form';
const isUpdate = ref(false);
@@ -55,12 +60,28 @@ const [Modal, modalApi] = useVbenModal({
modalApi.setState({ confirmLoading: false });
}
},
onOpenChange(isOpen: boolean) {
async onOpenChange(isOpen: boolean) {
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;
if (!isOpen) {
formApi.resetForm();
return;
}
// 打开时拉取金方来源下拉选项并注入
try {
const list = await getGoldenFormulaSourceOption();
const options = (Array.isArray(list) ? list : []).map((item: any) => ({
label: String(item.name || ''),
value: Number(item.id || 0),
}));
formApi.updateSchema([
{
fieldName: 'source_id',
componentProps: { options },
},
]);
} catch {
// 拉取失败不阻塞表单
}
const { values, update } = modalApi.getData<Record<string, any>>() || {};
isUpdate.value = !!update;
if (values && update) {
@@ -76,7 +97,7 @@ const [Modal, modalApi] = useVbenModal({
});
} else {
formApi.resetForm();
formApi.setValues({ sort: 0, status: 1, drugs_json: '' });
formApi.setValues({ sort: 0, status: 1, drugs_json: '', source_id: 0 });
}
},
});

View File

@@ -26,10 +26,32 @@ export const modalFormProps: VbenFormProps = {
},
{
component: 'VbenInput',
fieldName: 'source',
fieldName: 'clause_number',
label: '条文编号',
help: '典籍条文编号如「第12条」可空',
componentProps: { placeholder: '如第12条' },
defaultValue: '',
},
{
component: 'VbenSelect',
fieldName: 'source_id',
label: '金方来源',
help: '选择来源字典;如无合适项可先在「金方来源管理」新增',
componentProps: {
showSearch: true,
optionFilterProp: 'label',
options: [],
allowClear: true,
placeholder: '选择金方来源',
},
defaultValue: 0,
},
{
component: 'VbenInput',
fieldName: 'source',
label: '来源(旧文本)',
formItemClass: 'col-span-6',
componentProps: { placeholder: '如:伤寒论' },
componentProps: { placeholder: '如:伤寒论(旧字段,优先用上面的来源选择)' },
defaultValue: '',
},
{

View File

@@ -21,6 +21,7 @@ import {
importGoldenFormula,
} from './api';
import FormModal from './components/modal.vue';
import ImportPreviewModal from './components/ImportPreviewModal.vue';
import { STATUS_OPTIONS } from './config/constants';
import { exportGoldenFormulaTemplate } from './utils/exportGoldenFormulaExcel';
import { parseGoldenFormulaExcelBuffer } from './utils/parseGoldenFormulaExcel';
@@ -51,6 +52,18 @@ const [Grid, gridApi] = useVbenVxeGrid({
label: '状态',
componentProps: { allowClear: true, options: STATUS_OPTIONS },
},
{
component: 'VbenSelect',
fieldName: 'is_abnormal',
label: '异常标记',
componentProps: {
allowClear: true,
options: [
{ label: '正常', value: 0 },
{ label: '异常', value: 1 },
],
},
},
],
},
gridOptions: {
@@ -58,10 +71,27 @@ const [Grid, gridApi] = useVbenVxeGrid({
columns: [
{ type: 'checkbox', width: 60 },
{ field: 'id', title: 'ID', width: 80 },
{ field: 'name', title: '药方名字', minWidth: 140 },
{ field: 'source', title: '来源', width: 120 },
{
field: 'name',
title: '药方名字',
minWidth: 140,
slots: { default: 'name' },
},
{ field: 'clause_number', title: '条文编号', width: 110 },
{
field: 'source_name',
title: '来源',
width: 120,
slots: { default: 'source_name' },
},
{ field: 'herb_overview', title: '药材速览', minWidth: 180 },
{ field: 'drug_count', title: '药味数', width: 80 },
{
field: 'is_abnormal',
title: '异常',
width: 90,
slots: { default: 'is_abnormal' },
},
{ field: 'pinyin_initials', title: '首拼', width: 100 },
{ field: 'status', title: '状态', width: 90, slots: { default: 'status' } },
{ field: 'sort', title: '排序', width: 80 },
@@ -104,6 +134,11 @@ const [FormModalComp, formModalApi] = useVbenModal({
connectedComponent: FormModal,
});
/** 导入预览弹窗:解析后预览 + 选金方来源 + 确认导入 */
const [ImportPreviewModalComp, importPreviewApi] = useVbenModal({
connectedComponent: ImportPreviewModal,
});
const showModal = (data: any = {}, isUpdate = false) => {
formModalApi.setData({
values: data,
@@ -144,7 +179,27 @@ async function handleImportFile(file: File) {
);
return false;
}
const res = await importGoldenFormula({ rows: parsed.items });
// 打开预览弹窗:用户选金方来源 + 确认后再提交
importPreviewApi.setData({ rows: parsed.items });
importPreviewApi.open();
} catch (e: any) {
message.error(e?.message || '解析失败');
} finally {
importing.value = false;
}
return false;
}
/**
* 预览弹窗确认后:带 source_id 提交导入,弹结果框
*/
async function handleConfirmImport(payload: { rows: any[]; sourceId: number }) {
importing.value = true;
try {
const res = await importGoldenFormula({
rows: payload.rows,
source_id: payload.sourceId,
});
const ok = Number(res?.success || 0);
const fail = Number(res?.fail || 0);
const errors = Array.isArray(res?.errors) ? res.errors : [];
@@ -161,12 +216,12 @@ async function handleImportFile(file: File) {
} finally {
importing.value = false;
}
return false;
}
</script>
<template>
<Page auto-content-height title="金方管理">
<FormModalComp />
<ImportPreviewModalComp @confirm="handleConfirmImport" />
<Grid>
<template #toolbar-buttons>
<TableAction
@@ -203,6 +258,19 @@ async function handleImportFile(file: File) {
<Button :loading="importing" type="default">导入 Excel</Button>
</Upload>
</template>
<!-- 异常处方药方名字整列标红 -->
<template #name="{ row }">
<span :class="{ 'gf-name-abnormal': row.is_abnormal === 1 }">
{{ row.name }}
</span>
</template>
<template #source_name="{ row }">
{{ row.source_name || row.source || '—' }}
</template>
<template #is_abnormal="{ row }">
<Tag v-if="row.is_abnormal === 1" color="error">异常</Tag>
<Tag v-else color="success">正常</Tag>
</template>
<template #status="{ row }">
<Tag :color="row.status === 1 ? 'success' : 'default'">
{{ row.status === 1 ? '启用' : '禁用' }}
@@ -229,3 +297,10 @@ async function handleImportFile(file: File) {
</Grid>
</Page>
</template>
<style scoped>
/* 异常处方药方名字:用主题 destructive 色,暗色下可读 */
.gf-name-abnormal {
color: hsl(var(--destructive));
font-weight: 600;
}
</style>

View File

@@ -8,17 +8,16 @@ const THIN_BORDER: Partial<ExcelJS.Borders> = {
};
const HEADERS = [
'序号',
'药方名字',
'条文编号',
'药品JSON',
'药材速览',
'原方',
'药方主治(原文)',
'药方主治(译文)',
'简介',
'封面',
'药方拼音首拼',
'药方拼音全拼',
'金方来源',
'药方主治(文言文)',
'现代主治(白话文)',
'拼音首拼',
'拼音全拼',
] as const;
/**
@@ -28,17 +27,16 @@ export async function exportGoldenFormulaTemplate(): Promise<ArrayBuffer> {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('金方导入');
sheet.columns = [
{ width: 14 },
{ width: 52 },
{ width: 24 },
{ width: 48 },
{ width: 24 },
{ width: 24 },
{ width: 20 },
{ width: 20 },
{ width: 12 },
{ width: 16 },
{ width: 12 },
{ width: 8 }, // 序号
{ width: 14 }, // 药方名字
{ width: 14 }, // 条文编号
{ width: 52 }, // 药品JSON
{ width: 24 }, // 药材速览
{ width: 48 }, // 原方
{ width: 28 }, // 药方主治(文言文)
{ width: 28 }, // 现代主治(白话文)
{ width: 12 }, // 拼音首拼
{ width: 16 }, // 拼音全拼
];
const headerRow = sheet.addRow([...HEADERS]);
headerRow.height = 28;
@@ -58,17 +56,16 @@ export async function exportGoldenFormulaTemplate(): Promise<ArrayBuffer> {
const sampleOriginal =
'桂枝三两,去皮 芍药三两 甘草二两,炙 生姜三两,切 大枣十二枚,擘。右五味,㕮咀三味,以水七升,微火煮取三升,去滓,适寒温,服一升。服已须臾,啜热稀粥一升余,以助药力。禁生冷、粘滑、肉面、五辛、酒酪、臭恶等物。';
const sample = sheet.addRow([
'桂枝汤',
sampleDrugs,
'桂枝、芍药、甘草、生姜、大枣',
sampleOriginal,
'太阳中风,阳浮而阴弱',
'外感风寒表虚证',
'解肌发表,调和营卫',
'',
'gzt',
'guizhitang',
'伤寒论',
1, // 序号
'桂枝汤', // 药方名字
'第12条', // 条文编号
sampleDrugs, // 药品JSON
'桂枝、芍药、甘草、生姜、大枣', // 药材速览
sampleOriginal, // 原方
'太阳中风,阳浮而阴弱', // 药方主治(文言文)
'外感风寒表虚证', // 现代主治(白话文)
'gzt', // 拼音首拼
'guizhitang', // 拼音全拼
]);
sample.height = 80;
sample.eachCell((cell) => {
@@ -90,7 +87,9 @@ export async function exportGoldenFormulaTemplate(): Promise<ArrayBuffer> {
cell.border = THIN_BORDER;
});
const tips: [string, string][] = [
['序号', '仅作行号,不导入;可空'],
['药方名字', '必填'],
['条文编号', '典籍条文编号如「第12条」可空'],
[
'药品JSON',
'系统字段导入用name/dose(克数数字)/unit(固定g)/usage(先煎、后下等,可空)。展示扩展仅展示ancient_dose(如三两、十二枚)、prep(去皮、炙、切、擘)。勿把古代单位或炮制写进 dose/unit/usage。',
@@ -100,13 +99,10 @@ export async function exportGoldenFormulaTemplate(): Promise<ArrayBuffer> {
'原方',
'典籍药味+煮法+服法+禁忌全文,如「桂枝三两,去皮…服一升…禁生冷…」',
],
['药方主治(原文)', '主治典籍原文'],
['药方主治(译文)', '主治白话译文'],
['简介', '方义/说明'],
['封面', '封面图完整 URL可空'],
['药方拼音首拼', '可空,系统按药方名自动生成'],
['药方拼音全拼', '可空,系统按药方名自动生成'],
['金方来源', '如伤寒论、金匮要略'],
['药方主治(文言文)', '主治典籍原文'],
['现代主治(白话文)', '主治白话译文'],
['拼音首拼', '可空,系统按药方名自动生成'],
['拼音全拼', '可空,系统按药方名自动生成'],
];
tips.forEach(([k, v]) => {
const row = tip.addRow([k, v]);

View File

@@ -4,16 +4,14 @@ const MAX_ROWS = 2000;
export type ParsedGoldenFormulaRow = {
name: string;
clause_number: string;
drugs_json: string;
herb_overview: string;
original_formula: string;
indication_original: string;
indication_translation: string;
intro: string;
cover: string;
pinyin_initials: string;
pinyin: string;
source: string;
status: number;
sort: number;
};
@@ -51,16 +49,31 @@ function normalizeHeader(value: string): string {
}
const NAME_ALIASES = ['药方名字', '药方名称', '方名', 'name'];
const CLAUSE_ALIASES = ['条文编号', '条文', '条款', 'clause_number'];
const DRUGS_ALIASES = ['药品JSON', '药品json', '药品', 'drugs_json', 'drugs'];
const HERB_ALIASES = ['药材速览', '速览', 'herb_overview'];
const ORIGINAL_ALIASES = ['原方', '原方全文', 'original_formula', '方文'];
const IND_ORI_ALIASES = ['药方主治(原文)', '药方主治原文', '主治原文', 'indication_original'];
const IND_TR_ALIASES = ['药方主治(译文)', '药方主治译文', '主治译文', 'indication_translation'];
const INTRO_ALIASES = ['简介', 'intro'];
const COVER_ALIASES = ['封面', '封面图', 'cover'];
const PY_ABBR_ALIASES = ['药方拼音首拼', '拼音首拼', '首拼', 'pinyin_initials'];
const PY_FULL_ALIASES = ['药方拼音全拼', '拼音全拼', '全拼', 'pinyin'];
const SOURCE_ALIASES = ['金方来源', '来源', 'source'];
const IND_ORI_ALIASES = [
'药方主治(文言文)',
'药方主治(文言文)',
'药方主治原文',
'主治文言文',
'文言文',
'indication_original',
];
const IND_TR_ALIASES = [
'现代主治(白话文)',
'现代主治(白话文)',
'现代主治',
'白话文',
'药方主治(译文)',
'药方主治(译文)',
'药方主治译文',
'主治译文',
'indication_translation',
];
const PY_ABBR_ALIASES = ['拼音首拼', '药方拼音首拼', '首拼', 'pinyin_initials'];
const PY_FULL_ALIASES = ['拼音全拼', '药方拼音全拼', '全拼', 'pinyin'];
const STATUS_ALIASES = ['状态', 'status'];
const SORT_ALIASES = ['排序', 'sort'];
@@ -129,16 +142,14 @@ export async function parseGoldenFormulaExcelBuffer(
if (!nameCol) {
throw new Error('模板表头缺少「药方名字」列,请重新下载模板');
}
const clauseCol = pickColumn(headerMap, CLAUSE_ALIASES);
const drugsCol = pickColumn(headerMap, DRUGS_ALIASES);
const herbCol = pickColumn(headerMap, HERB_ALIASES);
const originalCol = pickColumn(headerMap, ORIGINAL_ALIASES);
const indOriCol = pickColumn(headerMap, IND_ORI_ALIASES);
const indTrCol = pickColumn(headerMap, IND_TR_ALIASES);
const introCol = pickColumn(headerMap, INTRO_ALIASES);
const coverCol = pickColumn(headerMap, COVER_ALIASES);
const pyAbbrCol = pickColumn(headerMap, PY_ABBR_ALIASES);
const pyFullCol = pickColumn(headerMap, PY_FULL_ALIASES);
const sourceCol = pickColumn(headerMap, SOURCE_ALIASES);
const statusCol = pickColumn(headerMap, STATUS_ALIASES);
const sortCol = pickColumn(headerMap, SORT_ALIASES);
const items: ParsedGoldenFormulaRow[] = [];
@@ -160,16 +171,14 @@ export async function parseGoldenFormulaExcelBuffer(
const statusRaw = getCellInt(row, statusCol, 1);
items.push({
name,
clause_number: getCellText(row, clauseCol),
drugs_json: getCellText(row, drugsCol),
herb_overview: getCellText(row, herbCol),
original_formula: getCellText(row, originalCol),
indication_original: getCellText(row, indOriCol),
indication_translation: getCellText(row, indTrCol),
intro: getCellText(row, introCol),
cover: getCellText(row, coverCol),
pinyin_initials: getCellText(row, pyAbbrCol),
pinyin: getCellText(row, pyFullCol),
source: getCellText(row, sourceCol),
status: statusRaw === 0 ? 0 : 1,
sort: getCellInt(row, sortCol, 0),
});