商品导入导出

This commit is contained in:
liu
2026-08-20 14:45:06 +08:00
parent 52ab195432
commit cd7007bc49
9 changed files with 1172 additions and 11 deletions
+42 -9
View File
@@ -1,4 +1,5 @@
import createAxios from '@/utils/request';
import { downloadBlob } from '@/api/common/download.ts';
import type IProduct from '@/domain/iProduct.ts';
import type { IBatchPriceUpdate, IPriceMatrix } from '@/domain/iProduct.ts';
@@ -9,6 +10,47 @@ export interface PriceMatrixParams {
pageSize?: number;
}
/** 导入校验错误行(Excel 行号,1 起) */
export interface IImportError {
row: number;
message: string;
}
/** 商品下拉选项(仅上架) */
export async function getProductOptions(keyword?: string) {
return createAxios<IProduct[]>({
url: '/product/goods/options',
method: 'get',
params: keyword ? { keyword } : {},
});
}
/** 商品列表导出(列格式与导入模板一致,导出文件修改后可直接重新导入) */
export async function exportProducts(params: { category_id?: number } = {}) {
return downloadBlob('/product/goods/export', params, '商品列表.xlsx');
}
/** 下载商品导入模板(列头 + 示例行) */
export async function downloadProductTemplate() {
return downloadBlob('/product/goods/export', { template: 1 }, '商品导入模板.xlsx');
}
/**
* Excel 批量导入商品(整表校验,有错全部不导入)
* 失败时 promise reject,错误行明细在 err.data.data.errors
*/
export async function importProducts(file: File) {
const formData = new FormData();
formData.append('file', file);
return createAxios<{ created: number }>({
url: '/product/goods/import',
method: 'post',
data: formData,
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 60000,
});
}
/**
* A2 价格矩阵:行=商品,列=启用等级,值=按等级上浮比例换算的售价(成本价未设置 null);
* 仅成本价可编辑
@@ -31,12 +73,3 @@ export async function batchPrice(updates: IBatchPriceUpdate[]) {
data: { updates },
});
}
/** 商品下拉选项(仅上架) */
export async function getProductOptions(keyword?: string) {
return createAxios<IProduct[]>({
url: '/product/goods/options',
method: 'get',
params: keyword ? { keyword } : {},
});
}
+113 -1
View File
@@ -1,20 +1,24 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
Alert,
Button, Card,
Drawer,
Image,
Input,
InputNumber,
message,
Modal,
Space,
Table,
Tag, Tree,
TreeSelect,
Typography,
Upload,
} from 'antd';
import { TableOutlined } from '@ant-design/icons';
import { DownloadOutlined, InboxOutlined, TableOutlined, UploadOutlined } from '@ant-design/icons';
import type { TableProps } from 'antd';
import XinTable from '@/components/XinTable';
import AuthButton from '@/components/AuthButton';
import type { XinTableColumn, XinTableInstance, XinTableProps } from '@/components/XinTable/typings.ts';
import type IProduct from '@/domain/iProduct.ts';
import type { IBatchPriceUpdate, IPriceMatrixRow } from '@/domain/iProduct.ts';
@@ -25,6 +29,8 @@ import { getSupplierOptions } from '@/api/customer/supplier.ts';
import type ISupplier from '@/domain/iSupplier.ts';
import { getCategoryTree } from '@/api/product/category.ts';
import { batchPrice, getPriceMatrix } from '@/api/product/goods.ts';
import type { IImportError } from '@/api/product/goods.ts';
import { downloadProductTemplate, exportProducts, importProducts } from '@/api/product/goods.ts';
const { Title, Text } = Typography;
@@ -80,6 +86,45 @@ const ProductGoodsPage: React.FC = () => {
/** 跨页未保存的成本价:productId → costnull 视为未修改,不提交) */
const costDirtyRef = useRef<Record<number, number | null>>({});
// ===== Excel 导入/导出 =====
const [exportLoading, setExportLoading] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [importFile, setImportFile] = useState<File | null>(null);
const [importing, setImporting] = useState(false);
const [importErrors, setImportErrors] = useState<IImportError[]>([]);
/** 导出商品列表(跟随侧栏分类筛选;列格式与导入模板一致) */
const handleExport = async () => {
setExportLoading(true);
try {
await exportProducts(activeCategory ? { category_id: activeCategory } : {});
} finally {
setExportLoading(false);
}
};
/** 提交导入:成功刷新列表;校验失败展示错误行明细(错误提示由拦截器统一弹出) */
const handleImport = async () => {
if (!importFile) {
return;
}
setImporting(true);
setImportErrors([]);
try {
await importProducts(importFile);
setImportOpen(false);
setImportFile(null);
tableRef.current?.reset();
} catch (err: any) {
const errors = err?.data?.data?.errors;
if (Array.isArray(errors)) {
setImportErrors(errors);
}
} finally {
setImporting(false);
}
};
useEffect(() => {
getSupplierOptions().then((res) => setSuppliers(res.data.data ?? []));
getCategoryTree().then((res) => setCategoryTree(res.data.data ?? []));
@@ -435,6 +480,16 @@ const ProductGoodsPage: React.FC = () => {
<Button key="matrix" icon={<TableOutlined />} onClick={openMatrix}>
</Button>,
<AuthButton key="export" auth="product.goods.export">
<Button icon={<DownloadOutlined />} loading={exportLoading} onClick={handleExport}>
</Button>
</AuthButton>,
<AuthButton key="import" auth="product.goods.import">
<Button icon={<UploadOutlined />} onClick={() => setImportOpen(true)}>
</Button>
</AuthButton>,
dom.keywordSearch,
],
formProps: {
@@ -484,6 +539,63 @@ const ProductGoodsPage: React.FC = () => {
</div>
</div>
<Modal
title="导入商品"
open={importOpen}
onCancel={() => setImportOpen(false)}
onOk={handleImport}
okText="开始导入"
okButtonProps={{ disabled: !importFile }}
confirmLoading={importing}
destroyOnHidden
width={560}
>
<div className="mb-3">
<Text type="secondary">
**/
//
</Text>
<Button type="link" className="px-0" onClick={downloadProductTemplate}>
</Button>
</div>
<Upload.Dragger
accept=".xlsx,.xls"
maxCount={1}
beforeUpload={(file) => {
setImportFile(file);
setImportErrors([]);
return false;
}}
onRemove={() => {
setImportFile(null);
setImportErrors([]);
}}
>
<p className="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p className="ant-upload-text"> Excel </p>
<p className="ant-upload-hint"> .xlsx / .xls 1000 </p>
</Upload.Dragger>
{importErrors.length > 0 && (
<Alert
className="mt-3"
type="error"
showIcon
message={`${importErrors.length} 处错误,修正后请重新导入`}
description={
<ul className="max-h-48 overflow-y-auto pl-4 mb-0 list-disc">
{importErrors.map((item, index) => (
<li key={index}> {item.row} {item.message}</li>
))}
</ul>
}
/>
)}
</Modal>
<Drawer
title="价格矩阵 · 批量调价"
open={matrixOpen}