diff --git a/src/pages/Product/CsvTool/index.tsx b/src/pages/Product/CsvTool/index.tsx index 936f51e..44f1f8d 100644 --- a/src/pages/Product/CsvTool/index.tsx +++ b/src/pages/Product/CsvTool/index.tsx @@ -1,7 +1,517 @@ -export default function CsvTool() { +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(null); + const [csvData, setCsvData] = useState([]); + const [processedData, setProcessedData] = useState([]); + const [isProcessing, setIsProcessing] = useState(false); + const [sites, setSites] = useState([]); + const [selectedSites, setSelectedSites] = useState([]); // 现在使用多选 + const [config, setConfig] = useState({ + brands: [], + categories: [], + flavors: [], + strengths: [], + humidities: [], + versions: [], + }); + // 所有属性名称到shortName的映射 + const [attributeMappings, setAttributeMappings] = useState({ + 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 ( -
-

产品CSV 工具

-
+ + + {/* 左侧:配置表单 */} + + + + + + + + + + + + + {/* 显示所有站点及其shortname */} + +
+ {sites.length > 0 ? ( + + + + + + + + + {sites.map(site => ( + + + + + ))} + +
站点名称ShortName
{site.name}{site.shortName}
+ ) : ( +

暂无站点信息

+ )} +
+
+

说明:所有站点的shortName将作为前缀添加到生成的SKU中,以分号分隔。

+
+
+ + + {/* 右侧:文件上传与操作 */} + + + { + setFile(null); + setCsvData([]); + setProcessedData([]); + }} + > + + +
+ + +
+ + + {/* 显示处理结果摘要 */} + {processedData.length > 0 && ( +
+

+ 已成功为 {processedData.length} 条产品记录生成SKU! +

+
+ )} +
+ +
+
); -} \ No newline at end of file +}; + +export default CsvTool; \ No newline at end of file diff --git a/src/pages/Product/List/EditForm.tsx b/src/pages/Product/List/EditForm.tsx index 4b2a454..aa0515f 100644 --- a/src/pages/Product/List/EditForm.tsx +++ b/src/pages/Product/List/EditForm.tsx @@ -36,7 +36,6 @@ const EditForm: React.FC<{ const [stockStatus, setStockStatus] = useState< 'in-stock' | 'out-of-stock' | null >(null); - const [siteSkuCodes, setSiteSkuCodes] = useState([]); const [sites, setSites] = useState([]); const [categories, setCategories] = useState([]); @@ -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 ( 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)} */}