商品导入导出
This commit is contained in:
@@ -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
@@ -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 → cost(null 视为未修改,不提交) */
|
||||
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}
|
||||
|
||||
Reference in New Issue
Block a user