feat(Product): 实现产品SKU批量生成工具页面
添加CSV工具页面,支持上传CSV/Excel文件并根据配置自动生成SKU 移除EditForm中未使用的siteSkuCodes状态及相关逻辑
This commit is contained in:
parent
7247015e4c
commit
49448cefb5
|
|
@ -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;
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue