forked from yoone/WEB
1
0
Fork 0

feat(Product): 实现产品SKU批量生成工具页面

添加CSV工具页面,支持上传CSV/Excel文件并根据配置自动生成SKU
移除EditForm中未使用的siteSkuCodes状态及相关逻辑
This commit is contained in:
tikkhun 2026-01-10 15:19:14 +08:00
parent 7247015e4c
commit 49448cefb5
3 changed files with 521 additions and 19 deletions

View File

@ -1,7 +1,517 @@
export default function CsvTool() {
return (
<div>
<h1>CSV </h1>
</div>
);
import { UploadOutlined } from '@ant-design/icons';
import {
PageContainer,
ProForm,
ProFormSelect,
ProFormText,
} from '@ant-design/pro-components';
import { request } from '@umijs/max';
import { Button, Card, Col, Input, message, Row, Upload } from 'antd';
import React, { useEffect, useState } from 'react';
import * as XLSX from 'xlsx';
import { productcontrollerGetcategoriesall } from '@/servers/api/product';
// 定义站点接口
interface Site {
id: number;
name: string;
skuPrefix?: string;
isDisabled?: boolean;
}
// 定义配置接口
interface SkuConfig {
brands: string[];
categories: string[];
flavors: string[];
strengths: string[];
humidities: string[];
versions: string[];
}
// 定义通用属性映射接口用于存储属性名称和shortName的对应关系
interface AttributeMapping {
[attributeName: string]: string; // key: 属性名称, value: 属性shortName
}
// 定义所有属性映射的接口
interface AttributeMappings {
brands: AttributeMapping;
categories: AttributeMapping;
flavors: AttributeMapping;
strengths: AttributeMapping;
humidities: AttributeMapping;
versions: AttributeMapping;
}
/**
* @description CSV工具页面SKU
*/
const CsvTool: React.FC = () => {
// 状态管理
const [form] = ProForm.useForm();
const [file, setFile] = useState<File | null>(null);
const [csvData, setCsvData] = useState<any[]>([]);
const [processedData, setProcessedData] = useState<any[]>([]);
const [isProcessing, setIsProcessing] = useState(false);
const [sites, setSites] = useState<Site[]>([]);
const [selectedSites, setSelectedSites] = useState<Site[]>([]); // 现在使用多选
const [config, setConfig] = useState<SkuConfig>({
brands: [],
categories: [],
flavors: [],
strengths: [],
humidities: [],
versions: [],
});
// 所有属性名称到shortName的映射
const [attributeMappings, setAttributeMappings] = useState<AttributeMappings>({
brands: {},
categories: {},
flavors: {},
strengths: {},
humidities: {},
versions: {}
});
// 在组件加载时获取站点列表和字典数据
useEffect(() => {
const fetchAllData = async () => {
try {
message.loading({ content: '正在加载数据...', key: 'loading' });
// 1. 获取站点列表
const sitesResponse = await request('/site/all');
const siteList = sitesResponse?.data || sitesResponse || [];
setSites(siteList);
// 默认选择所有站点
setSelectedSites(siteList);
// 2. 获取字典数据
const dictListResponse = await request('/dict/list');
const dictList = dictListResponse?.data || dictListResponse || [];
// 3. 根据字典名称获取字典项返回包含name和shortName的对象数组
const getDictItems = async (dictName: string) => {
try {
const dict = dictList.find((d: any) => d.name === dictName);
if (!dict) {
console.warn(`Dictionary ${dictName} not found`);
return { names: [], mapping: {} };
}
const itemsResponse = await request('/dict/items', {
params: { dictId: dict.id },
});
const items = itemsResponse?.data || itemsResponse || [];
// 提取名称数组
const names = items.map((item: any) => item.name);
// 创建name到shortName的映射
const mapping = items.reduce((acc: AttributeMapping, item: any) => {
acc[item.name] = item.shortName || item.name;
return acc;
}, {});
return { names, mapping };
} catch (error) {
console.error(`Failed to fetch items for ${dictName}:`, error);
return { names: [], mapping: {} };
}
};
// 4. 获取所有字典项(品牌、口味、强度、湿度、版本)
const [brandResult, flavorResult, strengthResult, humidityResult, versionResult] = await Promise.all([
getDictItems('brand'),
getDictItems('flavor'),
getDictItems('strength'),
getDictItems('humidity'),
getDictItems('version'),
]);
// 5. 获取商品分类列表
const categoriesResponse = await productcontrollerGetcategoriesall();
const categories = categoriesResponse?.data?.map((category: any) => category.name) || [];
// 商品分类的映射如果分类有shortName的话
const categoryMapping = categoriesResponse?.data?.reduce((acc: AttributeMapping, category: any) => {
acc[category.name] = category.shortName || category.name;
return acc;
}, {}) || {};
// 7. 设置所有属性映射
setAttributeMappings({
brands: brandResult.mapping,
categories: categoryMapping,
flavors: flavorResult.mapping,
strengths: strengthResult.mapping,
humidities: humidityResult.mapping,
versions: versionResult.mapping
});
// 更新配置状态
const newConfig = {
brands: brandResult.names,
categories,
flavors: flavorResult.names,
strengths: strengthResult.names,
humidities: humidityResult.names,
versions: versionResult.names,
};
setConfig(newConfig);
form.setFieldsValue(newConfig);
message.success({ content: '数据加载成功', key: 'loading' });
} catch (error) {
console.error('Failed to fetch data:', error);
message.error({ content: '数据加载失败,请刷新页面重试', key: 'loading' });
}
};
fetchAllData();
}, [form]);
/**
* @description
*/
const handleFileUpload = (uploadedFile: File) => {
// 检查文件类型
if (!uploadedFile.name.match(/\.(csv|xlsx|xls)$/)) {
message.error('请上传 CSV 或 Excel 格式的文件!');
return false;
}
setFile(uploadedFile);
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = e.target?.result;
// 如果是ArrayBuffer使用type: 'array'来处理
const workbook = XLSX.read(data, { type: 'array' });
const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName];
const jsonData = XLSX.utils.sheet_to_json(worksheet, { header: 1 });
if (jsonData.length < 2) {
message.error('文件为空或缺少表头!');
setCsvData([]);
return;
}
// 将数组转换为对象数组
const headers = jsonData[0] as string[];
const rows = jsonData.slice(1).map((rowArray: any) => {
const rowData: { [key: string]: any } = {};
headers.forEach((header, index) => {
rowData[header] = rowArray[index];
});
return rowData;
});
message.success(`成功解析 ${rows.length} 条数据.`);
setCsvData(rows);
setProcessedData([]); // 清空旧的处理结果
} catch (error) {
message.error('文件解析失败,请检查文件格式!');
console.error('File Parse Error:', error);
setCsvData([]);
}
};
reader.onerror = (error) => {
message.error('文件读取失败!');
console.error('File Read Error:', error);
};
// 使用readAsArrayBuffer替代已弃用的readAsBinaryString
reader.readAsArrayBuffer(uploadedFile);
return false; // 阻止antd Upload组件的默认上传行为
};
/**
* @description CSV并触发下载
*/
const downloadData = (data: any[]) => {
if (data.length === 0) return;
const workbook = XLSX.utils.book_new();
const worksheet = XLSX.utils.json_to_sheet(data);
XLSX.utils.book_append_sheet(workbook, worksheet, 'Products with SKU');
const fileName = `products_with_sku_${Date.now()}.xlsx`;
XLSX.writeFile(workbook, fileName);
message.success('下载任务已开始!');
};
/**
* @description SKU
* @param {string} brand -
* @param {string} category -
* @param {string} flavor -
* @param {string} strength -
* @param {string} humidity - 湿
* @param {string} version -
* @returns {string} SKU
*/
const generateSku = (
brand: string,
category: string,
flavor: string,
strength: string,
humidity: string,
version: string
): string => {
// 构建SKU组件不包含站点前缀
const skuComponents: string[] = [];
// 按顺序添加SKU组件所有属性都使用shortName
if (brand) {
// 使用品牌的shortName如果没有则使用品牌名称
const brandShortName = attributeMappings.brands[brand] || brand;
skuComponents.push(brandShortName);
}
if (category) {
// 使用分类的shortName如果没有则使用分类名称
const categoryShortName = attributeMappings.categories[category] || category;
skuComponents.push(categoryShortName);
}
if (flavor) {
// 使用口味的shortName如果没有则使用口味名称
const flavorShortName = attributeMappings.flavors[flavor] || flavor;
skuComponents.push(flavorShortName);
}
if (strength) {
// 使用强度的shortName如果没有则使用强度名称
const strengthShortName = attributeMappings.strengths[strength] || strength;
skuComponents.push(strengthShortName);
}
if (humidity) {
// 使用湿度的shortName如果没有则使用湿度名称
const humidityShortName = attributeMappings.humidities[humidity] || humidity;
skuComponents.push(humidityShortName);
}
if (version) {
// 使用版本的shortName如果没有则使用版本名称
const versionShortName = attributeMappings.versions[version] || version;
skuComponents.push(versionShortName);
}
// 合并所有组件,使用短横线分隔
return skuComponents.join('-').toUpperCase();
};
/**
* @description siteSkus
* @param {string} baseSku - SKU
* @returns {string} siteSkus
*/
const generateSiteSkus = (baseSku: string): string => {
// 如果没有站点或基础SKU为空返回空字符串
if (selectedSites.length === 0 || !baseSku) return '';
// 为每个站点生成siteSku
const siteSkus = selectedSites.map(site => {
// 如果站点有shortName则添加前缀否则使用基础SKU
if (site.skuPrefix) {
return `${site.skuPrefix}-${baseSku}`;
}
return baseSku;
});
// 使用分号分隔所有站点的siteSkus
return siteSkus.join(';').toUpperCase();
};
/**
* @description 核心逻辑:根据配置处理CSV数据并生成SKU
*/
const handleProcessData = async () => {
if (csvData.length === 0) {
message.warning('请先上传并成功解析一个CSV文件.');
return;
}
if (selectedSites.length === 0) {
message.warning('没有可用的站点.');
return;
}
setIsProcessing(true);
message.loading({ content: '正在生成SKU...', key: 'processing' });
try {
// 获取表单中的最新配置
await form.validateFields();
// 处理每条数据生成SKU和siteSkus
const dataWithSku = csvData.map((row) => {
const brand = row.attribute_brand || '';
const category = row.category || '';
const flavor = row.attribute_flavor || '';
const strength = row.attribute_strength || '';
const humidity = row.attribute_humidity || '';
const version = row.attribute_version || '';
// 生成基础SKU不包含站点前缀
const baseSku = generateSku(brand, category, flavor, strength, humidity, version);
// 为所有站点生成带前缀的siteSkus
const siteSkus = generateSiteSkus(baseSku);
// 返回包含新SKU和siteSkus的行数据
return { ...row, GeneratedSKU: baseSku, siteSkus };
});
setProcessedData(dataWithSku);
message.success({ content: 'SKU生成成功!正在自动下载...', key: 'processing' });
// 自动下载
downloadData(dataWithSku);
} catch (error) {
message.error({ content: '处理失败,请检查配置或文件.', key: 'processing' });
console.error('Processing Error:', error);
} finally {
setIsProcessing(false);
}
};
return (
<PageContainer title="产品SKU批量生成工具">
<Row gutter={[16, 16]}>
{/* 左侧:配置表单 */}
<Col xs={24} md={10}>
<Card title="1. 配置SKU生成规则">
<ProForm
form={form}
initialValues={config}
onFinish={handleProcessData}
submitter={false}
>
<ProFormSelect
name="brands"
label="品牌列表"
mode="tags"
placeholder="请输入品牌,按回车确认"
rules={[{ required: true, message: '至少需要一个品牌' }]}
tooltip="品牌名称会作为SKU的第一个组成部分"
/>
<ProFormSelect
name="categories"
label="商品分类"
mode="tags"
placeholder="请输入分类,按回车确认"
rules={[{ required: true, message: '至少需要一个分类' }]}
tooltip="分类名称会作为SKU的第二个组成部分"
/>
<ProFormSelect
name="flavors"
label="口味列表"
mode="tags"
placeholder="请输入口味,按回车确认"
rules={[{ required: true, message: '至少需要一个口味' }]}
tooltip="口味名称会作为SKU的第三个组成部分"
/>
<ProFormSelect
name="strengths"
label="强度列表"
mode="tags"
placeholder="请输入强度,按回车确认"
tooltip="强度信息会作为SKU的第四个组成部分"
/>
<ProFormSelect
name="humidities"
label="湿度列表"
mode="tags"
placeholder="请输入湿度,按回车确认"
tooltip="湿度信息会作为SKU的第五个组成部分"
/>
<ProFormSelect
name="versions"
label="版本列表"
mode="tags"
placeholder="请输入版本,按回车确认"
tooltip="版本信息会作为SKU的第六个组成部分"
/>
</ProForm>
</Card>
{/* 显示所有站点及其shortname */}
<Card title="3. 所有站点信息" style={{ marginTop: '16px' }}>
<div style={{ maxHeight: '200px', overflowY: 'auto' }}>
{sites.length > 0 ? (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ backgroundColor: '#fafafa' }}>
<th style={{ padding: '8px', textAlign: 'left', borderBottom: '1px solid #e8e8e8' }}></th>
<th style={{ padding: '8px', textAlign: 'left', borderBottom: '1px solid #e8e8e8' }}>ShortName</th>
</tr>
</thead>
<tbody>
{sites.map(site => (
<tr key={site.id}>
<td style={{ padding: '8px', borderBottom: '1px solid #e8e8e8' }}>{site.name}</td>
<td style={{ padding: '8px', borderBottom: '1px solid #e8e8e8', fontWeight: 'bold' }}>{site.shortName}</td>
</tr>
))}
</tbody>
</table>
) : (
<p style={{ textAlign: 'center', color: '#999' }}></p>
)}
</div>
<div style={{ marginTop: '10px', fontSize: '12px', color: '#666' }}>
<p>shortName将作为前缀添加到生成的SKU中</p>
</div>
</Card>
</Col>
{/* 右侧:文件上传与操作 */}
<Col xs={24} md={14}>
<Card title="2. 上传文件并操作">
<Upload
beforeUpload={handleFileUpload}
maxCount={1}
showUploadList={!!file}
onRemove={() => {
setFile(null);
setCsvData([]);
setProcessedData([]);
}}
>
<Button icon={<UploadOutlined />}> CSV </Button>
</Upload>
<div style={{ marginTop: 16 }}>
<label style={{ display: 'block', marginBottom: 8 }}>
</label>
<Input value={file ? file.name : '暂未选择文件'} readOnly />
</div>
<Button
type="primary"
onClick={handleProcessData}
disabled={csvData.length === 0 || isProcessing || selectedSites.length === 0}
loading={isProcessing}
style={{ marginTop: '20px' }}
>
SKU
</Button>
{/* 显示处理结果摘要 */}
{processedData.length > 0 && (
<div style={{ marginTop: '20px', padding: '10px', backgroundColor: '#f0f9eb', borderRadius: '4px' }}>
<p style={{ margin: 0, color: '#52c41a' }}>
{processedData.length} SKU
</p>
</div>
)}
</Card>
</Col>
</Row>
</PageContainer>
);
};
export default CsvTool;

