1. 金方
This commit is contained in:
李琦
2026-08-09 15:27:27 +08:00
parent 689a0f64a6
commit 871982fc07
11 changed files with 1563 additions and 123 deletions

View File

@@ -9,6 +9,11 @@ export interface UsePasteUploadListenOptions {
maxSize?: number | (() => number);
/** 过滤后的文件回调 */
onFiles: (files: File[]) => void | Promise<void>;
/**
* 粘贴成功拿到合法文件后自动停止监听
* 为什么默认 false发票等组件仍要手动点「停止」金方 Excel 导入需要成功即停
*/
autoStopOnSuccess?: boolean;
}
/**
@@ -136,6 +141,11 @@ export function usePasteUploadListen(options: UsePasteUploadListenOptions) {
return;
}
event.preventDefault();
// 成功拿到文件后先停监听(按需),再回调业务,避免重复粘贴
if (options.autoStopOnSuccess) {
stop();
message.success('已粘贴文件,监听已结束');
}
void options.onFiles(filtered);
};
window.addEventListener('paste', handler);

View File

@@ -1,8 +1,11 @@
<script lang="ts" setup>
/**
* 中药新增/编辑弹窗
* 支持通过 modalApi.setData({ onSuccess }) 在保存成功后回调(金方缺失药「添加药材」复用)
*/
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useUserStore } from '@vben/stores';
import { message } from 'ant-design-vue';
@@ -10,10 +13,7 @@ import { useVbenForm } from '#/adapter/form';
import { createChinaMedicine, updateChinaMedicine } from '../api';
import { modalFormProps } from '../config/form';
import {getDrugUseList} from "#/views/doctor/doctor-reception/api";
const userStore = useUserStore();
import { getDrugUseList } from '#/views/doctor/doctor-reception/api';
const drugTime = ref([]);
const drugType = ref([]);
@@ -49,10 +49,11 @@ getDrugUseList().then((res) => {
const isUpdate = ref(false);
const gridApi = ref();
/** 外部传入的成功回调(如金方缺失药处理) */
const onSuccessCb = ref<null | (() => void | Promise<void>)>(null);
const [Form, formApi] = useVbenForm(modalFormProps);
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
@@ -60,62 +61,70 @@ const [Modal, modalApi] = useVbenModal({
modalApi.close();
},
onConfirm: async () => {
formApi.validate().then(async (e: any) => {
if (e.valid) {
const values = await formApi.getValues();
modalApi.setState({ loading: true, confirmLoading: true });
const submitApi = isUpdate.value
? updateChinaMedicine
: createChinaMedicine;
submitApi(values)
.then(() => {
message.success('保存成功');
gridApi.value?.reload();
modalApi.close();
})
.finally(() => {
modalApi.setState({ loading: false, confirmLoading: false });
});
const e = await formApi.validate();
if (!e.valid) return;
const values = await formApi.getValues();
modalApi.setState({ loading: true, confirmLoading: true });
try {
const submitApi = isUpdate.value
? updateChinaMedicine
: createChinaMedicine;
await submitApi(values);
message.success('保存成功');
gridApi.value?.reload?.();
gridApi.value?.query?.();
// 外部回调:金方缺失药「添加药材」成功后标记已解决
if (typeof onSuccessCb.value === 'function') {
await onSuccessCb.value();
}
});
await formApi.validateAndSubmitForm();
modalApi.close();
} finally {
modalApi.setState({ loading: false, confirmLoading: false });
}
},
onOpenChange(isOpen: boolean) {
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;
if (isOpen) {
formApi.updateSchema([
{
componentProps: {
options: drugTime.value,
},
fieldName: 'time_id',
if (!isOpen) {
onSuccessCb.value = null;
formApi.resetForm();
return;
}
const data = modalApi.getData<Record<string, any>>() || {};
gridApi.value = data.gridApi || null;
onSuccessCb.value =
typeof data.onSuccess === 'function' ? data.onSuccess : null;
formApi.updateSchema([
{
componentProps: {
options: drugTime.value,
},
{
componentProps: {
options: drugUnit.value,
},
fieldName: 'unit_id',
fieldName: 'time_id',
},
{
componentProps: {
options: drugUnit.value,
},
{
componentProps: {
options: drugType.value,
},
fieldName: 'type_id',
fieldName: 'unit_id',
},
{
componentProps: {
options: drugType.value,
},
{
componentProps: {
options: drugFrequency.value,
},
fieldName: 'frequency_id',
fieldName: 'type_id',
},
{
componentProps: {
options: drugFrequency.value,
},
]);
const { values, update } = modalApi.getData<Record<string, any>>();
if (values) {
console.log(values, 'sssssssssssss')
isUpdate.value = update;
formApi.setValues(values);
}
fieldName: 'frequency_id',
},
]);
const { values, update } = data;
isUpdate.value = !!update;
if (values) {
formApi.setValues(values);
} else {
formApi.resetForm();
formApi.setValues({ status: 1 });
}
},
});

View File

@@ -6,6 +6,9 @@ const prefix = 'golden-formula/';
/** 金方来源字典 API */
const sourcePrefix = 'golden-formula-source/';
/** 金方缺失药品 API */
const missingPrefix = 'golden-formula-missing-drug/';
export async function getGoldenFormulaList(data: any) {
return requestClient.get<any>(`${prefix}list`, { params: data });
}
@@ -45,6 +48,32 @@ export async function checkGoldenFormulaDrugs(data: { drug_names: string[] }) {
);
}
/**
* 药方设置详情:组成 + 本方缺失药名
* xk-api GET /golden-formula/drugs-detail
*/
export async function getGoldenFormulaDrugsDetail(id: number) {
return requestClient.get<{
id: number;
name: string;
drugs_json: any[];
herb_overview: string;
is_abnormal: number;
missing: string[];
}>(`${prefix}drugs-detail`, { params: { id } });
}
/**
* 药方设置保存:只更新 drugs_json 相关字段
* xk-api POST /golden-formula/update-drugs
*/
export async function updateGoldenFormulaDrugs(data: {
id: number;
drugs_json: Record<string, any>[];
}) {
return requestClient.post<any>(`${prefix}update-drugs`, data);
}
/** 金方来源字典 - 下拉选项 */
export async function getGoldenFormulaSourceOption() {
return requestClient.get<any[]>(`${sourcePrefix}option`);
@@ -55,3 +84,114 @@ export async function createGoldenFormulaSource(data: { name: string }) {
return requestClient.post<any>(`${sourcePrefix}create`, data);
}
/** 缺失药品分页列表 */
export async function getGoldenFormulaMissingDrugList(data: any) {
return requestClient.get<any>(`${missingPrefix}list`, { params: data });
}
/** 批量标记缺失药品为已解决 */
export async function resolveGoldenFormulaMissingDrug(data: { ids: number[] }) {
return requestClient.post<any>(`${missingPrefix}resolve`, data);
}
/** 重新校验待处理缺失药品(已存在的自动标已解决) */
export async function recheckGoldenFormulaMissingDrug() {
return requestClient.post<{ checked: number; resolved: number }>(
`${missingPrefix}recheck`,
{},
);
}
/** 软删除缺失药品记录 */
export async function deleteGoldenFormulaMissingDrug(data: { ids: number[] }) {
return requestClient.post<any>(`${missingPrefix}delete`, data);
}
/**
* 缺失药名新建为中药饮片
* xk-api POST /golden-formula-missing-drug/create-as-drug
*/
export async function createMissingAsDrug(data: {
id?: number;
drug_name?: string;
}) {
return requestClient.post<{
drug_id: number;
drug_name: string;
refreshed: number;
}>(`${missingPrefix}create-as-drug`, data);
}
/**
* 仅标记缺失药已解决(中药管理弹窗新建成功后调用,不再二次建药)
* xk-api POST /golden-formula-missing-drug/resolve-by-name
*/
export async function resolveMissingByName(data: {
id?: number;
drug_name?: string;
remark?: string;
}) {
return requestClient.post<{
drug_name: string;
refreshed: number;
}>(`${missingPrefix}resolve-by-name`, data);
}
/**
* 缺失药名挂到目标药材别名表
* xk-api POST /golden-formula-missing-drug/bind-as-alias
*/
export async function bindMissingAsAlias(data: {
id?: number;
drug_name?: string;
target_drug_id: number;
}) {
return requestClient.post<{
drug_id: number;
drug_name: string;
alias: string;
refreshed: number;
}>(`${missingPrefix}bind-as-alias`, data);
}
/**
* 预览含某药名的金方(替换前勾选)
* xk-api GET /golden-formula-missing-drug/formulas-by-drug-name
*/
export async function getFormulasByDrugName(drugName: string) {
return requestClient.get<
{
id: number;
name: string;
clause_number: string;
herb_overview: string;
source_name: string;
is_abnormal: number;
has_unmatched: number;
status: number;
drug_count: number;
}[]
>(`${missingPrefix}formulas-by-drug-name`, {
params: { drug_name: drugName },
});
}
/**
* 替换为药材表已有药材:金方 name 改为目标药名,旧名保留 origin_name 并挂别名
* xk-api POST /golden-formula-missing-drug/replace-with-drug
*/
export async function replaceMissingWithDrug(data: {
id?: number;
drug_name?: string;
target_drug_id: number;
/** 预览勾选的金方 ID 列表(必填) */
formula_ids: number[];
}) {
return requestClient.post<{
drug_id: number;
old_name: string;
new_name: string;
formula_count: number;
refreshed: number;
}>(`${missingPrefix}replace-with-drug`, data);
}

View File

@@ -0,0 +1,328 @@
<script lang="ts" setup>
/**
* 金方「药方设置」弹窗
* 独立编辑药方组成name/dose/unit/usage/ancient_dose/prep并标红本方缺失药名
*/
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Button, Input, Space, Table, Tag, message } from 'ant-design-vue';
import {
getGoldenFormulaDrugsDetail,
updateGoldenFormulaDrugs,
} from '../api';
import ResolveMissingDrugPanel from './ResolveMissingDrugPanel.vue';
/** 单味药编辑行 */
interface DrugRow {
key: string;
name: string;
dose: string;
unit: string;
usage: string;
ancient_dose: string;
prep: string;
/** 替换已有药时保留的旧药名 */
origin_name: string;
}
const formulaId = ref(0);
const formulaName = ref('');
const drugRows = ref<DrugRow[]>([]);
const missingSet = ref<Set<string>>(new Set());
const loading = ref(false);
const gridApi = ref();
let rowKeySeq = 0;
/** 缺失药处理面板 */
const resolveOpen = ref(false);
const resolveDrugName = ref('');
/** 本方缺失药味数(按当前编辑行实时统计) */
const missingCount = computed(() => {
let n = 0;
for (const row of drugRows.value) {
const name = String(row.name || '').trim();
if (name && missingSet.value.has(name)) n += 1;
}
return n;
});
const columns = [
{ title: '药名', dataIndex: 'name', key: 'name', width: 130 },
{ title: '旧名', dataIndex: 'origin_name', key: 'origin_name', width: 100 },
{ title: '剂量(克)', dataIndex: 'dose', key: 'dose', width: 90 },
{ title: '单位', dataIndex: 'unit', key: 'unit', width: 70 },
{ title: '煎法', dataIndex: 'usage', key: 'usage', width: 100 },
{ title: '古代剂量', dataIndex: 'ancient_dose', key: 'ancient_dose', width: 100 },
{ title: '炮制', dataIndex: 'prep', key: 'prep', width: 90 },
{ title: '操作', key: 'action', width: 140, fixed: 'right' as const },
];
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
class: 'w-[960px]',
onCancel() {
modalApi.close();
},
onConfirm: async () => {
if (formulaId.value <= 0) {
message.warning('金方无效');
return;
}
// 过滤空药名行;至少允许空组成(异常方运营可后补)
const drugs_json = drugRows.value
.map((r) => {
const row: Record<string, string> = {
name: String(r.name || '').trim(),
dose: String(r.dose || '').trim(),
unit: String(r.unit || '').trim() || 'g',
usage: String(r.usage || '').trim(),
ancient_dose: String(r.ancient_dose || '').trim(),
prep: String(r.prep || '').trim(),
};
const origin = String(r.origin_name || '').trim();
if (origin) {
row.origin_name = origin;
}
return row;
})
.filter((r) => r.name !== '');
modalApi.setState({ confirmLoading: true });
try {
await updateGoldenFormulaDrugs({
id: formulaId.value,
drugs_json,
});
message.success('药方设置已保存');
gridApi.value?.query?.();
gridApi.value?.reload?.();
modalApi.close();
} catch (e: any) {
message.error(e?.message || '保存失败');
} finally {
modalApi.setState({ confirmLoading: false });
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formulaId.value = 0;
formulaName.value = '';
drugRows.value = [];
missingSet.value = new Set();
gridApi.value = null;
resolveOpen.value = false;
resolveDrugName.value = '';
return;
}
const data = modalApi.getData<{ id?: number; gridApi?: any }>() || {};
gridApi.value = data.gridApi || null;
const id = Number(data.id || 0);
if (id <= 0) {
message.error('缺少金方 ID');
modalApi.close();
return;
}
formulaId.value = id;
await loadDetail(id);
},
});
/**
* 拉取药方组成详情,并建立缺失药名集合
*/
async function loadDetail(id: number) {
loading.value = true;
try {
const res = await getGoldenFormulaDrugsDetail(id);
formulaName.value = String(res?.name || '');
const missing = Array.isArray(res?.missing) ? res.missing : [];
missingSet.value = new Set(missing.map((n: string) => String(n).trim()).filter(Boolean));
const raw = Array.isArray(res?.drugs_json) ? res.drugs_json : [];
// 关联对象含 error 时无法拆成药味行,给空表让运营重填
if (raw.length === 0 || (raw as any).error || !Array.isArray(raw)) {
const asObj = res?.drugs_json;
if (asObj && typeof asObj === 'object' && !Array.isArray(asObj) && (asObj as any).error) {
drugRows.value = [];
return;
}
}
drugRows.value = (Array.isArray(raw) ? raw : []).map((item: any) =>
toDrugRow(item),
);
if (drugRows.value.length === 0) {
addRow();
}
} catch (e: any) {
message.error(e?.message || '加载药方组成失败');
drugRows.value = [];
} finally {
loading.value = false;
}
}
/** 后端一行转为可编辑行 */
function toDrugRow(item: any): DrugRow {
rowKeySeq += 1;
return {
key: `d-${rowKeySeq}`,
name: String(item?.name || item?.drug_name || ''),
dose: String(item?.dose ?? ''),
unit: String(item?.unit || 'g'),
usage: String(item?.usage || ''),
ancient_dose: String(item?.ancient_dose || ''),
prep: String(item?.prep || ''),
origin_name: String(item?.origin_name || item?.old_name || ''),
};
}
/** 新增一味空药 */
function addRow() {
rowKeySeq += 1;
drugRows.value.push({
key: `d-${rowKeySeq}`,
name: '',
dose: '',
unit: 'g',
usage: '',
ancient_dose: '',
prep: '',
origin_name: '',
});
}
/** 删除一行 */
function removeRow(key: string) {
drugRows.value = drugRows.value.filter((r) => r.key !== key);
}
/**
* 打开缺失药处理:添加药材 / 挂别名
*/
function openResolve(name: string) {
const n = String(name || '').trim();
if (!n || !missingSet.value.has(n)) {
return;
}
resolveDrugName.value = n;
resolveOpen.value = true;
}
/**
* 处理完成后重新拉取组成(替换会改 name/origin_name需整表回填
*/
async function onResolveDone() {
if (formulaId.value <= 0) return;
try {
await loadDetail(formulaId.value);
gridApi.value?.query?.();
} catch {
// 刷新失败不阻塞
}
}
</script>
<template>
<Modal :title="`药方设置${formulaName ? ` · ${formulaName}` : ''}`">
<div class="gf-drugs-toolbar">
<div class="gf-drugs-summary">
<span> {{ drugRows.length }} </span>
<Tag v-if="missingCount > 0" color="error">
本方缺失 {{ missingCount }} 红字可点处理
</Tag>
<Tag v-else color="success">本方药名均已匹配</Tag>
</div>
<Button type="dashed" size="small" @click="addRow">添加药味</Button>
</div>
<Table
:columns="columns"
:data-source="drugRows"
:loading="loading"
:pagination="false"
row-key="key"
size="small"
bordered
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'name'">
<Input
v-model:value="record.name"
:class="{ 'gf-drug-missing': missingSet.has(String(record.name || '').trim()) }"
placeholder="药名"
allow-clear
/>
</template>
<template v-else-if="column.key === 'origin_name'">
<span class="gf-origin-name">{{ record.origin_name || '—' }}</span>
</template>
<template v-else-if="column.key === 'dose'">
<Input v-model:value="record.dose" placeholder="克数" allow-clear />
</template>
<template v-else-if="column.key === 'unit'">
<Input v-model:value="record.unit" placeholder="g" allow-clear />
</template>
<template v-else-if="column.key === 'usage'">
<Input v-model:value="record.usage" placeholder="先煎/后下" allow-clear />
</template>
<template v-else-if="column.key === 'ancient_dose'">
<Input v-model:value="record.ancient_dose" placeholder="三两" allow-clear />
</template>
<template v-else-if="column.key === 'prep'">
<Input v-model:value="record.prep" placeholder="去皮/炙" allow-clear />
</template>
<template v-else-if="column.key === 'action'">
<Space :size="0">
<Button
v-if="missingSet.has(String(record.name || '').trim())"
type="link"
size="small"
@click="openResolve(record.name)"
>
处理
</Button>
<Button type="link" danger size="small" @click="removeRow(record.key)">
删除
</Button>
</Space>
</template>
</template>
</Table>
<ResolveMissingDrugPanel
v-model:open="resolveOpen"
:drug-name="resolveDrugName"
:formula-id="formulaId"
@done="onResolveDone"
/>
</Modal>
</template>
<style scoped>
.gf-drugs-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
gap: 12px;
}
.gf-drugs-summary {
display: flex;
align-items: center;
gap: 8px;
color: hsl(var(--muted-foreground));
font-size: 13px;
}
.gf-origin-name {
color: hsl(var(--muted-foreground));
font-size: 12px;
}
/* 缺失药名输入框:用 destructive 强调,暗色可读 */
:deep(.gf-drug-missing) {
color: hsl(var(--destructive));
font-weight: 600;
}
:deep(.gf-drug-missing input) {
color: hsl(var(--destructive));
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,202 @@
<script lang="ts" setup>
/**
* 金方 Excel 导入选文件弹窗
* - 点击 / 拖拽UploadDragger
* - 粘贴:点「开始监听粘贴」后 Ctrl+V成功拿到文件后自动结束监听
* 确认后由父级解析并打开预览弹窗
*/
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { InboxOutlined } from '@ant-design/icons-vue';
import {
Button,
UploadDragger,
message,
type UploadFile,
} from 'ant-design-vue';
import { usePasteUploadListen } from '#/utils/use-paste-upload-listen';
const ACCEPT = '.xlsx,.xls';
const selectedFile = ref<File | null>(null);
const fileList = ref<UploadFile[]>([]);
/** 父级传入:选中文件确认后回调(解析 Excel */
const onConfirmFile = ref<null | ((file: File) => void | Promise<void>)>(null);
const {
listening: pasteListening,
start: startPasteListen,
stop: stopPasteListen,
} = usePasteUploadListen({
accept: ACCEPT,
// Excel 体积放宽到 30MB
maxSize: 30 * 1024 * 1024,
autoStopOnSuccess: true,
onFiles: (files) => {
const file = files[0];
if (!file) return;
applySelectedFile(file);
},
});
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
onCancel() {
resetState();
modalApi.close();
},
onConfirm: async () => {
if (!selectedFile.value) {
message.warning('请先选择、拖入或粘贴 Excel 文件');
return;
}
const file = selectedFile.value;
const cb = onConfirmFile.value;
modalApi.setState({ confirmLoading: true });
try {
if (typeof cb === 'function') {
await cb(file);
}
// 解析成功才关弹窗;失败则保留文件便于重选/重试
resetState();
modalApi.close();
} catch {
// 错误提示由父级 handleImportFile 负责
} finally {
modalApi.setState({ confirmLoading: false });
}
},
onOpenChange(isOpen: boolean) {
if (!isOpen) {
resetState();
return;
}
const data = modalApi.getData<{
onConfirmFile?: (file: File) => void | Promise<void>;
}>() || {};
onConfirmFile.value =
typeof data.onConfirmFile === 'function' ? data.onConfirmFile : null;
},
});
/** 重置选中与粘贴监听 */
function resetState() {
selectedFile.value = null;
fileList.value = [];
onConfirmFile.value = null;
stopPasteListen();
}
/** 统一写入选中文件(点击/拖拽/粘贴共用) */
function applySelectedFile(file: File) {
const name = (file.name || '').toLowerCase();
if (!name.endsWith('.xlsx') && !name.endsWith('.xls')) {
message.warning('仅支持 .xlsx / .xls 文件');
return;
}
selectedFile.value = file;
fileList.value = [
{
uid: `gf-${Date.now()}`,
name: file.name,
status: 'done',
originFileObj: file as any,
},
];
message.success(`已选择:${file.name}`);
}
/**
* Dragger 选中变化before-upload 已拦截实际上传,这里只取本地 File
*/
function handleChange(info: { file: UploadFile; fileList: UploadFile[] }) {
const { file } = info;
if (file.status === 'removed') {
selectedFile.value = null;
fileList.value = [];
return;
}
const raw = (file.originFileObj || file) as File;
if (raw instanceof File) {
applySelectedFile(raw);
}
}
/** 切换粘贴监听:开始时提示;手动停止时也提示 */
function handlePasteListenClick() {
if (pasteListening.value) {
stopPasteListen();
message.info('已停止粘贴监听');
return;
}
startPasteListen();
message.success('已开启粘贴监听,请 Ctrl+V 粘贴 Excel成功后自动结束');
}
</script>
<template>
<Modal title="导入金方 Excel" class="w-[560px]">
<div v-if="selectedFile" class="gf-import-selected">
已选择文件{{ selectedFile.name }}
</div>
<UploadDragger
v-model:file-list="fileList"
:before-upload="() => false"
:max-count="1"
:accept="ACCEPT"
name="file"
@change="handleChange"
>
<p class="gf-import-icon">
<InboxOutlined />
</p>
<p class="ant-upload-text">点击或拖拽 Excel 到此区域</p>
<p class="ant-upload-hint">仅支持 .xlsx / .xls确认后进入导入预览</p>
</UploadDragger>
<div class="gf-import-paste">
<Button
:type="pasteListening ? 'primary' : 'default'"
:danger="pasteListening"
@click="handlePasteListenClick"
>
{{ pasteListening ? '停止监听粘贴' : '开始监听粘贴' }}
</Button>
<span v-if="pasteListening" class="gf-import-paste-tip">
监听中Ctrl+V 粘贴 Excel成功后自动结束
</span>
<span v-else class="gf-import-paste-tip">
也可点击开始监听后粘贴文件
</span>
</div>
</Modal>
</template>
<style scoped>
.gf-import-selected {
margin-bottom: 12px;
padding: 10px 12px;
border: 1px solid hsl(var(--primary) / 0.35);
border-radius: 8px;
background: hsl(var(--primary) / 0.08);
color: hsl(var(--foreground));
font-size: 13px;
}
.gf-import-icon {
display: flex;
justify-content: center;
margin-bottom: 8px;
font-size: 48px;
color: hsl(var(--primary));
}
.gf-import-paste {
display: flex;
align-items: center;
gap: 12px;
margin-top: 16px;
}
.gf-import-paste-tip {
font-size: 12px;
color: hsl(var(--muted-foreground));
}
</style>

View File

@@ -0,0 +1,275 @@
<script lang="ts" setup>
/**
* 金方缺失药品弹窗
* 展示导入/药方设置产生的不存在药名;点击药名可选择「添加药材」或「写入别名表」
*/
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Button, Select, Table, Tag, message } from 'ant-design-vue';
import {
getGoldenFormulaMissingDrugList,
recheckGoldenFormulaMissingDrug,
resolveGoldenFormulaMissingDrug,
} from '../api';
import { MISSING_DRUG_STATUS_OPTIONS } from '../config/constants';
import ResolveMissingDrugPanel from './ResolveMissingDrugPanel.vue';
const loading = ref(false);
const rechecking = ref(false);
const resolving = ref(false);
const items = ref<any[]>([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(20);
const statusFilter = ref<number | undefined>(0);
const selectedRowKeys = ref<number[]>([]);
/** 处理面板状态 */
const resolveOpen = ref(false);
const resolveMissingId = ref(0);
const resolveDrugName = ref('');
const columns = [
{ title: '药名', dataIndex: 'drug_name', key: 'drug_name' },
{ title: '出现次数', dataIndex: 'hit_count', key: 'hit_count', width: 100 },
{ title: '状态', dataIndex: 'status', key: 'status', width: 100 },
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 170 },
{ title: '更新时间', dataIndex: 'updated_at', key: 'updated_at', width: 170 },
];
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
class: 'w-[860px]',
showConfirmButton: false,
onCancel() {
modalApi.close();
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
items.value = [];
total.value = 0;
page.value = 1;
selectedRowKeys.value = [];
statusFilter.value = 0;
resolveOpen.value = false;
return;
}
await loadList();
},
});
/**
* 拉取缺失药品分页列表
*/
async function loadList() {
loading.value = true;
selectedRowKeys.value = [];
try {
const params: Record<string, any> = {
page: page.value,
pageSize: pageSize.value,
};
if (statusFilter.value !== undefined && statusFilter.value !== null) {
params.status = statusFilter.value;
}
const res = await getGoldenFormulaMissingDrugList(params);
items.value = Array.isArray(res?.items) ? res.items : [];
total.value = Number(res?.total || 0);
} catch (e: any) {
items.value = [];
total.value = 0;
message.error(e?.message || '加载失败');
} finally {
loading.value = false;
}
}
/** 表格分页变更 */
function onTableChange(pag: any) {
page.value = Number(pag?.current || 1);
pageSize.value = Number(pag?.pageSize || 20);
loadList();
}
/** 状态筛选变更后回到第一页 */
function onStatusChange() {
page.value = 1;
loadList();
}
/**
* 重新校验:药品库已存在的自动标已解决
*/
async function handleRecheck() {
rechecking.value = true;
try {
const res = await recheckGoldenFormulaMissingDrug();
message.success(
`校验 ${Number(res?.checked || 0)} 条,自动解决 ${Number(res?.resolved || 0)}`,
);
await loadList();
} catch (e: any) {
message.error(e?.message || '重新校验失败');
} finally {
rechecking.value = false;
}
}
/**
* 批量标记已解决(当前勾选)
*/
async function handleResolve() {
if (!selectedRowKeys.value.length) {
message.warning('请先勾选待处理记录');
return;
}
resolving.value = true;
try {
const res = await resolveGoldenFormulaMissingDrug({
ids: selectedRowKeys.value,
});
message.success(`已标记 ${Number(res?.resolved || 0)} 条为已解决`);
await loadList();
} catch (e: any) {
message.error(e?.message || '操作失败');
} finally {
resolving.value = false;
}
}
/** 勾选变更 */
function onSelectChange(keys: (string | number)[]) {
selectedRowKeys.value = keys.map((k) => Number(k));
}
/**
* 点击待处理药名:打开「添加药材 / 挂别名」选择面板
*/
function openResolve(record: any) {
if (Number(record?.status) === 1) {
message.info('该药名已解决');
return;
}
resolveMissingId.value = Number(record?.id || 0);
resolveDrugName.value = String(record?.drug_name || '').trim();
if (!resolveDrugName.value) {
message.warning('药名为空');
return;
}
resolveOpen.value = true;
}
</script>
<template>
<Modal title="缺失药品">
<div class="gf-missing-toolbar">
<div class="gf-missing-filters">
<span class="gf-missing-label">状态</span>
<Select
v-model:value="statusFilter"
allow-clear
placeholder="全部"
style="width: 140px"
:options="MISSING_DRUG_STATUS_OPTIONS"
@change="onStatusChange"
/>
</div>
<div class="gf-missing-actions">
<Button :loading="rechecking" @click="handleRecheck">重新校验</Button>
<Button
type="primary"
:loading="resolving"
:disabled="!selectedRowKeys.length"
@click="handleResolve"
>
标记已解决
</Button>
</div>
</div>
<p class="gf-missing-hint">
点击待处理药名可选择添加药材放入别名表替换为已有药材保留旧名
</p>
<Table
:columns="columns"
:data-source="items"
:loading="loading"
row-key="id"
size="small"
bordered
:row-selection="{
selectedRowKeys,
onChange: onSelectChange,
}"
:pagination="{
current: page,
pageSize,
total,
showSizeChanger: true,
showTotal: (t: number) => `共 ${t} 条`,
}"
@change="onTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'drug_name'">
<a
v-if="record.status !== 1"
class="gf-missing-name"
@click.prevent="openResolve(record)"
>
{{ record.drug_name }}
</a>
<span v-else>{{ record.drug_name }}</span>
</template>
<template v-else-if="column.key === 'status'">
<Tag v-if="record.status === 1" color="success">已解决</Tag>
<Tag v-else color="error">待处理</Tag>
</template>
</template>
</Table>
<ResolveMissingDrugPanel
v-model:open="resolveOpen"
:missing-id="resolveMissingId"
:drug-name="resolveDrugName"
@done="loadList"
/>
</Modal>
</template>
<style scoped>
.gf-missing-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
gap: 12px;
}
.gf-missing-filters {
display: flex;
align-items: center;
gap: 8px;
}
.gf-missing-label {
color: hsl(var(--muted-foreground));
font-size: 13px;
}
.gf-missing-actions {
display: flex;
align-items: center;
gap: 8px;
}
.gf-missing-hint {
margin: 0 0 12px;
font-size: 12px;
color: hsl(var(--muted-foreground));
}
.gf-missing-name {
color: hsl(var(--destructive));
font-weight: 600;
cursor: pointer;
}
.gf-missing-name:hover {
text-decoration: underline;
}
</style>

View File

@@ -0,0 +1,432 @@
<script lang="ts" setup>
/**
* 缺失药处理面板
* - 添加药材:打开中药管理「新增中药」模态框
* - 挂别名ChineseDrugNameBubble 选目标药
* - 替换已有:选目标药 → 预览勾选含同名药的金方 → 确认
*/
import { computed, ref, watch } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useUserStore } from '@vben/stores';
import { Button, Modal, Space, Table, Tag, message } from 'ant-design-vue';
import {
ChineseDrugNameBubble,
type ChineseDrugBubbleItem,
} from '#/components/chinese-drug-name-bubble';
import ChinaMedicineFormModal from '#/views/business/product/china-medicine/components/modal.vue';
import {
bindMissingAsAlias,
getFormulasByDrugName,
replaceMissingWithDrug,
resolveMissingByName,
} from '../api';
const props = defineProps<{
open: boolean;
missingId?: number;
drugName: string;
/** 药方设置场景:预览时默认勾选当前金方 */
formulaId?: number;
}>();
const emit = defineEmits<{
(e: 'update:open', v: boolean): void;
(e: 'done'): void;
}>();
const userStore = useUserStore();
const searchStoreId = Number(userStore.userInfo?.store_id || 0);
const step = ref<'choose' | 'alias' | 'replace'>('choose');
const submitting = ref(false);
const selectedDrug = ref<ChineseDrugBubbleItem | null>(null);
const selectedDisplayName = ref('');
/** 替换预览:含该药名的金方 */
const previewLoading = ref(false);
const previewFormulas = ref<any[]>([]);
const selectedFormulaIds = ref<number[]>([]);
const previewColumns = [
{ title: 'ID', dataIndex: 'id', key: 'id', width: 70 },
{ title: '药方名字', dataIndex: 'name', key: 'name', ellipsis: true },
{ title: '条文', dataIndex: 'clause_number', key: 'clause_number', width: 90 },
{ title: '来源', dataIndex: 'source_name', key: 'source_name', width: 100 },
{ title: '药材速览', dataIndex: 'herb_overview', key: 'herb_overview', ellipsis: true },
{ title: '标记', key: 'flags', width: 120 },
];
const selectedFormulaCount = computed(() => selectedFormulaIds.value.length);
const [ChinaMedicineModal, chinaMedicineModalApi] = useVbenModal({
connectedComponent: ChinaMedicineFormModal,
});
watch(
() => props.open,
(v) => {
if (v) {
step.value = 'choose';
selectedDrug.value = null;
selectedDisplayName.value = '';
previewFormulas.value = [];
selectedFormulaIds.value = [];
}
},
);
function close() {
emit('update:open', false);
}
function buildBasePayload() {
const payload: { id?: number; drug_name?: string } = {
drug_name: props.drugName,
};
if (props.missingId && props.missingId > 0) {
payload.id = props.missingId;
}
return payload;
}
/**
* 打开中药管理新增弹窗
*/
function openCreateMedicineModal() {
if (!props.drugName) {
message.warning('药名为空');
return;
}
const resolvePayload = buildBasePayload();
const missingName = props.drugName;
close();
chinaMedicineModalApi.setData({
update: false,
values: {
drug_name: missingName,
drug_alias: missingName,
drug_number: `GF${Date.now().toString().slice(-10)}`,
status: 1,
time_id: 1,
type_id: 1,
unit_id: 1,
frequency_id: 1,
},
onSuccess: async () => {
try {
await resolveMissingByName({
...resolvePayload,
remark: '已通过中药管理新增',
});
message.success(`缺失药「${missingName}」已标记为已解决`);
emit('done');
} catch (e: any) {
message.error(e?.message || '标记已解决失败,请在缺失药品里重新校验');
}
},
});
chinaMedicineModalApi.open();
}
function onSelectDrug(item: ChineseDrugBubbleItem) {
selectedDrug.value = item;
selectedDisplayName.value = String(
item?.drug?.drug_name || item?.drug_name || '',
).trim();
}
function resolveTargetDrugId(): number {
const item = selectedDrug.value;
if (!item) return 0;
return Number(item.drug_id || item.drug?.id || 0);
}
/**
* 加载含该缺失药名的金方预览,并默认勾选
* - 有 formulaId默认只勾当前方可再加选
* - 否则:默认全选
*/
async function loadFormulaPreview() {
if (!props.drugName) {
previewFormulas.value = [];
selectedFormulaIds.value = [];
return;
}
previewLoading.value = true;
try {
const list = await getFormulasByDrugName(props.drugName);
previewFormulas.value = Array.isArray(list) ? list : [];
const allIds = previewFormulas.value.map((r) => Number(r.id)).filter((id) => id > 0);
if (props.formulaId && props.formulaId > 0) {
// 当前方优先勾选;若列表里没有当前方(异常),仍尽量带上
selectedFormulaIds.value = allIds.includes(props.formulaId)
? [props.formulaId]
: allIds.length
? [allIds[0]]
: [];
} else {
selectedFormulaIds.value = allIds;
}
} catch (e: any) {
previewFormulas.value = [];
selectedFormulaIds.value = [];
message.error(e?.message || '加载受影响金方失败');
} finally {
previewLoading.value = false;
}
}
async function goPick(next: 'alias' | 'replace') {
step.value = next;
selectedDrug.value = null;
selectedDisplayName.value = '';
if (next === 'replace') {
await loadFormulaPreview();
} else {
previewFormulas.value = [];
selectedFormulaIds.value = [];
}
}
function onFormulaSelectChange(keys: (string | number)[]) {
selectedFormulaIds.value = keys.map((k) => Number(k)).filter((id) => id > 0);
}
function selectAllFormulas() {
selectedFormulaIds.value = previewFormulas.value
.map((r) => Number(r.id))
.filter((id) => id > 0);
}
function clearFormulaSelection() {
selectedFormulaIds.value = [];
}
async function confirmAlias() {
const targetDrugId = resolveTargetDrugId();
if (!targetDrugId) {
message.warning('请先搜索并选择目标药材');
return;
}
submitting.value = true;
try {
await bindMissingAsAlias({
...buildBasePayload(),
target_drug_id: targetDrugId,
});
message.success(`${props.drugName}」已写入别名表`);
emit('done');
close();
} catch (e: any) {
message.error(e?.message || '写入别名失败');
} finally {
submitting.value = false;
}
}
/**
* 确认替换:只改预览勾选的金方
*/
async function confirmReplace() {
const targetDrugId = resolveTargetDrugId();
if (!targetDrugId) {
message.warning('请先搜索并选择目标药材');
return;
}
if (!selectedFormulaIds.value.length) {
message.warning('请至少勾选一个要修改的金方');
return;
}
submitting.value = true;
try {
const res = await replaceMissingWithDrug({
...buildBasePayload(),
target_drug_id: targetDrugId,
formula_ids: selectedFormulaIds.value,
});
message.success(
`已将「${res?.old_name || props.drugName}」替换为「${res?.new_name || selectedDisplayName.value}」(共 ${Number(res?.formula_count || 0)} 个金方,旧名已保留)`,
);
emit('done');
close();
} catch (e: any) {
message.error(e?.message || '替换失败');
} finally {
submitting.value = false;
}
}
</script>
<template>
<ChinaMedicineModal />
<Modal
:open="open"
:title="`处理缺失药:${drugName || ''}`"
:footer="null"
destroy-on-close
:width="step === 'replace' ? 860 : 520"
@cancel="close"
>
<div v-if="step === 'choose'" class="gf-resolve-choose">
<p class="gf-resolve-tip">
该药名在药品库中不存在请选择处理方式
</p>
<Space direction="vertical" style="width: 100%">
<Button type="primary" block @click="openCreateMedicineModal">
添加药材打开中药管理新增
</Button>
<Button block @click="goPick('alias')">
放入药材别名表挂到已有药不改金方药名
</Button>
<Button block @click="goPick('replace')">
替换为已有药材预览勾选金方后确认
</Button>
</Space>
</div>
<div v-else-if="step === 'alias'" class="gf-resolve-alias">
<p class="gf-resolve-tip">
{{ drugName }}挂到下方目标药材的别名表金方组成里的药名不变
</p>
<div class="gf-resolve-search">
<span class="gf-resolve-search-label">搜索中药</span>
<ChineseDrugNameBubble
:display-name="selectedDisplayName"
:type="1"
:store-id="searchStoreId"
placeholder="输入药名搜索"
@select="onSelectDrug"
/>
</div>
<p v-if="selectedDisplayName" class="gf-resolve-selected">
已选{{ selectedDisplayName }}
<span v-if="resolveTargetDrugId()">ID {{ resolveTargetDrugId() }}</span>
</p>
<Space>
<Button @click="step = 'choose'">返回</Button>
<Button type="primary" :loading="submitting" @click="confirmAlias">
确认挂别名
</Button>
</Space>
</div>
<div v-else class="gf-resolve-replace">
<p class="gf-resolve-tip">
1搜索并选择目标药材2预览勾选要一起修改的金方3确认后把{{ drugName }}替换为目标药名旧名写入
origin_name 并挂别名
</p>
<div class="gf-resolve-search">
<span class="gf-resolve-search-label">搜索中药</span>
<ChineseDrugNameBubble
:display-name="selectedDisplayName"
:type="1"
:store-id="searchStoreId"
placeholder="输入药名搜索"
@select="onSelectDrug"
/>
</div>
<p v-if="selectedDisplayName" class="gf-resolve-selected">
已选目标{{ selectedDisplayName }}
<span v-if="resolveTargetDrugId()">ID {{ resolveTargetDrugId() }}</span>
</p>
<div class="gf-preview-toolbar">
<span class="gf-preview-title">
受影响金方预览已勾选 {{ selectedFormulaCount }} / {{ previewFormulas.length }}
</span>
<Space>
<Button size="small" :loading="previewLoading" @click="loadFormulaPreview">
刷新
</Button>
<Button size="small" @click="selectAllFormulas">全选</Button>
<Button size="small" @click="clearFormulaSelection">清空</Button>
</Space>
</div>
<Table
size="small"
bordered
row-key="id"
:loading="previewLoading"
:columns="previewColumns"
:data-source="previewFormulas"
:pagination="previewFormulas.length > 8 ? { pageSize: 8 } : false"
:row-selection="{
selectedRowKeys: selectedFormulaIds,
onChange: onFormulaSelectChange,
}"
class="gf-preview-table"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'flags'">
<Tag v-if="record.is_abnormal === 1" color="error">异常</Tag>
<Tag v-if="record.has_unmatched === 1" color="warning">未匹配</Tag>
<Tag
v-if="formulaId && Number(record.id) === Number(formulaId)"
color="processing"
>
当前
</Tag>
</template>
</template>
</Table>
<Space class="gf-resolve-actions">
<Button @click="step = 'choose'">返回</Button>
<Button
type="primary"
:loading="submitting"
:disabled="!selectedDisplayName || !selectedFormulaCount"
@click="confirmReplace"
>
确认替换{{ selectedFormulaCount }} 个金方
</Button>
</Space>
</div>
</Modal>
</template>
<style scoped>
.gf-resolve-tip {
margin-bottom: 16px;
color: hsl(var(--muted-foreground));
line-height: 1.6;
}
.gf-resolve-search {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.gf-resolve-search-label {
flex-shrink: 0;
color: hsl(var(--foreground));
font-size: 13px;
}
.gf-resolve-search :deep(.chinese-drug-name-bubble),
.gf-resolve-search :deep(.chinese-drug-name-bubble--compact) {
width: 100% !important;
min-width: 220px !important;
max-width: none !important;
flex: 1 1 auto !important;
}
.gf-resolve-selected {
margin: 0 0 12px;
color: hsl(var(--primary));
font-size: 13px;
}
.gf-preview-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
}
.gf-preview-title {
font-size: 13px;
color: hsl(var(--foreground));
}
.gf-preview-table {
margin-bottom: 16px;
}
.gf-resolve-actions {
margin-top: 4px;
}
</style>

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup>
/**
* 金方新增/编辑弹窗
* 打开时拉取金方来源下拉,通过 updateSchema 注入 source_id 选项
* 金方新增/编辑弹窗(基础信息)
* 药方组成已拆到独立「药方设置」弹窗,本弹窗不再编辑 drugs_json
*/
import { ref } from 'vue';
@@ -31,27 +31,11 @@ const [Modal, modalApi] = useVbenModal({
const e = await formApi.validate();
if (!e.valid) return;
const values = await formApi.getValues();
// 表单里 drugs_json 是字符串;空串转空数组交给后端
const drugsRaw = values.drugs_json;
let drugs_json: any = drugsRaw;
if (typeof drugsRaw === 'string') {
const t = drugsRaw.trim();
if (t === '') {
drugs_json = [];
} else {
try {
drugs_json = JSON.parse(t);
} catch {
message.error('药品JSON 不是合法 JSON');
return;
}
}
}
modalApi.setState({ confirmLoading: true });
try {
const payload = { ...values, drugs_json };
// 基础信息保存不带 drugs_json,避免覆盖已有药方组成
const api = isUpdate.value ? updateGoldenFormula : createGoldenFormula;
await api(payload);
await api(values);
message.success('保存成功');
gridApi.value?.query?.();
gridApi.value?.reload?.();
@@ -85,19 +69,10 @@ const [Modal, modalApi] = useVbenModal({
const { values, update } = modalApi.getData<Record<string, any>>() || {};
isUpdate.value = !!update;
if (values && update) {
const drugs = values.drugs_json;
formApi.setValues({
...values,
drugs_json:
typeof drugs === 'string'
? drugs
: drugs
? JSON.stringify(drugs, null, 2)
: '',
});
formApi.setValues({ ...values });
} else {
formApi.resetForm();
formApi.setValues({ sort: 0, status: 1, drugs_json: '', source_id: 0 });
formApi.setValues({ sort: 0, status: 1, source_id: 0 });
}
},
});

View File

@@ -4,3 +4,19 @@ export const STATUS_OPTIONS = [
{ label: '启用', value: 1 },
{ label: '禁用', value: 0 },
];
/**
* 异常标记筛选(前端传 is_abnormal
* 0=正常 / 1=异常(JSON error或0味) / 2=有未匹配药(后端映射 has_unmatched=1
*/
export const ABNORMAL_FILTER_OPTIONS = [
{ label: '正常', value: 0 },
{ label: '异常', value: 1 },
{ label: '有未匹配药', value: 2 },
];
/** 缺失药品处理状态 */
export const MISSING_DRUG_STATUS_OPTIONS = [
{ label: '待处理', value: 0 },
{ label: '已解决', value: 1 },
];

View File

@@ -74,22 +74,10 @@ export const modalFormProps: VbenFormProps = {
component: 'VbenInput',
fieldName: 'herb_overview',
label: '药材速览',
help: '展示用;药方组成请在行操作「药方设置」中维护,保存组成时会按药名重算本字段',
componentProps: { placeholder: '如:桂枝、芍药、甘草、生姜、大枣' },
defaultValue: '',
},
{
component: 'VbenInput',
fieldName: 'drugs_json',
label: '药品JSON',
help: '系统字段name/dose(克数)/unit(g)/usage(先煎后下)。展示扩展ancient_dose(三两)、prep(去皮/炙/切/擘),仅展示不参与导入',
componentProps: {
type: 'textarea',
rows: 5,
placeholder:
'[{"name":"桂枝","dose":"9","unit":"g","usage":"","ancient_dose":"三两","prep":"去皮"},{"name":"芍药","dose":"9","unit":"g","ancient_dose":"三两"},{"name":"甘草","dose":"6","unit":"g","ancient_dose":"二两","prep":"炙"},{"name":"生姜","dose":"9","unit":"g","ancient_dose":"三两","prep":"切"},{"name":"大枣","dose":"12","unit":"g","ancient_dose":"十二枚","prep":"擘"}]',
},
defaultValue: '',
},
{
component: 'VbenInput',
fieldName: 'original_formula',

View File

@@ -1,6 +1,6 @@
<script lang="ts" setup>
/**
* 金方库管理CRUD + Excel 导入
* 金方库管理CRUD + Excel 导入 + 药方设置 + 缺失药品
* VIP 功能码 golden_formula 由「VIP等级配置」勾选分配本页只管平台金方数据
*/
import type { VxeGridListeners } from '#/adapter/vxe-table';
@@ -9,7 +9,7 @@ import { ref } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { Button, Tag, Upload, message, Modal as AntModal } from 'ant-design-vue';
import { Tag, message, Modal as AntModal } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { TableAction } from '#/components/table-action';
@@ -21,8 +21,11 @@ import {
importGoldenFormula,
} from './api';
import FormModal from './components/modal.vue';
import ImportExcelModal from './components/ImportExcelModal.vue';
import ImportPreviewModal from './components/ImportPreviewModal.vue';
import { STATUS_OPTIONS } from './config/constants';
import FormulaDrugsModal from './components/FormulaDrugsModal.vue';
import MissingDrugModal from './components/MissingDrugModal.vue';
import { ABNORMAL_FILTER_OPTIONS, STATUS_OPTIONS } from './config/constants';
import { exportGoldenFormulaTemplate } from './utils/exportGoldenFormulaExcel';
import { parseGoldenFormulaExcelBuffer } from './utils/parseGoldenFormulaExcel';
@@ -58,10 +61,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
label: '异常标记',
componentProps: {
allowClear: true,
options: [
{ label: '正常', value: 0 },
{ label: '异常', value: 1 },
],
options: ABNORMAL_FILTER_OPTIONS,
},
},
],
@@ -89,14 +89,14 @@ const [Grid, gridApi] = useVbenVxeGrid({
{
field: 'is_abnormal',
title: '异常',
width: 90,
width: 110,
slots: { default: 'is_abnormal' },
},
{ field: 'pinyin_initials', title: '首拼', width: 100 },
{ field: 'status', title: '状态', width: 90, slots: { default: 'status' } },
{ field: 'sort', title: '排序', width: 80 },
{ field: 'created_at', title: '创建时间', width: 170 },
{ title: '操作', slots: { default: 'action' }, width: 160, fixed: 'right' },
{ title: '操作', slots: { default: 'action' }, width: 220, fixed: 'right' },
],
keepSource: true,
pagerConfig: {},
@@ -134,11 +134,26 @@ const [FormModalComp, formModalApi] = useVbenModal({
connectedComponent: FormModal,
});
/** 导入选文件弹窗:拖拽 / 点击 / 粘贴 */
const [ImportExcelModalComp, importExcelApi] = useVbenModal({
connectedComponent: ImportExcelModal,
});
/** 导入预览弹窗:解析后预览 + 选金方来源 + 确认导入 */
const [ImportPreviewModalComp, importPreviewApi] = useVbenModal({
connectedComponent: ImportPreviewModal,
});
/** 药方设置:独立编辑组成 */
const [FormulaDrugsModalComp, formulaDrugsModalApi] = useVbenModal({
connectedComponent: FormulaDrugsModal,
});
/** 缺失药品清单 */
const [MissingDrugModalComp, missingDrugModalApi] = useVbenModal({
connectedComponent: MissingDrugModal,
});
const showModal = (data: any = {}, isUpdate = false) => {
formModalApi.setData({
values: data,
@@ -148,6 +163,20 @@ const showModal = (data: any = {}, isUpdate = false) => {
formModalApi.open();
};
/** 打开药方设置弹窗 */
const showFormulaDrugsModal = (row: any) => {
formulaDrugsModalApi.setData({
id: Number(row?.id || 0),
gridApi,
});
formulaDrugsModalApi.open();
};
/** 打开缺失药品弹窗 */
const showMissingDrugModal = () => {
missingDrugModalApi.open();
};
const handleDelete = async (ids: number[]) => {
await deleteGoldenFormula({ ids });
message.success('删除成功');
@@ -163,6 +192,17 @@ async function handleDownloadTemplate() {
);
}
/** 打开导入选文件弹窗(不再直接调起系统文件选择) */
function openImportExcelModal() {
importExcelApi.setData({
onConfirmFile: handleImportFile,
});
importExcelApi.open();
}
/**
* 解析 Excel → 打开展示预览(由导入选文件弹窗确认后调用)
*/
async function handleImportFile(file: File) {
importing.value = true;
try {
@@ -172,22 +212,25 @@ async function handleImportFile(file: File) {
const detail = parsed.firstInvalidReason
? `${parsed.firstInvalidReason}`
: '';
// warning 已提示用户;抛错仅用于保持选文件弹窗不关闭
message.warning(
parsed.invalidCount
? `没有可导入的有效行(无效 ${parsed.invalidCount} 行)${detail}`
: '文件无有效数据,请确认填写的是「金方导入」工作表',
);
return false;
throw new Error('__import_empty__');
}
// 打开预览弹窗:用户选金方来源 + 确认后再提交
importPreviewApi.setData({ rows: parsed.items });
importPreviewApi.open();
} catch (e: any) {
message.error(e?.message || '解析失败');
if (e?.message !== '__import_empty__') {
message.error(e?.message || '解析失败');
}
throw e;
} finally {
importing.value = false;
}
return false;
}
/**
@@ -202,12 +245,13 @@ async function handleConfirmImport(payload: { rows: any[]; sourceId: number }) {
});
const ok = Number(res?.success || 0);
const fail = Number(res?.fail || 0);
const missingCount = Number(res?.missing_count || 0);
const errors = Array.isArray(res?.errors) ? res.errors : [];
AntModal.info({
title: '导入结果',
content: `成功 ${ok} 条,失败 ${fail}${
errors.length ? `\n${errors.slice(0, 10).join('\n')}` : ''
}`,
missingCount > 0 ? `,缺失药名 ${missingCount} 个(已写入缺失药品表)` : ''
}${errors.length ? `\n${errors.slice(0, 10).join('\n')}` : ''}`,
width: 520,
});
gridApi.reload();
@@ -221,7 +265,10 @@ async function handleConfirmImport(payload: { rows: any[]; sourceId: number }) {
<template>
<Page auto-content-height title="金方管理">
<FormModalComp />
<ImportExcelModalComp />
<ImportPreviewModalComp @confirm="handleConfirmImport" />
<FormulaDrugsModalComp />
<MissingDrugModalComp />
<Grid>
<template #toolbar-buttons>
<TableAction
@@ -235,6 +282,15 @@ async function handleConfirmImport(payload: { rows: any[]; sourceId: number }) {
label: '下载模板',
onClick: handleDownloadTemplate,
},
{
label: '导入 Excel',
loading: importing,
onClick: openImportExcelModal,
},
{
label: '缺失药品',
onClick: showMissingDrugModal,
},
{
label: '批量删除',
danger: true,
@@ -249,14 +305,6 @@ async function handleConfirmImport(payload: { rows: any[]; sourceId: number }) {
},
]"
/>
<Upload
:show-upload-list="false"
:before-upload="handleImportFile"
accept=".xlsx,.xls"
class="ml-2 inline-block"
>
<Button :loading="importing" type="default">导入 Excel</Button>
</Upload>
</template>
<!-- 异常处方药方名字整列标红 -->
<template #name="{ row }">
@@ -268,8 +316,16 @@ async function handleConfirmImport(payload: { rows: any[]; sourceId: number }) {
{{ 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>
<span class="gf-flag-cell">
<Tag v-if="row.is_abnormal === 1" color="error"></Tag>
<Tag v-if="row.has_unmatched === 1" color="warning">未匹配</Tag>
<Tag
v-if="row.is_abnormal !== 1 && row.has_unmatched !== 1"
color="success"
>
正常
</Tag>
</span>
</template>
<template #status="{ row }">
<Tag :color="row.status === 1 ? 'success' : 'default'">
@@ -283,6 +339,10 @@ async function handleConfirmImport(payload: { rows: any[]; sourceId: number }) {
label: '编辑',
onClick: () => showModal(row, true),
},
{
label: '药方设置',
onClick: () => showFormulaDrugsModal(row),
},
{
label: '删除',
danger: true,
@@ -303,4 +363,9 @@ async function handleConfirmImport(payload: { rows: any[]; sourceId: number }) {
color: hsl(var(--destructive));
font-weight: 600;
}
.gf-flag-cell {
display: inline-flex;
flex-wrap: wrap;
gap: 4px;
}
</style>