1. 加工费模块重构
Some checks failed
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
Close stale issues / stale (push) Has been cancelled
Some checks failed
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
Close stale issues / stale (push) Has been cancelled
2. 快递费模块重构 3. 订单导出功能优化 4. 商品订单的折扣功能 5. 修复了一些已知BUG
This commit is contained in:
@@ -103,3 +103,47 @@ export async function importWarehouseDrugManagementApi(data: Record<string, any>
|
||||
export async function importUpdatePriceApi(data: Record<string, any>) {
|
||||
return requestClient.upload(`${prefix}import-update-price`, data);
|
||||
}
|
||||
|
||||
export interface WarehouseBatchPriceItem {
|
||||
id: number;
|
||||
drug_id: number;
|
||||
drug_name: string;
|
||||
drug_number: string;
|
||||
pinyin_simple: string;
|
||||
market_price: number | string;
|
||||
price: number | string;
|
||||
}
|
||||
|
||||
/** 批量改价预览:全量仓库药品 */
|
||||
export async function getAllForBatchPriceApi(params: { type: number }) {
|
||||
return requestClient.get<{ items: WarehouseBatchPriceItem[] }>(
|
||||
`${prefix}list-all-for-batch-price`,
|
||||
{ params },
|
||||
);
|
||||
}
|
||||
|
||||
export interface BatchPriceUpdateItem {
|
||||
id: number;
|
||||
market_price: number;
|
||||
price: number;
|
||||
}
|
||||
|
||||
export interface BatchPriceCreateItem {
|
||||
drug_name: string;
|
||||
drug_number: string;
|
||||
market_price: number;
|
||||
price: number;
|
||||
}
|
||||
|
||||
/** 批量改价预览:应用更新与新增 */
|
||||
export async function applyBatchPriceApi(data: {
|
||||
type: number;
|
||||
updates?: BatchPriceUpdateItem[];
|
||||
creates?: BatchPriceCreateItem[];
|
||||
notify?: boolean;
|
||||
}) {
|
||||
return requestClient.post<{
|
||||
update_count: number;
|
||||
new_count: number;
|
||||
}>(`${prefix}apply-batch-price`, data);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,782 @@
|
||||
<script lang="ts" setup>
|
||||
import type { UploadFile } from 'ant-design-vue';
|
||||
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import {
|
||||
Button,
|
||||
message,
|
||||
Modal,
|
||||
Spin,
|
||||
Tag,
|
||||
UploadDragger,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import { downloadByData } from '#/util/tool';
|
||||
|
||||
import {
|
||||
applyBatchPriceApi,
|
||||
getAllForBatchPriceApi,
|
||||
type WarehouseBatchPriceItem,
|
||||
} from '../api';
|
||||
import { exportWarehousePriceExcel } from '../utils/exportWarehousePriceExcel';
|
||||
import { parseWarehousePriceExcelBuffer } from '../utils/parseWarehousePriceExcel';
|
||||
import {
|
||||
getPriceDelta,
|
||||
isFieldChanged,
|
||||
isMarketGtPrice,
|
||||
isPriceChanged,
|
||||
normalizePricePair,
|
||||
parsePriceValue,
|
||||
type PricePair,
|
||||
} from '../utils/priceCompare';
|
||||
import PriceCompareCardGrid, {
|
||||
type PriceCompareFilterTab,
|
||||
type PriceCompareRow,
|
||||
type UnmatchedRow,
|
||||
} from '../../shared/components/PriceCompareCardGrid.vue';
|
||||
|
||||
const gridApi = ref();
|
||||
const productType = ref(1);
|
||||
const fileList = ref<UploadFile[]>([]);
|
||||
const selectedFile = ref<File | null>(null);
|
||||
const loadingAll = ref(false);
|
||||
const parseLoading = ref(false);
|
||||
const applyLoading = ref(false);
|
||||
const savingRowId = ref<number | null>(null);
|
||||
const parseHint = ref('');
|
||||
const parsedReady = ref(false);
|
||||
const filterTab = ref<PriceCompareFilterTab>('all');
|
||||
|
||||
const warehouseItems = ref<WarehouseBatchPriceItem[]>([]);
|
||||
const initialSnapshot = ref<Record<number, PricePair>>({});
|
||||
const localPrices = ref<Record<number, PricePair>>({});
|
||||
const newRows = ref<
|
||||
Array<{
|
||||
tempId: number;
|
||||
drug_name: string;
|
||||
drug_number: string;
|
||||
market_price: number;
|
||||
price: number;
|
||||
}>
|
||||
>([]);
|
||||
const unmatchedRows = ref<UnmatchedRow[]>([]);
|
||||
const excelTouchedIds = ref<Set<number>>(new Set());
|
||||
let tempIdCounter = -1;
|
||||
let loadAllPromise: Promise<void> | null = null;
|
||||
|
||||
function buildSnapshot(items: WarehouseBatchPriceItem[]) {
|
||||
const init: Record<number, PricePair> = {};
|
||||
const loc: Record<number, PricePair> = {};
|
||||
for (const item of items) {
|
||||
const pair = normalizePricePair({
|
||||
market_price: parsePriceValue(item.market_price),
|
||||
price: parsePriceValue(item.price),
|
||||
});
|
||||
init[item.id] = { ...pair };
|
||||
loc[item.id] = { ...pair };
|
||||
}
|
||||
initialSnapshot.value = init;
|
||||
localPrices.value = loc;
|
||||
}
|
||||
|
||||
function resetState() {
|
||||
selectedFile.value = null;
|
||||
fileList.value = [];
|
||||
warehouseItems.value = [];
|
||||
initialSnapshot.value = {};
|
||||
localPrices.value = {};
|
||||
newRows.value = [];
|
||||
unmatchedRows.value = [];
|
||||
excelTouchedIds.value = new Set();
|
||||
parseHint.value = '';
|
||||
parsedReady.value = false;
|
||||
filterTab.value = 'all';
|
||||
tempIdCounter = -1;
|
||||
}
|
||||
|
||||
function buildCompareRow(
|
||||
item: {
|
||||
id: number;
|
||||
drug_id?: number;
|
||||
drug_name: string;
|
||||
drug_number: string;
|
||||
pinyin_simple?: string;
|
||||
},
|
||||
current: PricePair,
|
||||
orig: PricePair,
|
||||
isNew: boolean,
|
||||
): PriceCompareRow {
|
||||
return {
|
||||
id: item.id,
|
||||
drug_id: item.drug_id,
|
||||
drug_name: item.drug_name,
|
||||
drug_number: item.drug_number,
|
||||
pinyin_simple: item.pinyin_simple,
|
||||
orig_market_price: orig.market_price,
|
||||
orig_price: orig.price,
|
||||
market_price: current.market_price,
|
||||
price: current.price,
|
||||
_marketDelta: getPriceDelta(orig.market_price, current.market_price),
|
||||
_priceDelta: getPriceDelta(orig.price, current.price),
|
||||
_marketChanged: isFieldChanged(orig.market_price, current.market_price),
|
||||
_priceChanged: isFieldChanged(orig.price, current.price),
|
||||
_changed: isNew || isPriceChanged(orig, current),
|
||||
_marketGtPrice: isMarketGtPrice(current),
|
||||
_isNew: isNew,
|
||||
_fromExcel: isNew ? true : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const compareRows = computed<PriceCompareRow[]>(() => {
|
||||
const existingRows: PriceCompareRow[] = warehouseItems.value
|
||||
.filter((item) => excelTouchedIds.value.has(item.id))
|
||||
.map((item) => {
|
||||
const current = localPrices.value[item.id] ?? normalizePricePair({
|
||||
market_price: parsePriceValue(item.market_price),
|
||||
price: parsePriceValue(item.price),
|
||||
});
|
||||
const orig = initialSnapshot.value[item.id] ?? current;
|
||||
return buildCompareRow(item, current, orig, false);
|
||||
});
|
||||
|
||||
const pendingNewRows: PriceCompareRow[] = newRows.value.map((row) =>
|
||||
buildCompareRow(
|
||||
{
|
||||
id: row.tempId,
|
||||
drug_name: row.drug_name,
|
||||
drug_number: row.drug_number,
|
||||
},
|
||||
{ market_price: row.market_price, price: row.price },
|
||||
{ market_price: 0, price: 0 },
|
||||
true,
|
||||
),
|
||||
);
|
||||
|
||||
return [...existingRows, ...pendingNewRows];
|
||||
});
|
||||
|
||||
const changedCount = computed(
|
||||
() => compareRows.value.filter((r) => r._changed).length,
|
||||
);
|
||||
const newCount = computed(() => newRows.value.length);
|
||||
|
||||
function restoreLocalPricesFromSnapshot() {
|
||||
const loc: Record<number, PricePair> = {};
|
||||
for (const [id, pair] of Object.entries(initialSnapshot.value)) {
|
||||
loc[Number(id)] = { ...pair };
|
||||
}
|
||||
localPrices.value = loc;
|
||||
}
|
||||
|
||||
function clearParseResult() {
|
||||
newRows.value = [];
|
||||
unmatchedRows.value = [];
|
||||
excelTouchedIds.value = new Set();
|
||||
parseHint.value = '';
|
||||
parsedReady.value = false;
|
||||
restoreLocalPricesFromSnapshot();
|
||||
}
|
||||
|
||||
const [ModalComp, modalApi] = useVbenModal({
|
||||
fullscreen: true,
|
||||
fullscreenButton: true,
|
||||
draggable: true,
|
||||
confirmText: '批量确认更新',
|
||||
cancelText: '关闭',
|
||||
showConfirmButton: true,
|
||||
onConfirm: runBatchApply,
|
||||
onCancel() {
|
||||
resetState();
|
||||
modalApi.close();
|
||||
},
|
||||
onOpenChange(isOpen: boolean) {
|
||||
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;
|
||||
productType.value = isOpen ? modalApi.getData()?.productType ?? 1 : 1;
|
||||
if (isOpen) {
|
||||
modalApi.setState({ fullscreen: true });
|
||||
syncModalFooterState();
|
||||
loadAllDrugs();
|
||||
} else {
|
||||
resetState();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function syncModalFooterState() {
|
||||
modalApi.setState({
|
||||
confirmDisabled:
|
||||
!parsedReady.value ||
|
||||
(changedCount.value === 0 && newCount.value === 0),
|
||||
confirmLoading: applyLoading.value,
|
||||
});
|
||||
}
|
||||
|
||||
watch(
|
||||
[changedCount, newCount, applyLoading, parseLoading, parsedReady],
|
||||
syncModalFooterState,
|
||||
);
|
||||
|
||||
async function loadAllDrugs() {
|
||||
loadingAll.value = true;
|
||||
const task = (async () => {
|
||||
try {
|
||||
const res = await getAllForBatchPriceApi({ type: productType.value });
|
||||
warehouseItems.value = res?.items ?? [];
|
||||
buildSnapshot(warehouseItems.value);
|
||||
} catch {
|
||||
message.error('加载仓库药品失败');
|
||||
warehouseItems.value = [];
|
||||
initialSnapshot.value = {};
|
||||
localPrices.value = {};
|
||||
} finally {
|
||||
loadingAll.value = false;
|
||||
loadAllPromise = null;
|
||||
}
|
||||
})();
|
||||
loadAllPromise = task;
|
||||
await task;
|
||||
}
|
||||
|
||||
async function handleFileChange(info: { file: UploadFile }) {
|
||||
const { file } = info;
|
||||
if (file.status === 'removed') {
|
||||
selectedFile.value = null;
|
||||
clearParseResult();
|
||||
filterTab.value = 'all';
|
||||
return;
|
||||
}
|
||||
const raw = file.originFileObj ?? file;
|
||||
if (raw instanceof File) {
|
||||
selectedFile.value = raw;
|
||||
await runParse();
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadTemplate() {
|
||||
if (warehouseItems.value.length === 0) {
|
||||
message.warning('暂无药品数据');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const buffer = await exportWarehousePriceExcel(warehouseItems.value);
|
||||
downloadByData(
|
||||
buffer,
|
||||
'萧康云医-中药仓库改价模板.xlsx',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
);
|
||||
message.success('模板已下载');
|
||||
} catch {
|
||||
message.error('模板下载失败');
|
||||
}
|
||||
}
|
||||
|
||||
function findWarehouseByNumber(number: string) {
|
||||
const n = number.trim();
|
||||
if (!n) {
|
||||
return undefined;
|
||||
}
|
||||
return warehouseItems.value.find((d) => d.drug_number === n);
|
||||
}
|
||||
|
||||
function findWarehouseByName(name: string) {
|
||||
const n = name.trim();
|
||||
if (!n) {
|
||||
return undefined;
|
||||
}
|
||||
return warehouseItems.value.find((d) => d.drug_name === n);
|
||||
}
|
||||
|
||||
async function runParse() {
|
||||
if (!selectedFile.value) {
|
||||
return;
|
||||
}
|
||||
if (loadingAll.value && loadAllPromise) {
|
||||
await loadAllPromise;
|
||||
}
|
||||
parseLoading.value = true;
|
||||
syncModalFooterState();
|
||||
try {
|
||||
const buf = await selectedFile.value.arrayBuffer();
|
||||
const { items, invalidCount, rowCount } =
|
||||
await parseWarehousePriceExcelBuffer(buf);
|
||||
|
||||
if (items.length === 0) {
|
||||
message.warning('未解析到有效的价格行');
|
||||
return;
|
||||
}
|
||||
|
||||
parseHint.value =
|
||||
rowCount > 5000
|
||||
? `共 ${rowCount} 行,已按上限处理前 5000 行。`
|
||||
: `共解析 ${items.length} 条有效行。`;
|
||||
|
||||
const nextUnmatched: UnmatchedRow[] = [];
|
||||
const nextNew: typeof newRows.value = [];
|
||||
const nextLocal = { ...localPrices.value };
|
||||
const nextTouched = new Set<number>();
|
||||
let changedExistingCount = 0;
|
||||
|
||||
for (const row of items) {
|
||||
let matched = findWarehouseByNumber(row.drug_number);
|
||||
if (!matched) {
|
||||
matched = findWarehouseByName(row.drug_name);
|
||||
}
|
||||
|
||||
if (matched) {
|
||||
nextTouched.add(matched.id);
|
||||
const orig = initialSnapshot.value[matched.id];
|
||||
nextLocal[matched.id] = {
|
||||
market_price: row.market_price,
|
||||
price: row.price,
|
||||
};
|
||||
if (
|
||||
orig &&
|
||||
isPriceChanged(orig, {
|
||||
market_price: row.market_price,
|
||||
price: row.price,
|
||||
})
|
||||
) {
|
||||
changedExistingCount += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (row.drug_name || row.drug_number) {
|
||||
tempIdCounter -= 1;
|
||||
nextNew.push({
|
||||
tempId: tempIdCounter,
|
||||
drug_name: row.drug_name,
|
||||
drug_number: row.drug_number,
|
||||
market_price: row.market_price,
|
||||
price: row.price,
|
||||
});
|
||||
} else {
|
||||
nextUnmatched.push(row);
|
||||
}
|
||||
}
|
||||
|
||||
localPrices.value = nextLocal;
|
||||
newRows.value = nextNew;
|
||||
unmatchedRows.value = nextUnmatched;
|
||||
excelTouchedIds.value = nextTouched;
|
||||
|
||||
if (changedExistingCount > 0) {
|
||||
filterTab.value = 'changed';
|
||||
} else if (nextNew.length > 0) {
|
||||
filterTab.value = 'new';
|
||||
} else {
|
||||
filterTab.value = 'all';
|
||||
}
|
||||
|
||||
if (invalidCount > 0) {
|
||||
message.info(`Excel 中有 ${invalidCount} 行因缺少名称或编码被跳过`);
|
||||
}
|
||||
if (nextNew.length > 0) {
|
||||
message.info(`有 ${nextNew.length} 条药品将自动新增入库`);
|
||||
}
|
||||
|
||||
parsedReady.value = true;
|
||||
} catch {
|
||||
message.error('解析失败');
|
||||
} finally {
|
||||
parseLoading.value = false;
|
||||
syncModalFooterState();
|
||||
}
|
||||
}
|
||||
|
||||
function syncRowFromGrid(row: PriceCompareRow) {
|
||||
const normalized = normalizePricePair({
|
||||
market_price: parsePriceValue(row.market_price),
|
||||
price: parsePriceValue(row.price),
|
||||
});
|
||||
if (row._isNew) {
|
||||
newRows.value = newRows.value.map((item) =>
|
||||
item.tempId === row.id
|
||||
? {
|
||||
...item,
|
||||
market_price: normalized.market_price,
|
||||
price: normalized.price,
|
||||
}
|
||||
: item,
|
||||
);
|
||||
return;
|
||||
}
|
||||
localPrices.value = {
|
||||
...localPrices.value,
|
||||
[row.id]: normalized,
|
||||
};
|
||||
}
|
||||
|
||||
function confirmIfMarketGtPrice(rows: PriceCompareRow[]): Promise<boolean> {
|
||||
const hasWarning = rows.some((r) => r._marketGtPrice);
|
||||
if (!hasWarning) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
Modal.confirm({
|
||||
title: '供货价高于售价',
|
||||
content: '存在供货价高于售价的药品,确认继续保存吗?',
|
||||
onOk: () => resolve(true),
|
||||
onCancel: () => resolve(false),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function handleRowSave(row: PriceCompareRow) {
|
||||
syncRowFromGrid(row);
|
||||
const ok = await confirmIfMarketGtPrice([row]);
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const saved = normalizePricePair({
|
||||
market_price: parsePriceValue(row.market_price),
|
||||
price: parsePriceValue(row.price),
|
||||
});
|
||||
|
||||
savingRowId.value = row.id;
|
||||
try {
|
||||
if (row._isNew) {
|
||||
await applyBatchPriceApi({
|
||||
type: productType.value,
|
||||
updates: [],
|
||||
creates: [
|
||||
{
|
||||
drug_name: row.drug_name,
|
||||
drug_number: row.drug_number,
|
||||
market_price: saved.market_price,
|
||||
price: saved.price,
|
||||
},
|
||||
],
|
||||
notify: false,
|
||||
});
|
||||
newRows.value = newRows.value.filter((item) => item.tempId !== row.id);
|
||||
} else {
|
||||
await applyBatchPriceApi({
|
||||
type: productType.value,
|
||||
updates: [
|
||||
{
|
||||
id: row.id,
|
||||
market_price: saved.market_price,
|
||||
price: saved.price,
|
||||
},
|
||||
],
|
||||
creates: [],
|
||||
notify: false,
|
||||
});
|
||||
initialSnapshot.value = {
|
||||
...initialSnapshot.value,
|
||||
[row.id]: { ...saved },
|
||||
};
|
||||
localPrices.value = {
|
||||
...localPrices.value,
|
||||
[row.id]: { ...saved },
|
||||
};
|
||||
warehouseItems.value = warehouseItems.value.map((item) =>
|
||||
item.id === row.id
|
||||
? { ...item, market_price: saved.market_price, price: saved.price }
|
||||
: item,
|
||||
);
|
||||
}
|
||||
message.success('保存成功,订阅门店价格正在后台同步');
|
||||
syncModalFooterState();
|
||||
gridApi.value?.reload?.();
|
||||
} catch {
|
||||
message.error('保存失败');
|
||||
} finally {
|
||||
savingRowId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function runBatchApply() {
|
||||
const changedExisting = compareRows.value.filter(
|
||||
(r) => r._changed && !r._isNew,
|
||||
);
|
||||
const creates = newRows.value.map((row) => {
|
||||
const prices = normalizePricePair({
|
||||
market_price: row.market_price,
|
||||
price: row.price,
|
||||
});
|
||||
return {
|
||||
drug_name: row.drug_name,
|
||||
drug_number: row.drug_number,
|
||||
market_price: prices.market_price,
|
||||
price: prices.price,
|
||||
};
|
||||
});
|
||||
|
||||
if (changedExisting.length === 0 && creates.length === 0) {
|
||||
message.info('没有需要保存的变更');
|
||||
return;
|
||||
}
|
||||
|
||||
const ok = await confirmIfMarketGtPrice([
|
||||
...changedExisting,
|
||||
...creates.map((c) =>
|
||||
buildCompareRow(
|
||||
{
|
||||
id: 0,
|
||||
drug_name: c.drug_name,
|
||||
drug_number: c.drug_number,
|
||||
},
|
||||
{ market_price: c.market_price, price: c.price },
|
||||
{ market_price: 0, price: 0 },
|
||||
true,
|
||||
),
|
||||
),
|
||||
]);
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
applyLoading.value = true;
|
||||
modalApi.setState({ loading: true, confirmLoading: true });
|
||||
try {
|
||||
const res = await applyBatchPriceApi({
|
||||
type: productType.value,
|
||||
updates: changedExisting.map((row) => {
|
||||
const prices = normalizePricePair({
|
||||
market_price: parsePriceValue(row.market_price),
|
||||
price: parsePriceValue(row.price),
|
||||
});
|
||||
return {
|
||||
id: row.id,
|
||||
market_price: prices.market_price,
|
||||
price: prices.price,
|
||||
};
|
||||
}),
|
||||
creates,
|
||||
notify: true,
|
||||
});
|
||||
const updateN = res?.update_count ?? 0;
|
||||
const newN = res?.new_count ?? 0;
|
||||
message.success(`已更新 ${updateN} 条,新增 ${newN} 条`);
|
||||
gridApi.value?.reload?.();
|
||||
resetState();
|
||||
modalApi.close();
|
||||
} catch {
|
||||
message.error('批量更新失败');
|
||||
} finally {
|
||||
applyLoading.value = false;
|
||||
modalApi.setState({ loading: false, confirmLoading: false });
|
||||
syncModalFooterState();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalComp title="批量改价预览(中药总仓库)">
|
||||
<!-- Wrap main content in transition for smooth View swapping -->
|
||||
<Transition name="fade-slide" mode="out-in">
|
||||
|
||||
<!-- State A: Hero Upload View -->
|
||||
<div
|
||||
v-if="!parsedReady"
|
||||
class="flex h-[calc(100vh-220px)] w-full flex-col items-center justify-center px-4 sm:px-12 md:px-24"
|
||||
>
|
||||
<!-- 将宽度限制放开,外层容器最高占屏幕85%宽度 -->
|
||||
<div class="w-full max-w-[85vw] xl:max-w-screen-xl">
|
||||
<Spin :spinning="loadingAll || parseLoading" class="w-full" wrapperClassName="w-full">
|
||||
<div class="flex w-full flex-col items-center gap-8">
|
||||
|
||||
<div class="text-center">
|
||||
<h2 class="mb-3 text-3xl font-medium text-gray-800 dark:text-gray-100">导入药品价格表格</h2>
|
||||
<p class="text-lg text-gray-500">上传 Excel 自动对比并提示价格涨跌差异</p>
|
||||
</div>
|
||||
|
||||
<!-- 拖拽上传容器,配合 CSS :deep 强行 100% 宽度 -->
|
||||
<div class="batch-price-upload batch-price-upload--hero w-full transition-transform duration-300 hover:scale-[1.01]">
|
||||
<UploadDragger
|
||||
v-model:file-list="fileList"
|
||||
:before-upload="() => false"
|
||||
:max-count="1"
|
||||
:show-upload-list="false"
|
||||
accept=".xlsx,.xls"
|
||||
name="file"
|
||||
@change="handleFileChange"
|
||||
>
|
||||
<div class="flex min-h-[420px] w-full flex-col items-center justify-center py-16">
|
||||
<div class="mb-8 rounded-full bg-blue-50 p-6 text-blue-500 ring-8 ring-blue-50/50 dark:bg-blue-900/30 dark:ring-blue-900/20">
|
||||
<svg class="h-14 w-14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-2xl font-medium text-gray-700 dark:text-gray-200">点击或将 Excel 文件拖拽至此</p>
|
||||
<p class="mt-4 text-lg text-gray-400">
|
||||
纯前端本地解析,保护数据隐私,不会上传至服务器
|
||||
</p>
|
||||
</div>
|
||||
</UploadDragger>
|
||||
</div>
|
||||
|
||||
<div class="flex min-h-[28px] items-center gap-2">
|
||||
<p v-if="selectedFile" class="text-lg font-medium text-blue-600 dark:text-blue-400">
|
||||
已选文件:{{ selectedFile.name }}
|
||||
<span v-if="parseLoading" class="ml-2 animate-pulse text-gray-400">解析中...</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
:disabled="loadingAll || warehouseItems.length === 0"
|
||||
:loading="loadingAll"
|
||||
size="large"
|
||||
type="dashed"
|
||||
class="!rounded-full px-10 py-2 shadow-sm"
|
||||
@click="downloadTemplate"
|
||||
>
|
||||
下载标准模板
|
||||
</Button>
|
||||
</div>
|
||||
</Spin>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- State B: Result Grid View -->
|
||||
<div v-else class="flex h-[calc(100vh-220px)] flex-col gap-4">
|
||||
|
||||
<!-- Header Info Bar -->
|
||||
<div class="flex flex-col gap-4 rounded-xl border border-gray-100 bg-gray-50/50 p-4 shadow-sm backdrop-blur-sm dark:border-gray-800 dark:bg-gray-900/30 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="batch-price-upload batch-price-upload--mini">
|
||||
<UploadDragger
|
||||
v-model:file-list="fileList"
|
||||
:before-upload="() => false"
|
||||
:max-count="1"
|
||||
:show-upload-list="false"
|
||||
accept=".xlsx,.xls"
|
||||
name="file"
|
||||
class="!rounded-lg"
|
||||
@change="handleFileChange"
|
||||
>
|
||||
<div class="flex items-center gap-2 px-4 py-1.5 text-sm font-medium text-gray-600 dark:text-gray-300">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||
</svg>
|
||||
重新上传
|
||||
</div>
|
||||
</UploadDragger>
|
||||
</div>
|
||||
<div v-if="selectedFile" class="text-sm text-gray-500">
|
||||
当前: <span class="font-medium text-gray-700 dark:text-gray-200">{{ selectedFile.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<Button size="small" type="link" @click="downloadTemplate">下载模板</Button>
|
||||
<div class="h-4 w-px bg-gray-300 dark:bg-gray-700"></div>
|
||||
<Button
|
||||
v-if="selectedFile"
|
||||
:loading="parseLoading"
|
||||
size="small"
|
||||
type="link"
|
||||
@click="runParse"
|
||||
>
|
||||
重新解析
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Compare Grid -->
|
||||
<div class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl bg-white shadow-sm ring-1 ring-gray-100 dark:bg-gray-900 dark:ring-gray-800">
|
||||
<Spin :spinning="loadingAll || parseLoading" wrapperClassName="h-full flex flex-col min-h-0">
|
||||
<PriceCompareCardGrid
|
||||
v-model:filter-tab="filterTab"
|
||||
class="flex min-h-0 flex-1 flex-col overflow-hidden p-4"
|
||||
:rows="compareRows"
|
||||
:saving-row-id="savingRowId"
|
||||
:unmatched-rows="unmatchedRows"
|
||||
@cell-change="syncRowFromGrid"
|
||||
@row-save="handleRowSave"
|
||||
/>
|
||||
</Spin>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<template v-if="parsedReady" #prepend-footer>
|
||||
<div class="flex items-center gap-2">
|
||||
<Tag :color="changedCount > 0 ? 'warning' : 'default'" class="!rounded-md shadow-sm">
|
||||
待保存修改 {{ changedCount }}
|
||||
</Tag>
|
||||
<Tag :color="newCount > 0 ? 'processing' : 'default'" class="!rounded-md shadow-sm">
|
||||
待新增数据 {{ newCount }}
|
||||
</Tag>
|
||||
<span v-if="parseHint" class="ml-2 text-xs text-gray-400">
|
||||
{{ parseHint }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</ModalComp>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* View Transition */
|
||||
.fade-slide-enter-active,
|
||||
.fade-slide-leave-active {
|
||||
transition: all 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
}
|
||||
|
||||
.fade-slide-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
.fade-slide-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
|
||||
/* Upload Dragger overrides for modern aesthetic & FORCE FULL WIDTH */
|
||||
.batch-price-upload {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.batch-price-upload :deep(.ant-upload-wrapper),
|
||||
.batch-price-upload :deep(.ant-upload-list),
|
||||
.batch-price-upload :deep(.ant-upload-drag) {
|
||||
width: 100% !important;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Hero Dragger Specific Styles */
|
||||
.batch-price-upload--hero :deep(.ant-upload-drag) {
|
||||
background: rgba(249, 250, 251, 0.5); /* subtle gray */
|
||||
border: 2px dashed #d1d5db;
|
||||
border-radius: 1.5rem; /* 圆润大边角 */
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.batch-price-upload--hero :deep(.ant-upload-drag:hover) {
|
||||
border-color: #3b82f6;
|
||||
background: rgba(239, 246, 255, 0.5); /* subtle blue */
|
||||
}
|
||||
:deep(.dark .batch-price-upload--hero .ant-upload-drag) {
|
||||
background: rgba(31, 41, 55, 0.3);
|
||||
border-color: #4b5563;
|
||||
}
|
||||
:deep(.dark .batch-price-upload--hero .ant-upload-drag:hover) {
|
||||
border-color: #3b82f6;
|
||||
background: rgba(29, 78, 216, 0.1);
|
||||
}
|
||||
|
||||
/* Mini Dragger (Header state) */
|
||||
.batch-price-upload--mini :deep(.ant-upload-drag) {
|
||||
border: 1px dashed #d1d5db;
|
||||
border-radius: 0.5rem;
|
||||
background: transparent;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.batch-price-upload--mini :deep(.ant-upload-drag:hover) {
|
||||
border-color: #3b82f6;
|
||||
background: rgba(239, 246, 255, 0.5);
|
||||
}
|
||||
:deep(.dark .batch-price-upload--mini .ant-upload-drag) {
|
||||
border-color: #4b5563;
|
||||
}
|
||||
:deep(.dark .batch-price-upload--mini .ant-upload-drag:hover) {
|
||||
border-color: #3b82f6;
|
||||
background: rgba(29, 78, 216, 0.1);
|
||||
}
|
||||
</style>
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from './api';
|
||||
import FormModalDemo from './components/modal.vue';
|
||||
import ExcelUpload from './components/ExcelUpload.vue';
|
||||
import TcmPriceBatchModal from './components/TcmPriceBatchModal.vue';
|
||||
import { formOptions } from './config/search';
|
||||
import { createGridOptions } from './config/table';
|
||||
|
||||
@@ -57,6 +58,10 @@ const [ExcelUploadModal, ExcelUploadModalApi] = useVbenModal({
|
||||
connectedComponent: ExcelUpload,
|
||||
});
|
||||
|
||||
const [TcmPriceBatchModalComp, TcmPriceBatchModalApi] = useVbenModal({
|
||||
connectedComponent: TcmPriceBatchModal,
|
||||
});
|
||||
|
||||
const TCM_PLACEHOLDER_IMAGE =
|
||||
'https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/xk_upload_20250510/cd470ebf97e31c4048ba502ba71b66a3.jpg';
|
||||
|
||||
@@ -111,6 +116,14 @@ const openExcelUploadModal = (type = 1) => {
|
||||
ExcelUploadModalApi.open();
|
||||
};
|
||||
|
||||
const openTcmPriceBatchModal = () => {
|
||||
TcmPriceBatchModalApi.setData({
|
||||
gridApi,
|
||||
productType: productType.value,
|
||||
});
|
||||
TcmPriceBatchModalApi.open();
|
||||
};
|
||||
|
||||
const updateStatus = (id: number) => {
|
||||
updateWarehouseDrugManagementStatusApi(id).then(() => {
|
||||
message.success('修改成功!');
|
||||
@@ -122,6 +135,7 @@ const updateStatus = (id: number) => {
|
||||
<template>
|
||||
<Page auto-content-height :title="typeLabel || '总仓库管理'">
|
||||
<ExcelUploadModal />
|
||||
<TcmPriceBatchModalComp />
|
||||
<FormModal />
|
||||
<Grid v-if="productType">
|
||||
<template #toolbar-buttons>
|
||||
@@ -148,6 +162,13 @@ const updateStatus = (id: number) => {
|
||||
// auth: ['超级西(中成)药', 'sys:user:save'],
|
||||
onClick: openExcelUploadModal.bind(null, 2),
|
||||
},
|
||||
{
|
||||
label: '批量改价预览',
|
||||
type: 'primary',
|
||||
icon: 'mdi:file-compare',
|
||||
ifShow: () => productType === 1,
|
||||
onClick: openTcmPriceBatchModal,
|
||||
},
|
||||
{
|
||||
label: '导出',
|
||||
type: 'primary',
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import ExcelJS from 'exceljs';
|
||||
|
||||
import type { WarehouseBatchPriceItem } from '../api';
|
||||
|
||||
const THIN_BORDER: Partial<ExcelJS.Borders> = {
|
||||
top: { style: 'thin', color: { argb: 'FF000000' } },
|
||||
left: { style: 'thin', color: { argb: 'FF000000' } },
|
||||
bottom: { style: 'thin', color: { argb: 'FF000000' } },
|
||||
right: { style: 'thin', color: { argb: 'FF000000' } },
|
||||
};
|
||||
|
||||
const HEADERS = [
|
||||
'药品ID',
|
||||
'商品编码',
|
||||
'通用名',
|
||||
'拼音首拼',
|
||||
'每克供货价',
|
||||
'每克零售价',
|
||||
] as const;
|
||||
|
||||
export async function exportWarehousePriceExcel(
|
||||
items: WarehouseBatchPriceItem[],
|
||||
): Promise<ArrayBuffer> {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const sheet = workbook.addWorksheet('中药价格');
|
||||
|
||||
sheet.columns = [
|
||||
{ width: 10 },
|
||||
{ width: 14 },
|
||||
{ width: 18 },
|
||||
{ width: 12 },
|
||||
{ width: 14 },
|
||||
{ width: 14 },
|
||||
];
|
||||
|
||||
const headerRow = sheet.addRow([...HEADERS]);
|
||||
headerRow.height = 28;
|
||||
headerRow.eachCell((cell) => {
|
||||
cell.font = { bold: true };
|
||||
cell.fill = {
|
||||
type: 'pattern',
|
||||
pattern: 'solid',
|
||||
fgColor: { argb: 'FFFFFF00' },
|
||||
};
|
||||
cell.alignment = { horizontal: 'center', vertical: 'middle' };
|
||||
cell.border = THIN_BORDER;
|
||||
});
|
||||
|
||||
items.forEach((item, index) => {
|
||||
const row = sheet.addRow([
|
||||
item.drug_id,
|
||||
item.drug_number ?? '',
|
||||
item.drug_name ?? '',
|
||||
item.pinyin_simple ?? '',
|
||||
item.market_price ?? 0,
|
||||
item.price ?? 0,
|
||||
]);
|
||||
row.eachCell((cell, colNumber) => {
|
||||
cell.border = THIN_BORDER;
|
||||
cell.alignment = { horizontal: 'center', vertical: 'middle' };
|
||||
if (colNumber === 2) {
|
||||
cell.numFmt = '@';
|
||||
} else if (colNumber >= 5) {
|
||||
cell.numFmt = '#,##0.00';
|
||||
}
|
||||
});
|
||||
if (index % 2 === 1) {
|
||||
row.eachCell((cell) => {
|
||||
cell.fill = {
|
||||
type: 'pattern',
|
||||
pattern: 'solid',
|
||||
fgColor: { argb: 'FFF2F2F2' },
|
||||
};
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
sheet.views = [{ state: 'frozen', ySplit: 1 }];
|
||||
|
||||
const buffer = await workbook.xlsx.writeBuffer();
|
||||
return buffer as ArrayBuffer;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import ExcelJS from 'exceljs';
|
||||
|
||||
import { formatPriceLikeBackend, parsePriceValue } from './priceCompare';
|
||||
|
||||
const MAX_ROWS = 5000;
|
||||
|
||||
export type ParsedWarehousePriceRow = {
|
||||
drug_name: string;
|
||||
drug_number: string;
|
||||
market_price: number;
|
||||
price: number;
|
||||
};
|
||||
|
||||
export type ParseWarehousePriceResult = {
|
||||
items: ParsedWarehousePriceRow[];
|
||||
invalidCount: number;
|
||||
rowCount: number;
|
||||
};
|
||||
|
||||
function normalizeCell(value: ExcelJS.CellValue): string {
|
||||
if (value === null || value === undefined) {
|
||||
return '';
|
||||
}
|
||||
if (typeof value === 'object' && 'text' in value) {
|
||||
return String(value.text ?? '').trim();
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return Number.isInteger(value) ? String(value) : String(value).trim();
|
||||
}
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
function normalizeHeader(value: string): string {
|
||||
return value.replace(/\s+/g, '').replace(/\u3000/g, '');
|
||||
}
|
||||
|
||||
const NUMBER_ALIASES = ['商品编码', '药品编码', '编码', 'drug_number'];
|
||||
const NAME_ALIASES = ['通用名', '药品名称', '药名', 'drug_name'];
|
||||
const MARKET_ALIASES = ['每克供货价', '供货价', 'market_price'];
|
||||
const PRICE_ALIASES = ['每克零售价', '建议售价', '零售价', 'price'];
|
||||
|
||||
function buildHeaderIndexMap(headerRow: ExcelJS.Row): Record<string, number> {
|
||||
const map: Record<string, number> = {};
|
||||
headerRow.eachCell({ includeEmpty: true }, (cell, colNumber) => {
|
||||
const key = normalizeHeader(normalizeCell(cell.value));
|
||||
if (key) {
|
||||
map[key] = colNumber;
|
||||
}
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
||||
function pickColumn(
|
||||
headerMap: Record<string, number>,
|
||||
aliases: string[],
|
||||
): number | undefined {
|
||||
for (const alias of aliases) {
|
||||
const col = headerMap[normalizeHeader(alias)];
|
||||
if (col) {
|
||||
return col;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getCellNumber(row: ExcelJS.Row, col?: number): number {
|
||||
if (!col) {
|
||||
return 0;
|
||||
}
|
||||
return parsePriceValue(row.getCell(col).value);
|
||||
}
|
||||
|
||||
function getCellText(row: ExcelJS.Row, col?: number): string {
|
||||
if (!col) {
|
||||
return '';
|
||||
}
|
||||
return normalizeCell(row.getCell(col).value);
|
||||
}
|
||||
|
||||
function rowIsEmpty(row: ExcelJS.Row): boolean {
|
||||
let hasValue = false;
|
||||
row.eachCell({ includeEmpty: false }, (cell) => {
|
||||
if (normalizeCell(cell.value) !== '') {
|
||||
hasValue = true;
|
||||
}
|
||||
});
|
||||
return !hasValue;
|
||||
}
|
||||
|
||||
export async function parseWarehousePriceExcelBuffer(
|
||||
buffer: ArrayBuffer,
|
||||
): Promise<ParseWarehousePriceResult> {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
await workbook.xlsx.load(buffer);
|
||||
|
||||
const sheet = workbook.worksheets[0];
|
||||
if (!sheet) {
|
||||
return { items: [], invalidCount: 0, rowCount: 0 };
|
||||
}
|
||||
|
||||
const headerRow = sheet.getRow(1);
|
||||
const headerMap = buildHeaderIndexMap(headerRow);
|
||||
|
||||
const numberCol = pickColumn(headerMap, NUMBER_ALIASES);
|
||||
const nameCol = pickColumn(headerMap, NAME_ALIASES);
|
||||
const marketCol = pickColumn(headerMap, MARKET_ALIASES);
|
||||
const priceCol = pickColumn(headerMap, PRICE_ALIASES);
|
||||
|
||||
const items: ParsedWarehousePriceRow[] = [];
|
||||
let invalidCount = 0;
|
||||
const rowCount = Math.min(sheet.rowCount, MAX_ROWS + 1);
|
||||
|
||||
for (let rowIndex = 2; rowIndex <= rowCount; rowIndex++) {
|
||||
const row = sheet.getRow(rowIndex);
|
||||
if (rowIsEmpty(row)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const drugNumber = getCellText(row, numberCol);
|
||||
const drugName = getCellText(row, nameCol);
|
||||
const marketPrice = getCellNumber(row, marketCol);
|
||||
const price = getCellNumber(row, priceCol);
|
||||
|
||||
if (!drugName && !drugNumber && marketPrice === 0 && price === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!drugName && !drugNumber) {
|
||||
invalidCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
items.push({
|
||||
drug_name: drugName,
|
||||
drug_number: drugNumber,
|
||||
market_price: formatPriceLikeBackend(marketPrice),
|
||||
price: formatPriceLikeBackend(price),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
items,
|
||||
invalidCount,
|
||||
rowCount: Math.max(0, rowCount - 1),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
export type PricePair = {
|
||||
market_price: number;
|
||||
price: number;
|
||||
};
|
||||
|
||||
export function parsePriceValue(value: unknown): number {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return 0;
|
||||
}
|
||||
const num = Number(value);
|
||||
return Number.isFinite(num) ? num : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 与后端 format_price 一致:第三位小数非 0 则分位进 1,再保留两位小数。
|
||||
*/
|
||||
export function formatPriceLikeBackend(value: unknown): number {
|
||||
const price = parsePriceValue(value);
|
||||
let milli = Math.round(price * 1000);
|
||||
if (milli % 10 > 0) {
|
||||
milli += 10;
|
||||
}
|
||||
milli -= milli % 10;
|
||||
return Math.round((milli / 1000) * 100) / 100;
|
||||
}
|
||||
|
||||
export function normalizePricePair(pair: PricePair): PricePair {
|
||||
return {
|
||||
market_price: formatPriceLikeBackend(pair.market_price),
|
||||
price: formatPriceLikeBackend(pair.price),
|
||||
};
|
||||
}
|
||||
|
||||
export function toPriceUnits(value: unknown): number {
|
||||
return Math.round(formatPriceLikeBackend(value) * 100);
|
||||
}
|
||||
|
||||
export function formatPriceDisplay(value: unknown): string {
|
||||
return formatPriceLikeBackend(value).toFixed(2);
|
||||
}
|
||||
|
||||
export type PriceDelta = 'up' | 'down' | 'same';
|
||||
|
||||
export function getPriceDelta(orig: number, next: number): PriceDelta {
|
||||
const a = toPriceUnits(orig);
|
||||
const b = toPriceUnits(next);
|
||||
if (b > a) {
|
||||
return 'up';
|
||||
}
|
||||
if (b < a) {
|
||||
return 'down';
|
||||
}
|
||||
return 'same';
|
||||
}
|
||||
|
||||
export function isFieldChanged(orig: number, next: number): boolean {
|
||||
return getPriceDelta(orig, next) !== 'same';
|
||||
}
|
||||
|
||||
export function isPriceChanged(
|
||||
initial: PricePair | undefined,
|
||||
current: PricePair,
|
||||
): boolean {
|
||||
if (!initial) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
isFieldChanged(initial.market_price, current.market_price) ||
|
||||
isFieldChanged(initial.price, current.price)
|
||||
);
|
||||
}
|
||||
|
||||
export function isMarketGtPrice(current: PricePair): boolean {
|
||||
return toPriceUnits(current.market_price) > toPriceUnits(current.price);
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VxeGridProps } from '#/adapter/vxe-table';
|
||||
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
|
||||
import { Button, Input, Segmented, Tag } from 'ant-design-vue';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
|
||||
import {
|
||||
formatPriceDisplay,
|
||||
isMarketGtPrice,
|
||||
isPriceChanged,
|
||||
type PricePair,
|
||||
} from '../../admin/utils/priceCompare';
|
||||
|
||||
export type PriceCompareFilterTab =
|
||||
| 'all'
|
||||
| 'changed'
|
||||
| 'unchanged'
|
||||
| 'marketGtPrice'
|
||||
| 'new'
|
||||
| 'unmatched';
|
||||
|
||||
export type PriceCompareRow = {
|
||||
id: number;
|
||||
drug_id?: number;
|
||||
drug_name: string;
|
||||
drug_number: string;
|
||||
pinyin_simple?: string;
|
||||
orig_market_price: number;
|
||||
orig_price: number;
|
||||
market_price: number;
|
||||
price: number;
|
||||
_changed: boolean;
|
||||
_marketGtPrice: boolean;
|
||||
_isNew: boolean;
|
||||
_fromExcel?: boolean;
|
||||
};
|
||||
|
||||
export type UnmatchedRow = {
|
||||
drug_name: string;
|
||||
drug_number: string;
|
||||
market_price: number;
|
||||
price: number;
|
||||
};
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
rows: PriceCompareRow[];
|
||||
unmatchedRows?: UnmatchedRow[];
|
||||
filterTab?: PriceCompareFilterTab;
|
||||
savingRowId?: number | null;
|
||||
gridMaxHeight?: string;
|
||||
}>(),
|
||||
{
|
||||
unmatchedRows: () => [],
|
||||
filterTab: 'all',
|
||||
savingRowId: null,
|
||||
gridMaxHeight: 'calc(100vh - 260px)',
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:filterTab': [PriceCompareFilterTab];
|
||||
cellChange: [row: PriceCompareRow];
|
||||
rowSave: [row: PriceCompareRow];
|
||||
}>();
|
||||
|
||||
const keyword = ref('');
|
||||
const innerFilterTab = ref<PriceCompareFilterTab>(props.filterTab);
|
||||
|
||||
watch(
|
||||
() => props.filterTab,
|
||||
(v) => {
|
||||
innerFilterTab.value = v;
|
||||
},
|
||||
);
|
||||
|
||||
watch(innerFilterTab, (v) => {
|
||||
emit('update:filterTab', v);
|
||||
});
|
||||
|
||||
const tabCounts = computed(() => {
|
||||
const all = props.rows.length;
|
||||
const changed = props.rows.filter((r) => r._changed).length;
|
||||
const unchanged = all - changed;
|
||||
const marketGtPrice = props.rows.filter((r) => r._marketGtPrice).length;
|
||||
const newCount = props.rows.filter((r) => r._isNew).length;
|
||||
const unmatched = props.unmatchedRows.length;
|
||||
return { all, changed, unchanged, marketGtPrice, new: newCount, unmatched };
|
||||
});
|
||||
|
||||
const filteredRows = computed(() => {
|
||||
const q = keyword.value.trim().toLowerCase();
|
||||
let list = props.rows;
|
||||
|
||||
if (innerFilterTab.value === 'changed') {
|
||||
list = list.filter((r) => r._changed);
|
||||
} else if (innerFilterTab.value === 'unchanged') {
|
||||
list = list.filter((r) => !r._changed);
|
||||
} else if (innerFilterTab.value === 'marketGtPrice') {
|
||||
list = list.filter((r) => r._marketGtPrice);
|
||||
} else if (innerFilterTab.value === 'new') {
|
||||
list = list.filter((r) => r._isNew);
|
||||
}
|
||||
|
||||
if (q) {
|
||||
list = list.filter((r) => {
|
||||
const nameOk = r.drug_name.toLowerCase().includes(q);
|
||||
const py = (r.pinyin_simple ?? '').toLowerCase();
|
||||
const numOk = r.drug_number.toLowerCase().includes(q);
|
||||
return nameOk || py.includes(q) || numOk;
|
||||
});
|
||||
}
|
||||
|
||||
return list;
|
||||
});
|
||||
|
||||
const showSaveColumn = computed(() => innerFilterTab.value === 'changed');
|
||||
|
||||
const gridColumns = computed(() => [
|
||||
{ type: 'seq', width: 56, title: '序号', fixed: 'left' as const },
|
||||
{
|
||||
field: 'drug_name',
|
||||
title: '药品名称',
|
||||
minWidth: 140,
|
||||
fixed: 'left' as const,
|
||||
},
|
||||
{ field: 'drug_number', title: '商品编码', width: 110 },
|
||||
{
|
||||
field: 'orig_market_price',
|
||||
title: '原供货价',
|
||||
width: 100,
|
||||
formatter: ({ cellValue }: { cellValue: unknown }) =>
|
||||
formatPriceDisplay(cellValue),
|
||||
},
|
||||
{
|
||||
field: 'market_price',
|
||||
title: '新供货价',
|
||||
width: 110,
|
||||
editRender: {
|
||||
name: 'input',
|
||||
props: { type: 'number', step: 0.01, min: 0 },
|
||||
},
|
||||
formatter: ({ cellValue }: { cellValue: unknown }) =>
|
||||
formatPriceDisplay(cellValue),
|
||||
},
|
||||
{
|
||||
field: 'orig_price',
|
||||
title: '原售价',
|
||||
width: 100,
|
||||
formatter: ({ cellValue }: { cellValue: unknown }) =>
|
||||
formatPriceDisplay(cellValue),
|
||||
},
|
||||
{
|
||||
field: 'price',
|
||||
title: '新售价',
|
||||
width: 110,
|
||||
editRender: {
|
||||
name: 'input',
|
||||
props: { type: 'number', step: 0.01, min: 0 },
|
||||
},
|
||||
formatter: ({ cellValue }: { cellValue: unknown }) =>
|
||||
formatPriceDisplay(cellValue),
|
||||
},
|
||||
{
|
||||
field: '_changed',
|
||||
title: '状态',
|
||||
width: 96,
|
||||
slots: { default: 'status' },
|
||||
},
|
||||
...(showSaveColumn.value
|
||||
? [
|
||||
{
|
||||
title: '操作',
|
||||
width: 88,
|
||||
fixed: 'right' as const,
|
||||
slots: { default: 'action' },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
|
||||
const gridOptions = computed<VxeGridProps<PriceCompareRow>>(() => ({
|
||||
data: filteredRows.value,
|
||||
height: props.gridMaxHeight,
|
||||
border: true,
|
||||
stripe: true,
|
||||
showOverflow: true,
|
||||
proxyConfig: {
|
||||
enabled: false,
|
||||
autoLoad: false,
|
||||
},
|
||||
rowConfig: {
|
||||
useKey: true,
|
||||
keyField: 'id',
|
||||
isHover: true,
|
||||
},
|
||||
columnConfig: {
|
||||
useKey: true,
|
||||
},
|
||||
editConfig: {
|
||||
mode: 'cell',
|
||||
trigger: 'click',
|
||||
showStatus: true,
|
||||
},
|
||||
rowClassName: ({ row }: { row: PriceCompareRow }) => {
|
||||
// Enhanced status classes for visual hierarchy
|
||||
if (row._marketGtPrice) {
|
||||
return 'excel-compare-row-warning';
|
||||
}
|
||||
if (row._changed) {
|
||||
return 'excel-compare-row-changed';
|
||||
}
|
||||
return 'excel-compare-row-normal';
|
||||
},
|
||||
columns: gridColumns.value,
|
||||
}));
|
||||
|
||||
function onEditClosed(params: { row: PriceCompareRow }) {
|
||||
const row = params.row;
|
||||
const snapshot: PricePair = {
|
||||
market_price: row.orig_market_price,
|
||||
price: row.orig_price,
|
||||
};
|
||||
const current: PricePair = {
|
||||
market_price: row.market_price,
|
||||
price: row.price,
|
||||
};
|
||||
row._changed = isPriceChanged(snapshot, current);
|
||||
row._marketGtPrice = isMarketGtPrice(current);
|
||||
emit('cellChange', { ...row });
|
||||
}
|
||||
|
||||
function handleRowSave(row: PriceCompareRow) {
|
||||
emit('rowSave', row);
|
||||
}
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
gridOptions,
|
||||
gridEvents: {
|
||||
editClosed: onEditClosed,
|
||||
},
|
||||
});
|
||||
|
||||
async function refreshGridData() {
|
||||
await nextTick();
|
||||
const data = filteredRows.value;
|
||||
gridApi.setGridOptions({
|
||||
data,
|
||||
columns: gridColumns.value,
|
||||
height: props.gridMaxHeight,
|
||||
});
|
||||
if (gridApi.grid?.loadData) {
|
||||
await gridApi.grid.loadData(data);
|
||||
}
|
||||
gridApi.grid?.recalculate?.();
|
||||
}
|
||||
|
||||
watch([filteredRows, showSaveColumn, () => props.rows, () => props.gridMaxHeight], refreshGridData, {
|
||||
deep: true,
|
||||
flush: 'post',
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="excel-compare-grid min-h-0 flex-1 flex flex-col">
|
||||
<!-- Header Controls -->
|
||||
<div class="mb-4 flex flex-wrap items-center gap-3">
|
||||
<Input
|
||||
v-model:value="keyword"
|
||||
allow-clear
|
||||
class="max-w-xs !rounded-lg shadow-sm"
|
||||
placeholder="按药品名称、编码或拼音筛选"
|
||||
/>
|
||||
<Tag class="!rounded-full !border-gray-200 !bg-white px-3 py-1 shadow-sm dark:!border-gray-700 dark:!bg-gray-800">
|
||||
{{ filteredRows.length }} 条
|
||||
</Tag>
|
||||
</div>
|
||||
|
||||
<!-- Segmented Control -->
|
||||
<Segmented
|
||||
v-model:value="innerFilterTab"
|
||||
class="mb-4 flex-wrap !rounded-lg !p-1 shadow-sm"
|
||||
:options="[
|
||||
{ label: `全部 ${tabCounts.all}`, value: 'all' },
|
||||
{ label: `有修改 ${tabCounts.changed}`, value: 'changed' },
|
||||
{ label: `无修改 ${tabCounts.unchanged}`, value: 'unchanged' },
|
||||
{
|
||||
label: `供价超售价 ${tabCounts.marketGtPrice}`,
|
||||
value: 'marketGtPrice',
|
||||
},
|
||||
{ label: `待新增 ${tabCounts.new}`, value: 'new' },
|
||||
{ label: `未匹配 ${tabCounts.unmatched}`, value: 'unmatched' },
|
||||
]"
|
||||
/>
|
||||
|
||||
<!-- Main Grid / List -->
|
||||
<div class="relative flex-1 min-h-0 overflow-hidden rounded-xl border border-gray-200 shadow-sm dark:border-gray-700">
|
||||
<Transition name="fade" mode="out-in">
|
||||
<Grid v-if="innerFilterTab !== 'unmatched'" class="h-full min-h-0">
|
||||
<template #status="{ row }">
|
||||
<Tag v-if="row._isNew" color="processing" class="!border-blue-200 shadow-sm">待新增</Tag>
|
||||
<Tag v-else-if="row._changed" color="warning" class="!border-amber-200 shadow-sm">已变更</Tag>
|
||||
<Tag v-else color="default" class="!bg-gray-50 dark:!bg-gray-800">无变化</Tag>
|
||||
</template>
|
||||
<template #action="{ row }">
|
||||
<Button
|
||||
:loading="savingRowId === row.id"
|
||||
size="small"
|
||||
type="primary"
|
||||
ghost
|
||||
class="!rounded-md"
|
||||
@click="handleRowSave(row)"
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</template>
|
||||
</Grid>
|
||||
|
||||
<!-- Unmatched Rows View -->
|
||||
<div
|
||||
v-else
|
||||
class="h-full overflow-auto bg-gradient-to-b from-orange-50/50 to-orange-50/10 p-4 custom-scrollbar dark:from-orange-950/20 dark:to-transparent"
|
||||
>
|
||||
<div
|
||||
v-if="unmatchedRows.length === 0"
|
||||
class="flex h-full items-center justify-center text-sm text-gray-400"
|
||||
>
|
||||
暂无未匹配行
|
||||
</div>
|
||||
<TransitionGroup name="list" tag="div" class="space-y-2">
|
||||
<div
|
||||
v-for="(row, index) in unmatchedRows"
|
||||
:key="`${row.drug_name}-${index}`"
|
||||
class="group flex items-center justify-between rounded-lg bg-white p-3 shadow-sm transition-all hover:shadow-md dark:bg-gray-800"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="font-medium text-gray-800 dark:text-gray-200">{{ row.drug_name || '—' }}</span>
|
||||
<span class="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-500 dark:bg-gray-700">{{ row.drug_number || '—' }}</span>
|
||||
</div>
|
||||
<div class="flex gap-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
<span>供货价 <strong class="font-semibold text-gray-800 dark:text-gray-200">{{ formatPriceDisplay(row.market_price) }}</strong></span>
|
||||
<span class="text-gray-300">|</span>
|
||||
<span>售价 <strong class="font-semibold text-gray-800 dark:text-gray-200">{{ formatPriceDisplay(row.price) }}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Modern styling for vxe-table custom rows */
|
||||
/* Use subtle tinting instead of harsh solid colors, add a left accent border */
|
||||
|
||||
:deep(.excel-compare-row-changed) > td {
|
||||
background-color: rgba(253, 246, 227, 0.6) !important; /* Soft Amber */
|
||||
}
|
||||
:deep(.excel-compare-row-changed) > td:first-child {
|
||||
box-shadow: inset 3px 0 0 0 #f59e0b; /* Amber accent */
|
||||
}
|
||||
|
||||
:deep(.dark .excel-compare-row-changed) > td {
|
||||
background-color: rgba(120, 53, 15, 0.15) !important;
|
||||
}
|
||||
:deep(.dark .excel-compare-row-changed) > td:first-child {
|
||||
box-shadow: inset 3px 0 0 0 #d97706;
|
||||
}
|
||||
|
||||
:deep(.excel-compare-row-warning) > td {
|
||||
background-color: rgba(254, 242, 242, 0.7) !important; /* Soft Red */
|
||||
}
|
||||
:deep(.excel-compare-row-warning) > td:first-child {
|
||||
box-shadow: inset 3px 0 0 0 #ef4444; /* Red accent */
|
||||
}
|
||||
|
||||
:deep(.dark .excel-compare-row-warning) > td {
|
||||
background-color: rgba(127, 29, 29, 0.15) !important;
|
||||
}
|
||||
:deep(.dark .excel-compare-row-warning) > td:first-child {
|
||||
box-shadow: inset 3px 0 0 0 #dc2626;
|
||||
}
|
||||
|
||||
:deep(.excel-compare-row-normal) > td {
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
/* Transitions for Unmatched List */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.list-enter-active,
|
||||
.list-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.list-enter-from,
|
||||
.list-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-10px);
|
||||
}
|
||||
|
||||
/* Custom Scrollbar */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(156, 163, 175, 0.3);
|
||||
border-radius: 20px;
|
||||
}
|
||||
.custom-scrollbar:hover::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(156, 163, 175, 0.5);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,452 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { Button, Input, InputNumber, Segmented, Tag } from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
formatPriceDisplay,
|
||||
getPriceDelta,
|
||||
isFieldChanged,
|
||||
isMarketGtPrice,
|
||||
isPriceChanged,
|
||||
type PriceDelta,
|
||||
type PricePair,
|
||||
} from '../../admin/utils/priceCompare';
|
||||
|
||||
export type PriceCompareFilterTab =
|
||||
| 'all'
|
||||
| 'changed'
|
||||
| 'unchanged'
|
||||
| 'marketGtPrice'
|
||||
| 'new'
|
||||
| 'unmatched';
|
||||
|
||||
export type PriceCompareRow = {
|
||||
id: number;
|
||||
drug_id?: number;
|
||||
drug_name: string;
|
||||
drug_number: string;
|
||||
pinyin_simple?: string;
|
||||
orig_market_price: number;
|
||||
orig_price: number;
|
||||
market_price: number;
|
||||
price: number;
|
||||
_changed: boolean;
|
||||
_marketGtPrice: boolean;
|
||||
_isNew: boolean;
|
||||
_fromExcel?: boolean;
|
||||
_marketDelta: PriceDelta;
|
||||
_priceDelta: PriceDelta;
|
||||
_marketChanged: boolean;
|
||||
_priceChanged: boolean;
|
||||
};
|
||||
|
||||
export type UnmatchedRow = {
|
||||
drug_name: string;
|
||||
drug_number: string;
|
||||
market_price: number;
|
||||
price: number;
|
||||
};
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
rows: PriceCompareRow[];
|
||||
unmatchedRows?: UnmatchedRow[];
|
||||
filterTab?: PriceCompareFilterTab;
|
||||
savingRowId?: number | null;
|
||||
}>(),
|
||||
{
|
||||
unmatchedRows: () => [],
|
||||
filterTab: 'all',
|
||||
savingRowId: null,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:filterTab': [PriceCompareFilterTab];
|
||||
cellChange: [row: PriceCompareRow];
|
||||
rowSave: [row: PriceCompareRow];
|
||||
}>();
|
||||
|
||||
const keyword = ref('');
|
||||
const innerFilterTab = ref<PriceCompareFilterTab>(props.filterTab);
|
||||
|
||||
watch(
|
||||
() => props.filterTab,
|
||||
(v) => {
|
||||
innerFilterTab.value = v;
|
||||
},
|
||||
);
|
||||
|
||||
watch(innerFilterTab, (v) => {
|
||||
emit('update:filterTab', v);
|
||||
});
|
||||
|
||||
const tabCounts = computed(() => {
|
||||
const all = props.rows.length;
|
||||
const changed = props.rows.filter((r) => r._changed).length;
|
||||
const unchanged = all - changed;
|
||||
const marketGtPrice = props.rows.filter((r) => r._marketGtPrice).length;
|
||||
const newCount = props.rows.filter((r) => r._isNew).length;
|
||||
const unmatched = props.unmatchedRows.length;
|
||||
return { all, changed, unchanged, marketGtPrice, new: newCount, unmatched };
|
||||
});
|
||||
|
||||
const filteredRows = computed(() => {
|
||||
const q = keyword.value.trim().toLowerCase();
|
||||
let list = props.rows;
|
||||
|
||||
if (innerFilterTab.value === 'changed') {
|
||||
list = list.filter((r) => r._changed);
|
||||
} else if (innerFilterTab.value === 'unchanged') {
|
||||
list = list.filter((r) => !r._changed);
|
||||
} else if (innerFilterTab.value === 'marketGtPrice') {
|
||||
list = list.filter((r) => r._marketGtPrice);
|
||||
} else if (innerFilterTab.value === 'new') {
|
||||
list = list.filter((r) => r._isNew);
|
||||
}
|
||||
|
||||
if (q) {
|
||||
list = list.filter((r) => {
|
||||
const nameOk = r.drug_name.toLowerCase().includes(q);
|
||||
const py = (r.pinyin_simple ?? '').toLowerCase();
|
||||
const numOk = r.drug_number.toLowerCase().includes(q);
|
||||
return nameOk || py.includes(q) || numOk;
|
||||
});
|
||||
}
|
||||
|
||||
return list;
|
||||
});
|
||||
|
||||
function deltaClass(delta: PriceDelta): string {
|
||||
if (delta === 'up') {
|
||||
return 'text-rose-500 bg-rose-50 px-1 rounded dark:text-rose-400 dark:bg-rose-500/10 font-medium';
|
||||
}
|
||||
if (delta === 'down') {
|
||||
return 'text-emerald-500 bg-emerald-50 px-1 rounded dark:text-emerald-400 dark:bg-emerald-500/10 font-medium';
|
||||
}
|
||||
return 'text-gray-400 dark:text-gray-500';
|
||||
}
|
||||
|
||||
function deltaSymbol(delta: PriceDelta): string {
|
||||
if (delta === 'up') return '↑';
|
||||
if (delta === 'down') return '↓';
|
||||
return '';
|
||||
}
|
||||
|
||||
function cardClass(row: PriceCompareRow): string[] {
|
||||
// Enhanced card UI with smooth transitions and hover physics
|
||||
const classes = [
|
||||
'relative group rounded-xl border p-3 shadow-sm transition-all duration-300 ease-out hover:-translate-y-1 hover:shadow-md dark:bg-gray-800/80',
|
||||
];
|
||||
if (row._isNew) {
|
||||
classes.push('border-blue-200 bg-blue-50/30 dark:border-blue-500/30 dark:bg-blue-900/10');
|
||||
} else if (row._marketGtPrice) {
|
||||
classes.push(
|
||||
'border-red-200 bg-red-50/50 ring-1 ring-red-100 dark:border-red-500/40 dark:bg-red-900/10 dark:ring-red-900/30',
|
||||
);
|
||||
} else if (row._changed) {
|
||||
classes.push(
|
||||
'border-amber-200 bg-amber-50/50 dark:border-amber-500/30 dark:bg-amber-900/10',
|
||||
);
|
||||
} else {
|
||||
classes.push('border-gray-100 bg-white dark:border-gray-700/60');
|
||||
}
|
||||
return classes;
|
||||
}
|
||||
|
||||
function refreshRowFlags(row: PriceCompareRow) {
|
||||
const snapshot: PricePair = {
|
||||
market_price: row.orig_market_price,
|
||||
price: row.orig_price,
|
||||
};
|
||||
const current: PricePair = {
|
||||
market_price: row.market_price,
|
||||
price: row.price,
|
||||
};
|
||||
row._marketDelta = getPriceDelta(snapshot.market_price, current.market_price);
|
||||
row._priceDelta = getPriceDelta(snapshot.price, current.price);
|
||||
row._marketChanged = isFieldChanged(
|
||||
snapshot.market_price,
|
||||
current.market_price,
|
||||
);
|
||||
row._priceChanged = isFieldChanged(snapshot.price, current.price);
|
||||
row._changed = row._isNew || isPriceChanged(snapshot, current);
|
||||
row._marketGtPrice = isMarketGtPrice(current);
|
||||
}
|
||||
|
||||
function onPriceChange(
|
||||
row: PriceCompareRow,
|
||||
field: 'market_price' | 'price',
|
||||
value: number | string | null,
|
||||
) {
|
||||
row[field] = Number(value) || 0;
|
||||
refreshRowFlags(row);
|
||||
emit('cellChange', { ...row });
|
||||
}
|
||||
|
||||
function handleRowSave(row: PriceCompareRow) {
|
||||
emit('rowSave', row);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<!-- Header Controls -->
|
||||
<div class="mb-4 flex flex-shrink-0 flex-wrap items-center gap-3">
|
||||
<Input
|
||||
v-model:value="keyword"
|
||||
allow-clear
|
||||
class="max-w-xs !rounded-lg shadow-sm"
|
||||
placeholder="按药品名称、编码或拼音筛选"
|
||||
/>
|
||||
<Tag class="!rounded-full !border-gray-200 !bg-white px-3 py-1 shadow-sm dark:!border-gray-700 dark:!bg-gray-800">
|
||||
{{ filteredRows.length }} 条
|
||||
</Tag>
|
||||
</div>
|
||||
|
||||
<!-- Segmented Control -->
|
||||
<Segmented
|
||||
v-model:value="innerFilterTab"
|
||||
class="mb-4 flex-shrink-0 flex-wrap !rounded-lg !p-1 shadow-sm"
|
||||
:options="[
|
||||
{ label: `全部 ${tabCounts.all}`, value: 'all' },
|
||||
{ label: `有修改 ${tabCounts.changed}`, value: 'changed' },
|
||||
{ label: `无修改 ${tabCounts.unchanged}`, value: 'unchanged' },
|
||||
{
|
||||
label: `供价超售价 ${tabCounts.marketGtPrice}`,
|
||||
value: 'marketGtPrice',
|
||||
},
|
||||
{ label: `待新增 ${tabCounts.new}`, value: 'new' },
|
||||
{ label: `未匹配 ${tabCounts.unmatched}`, value: 'unmatched' },
|
||||
]"
|
||||
/>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<div
|
||||
v-if="innerFilterTab !== 'unmatched'"
|
||||
class="min-h-0 flex-1 overflow-y-auto pr-1 custom-scrollbar"
|
||||
>
|
||||
<Transition name="fade" mode="out-in">
|
||||
<div
|
||||
v-if="filteredRows.length === 0"
|
||||
class="flex h-32 items-center justify-center text-sm text-gray-400"
|
||||
>
|
||||
暂无匹配的数据
|
||||
</div>
|
||||
|
||||
<!-- Animated Grid -->
|
||||
<TransitionGroup
|
||||
v-else
|
||||
tag="div"
|
||||
name="list"
|
||||
class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5"
|
||||
>
|
||||
<div
|
||||
v-for="row in filteredRows"
|
||||
:key="row.id"
|
||||
:class="cardClass(row)"
|
||||
>
|
||||
<!-- Card Header -->
|
||||
<div class="mb-3 flex items-start justify-between gap-2 border-b border-gray-100/50 pb-2 dark:border-gray-700/50">
|
||||
<div
|
||||
class="min-w-0 flex-1 truncate text-sm font-semibold text-gray-800 dark:text-gray-100"
|
||||
:title="row.drug_name"
|
||||
>
|
||||
{{ row.drug_name || '—' }}
|
||||
</div>
|
||||
<span
|
||||
class="flex-shrink-0 rounded bg-gray-100 px-1.5 py-0.5 text-[11px] text-gray-500 dark:bg-gray-700 dark:text-gray-300"
|
||||
:title="row.drug_number"
|
||||
>
|
||||
{{ row.drug_number || '—' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Status Tags -->
|
||||
<div v-if="row._isNew" class="mb-2">
|
||||
<Tag color="blue" class="!mr-0 !border-blue-200 !text-[11px] shadow-sm">待新增</Tag>
|
||||
</div>
|
||||
|
||||
<!-- Price Inputs Area -->
|
||||
<div class="space-y-2.5 text-xs">
|
||||
<!-- Market Price Row -->
|
||||
<div class="flex items-center justify-between group/row">
|
||||
<span class="w-12 flex-shrink-0 font-medium text-gray-500 dark:text-gray-400">
|
||||
供货价
|
||||
</span>
|
||||
<div class="flex flex-1 items-center justify-end gap-2">
|
||||
<template v-if="!row._isNew">
|
||||
<span class="text-gray-400 line-through decoration-gray-300 dark:text-gray-500 dark:decoration-gray-600">
|
||||
{{ formatPriceDisplay(row.orig_market_price) }}
|
||||
</span>
|
||||
<span class="text-gray-300 dark:text-gray-600">→</span>
|
||||
</template>
|
||||
<InputNumber
|
||||
class="!w-[85px] !rounded-md shadow-sm transition-all focus-within:ring-2 focus-within:ring-blue-100"
|
||||
:min="0"
|
||||
:precision="4"
|
||||
:step="0.0001"
|
||||
size="small"
|
||||
:value="row.market_price"
|
||||
@update:value="
|
||||
(v) => onPriceChange(row, 'market_price', v as number)
|
||||
"
|
||||
/>
|
||||
<span
|
||||
v-if="!row._isNew && row._marketChanged"
|
||||
class="w-4 text-center transition-opacity"
|
||||
:class="deltaClass(row._marketDelta)"
|
||||
>
|
||||
{{ deltaSymbol(row._marketDelta) }}
|
||||
</span>
|
||||
<span v-else class="w-4"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sell Price Row -->
|
||||
<div class="flex items-center justify-between group/row">
|
||||
<span class="w-12 flex-shrink-0 font-medium text-gray-500 dark:text-gray-400">
|
||||
售价
|
||||
</span>
|
||||
<div class="flex flex-1 items-center justify-end gap-2">
|
||||
<template v-if="!row._isNew">
|
||||
<span class="text-gray-400 line-through decoration-gray-300 dark:text-gray-500 dark:decoration-gray-600">
|
||||
{{ formatPriceDisplay(row.orig_price) }}
|
||||
</span>
|
||||
<span class="text-gray-300 dark:text-gray-600">→</span>
|
||||
</template>
|
||||
<InputNumber
|
||||
class="!w-[85px] !rounded-md shadow-sm transition-all focus-within:ring-2 focus-within:ring-blue-100"
|
||||
:min="0"
|
||||
:precision="4"
|
||||
:step="0.0001"
|
||||
size="small"
|
||||
:value="row.price"
|
||||
@update:value="
|
||||
(v) => onPriceChange(row, 'price', v as number)
|
||||
"
|
||||
/>
|
||||
<span
|
||||
v-if="!row._isNew && row._priceChanged"
|
||||
class="w-4 text-center transition-opacity"
|
||||
:class="deltaClass(row._priceDelta)"
|
||||
>
|
||||
{{ deltaSymbol(row._priceDelta) }}
|
||||
</span>
|
||||
<span v-else class="w-4"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Area -->
|
||||
<div
|
||||
class="mt-3 flex h-7 items-end justify-end overflow-hidden"
|
||||
>
|
||||
<Transition name="slide-up">
|
||||
<Button
|
||||
v-if="row._changed"
|
||||
:loading="savingRowId === row.id"
|
||||
size="small"
|
||||
type="primary"
|
||||
class="!rounded-md !px-4 shadow-sm hover:shadow-md"
|
||||
@click="handleRowSave(row)"
|
||||
>
|
||||
保存更新
|
||||
</Button>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<!-- Unmatched Rows View -->
|
||||
<Transition name="fade" mode="out-in">
|
||||
<div
|
||||
v-if="innerFilterTab === 'unmatched'"
|
||||
class="min-h-0 flex-1 overflow-y-auto rounded-xl border border-orange-100 bg-gradient-to-b from-orange-50/50 to-orange-50/10 p-4 shadow-inner custom-scrollbar dark:border-orange-900/40 dark:from-orange-950/20 dark:to-transparent"
|
||||
>
|
||||
<div
|
||||
v-if="unmatchedRows.length === 0"
|
||||
class="flex h-full items-center justify-center text-sm text-gray-400"
|
||||
>
|
||||
暂无未匹配行
|
||||
</div>
|
||||
<div
|
||||
v-for="(row, index) in unmatchedRows"
|
||||
:key="`${row.drug_name}-${index}`"
|
||||
class="group mb-2 flex items-center justify-between rounded-lg bg-white p-3 shadow-sm transition-all hover:shadow-md dark:bg-gray-800"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="font-medium text-gray-800 dark:text-gray-200">{{ row.drug_name || '—' }}</span>
|
||||
<span class="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-500 dark:bg-gray-700">{{ row.drug_number || '—' }}</span>
|
||||
</div>
|
||||
<div class="flex gap-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
<span>供货价 <strong class="font-semibold text-gray-800 dark:text-gray-200">{{ formatPriceDisplay(row.market_price) }}</strong></span>
|
||||
<span class="text-gray-300">|</span>
|
||||
<span>售价 <strong class="font-semibold text-gray-800 dark:text-gray-200">{{ formatPriceDisplay(row.price) }}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* List Transitions for Grid Cards */
|
||||
.list-move,
|
||||
.list-enter-active,
|
||||
.list-leave-active {
|
||||
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.list-enter-from,
|
||||
.list-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(15px) scale(0.98);
|
||||
}
|
||||
|
||||
/* Ensure leaving items are taken out of flow so moving items animate smoothly */
|
||||
.list-leave-active {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
/* Fade Transitions */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Slide Up Transition for Button */
|
||||
.slide-up-enter-active,
|
||||
.slide-up-leave-active {
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.slide-up-enter-from,
|
||||
.slide-up-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
/* Scrollbar styling for modern look */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(156, 163, 175, 0.3);
|
||||
border-radius: 20px;
|
||||
}
|
||||
.custom-scrollbar:hover::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(156, 163, 175, 0.5);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user