View File

@ -36,7 +36,6 @@ const EditForm: React.FC<{
const [stockStatus, setStockStatus] = useState<
'in-stock' | 'out-of-stock' | null
>(null);
const [siteSkuCodes, setSiteSkuCodes] = useState<string[]>([]);
const [sites, setSites] = useState<any[]>([]);
const [categories, setCategories] = useState<any[]>([]);
@ -100,15 +99,6 @@ const EditForm: React.FC<{
const { data: componentsData } =
await productcontrollerGetproductcomponents({ id: record.id });
setComponents(componentsData || []);
// 获取站点SKU详细信息
const { data: siteSkusData } = await productcontrollerGetproductsiteskus({
id: record.id,
});
// 只提取code字段组成字符串数组
const codes = siteSkusData
? siteSkusData.map((item: any) => item.code)
: [];
setSiteSkuCodes(codes);
})();
}, [record]);
@ -130,10 +120,10 @@ const EditForm: React.FC<{
type: type,
categoryId: (record as any).categoryId || (record as any).category?.id,
// 初始化站点SKU为字符串数组
siteSkus: siteSkuCodes,
// 修改后代码:
siteSkus:(record.siteSkus||[]).map(code => ({ code })),
};
}, [record, components, type, siteSkuCodes]);
}, [record, components, type]);
return (
<DrawerForm<any>
title="编辑"
@ -198,7 +188,7 @@ const EditForm: React.FC<{
attributes,
type: values.type, // 直接使用 type
categoryId: values.categoryId,
siteSkus: values.siteSkus || [], // 直接传递字符串数组
siteSkus: values.siteSkus.map((v: {code: string}) => (v.code)) || [], // 直接传递字符串数组
// 连带更新 components
components:
values.type === 'bundle'
@ -221,6 +211,8 @@ const EditForm: React.FC<{
return false;
}}
>
{/* {JSON.stringify(record)}
{JSON.stringify(initialValues)} */}
<ProForm.Group>
<ProFormText
name="sku"