WEB/src/pages/Product/CsvTool/index.tsx

906 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { productcontrollerGetcategoriesall } from '@/servers/api/product';
import { UploadOutlined } from '@ant-design/icons';
import {
PageContainer,
ProForm,
ProFormSelect,
} from '@ant-design/pro-components';
import { request } from '@umijs/max';
import { Button, Card, Checkbox, Col, Input, message, Row, Upload } from 'antd';
import React, { useEffect, useState } from 'react';
import * as XLSX from 'xlsx';
// 定义站点接口
interface Site {
id: number;
name: string;
skuPrefix?: string;
isDisabled?: boolean;
}
// 定义选项接口,用于下拉选择框的选项
interface Option {
name: string; // 显示名称
shortName: string; // 短名称用于生成SKU
}
// 定义配置接口
interface SkuConfig {
brands: Option[];
categories: Option[];
flavors: Option[];
strengths: Option[];
humidities: Option[];
versions: Option[];
sizes: Option[];
quantities: Option[];
}
// 定义通用属性映射接口用于存储属性名称和shortName的对应关系
interface AttributeMapping {
[attributeName: string]: string; // key: 属性名称, value: 属性shortName
}
// 定义所有属性映射的接口
interface AttributeMappings {
brands: AttributeMapping;
categories: AttributeMapping;
flavors: AttributeMapping;
strengths: AttributeMapping;
humidities: AttributeMapping;
versions: AttributeMapping;
sizes: AttributeMapping;
quantities: 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 [generateBundleSkuForSingle, setGenerateBundleSkuForSingle] =
useState(true); // 是否为type为single的记录生成包含quantity的bundle SKU
const [config, setConfig] = useState<SkuConfig>({
brands: [],
categories: [],
flavors: [],
strengths: [],
humidities: [],
versions: [],
sizes: [],
quantities: [],
});
// 所有属性名称到shortName的映射
const [attributeMappings, setAttributeMappings] = useState<AttributeMappings>(
{
brands: {},
categories: {},
flavors: {},
strengths: {},
humidities: {},
versions: {},
sizes: {},
quantities: {},
},
);
// 在组件加载时获取站点列表和字典数据
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 { options: [], mapping: {} };
}
const itemsResponse = await request('/dict/items', {
params: { dictId: dict.id },
});
const items = itemsResponse?.data || itemsResponse || [];
// 创建完整的选项数组
const options = items.map((item: any) => ({
name: item.name,
shortName: item.shortName || item.name,
}));
// 创建name到shortName的映射
const mapping = items.reduce((acc: AttributeMapping, item: any) => {
acc[item.name] = item.shortName || item.name;
return acc;
}, {});
return { options, mapping };
} catch (error) {
console.error(`Failed to fetch items for ${dictName}:`, error);
return { options: [], mapping: {} };
}
};
// 4. 获取所有字典项(品牌、口味、强度、湿度、版本、尺寸、数量)
const [
brandResult,
flavorResult,
strengthResult,
humidityResult,
versionResult,
sizeResult,
quantityResult,
] = await Promise.all([
getDictItems('brand'),
getDictItems('flavor'),
getDictItems('strength'),
getDictItems('humidity'),
getDictItems('version'),
getDictItems('size'),
getDictItems('quantity'),
]);
// 5. 获取商品分类列表
const categoriesResponse = await productcontrollerGetcategoriesall();
const categoryOptions =
categoriesResponse?.data?.map((category: any) => ({
name: category.name,
shortName: category.shortName || category.name,
})) || [];
// 商品分类的映射如果分类有shortName的话
const categoryMapping =
categoriesResponse?.data?.reduce(
(acc: AttributeMapping, category: any) => {
acc[category.name] = category.shortName || category.name;
return acc;
},
{},
) || {};
// 6. 设置所有属性映射
setAttributeMappings({
brands: brandResult.mapping,
categories: categoryMapping,
flavors: flavorResult.mapping,
strengths: strengthResult.mapping,
humidities: humidityResult.mapping,
versions: versionResult.mapping,
sizes: sizeResult.mapping,
quantities: quantityResult.mapping,
});
// 更新配置状态
const newConfig = {
brands: brandResult.options,
categories: categoryOptions,
flavors: flavorResult.options,
strengths: strengthResult.options,
humidities: humidityResult.options,
versions: versionResult.options,
sizes: sizeResult.options,
quantities: quantityResult.options,
};
setConfig(newConfig);
// 设置表单值时只需要name数组
form.setFieldsValue({
brands: brandResult.options.map((opt) => opt.name),
categories: categoryOptions.map((opt) => opt.name),
flavors: flavorResult.options.map((opt) => opt.name),
strengths: strengthResult.options.map((opt) => opt.name),
humidities: humidityResult.options.map((opt) => opt.name),
versions: versionResult.options.map((opt) => opt.name),
sizes: sizeResult.options.map((opt) => opt.name),
quantities: quantityResult.options.map((opt) => opt.name),
generateBundleSkuForSingle: true,
});
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();
// 检查是否为CSV文件
const isCsvFile = uploadedFile.name.match(/\.csv$/i);
if (isCsvFile) {
// 对于CSV文件使用readAsText并指定UTF-8编码以正确处理中文
reader.onload = (e) => {
try {
const textData = e.target?.result as string;
// 使用XLSX.read处理CSV文本数据指定type为'csv'并设置编码
const workbook = XLSX.read(textData, {
type: 'string',
codepage: 65001, // UTF-8 encoding
cellText: true,
cellDates: true,
});
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('CSV文件解析失败,请检查文件格式和编码!');
console.error('CSV Parse Error:', error);
setCsvData([]);
}
};
reader.readAsText(uploadedFile, 'UTF-8');
} else {
// 对于Excel文件继续使用readAsArrayBuffer
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('Excel文件解析失败,请检查文件格式!');
console.error('Excel Parse Error:', error);
setCsvData([]);
}
};
reader.readAsArrayBuffer(uploadedFile);
}
reader.onerror = (error) => {
message.error('文件读取失败!');
console.error('File Read Error:', error);
};
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 - 版本
* @param {string} type - 产品类型
* @returns {string} 生成的SKU
*/
const generateSku = (
brand: string,
version: string,
category: string,
flavor: string,
strength: string,
humidity: string,
size: string,
quantity?: any,
type?: string,
): string => {
// 构建SKU组件不包含站点前缀
const skuComponents: string[] = [];
// 按顺序添加SKU组件所有属性都使用shortName
if (brand) {
// 使用品牌的shortName如果没有则使用品牌名称
const brandShortName = attributeMappings.brands[brand] || brand;
skuComponents.push(brandShortName);
}
if (version) {
// 使用版本的shortName如果没有则使用版本名称
const versionShortName = attributeMappings.versions[version] || version;
skuComponents.push(versionShortName);
}
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 (size) {
// 使用尺寸的shortName如果没有则使用尺寸名称
const sizeShortName = attributeMappings.sizes[size] || size;
skuComponents.push(sizeShortName);
}
// 如果type为single且启用了生成bundle SKU则添加quantity
if (
quantity
) {
console.log(quantity, attributeMappings.quantities[quantity])
// 使用quantity的shortName如果没有则使用quantity但匹配 4 个零
const quantityShortName = attributeMappings.quantities[quantity] || Number(quantity).toString().padStart(4, '0');
skuComponents.push(quantityShortName);
}
// 合并所有组件,使用短横线分隔
return skuComponents.join('-').toUpperCase();
};
/**
* @description 根据配置生成产品名称(使用属性的完整名称,空格分隔)
* @param {string} brand - 品牌
* @param {string} version - 版本
* @param {string} category - 分类
* @param {string} flavor - 口味
* @param {string} strength - 强度
* @param {string} humidity - 湿度
* @param {string} size - 型号
* @param {any} quantity - 数量
* @param {string} type - 产品类型
* @returns {string} 生成的产品名称
*/
const generateName = (
brand: string,
version: string,
category: string,
flavor: string,
strength: string,
humidity: string,
size: string,
quantity?: any,
type?: string,
): string => {
// 构建产品名称组件数组
const nameComponents: string[] = [];
// 按顺序添加组件:品牌 -> 版本 -> 品类 -> 风味 -> 毫克数(强度) -> 湿度 -> 型号 -> 数量
if (brand) nameComponents.push(brand);
if (version) nameComponents.push(version);
if (category) nameComponents.push(category);
if (flavor) nameComponents.push(flavor);
if (strength) nameComponents.push(strength);
if (humidity) nameComponents.push(humidity);
if (size) nameComponents.push(size);
// 如果有数量且类型为bundle或者生成bundle的single产品则添加数量
if (
type==='bundle' && quantity
) {
nameComponents.push(String(quantity));
}
// 使用空格连接所有组件
return nameComponents.join(' ');
};
/**
* @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 [baseSku, ...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 || '';
const size = row.attribute_size || row.size || '';
// 将quantity保存到attribute_quantity字段
const quantity = row.attribute_quantity || row.quantity;
// 获取产品类型
const type = row.type || '';
// 生成基础SKU不包含站点前缀
const baseSku = generateSku(
brand,
version,
category,
flavor,
strength,
humidity,
size,
quantity,
type,
);
const name = generateName(
brand,
version,
category,
flavor,
strength,
humidity,
size,
quantity,
type,
);
// 为所有站点生成带前缀的siteSkus
const siteSkus = generateSiteSkus(baseSku);
// 返回包含新SKU和siteSkus的行数据将SKU直接保存到sku栏
return {
...row,
sku: baseSku, // 直接生成在sku栏
generatedName: name,
// name: name, // 生成的产品名称
siteSkus,
attribute_quantity: quantity, // 确保quantity保存到attribute_quantity
};
});
// Determine which data to use for processing and download
let finalData = dataWithSku;
// If generateBundleSkuForSingle is enabled, generate bundle products for single products
if (generateBundleSkuForSingle) {
// Filter out single records
const singleRecords = dataWithSku.filter(
(row) => row.type === 'single',
);
// Get quantity values from the config (same source as other attributes like brand)
const quantityValues = config.quantities.map(quantity=>quantity.name)
// Generate bundle products for each single record and quantity
const generatedBundleRecords = singleRecords.flatMap((singleRecord) => {
return quantityValues.map((quantity) => {
// Extract all necessary attributes from the single record
const brand = singleRecord.attribute_brand || '';
const version = singleRecord.attribute_version || '';
const category = singleRecord.category || '';
const flavor = singleRecord.attribute_flavor || '';
const strength = singleRecord.attribute_strength || '';
const humidity = singleRecord.attribute_humidity || '';
const size = singleRecord.attribute_size || singleRecord.size || '';
// Generate bundle SKU with the quantity
const bundleSku = generateSku(
brand,
version,
category,
flavor,
strength,
humidity,
size,
quantity,
'bundle',
);
// Generate bundle name with the quantity
const bundleName = generateName(
brand,
version,
category,
flavor,
strength,
humidity,
size,
quantity,
'bundle',
);
// Generate siteSkus for the bundle
const bundleSiteSkus = generateSiteSkus(bundleSku);
// Create the bundle record
return {
...singleRecord,
type: 'bundle', // Change type to bundle
sku: bundleSku, // Use the new bundle SKU
name: bundleName, // Use the new bundle name
siteSkus: bundleSiteSkus,
attribute_quantity: quantity, // Set the attribute_quantity
component_1_sku: singleRecord.sku, // Set component_1_sku to the single product's sku
component_1_quantity: Number(quantity), // Set component_1_quantity to the same as attribute_quantity
};
});
});
// Combine original dataWithSku with generated bundle records
finalData = [...dataWithSku, ...generatedBundleRecords];
}
// Set the processed data
setProcessedData(finalData);
message.success({
content: 'SKU生成成功!正在自动下载...',
key: 'processing',
});
// 自动下载 the final data (with or without generated bundle products)
downloadData(finalData);
} 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的第一个组成部分"
options={config.brands.map((opt) => ({
label: `${opt.name} (${opt.shortName})`,
value: opt.name,
}))}
/>
<ProFormSelect
name="categories"
label="商品分类"
mode="tags"
placeholder="请输入分类,按回车确认"
rules={[{ required: true, message: '至少需要一个分类' }]}
tooltip="分类名称会作为SKU的第二个组成部分"
options={config.categories.map((opt) => ({
label: `${opt.name} (${opt.shortName})`,
value: opt.name,
}))}
/>
<ProFormSelect
name="flavors"
label="口味列表"
mode="tags"
placeholder="请输入口味,按回车确认"
rules={[{ required: true, message: '至少需要一个口味' }]}
tooltip="口味名称会作为SKU的第三个组成部分"
options={config.flavors.map((opt) => ({
label: `${opt.name} (${opt.shortName})`,
value: opt.name,
}))}
/>
<ProFormSelect
name="strengths"
label="强度列表"
mode="tags"
placeholder="请输入强度,按回车确认"
tooltip="强度信息会作为SKU的第四个组成部分"
options={config.strengths.map((opt) => ({
label: `${opt.name} (${opt.shortName})`,
value: opt.name,
}))}
/>
<ProFormSelect
name="humidities"
label="湿度列表"
mode="tags"
placeholder="请输入湿度,按回车确认"
tooltip="湿度信息会作为SKU的第五个组成部分"
options={config.humidities.map((opt) => ({
label: `${opt.name} (${opt.shortName})`,
value: opt.name,
}))}
/>
<ProFormSelect
name="versions"
label="版本列表"
mode="tags"
placeholder="请输入版本,按回车确认"
tooltip="版本信息会作为SKU的第六个组成部分"
options={config.versions.map((opt) => ({
label: `${opt.name} (${opt.shortName})`,
value: opt.name,
}))}
/>
<ProFormSelect
name="sizes"
label="尺寸列表"
mode="tags"
placeholder="请输入尺寸,按回车确认"
tooltip="尺寸信息会作为SKU的第七个组成部分"
options={config.sizes.map((opt) => ({
label: `${opt.name} (${opt.shortName})`,
value: opt.name,
}))}
/>
<ProFormSelect
name="quantities"
label="数量列表"
mode="tags"
placeholder="请输入数量,按回车确认"
tooltip="数量信息会作为bundle SKU的组成部分"
options={config.quantities.map((opt) => ({
label: `${opt.name} (${opt.shortName})`,
value: opt.name,
}))}
fieldProps={{ allowClear: true }}
/>
<ProForm.Item
name="generateBundleSkuForSingle"
label="为type=single生成bundle产品数据行"
tooltip="为类型为single的记录生成包含quantity的bundle SKU"
valuePropName="checked"
initialValue={true}
>
<Checkbox onChange={setGenerateBundleSkuForSingle}>
single类型生成bundle SKU
</Checkbox>
</ProForm.Item>
</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.skuPrefix}
</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;