批量修改药品编号
Some checks failed
Close stale issues / stale (push) Has been cancelled
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Some checks failed
Close stale issues / stale (push) Has been cancelled
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
This commit is contained in:
@@ -47,6 +47,7 @@
|
||||
"markdown-it": "^14.1.0",
|
||||
"pinia": "catalog:",
|
||||
"vue": "catalog:",
|
||||
"vue-router": "catalog:"
|
||||
"vue-router": "catalog:",
|
||||
"xlsx": "^0.18.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { RequestResponse } from '@vben/request';
|
||||
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
const prefix = 'china-medicine/';
|
||||
@@ -66,3 +64,35 @@ export async function deleteChinaMedicine(data: Record<string, any>) {
|
||||
export async function importChinaMedicineApi(data: Record<string, any>) {
|
||||
return requestClient.upload(`${prefix}import`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览批量更新药品编码(只读)
|
||||
*/
|
||||
export async function previewBatchDrugNumberApi(data: {
|
||||
items: { drug_name: string; drug_number: string }[];
|
||||
}) {
|
||||
return requestClient.post<any>(`${prefix}preview-batch-drug-number`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量更新编码:全量中药 id/名称/编号(卡片参考)
|
||||
*/
|
||||
export async function getAllDrugNumbersForBatchApi() {
|
||||
return requestClient.get<{
|
||||
items: {
|
||||
id: number;
|
||||
drug_name: string;
|
||||
drug_number: null | string;
|
||||
pinyin_simple: string;
|
||||
}[];
|
||||
}>(`${prefix}list-all-drug-numbers-for-batch`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认批量更新药品编码
|
||||
*/
|
||||
export async function applyBatchDrugNumberApi(data: {
|
||||
items: { id: number; drug_number: string }[];
|
||||
}) {
|
||||
return requestClient.post<any>(`${prefix}apply-batch-drug-number`, data);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,485 @@
|
||||
<script lang="ts" setup>
|
||||
import type { UploadFile } from 'ant-design-vue';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Input,
|
||||
message,
|
||||
Modal,
|
||||
Segmented,
|
||||
Spin,
|
||||
Table,
|
||||
Tag,
|
||||
UploadDragger,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
applyBatchDrugNumberApi,
|
||||
getAllDrugNumbersForBatchApi,
|
||||
previewBatchDrugNumberApi,
|
||||
} from '../api';
|
||||
import {
|
||||
isDrugNumberChanged,
|
||||
parseDrugNumberExcelBuffer,
|
||||
} from '../utils/parseDrugNumberExcel';
|
||||
|
||||
type MatchedRow = {
|
||||
id: number;
|
||||
drug_name: string;
|
||||
drug_number_old: string | null;
|
||||
drug_number_new: string;
|
||||
};
|
||||
|
||||
type MatchedRowView = MatchedRow & { _changed: boolean };
|
||||
|
||||
type AllDrugItem = {
|
||||
id: number;
|
||||
drug_name: string;
|
||||
drug_number: null | string;
|
||||
pinyin_simple?: string | null;
|
||||
};
|
||||
type CardStatusFilter = 'all' | 'changed' | 'unchanged';
|
||||
|
||||
const gridApi = ref();
|
||||
const fileList = ref<UploadFile[]>([]);
|
||||
const selectedFile = ref<File | null>(null);
|
||||
const matched = ref<MatchedRow[]>([]);
|
||||
const notFound = ref<{ drug_name: string; drug_number: string }[]>([]);
|
||||
const invalidRows = ref<{ row: number; drug_name: string; drug_number: string }[]>(
|
||||
[],
|
||||
);
|
||||
const previewLoading = ref(false);
|
||||
const applyLoading = ref(false);
|
||||
const parseHint = ref('');
|
||||
|
||||
const allDrugs = ref<AllDrugItem[]>([]);
|
||||
/** 打开弹窗时服务端原始编号,用于判断是否有变更 */
|
||||
const initialNumbers = ref<Record<number, string>>({});
|
||||
/** 当前编辑中的编号(卡片可改;导入解析会写入) */
|
||||
const localNumbers = ref<Record<number, string>>({});
|
||||
const loadingAll = ref(false);
|
||||
const cardFilter = ref('');
|
||||
const cardStatusFilter = ref<CardStatusFilter>('all');
|
||||
|
||||
const matchedView = computed<MatchedRowView[]>(() =>
|
||||
matched.value.map((row) => {
|
||||
const newNum =
|
||||
localNumbers.value[row.id] ?? String(row.drug_number_new ?? '');
|
||||
return {
|
||||
...row,
|
||||
drug_number_new: newNum,
|
||||
_changed: isDrugNumberChanged(initialNumbers.value[row.id], newNum),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const changedCount = computed(
|
||||
() => matchedView.value.filter((r) => r._changed).length,
|
||||
);
|
||||
const cardStatusCounts = computed(() => {
|
||||
const all = allDrugs.value.length;
|
||||
const changed = allDrugs.value.filter((d) => cardIsChanged(d)).length;
|
||||
return {
|
||||
all,
|
||||
changed,
|
||||
unchanged: all - changed,
|
||||
};
|
||||
});
|
||||
|
||||
/** 含卡片直接编辑:任意与初始值不同的条数 */
|
||||
const totalPendingChanges = computed(() =>
|
||||
allDrugs.value.filter((d) =>
|
||||
isDrugNumberChanged(
|
||||
initialNumbers.value[d.id],
|
||||
localNumbers.value[d.id],
|
||||
),
|
||||
).length,
|
||||
);
|
||||
|
||||
function cardIsChanged(d: AllDrugItem) {
|
||||
return isDrugNumberChanged(
|
||||
initialNumbers.value[d.id],
|
||||
localNumbers.value[d.id],
|
||||
);
|
||||
}
|
||||
|
||||
function setLocalNumber(id: number, v: string) {
|
||||
localNumbers.value = { ...localNumbers.value, [id]: v ?? '' };
|
||||
}
|
||||
|
||||
const filteredCards = computed(() => {
|
||||
const q = cardFilter.value.trim().toLowerCase();
|
||||
const keywordFiltered = allDrugs.value.filter((d) => {
|
||||
const nameOk = d.drug_name.toLowerCase().includes(q);
|
||||
const py = (d.pinyin_simple ?? '').toLowerCase();
|
||||
const pyOk = py.includes(q);
|
||||
return q ? nameOk || pyOk : true;
|
||||
});
|
||||
if (cardStatusFilter.value === 'changed') {
|
||||
return keywordFiltered.filter((d) => cardIsChanged(d));
|
||||
}
|
||||
if (cardStatusFilter.value === 'unchanged') {
|
||||
return keywordFiltered.filter((d) => !cardIsChanged(d));
|
||||
}
|
||||
return keywordFiltered;
|
||||
});
|
||||
|
||||
const columns = [
|
||||
{ title: 'ID', dataIndex: 'id', key: 'id', width: 72 },
|
||||
{ title: '药品名称', dataIndex: 'drug_name', key: 'drug_name', ellipsis: true },
|
||||
{
|
||||
title: '当前编号',
|
||||
dataIndex: 'drug_number_old',
|
||||
key: 'drug_number_old',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '新编号',
|
||||
dataIndex: 'drug_number_new',
|
||||
key: 'drug_number_new',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: '_changed',
|
||||
key: 'status',
|
||||
width: 88,
|
||||
},
|
||||
];
|
||||
|
||||
function resetState() {
|
||||
selectedFile.value = null;
|
||||
fileList.value = [];
|
||||
matched.value = [];
|
||||
notFound.value = [];
|
||||
invalidRows.value = [];
|
||||
parseHint.value = '';
|
||||
cardFilter.value = '';
|
||||
cardStatusFilter.value = 'all';
|
||||
allDrugs.value = [];
|
||||
initialNumbers.value = {};
|
||||
localNumbers.value = {};
|
||||
}
|
||||
|
||||
const [ModalComp, modalApi] = useVbenModal({
|
||||
fullscreenButton: false,
|
||||
draggable: true,
|
||||
showConfirmButton: false,
|
||||
class: '!w-[min(960px,92vw)]',
|
||||
onCancel() {
|
||||
resetState();
|
||||
modalApi.close();
|
||||
},
|
||||
onOpenChange(isOpen: boolean) {
|
||||
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;
|
||||
if (isOpen) {
|
||||
loadAllDrugs();
|
||||
} else {
|
||||
resetState();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
async function loadAllDrugs() {
|
||||
loadingAll.value = true;
|
||||
try {
|
||||
const res = await getAllDrugNumbersForBatchApi();
|
||||
const items = res?.items ?? [];
|
||||
allDrugs.value = items;
|
||||
const init: Record<number, string> = {};
|
||||
const loc: Record<number, string> = {};
|
||||
for (const d of items) {
|
||||
const s = d.drug_number ?? '';
|
||||
init[d.id] = s;
|
||||
loc[d.id] = s;
|
||||
}
|
||||
initialNumbers.value = init;
|
||||
localNumbers.value = loc;
|
||||
} catch {
|
||||
message.error('加载中药列表失败');
|
||||
allDrugs.value = [];
|
||||
initialNumbers.value = {};
|
||||
localNumbers.value = {};
|
||||
} finally {
|
||||
loadingAll.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileChange = (info: { file: UploadFile }) => {
|
||||
const { file } = info;
|
||||
if (file.status === 'removed') {
|
||||
selectedFile.value = null;
|
||||
matched.value = [];
|
||||
notFound.value = [];
|
||||
invalidRows.value = [];
|
||||
parseHint.value = '';
|
||||
return;
|
||||
}
|
||||
const raw = file.originFileObj ?? file;
|
||||
if (raw instanceof File) {
|
||||
selectedFile.value = raw;
|
||||
}
|
||||
};
|
||||
|
||||
function rowClassName(record: MatchedRowView) {
|
||||
return record._changed ? 'drug-batch-row-changed' : 'drug-batch-row-unchanged';
|
||||
}
|
||||
|
||||
async function runPreview() {
|
||||
if (!selectedFile.value) {
|
||||
message.warning('请先选择 Excel 文件');
|
||||
return;
|
||||
}
|
||||
previewLoading.value = true;
|
||||
try {
|
||||
const buf = await selectedFile.value.arrayBuffer();
|
||||
const { items, invalidCount, rowCount } = parseDrugNumberExcelBuffer(buf);
|
||||
if (items.length === 0) {
|
||||
message.warning('未解析到有效的「药品编号」与「药品名称」行');
|
||||
matched.value = [];
|
||||
notFound.value = [];
|
||||
invalidRows.value = [];
|
||||
parseHint.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
parseHint.value =
|
||||
rowCount > 5000
|
||||
? `共 ${rowCount} 行,已按上限处理前 5000 行。`
|
||||
: `共解析 ${items.length} 条有效行。`;
|
||||
|
||||
const res = await previewBatchDrugNumberApi({ items });
|
||||
matched.value = (res.matched || []) as MatchedRow[];
|
||||
notFound.value = res.not_found || [];
|
||||
invalidRows.value = res.invalid || [];
|
||||
for (const m of matched.value) {
|
||||
const v = String(m.drug_number_new ?? '');
|
||||
localNumbers.value = { ...localNumbers.value, [m.id]: v };
|
||||
}
|
||||
if (invalidCount > 0) {
|
||||
message.info(`Excel 中有 ${invalidCount} 行因缺少编号或名称被跳过`);
|
||||
}
|
||||
if ((notFound.value?.length || 0) > 0) {
|
||||
Modal.warning({
|
||||
title: '部分药品名称在系统中未找到',
|
||||
content: `未匹配 ${notFound.value.length} 条,请核对药名是否与后台完全一致。`,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
message.error('预览失败');
|
||||
} finally {
|
||||
previewLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function runApply() {
|
||||
const items = allDrugs.value
|
||||
.filter((d) =>
|
||||
isDrugNumberChanged(
|
||||
initialNumbers.value[d.id],
|
||||
localNumbers.value[d.id],
|
||||
),
|
||||
)
|
||||
.map((d) => ({
|
||||
id: d.id,
|
||||
drug_number: String(localNumbers.value[d.id] ?? ''),
|
||||
}));
|
||||
if (items.length === 0) {
|
||||
message.info('没有需要更新的编号(与打开弹窗时一致)');
|
||||
return;
|
||||
}
|
||||
|
||||
applyLoading.value = true;
|
||||
modalApi.setState({ loading: true });
|
||||
try {
|
||||
const res = await applyBatchDrugNumberApi({ items });
|
||||
const n = res?.updated_count ?? res?.updatedCount ?? 0;
|
||||
message.success(`已更新 ${n} 条记录`);
|
||||
gridApi.value?.reload();
|
||||
resetState();
|
||||
modalApi.close();
|
||||
} catch {
|
||||
message.error('更新失败');
|
||||
} finally {
|
||||
applyLoading.value = false;
|
||||
modalApi.setState({ loading: false });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalComp title="批量更新药品编码">
|
||||
<div class="mb-3 text-sm text-gray-600 dark:text-gray-300">
|
||||
下方卡片可直接修改编号;亦可使用 Excel:首列表头为「药品编号/药品编码」、第二列「药品名称」,或与之一致的列顺序。仅更新编号与更新时间。
|
||||
</div>
|
||||
|
||||
<Spin :spinning="loadingAll">
|
||||
<div class="mb-1 text-sm font-medium text-gray-800 dark:text-gray-100">
|
||||
当前中药参考
|
||||
<Tag>{{ filteredCards.length }}</Tag>
|
||||
条
|
||||
</div>
|
||||
<Input
|
||||
v-model:value="cardFilter"
|
||||
allow-clear
|
||||
class="mb-2"
|
||||
placeholder="按药品名称或拼音首拼筛选"
|
||||
/>
|
||||
<Segmented
|
||||
v-model:value="cardStatusFilter"
|
||||
class="mb-2"
|
||||
:options="[
|
||||
{ label: `全部 ${cardStatusCounts.all}`, value: 'all' },
|
||||
{ label: `有变动 ${cardStatusCounts.changed}`, value: 'changed' },
|
||||
{
|
||||
label: `无变动 ${cardStatusCounts.unchanged}`,
|
||||
value: 'unchanged',
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<div
|
||||
class="mb-4 grid max-h-56 grid-cols-2 gap-2 overflow-y-auto sm:grid-cols-3 md:grid-cols-4"
|
||||
>
|
||||
<Card
|
||||
v-for="d in filteredCards"
|
||||
:key="d.id"
|
||||
:class="[
|
||||
'!mb-0 shadow-sm transition-colors',
|
||||
cardIsChanged(d)
|
||||
? '!border-amber-400 bg-amber-50 dark:!border-amber-500/70 dark:bg-amber-950/45'
|
||||
: '',
|
||||
]"
|
||||
size="small"
|
||||
>
|
||||
<div
|
||||
class="truncate text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||
:title="d.drug_name"
|
||||
>
|
||||
{{ d.drug_name }}
|
||||
</div>
|
||||
<Input
|
||||
class="mt-1"
|
||||
placeholder="药品编号"
|
||||
size="small"
|
||||
:value="localNumbers[d.id] ?? ''"
|
||||
@update:value="(v: string) => setLocalNumber(d.id, v)"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</Spin>
|
||||
|
||||
<UploadDragger
|
||||
v-model:file-list="fileList"
|
||||
:before-upload="() => false"
|
||||
:max-count="1"
|
||||
accept=".xlsx,.xls"
|
||||
name="file"
|
||||
@change="handleFileChange"
|
||||
>
|
||||
<p class="ant-upload-text">点击或拖拽选择 Excel</p>
|
||||
<p class="ant-upload-hint">选择后点击「导入并解析」从服务器比对当前编号</p>
|
||||
</UploadDragger>
|
||||
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<Button
|
||||
:loading="previewLoading"
|
||||
type="primary"
|
||||
@click="runPreview"
|
||||
>
|
||||
导入并解析
|
||||
</Button>
|
||||
<Button
|
||||
:disabled="totalPendingChanges === 0"
|
||||
:loading="applyLoading"
|
||||
danger
|
||||
type="primary"
|
||||
@click="runApply"
|
||||
>
|
||||
确认更新到数据库
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p v-if="parseHint" class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ parseHint }}
|
||||
</p>
|
||||
|
||||
<div class="mt-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
待保存变更:
|
||||
<Tag :color="totalPendingChanges > 0 ? 'orange' : 'default'">{{
|
||||
totalPendingChanges
|
||||
}}</Tag>
|
||||
条(含卡片修改与导入)
|
||||
</div>
|
||||
|
||||
<div v-if="matched.length > 0" class="mt-4">
|
||||
<div class="mb-2 text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
本次 Excel 匹配
|
||||
<Tag color="blue">{{ matched.length }}</Tag>
|
||||
条,其中
|
||||
<Tag v-if="changedCount > 0" color="orange">{{ changedCount }}</Tag>
|
||||
<Tag v-else color="default">0</Tag>
|
||||
条与初始编号不同
|
||||
</div>
|
||||
<Table
|
||||
:columns="columns"
|
||||
:data-source="matchedView"
|
||||
:pagination="{ pageSize: 8 }"
|
||||
:row-class-name="(record: MatchedRowView) => rowClassName(record)"
|
||||
:row-key="(row: MatchedRowView, index: number) => `${row.id}-${index}`"
|
||||
:scroll="{ y: 280 }"
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'status'">
|
||||
<Tag v-if="record._changed" color="orange">已变更</Tag>
|
||||
<Tag v-else color="default">无变化</Tag>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div v-if="notFound.length > 0" class="mt-4">
|
||||
<div class="mb-1 text-sm font-medium text-orange-600 dark:text-orange-400">
|
||||
未找到对应中药({{ notFound.length }})
|
||||
</div>
|
||||
<div
|
||||
class="max-h-32 overflow-auto rounded border border-orange-100 bg-orange-50 p-2 text-xs text-gray-800 dark:border-orange-900/60 dark:bg-orange-950/40 dark:text-gray-200"
|
||||
>
|
||||
<div v-for="(row, i) in notFound.slice(0, 50)" :key="i">
|
||||
{{ row.drug_name }} — {{ row.drug_number }}
|
||||
</div>
|
||||
<div v-if="notFound.length > 50">… 其余 {{ notFound.length - 50 }} 条略</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="invalidRows.length > 0"
|
||||
class="mt-2 text-xs text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
服务端标记无效行:{{ invalidRows.length }} 条
|
||||
</div>
|
||||
</ModalComp>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.drug-batch-row-changed) > td {
|
||||
background-color: rgb(254 252 232) !important;
|
||||
}
|
||||
|
||||
:deep(.dark .drug-batch-row-changed) > td {
|
||||
background-color: rgb(69 26 3 / 0.45) !important;
|
||||
}
|
||||
|
||||
:deep(.drug-batch-row-unchanged) > td {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
:deep(.dark .drug-batch-row-unchanged) > td {
|
||||
opacity: 0.92;
|
||||
}
|
||||
</style>
|
||||
@@ -25,6 +25,7 @@ export const gridOptions: VxeGridProps<RowType> = {
|
||||
{ type: 'checkbox', width: 60 },
|
||||
{ field: 'id', align: 'left', title: 'ID', width: 100 },
|
||||
{ field: 'drug_name', align: 'left', title: '药品名称' },
|
||||
{ field: 'drug_number', align: 'left', title: '药品编码(erp)' },
|
||||
{ field: 'pinyin_simple', title: '拼音' },
|
||||
{ field: 'status', title: '状态', slots: { default: 'status' } },
|
||||
{ field: 'created_at', title: '发布时间' },
|
||||
|
||||
@@ -12,6 +12,7 @@ import { TableAction } from '#/components/table-action';
|
||||
import { downloadByData } from '#/util/tool';
|
||||
|
||||
import { deleteChinaMedicine, exportChinaMedicineApi } from './api';
|
||||
import DrugNumberBatchModal from './components/DrugNumberBatchModal.vue';
|
||||
import FormModalDemo from './components/modal.vue';
|
||||
import ExcelUpload from './components/ExcelUpload.vue';
|
||||
import { formOptions } from './config/search';
|
||||
@@ -46,6 +47,10 @@ const [ExcelUploadModal, ExcelUploadModalApi] = useVbenModal({
|
||||
connectedComponent: ExcelUpload,
|
||||
});
|
||||
|
||||
const [DrugNumberBatchModalComp, drugNumberBatchModalApi] = useVbenModal({
|
||||
connectedComponent: DrugNumberBatchModal,
|
||||
});
|
||||
|
||||
const showModal = (data = {}, isUpdate = false) => {
|
||||
formModalApi.setData({
|
||||
// 表单值
|
||||
@@ -86,11 +91,19 @@ const openExcelUploadModal = () => {
|
||||
});
|
||||
ExcelUploadModalApi.open();
|
||||
};
|
||||
|
||||
const openDrugNumberBatchModal = () => {
|
||||
drugNumberBatchModalApi.setData({
|
||||
gridApi,
|
||||
});
|
||||
drugNumberBatchModalApi.open();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height title="中药管理">
|
||||
<ExcelUploadModal />
|
||||
<DrugNumberBatchModalComp />
|
||||
<FormModal />
|
||||
<Grid>
|
||||
<template #toolbar-buttons>
|
||||
@@ -110,6 +123,12 @@ const openExcelUploadModal = () => {
|
||||
// auth: ['超级中药', 'sys:user:save'],
|
||||
onClick: openExcelUploadModal.bind(null),
|
||||
},
|
||||
{
|
||||
label: '批量更新编码',
|
||||
type: 'default',
|
||||
icon: 'ant-design:barcode-outlined',
|
||||
onClick: openDrugNumberBatchModal.bind(null),
|
||||
},
|
||||
{
|
||||
label: '导出',
|
||||
type: 'primary',
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
const MAX_ROWS = 5000;
|
||||
|
||||
export type ParsedDrugNumberRow = {
|
||||
drug_name: string;
|
||||
drug_number: string;
|
||||
};
|
||||
|
||||
function normalizeCell(value: unknown): string {
|
||||
if (value === null || value === undefined) {
|
||||
return '';
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return Number.isInteger(value) ? String(value) : String(value).trim();
|
||||
}
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
/** 表头含空白或全角空格时仍能匹配 */
|
||||
function pickByHeaderAliases(
|
||||
row: Record<string, unknown>,
|
||||
aliases: string[],
|
||||
): string {
|
||||
for (const key of Object.keys(row)) {
|
||||
const k = key.replace(/\s+/g, '').replace(/\u3000/g, '');
|
||||
for (const alias of aliases) {
|
||||
if (k === alias.replace(/\s+/g, '').replace(/\u3000/g, '')) {
|
||||
return normalizeCell(row[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function parseRowsByObjectKeys(
|
||||
rows: Record<string, unknown>[],
|
||||
): { items: ParsedDrugNumberRow[]; invalidCount: number } {
|
||||
const items: ParsedDrugNumberRow[] = [];
|
||||
let invalidCount = 0;
|
||||
const n = Math.min(rows.length, MAX_ROWS);
|
||||
|
||||
const numberAliases = [
|
||||
'药品编号',
|
||||
'药品编码',
|
||||
'编号',
|
||||
'drug_number',
|
||||
'编码',
|
||||
];
|
||||
const nameAliases = ['药品名称', '药名', 'drug_name', '名称'];
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const row = rows[i];
|
||||
if (!row || typeof row !== 'object') {
|
||||
invalidCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
let drugNumber =
|
||||
pickByHeaderAliases(row, numberAliases) ||
|
||||
normalizeCell(row['药品编号']) ||
|
||||
normalizeCell(row['药品编码']) ||
|
||||
normalizeCell(row['编号']) ||
|
||||
normalizeCell(row['drug_number']);
|
||||
|
||||
let drugName =
|
||||
pickByHeaderAliases(row, nameAliases) ||
|
||||
normalizeCell(row['药品名称']) ||
|
||||
normalizeCell(row['药名']) ||
|
||||
normalizeCell(row['drug_name']);
|
||||
|
||||
if (!drugName || !drugNumber) {
|
||||
const empty = !Object.values(row).some(
|
||||
(v) => normalizeCell(v) !== '',
|
||||
);
|
||||
if (empty) {
|
||||
continue;
|
||||
}
|
||||
invalidCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
items.push({
|
||||
drug_name: drugName,
|
||||
drug_number: drugNumber,
|
||||
});
|
||||
}
|
||||
|
||||
return { items, invalidCount };
|
||||
}
|
||||
|
||||
/**
|
||||
* 标准「药品编码」表:第 1 列为编号、第 2 列为名称(表头行自动跳过)
|
||||
*/
|
||||
function parseRowsByPosition(sheet: XLSX.WorkSheet): {
|
||||
items: ParsedDrugNumberRow[];
|
||||
invalidCount: number;
|
||||
rowCount: number;
|
||||
} {
|
||||
const aoa = XLSX.utils.sheet_to_json(sheet, {
|
||||
header: 1,
|
||||
defval: '',
|
||||
}) as unknown[][];
|
||||
|
||||
const items: ParsedDrugNumberRow[] = [];
|
||||
let invalidCount = 0;
|
||||
if (aoa.length === 0) {
|
||||
return { items: [], invalidCount: 0, rowCount: 0 };
|
||||
}
|
||||
|
||||
let start = 0;
|
||||
const h0 = normalizeCell(aoa[0]?.[0]);
|
||||
const h1 = normalizeCell(aoa[0]?.[1]);
|
||||
if (
|
||||
h0 &&
|
||||
(h0.includes('编号') ||
|
||||
h0.includes('编码') ||
|
||||
h0.includes('代码') ||
|
||||
/药品/.test(h0))
|
||||
) {
|
||||
start = 1;
|
||||
}
|
||||
if (
|
||||
h1 &&
|
||||
(h1.includes('名称') || h1.includes('药名') || h1.includes('品名'))
|
||||
) {
|
||||
start = 1;
|
||||
}
|
||||
|
||||
const limit = Math.min(aoa.length, start + MAX_ROWS);
|
||||
for (let i = start; i < limit; i++) {
|
||||
const row = aoa[i];
|
||||
if (!row?.length) {
|
||||
continue;
|
||||
}
|
||||
const drugNumber = normalizeCell(row[0]);
|
||||
const drugName = normalizeCell(row[1]);
|
||||
if (!drugName || !drugNumber) {
|
||||
const anyCell = row.some((c) => normalizeCell(c) !== '');
|
||||
if (anyCell) {
|
||||
invalidCount++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
items.push({ drug_name: drugName, drug_number: drugNumber });
|
||||
}
|
||||
|
||||
return { items, invalidCount, rowCount: aoa.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* 从第一张工作表解析「药品编号」「药品名称」列(兼容表头别名 + 列位置回退)
|
||||
*/
|
||||
export function parseDrugNumberExcelBuffer(buffer: ArrayBuffer): {
|
||||
items: ParsedDrugNumberRow[];
|
||||
invalidCount: number;
|
||||
rowCount: number;
|
||||
} {
|
||||
const workbook = XLSX.read(buffer, { type: 'array' });
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
if (!sheetName) {
|
||||
return { items: [], invalidCount: 0, rowCount: 0 };
|
||||
}
|
||||
const sheet = workbook.Sheets[sheetName];
|
||||
if (!sheet) {
|
||||
return { items: [], invalidCount: 0, rowCount: 0 };
|
||||
}
|
||||
|
||||
const rows = XLSX.utils.sheet_to_json<Record<string, unknown>>(sheet, {
|
||||
defval: '',
|
||||
});
|
||||
|
||||
let { items, invalidCount } = parseRowsByObjectKeys(rows);
|
||||
let rowCount = rows.length;
|
||||
|
||||
if (items.length === 0) {
|
||||
const pos = parseRowsByPosition(sheet);
|
||||
items = pos.items;
|
||||
invalidCount = pos.invalidCount;
|
||||
rowCount = pos.rowCount;
|
||||
}
|
||||
|
||||
return {
|
||||
items,
|
||||
invalidCount,
|
||||
rowCount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 与后端 `normalizeDrugNumberForBatch` 对齐,用于对比是否变更
|
||||
*/
|
||||
export function normalizeDrugNumberForCompare(value: unknown): string {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '';
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return '';
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
if (Number.isInteger(value) || Math.floor(value) === value) {
|
||||
return String(Math.trunc(value));
|
||||
}
|
||||
return String(value).trim();
|
||||
}
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
export function isDrugNumberChanged(
|
||||
oldVal: unknown,
|
||||
newVal: unknown,
|
||||
): boolean {
|
||||
return normalizeDrugNumberForCompare(oldVal) !== normalizeDrugNumberForCompare(newVal);
|
||||
}
|
||||
@@ -221,10 +221,10 @@ function getDrugUseListByWesternModal() {
|
||||
|
||||
getDrugUseListByWesternModal();
|
||||
|
||||
const selectProductId = ref(0);
|
||||
const selectProductIndex = ref(-1);
|
||||
|
||||
function selectProductChange(id) {
|
||||
selectProductId.value = id;
|
||||
selectProductIndex.value = id;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -232,16 +232,18 @@ function selectProductChange(id) {
|
||||
* @param id
|
||||
*/
|
||||
function selectDrugUseWayChange(id) {
|
||||
currentDrugs.value = currentDrugs.value.map((item) =>
|
||||
// 修改item下面drug的 frequency_id = id
|
||||
item.index_id === selectProductId.value
|
||||
? {
|
||||
...item,
|
||||
way_id: id,
|
||||
use_ways: drugUseWay.value.find((value) => value.id === id),
|
||||
}
|
||||
: item,
|
||||
);
|
||||
if (
|
||||
selectProductIndex.value < 0 ||
|
||||
selectProductIndex.value >= currentDrugs.value.length
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const targetDrug = currentDrugs.value[selectProductIndex.value];
|
||||
if (!targetDrug) {
|
||||
return;
|
||||
}
|
||||
targetDrug.way_id = id;
|
||||
targetDrug.use_ways = drugUseWay.value.find((value) => value.id === id);
|
||||
updateLocalStorage();
|
||||
}
|
||||
|
||||
@@ -1945,7 +1947,7 @@ watch(
|
||||
<Row>
|
||||
<Col
|
||||
v-for="(drug, index) in currentDrugs"
|
||||
:key="drug.id"
|
||||
:key="`${drug.index_id || drug.id}-${index}`"
|
||||
:lg="12"
|
||||
:md="24"
|
||||
:sm="24"
|
||||
@@ -2000,7 +2002,7 @@ watch(
|
||||
placeholder="用法"
|
||||
@change="selectDrugUseWayChange"
|
||||
@dropdown-visible-change="
|
||||
selectProductChange(drug.index_id)
|
||||
selectProductChange(index)
|
||||
"
|
||||
>
|
||||
<SelectOption :value="0">煎服</SelectOption>
|
||||
|
||||
Reference in New Issue
Block a user