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

2. 快递费模块重构
3. 订单导出功能优化
4. 商品订单的折扣功能
5. 修复了一些已知BUG
This commit is contained in:
李琦
2026-06-25 08:17:00 +08:00
parent e6306eea75
commit 8cfef12a56
8 changed files with 2028 additions and 0 deletions

View File

@@ -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);
}

View File

@@ -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>

View File

@@ -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',

View File

@@ -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;
}

View File

@@ -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),
};
}

View File

@@ -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);
}

View File

@@ -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>

View File

@@ -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>