diff --git a/.umirc.ts b/.umirc.ts index 0e17571..950faba 100644 --- a/.umirc.ts +++ b/.umirc.ts @@ -85,6 +85,11 @@ export default defineConfig({ path: '/product/wp_list', component: './Product/WpList', }, + { + name: 'WP工具箱', + path: '/product/wp_tool', + component: './Product/WpTool', + }, ], }, { diff --git a/package.json b/package.json index d77a71f..46ba166 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@ant-design/icons": "^5.0.1", "@ant-design/pro-components": "^2.4.4", "@fingerprintjs/fingerprintjs": "^4.6.2", + "@umijs/max": "^4.4.4", "@umijs/max-plugin-openapi": "^2.0.3", "@umijs/plugin-openapi": "^1.3.3", @@ -24,6 +25,7 @@ "echarts": "^5.6.0", "echarts-for-react": "^3.0.2", "file-saver": "^2.0.5", + "papaparse": "^5.5.3", "print-js": "^1.6.0", "react-phone-input-2": "^2.15.1", "react-toastify": "^11.0.5", @@ -32,6 +34,7 @@ "devDependencies": { "@types/react": "^18.0.33", "@types/react-dom": "^18.0.11", + "@types/papaparse": "^5.5.0", "code-inspector-plugin": "^1.2.10", "husky": "^9", "lint-staged": "^13.2.0", diff --git a/src/pages/Product/WpTool/index.tsx b/src/pages/Product/WpTool/index.tsx new file mode 100644 index 0000000..38f48cd --- /dev/null +++ b/src/pages/Product/WpTool/index.tsx @@ -0,0 +1,382 @@ + +import React, { useState } from 'react'; +import { + PageContainer, + ProForm, + ProFormSelect, +} from '@ant-design/pro-components'; +import { Button, Card, Col, Input, message, Row, Upload } from 'antd'; +import { UploadOutlined } from '@ant-design/icons'; +import Papa from 'papaparse'; + +// 定义配置接口 +interface TagConfig { + brands: string[]; + fruitKeys: string[]; + mintKeys: string[]; + nonFlavorTokens: string[]; +} + +// 移植 Python 脚本中的核心函数 + +/** + * @description 从产品名称中解析出品牌、口味、毫克含量和干燥度 + */ +const parseName = (name: string, brands: string[]): [string, string, string, string] => { + const nm = name.trim(); + const dryMatch = nm.match(/\(([^)]*)\)/); + const dryness = dryMatch ? dryMatch[1].trim() : ''; + + const mgMatch = nm.match(/(\d+)\s*MG/i); + const mg = mgMatch ? mgMatch[1] : ''; + + for (const b of brands) { + if (nm.toUpperCase().startsWith(b.toUpperCase())) { + const brand = b.toUpperCase(); + const start = b.length; + const end = mgMatch ? mgMatch.index : nm.length; + let flavorPart = nm.substring(start, end); + flavorPart = flavorPart.replace(/-/g, ' ').trim(); + flavorPart = flavorPart.replace(/\s*\([^)]*\)$/, '').trim(); + return [brand, flavorPart, mg, dryness]; + } + } + + const firstWord = nm.split(' ')[0]?.toUpperCase() || ''; + const brand = firstWord; + const end = mgMatch ? mgMatch.index : nm.length; + const flavorPart = nm.substring(brand.length, end).trim(); + return [brand, flavorPart, mg, dryness]; +}; + +/** + * @description 将口味部分拆分为规范的令牌 + */ +const splitFlavorTokens = (flavorPart: string): string[] => { + const rawTokens = flavorPart.match(/[A-Za-z]+/g) || []; + const tokens: string[] = []; + const EXCEPT_SPLIT = new Set(['spearmint', 'peppermint']); + + for (const tok of rawTokens) { + const t = tok.toLowerCase(); + if (t.endsWith('mint') && t.length > 4 && !EXCEPT_SPLIT.has(t)) { + const pre = t.slice(0, -4); + if (pre) { + tokens.push(pre); + } + tokens.push('mint'); + } else { + tokens.push(t); + } + } + return tokens; +}; + +/** + * @description 根据口味分类额外的标签(如 Fruit, Mint) + */ +const classifyExtraTags = (flavorPart: string, fruitKeys: string[], mintKeys: string[]): string[] => { + const tokens = splitFlavorTokens(flavorPart); + const fLower = flavorPart.toLowerCase(); + const isFruit = fruitKeys.some(key => fLower.includes(key)) || tokens.some(t => fruitKeys.includes(t)); + const isMint = mintKeys.some(key => fLower.includes(key)) || tokens.includes('mint'); + + const extras: string[] = []; + if (isFruit) extras.push('Fruit'); + if (isMint) extras.push('Mint'); + return extras; +}; + +/** + * @description 计算最终的 Tags 字符串 + */ +const computeTags = (name: string, sku: string, config: TagConfig): string => { + const [brand, flavorPart, mg, dryness] = parseName(name, config.brands); + const tokens = splitFlavorTokens(flavorPart); + + const tokensForFlavor = tokens.filter(t => !config.nonFlavorTokens.includes(t)); + const flavorTag = tokensForFlavor.map(t => t.charAt(0).toUpperCase() + t.slice(1)).join(''); + + let tags: string[] = []; + if (brand) tags.push(brand); + if (flavorTag) tags.push(flavorTag); + + for (const t of tokensForFlavor) { + if (config.fruitKeys.includes(t) && t !== 'fruit') { + tags.push(t.charAt(0).toUpperCase() + t.slice(1)); + } + if (t === 'mint') { + tags.push('Mint'); + } + } + + if (tokens.includes('slim') || /\bslim\b/i.test(name)) { + tags.push('Slim'); + } + if (tokens.includes('mini') || /\bmini\b/i.test(name)) { + tags.push('Mini'); + } + if (tokens.includes('dry') || /\bdry\b/i.test(name)) { + tags.push('Dry'); + } + if (/mix/i.test(name) || (sku && /mix/i.test(sku))) { + tags.push('Mix Pack'); + } + if (mg) { + tags.push(`${mg} mg`); + } + if (dryness) { + if (/moist/i.test(dryness)) { + tags.push('Moisture'); + } else { + tags.push(dryness.charAt(0).toUpperCase() + dryness.slice(1)); + } + } + + tags.push(...classifyExtraTags(flavorPart, config.fruitKeys, config.mintKeys)); + + // 去重并保留顺序 + const seen = new Set(); + const finalTags = tags.filter(t => { + if (t && !seen.has(t)) { + seen.add(t); + return true; + } + return false; + }); + + return finalTags.join(', '); +}; + +// 从 Python 脚本中提取的默认配置 +const DEFAULT_CONFIG = { + brands: ['YOONE', 'ZYN', 'ZEX', 'JUX', 'WHITE FOX'], + fruitKeys: [ + 'apple', 'blueberry', 'citrus', 'mango', 'peach', 'grape', 'cherry', + 'strawberry', 'watermelon', 'orange', 'lemon', 'lemonade', + 'razz', 'pineapple', 'berry', 'fruit', + ], + mintKeys: ['mint', 'wintergreen', 'peppermint', 'spearmint', 'menthol'], + nonFlavorTokens: ['slim', 'pouches', 'pouch', 'mini', 'dry'], +}; + +/** + * @description WordPress 产品工具页面,用于处理产品 CSV 并生成 Tags + */ +const WpToolPage: React.FC = () => { + // 状态管理 + const [form] = ProForm.useForm(); // 表单实例 + const [file, setFile] = useState(null); // 上传的文件 + const [csvData, setCsvData] = useState([]); // 解析后的 CSV 数据 + const [processedData, setProcessedData] = useState([]); // 处理后待下载的数据 + const [isProcessing, setIsProcessing] = useState(false); // 是否正在处理中 + + /** + * @description 处理文件上传 + * @param {File} uploadedFile - 用户上传的文件 + */ + const handleFileUpload = (uploadedFile: File) => { + // 检查文件类型是否为 CSV + if (uploadedFile.type !== 'text/csv') { + message.error('请上传 CSV 格式的文件!'); + return false; + } + setFile(uploadedFile); + // 使用 Papaparse 解析 CSV 文件 + Papa.parse(uploadedFile, { + header: true, // 将第一行作为表头 + skipEmptyLines: true, + // 简化配置,依赖解析器的自动检测能力,同时保持必要的兼容性 + quoteChar: '"', + dynamicTyping: false, // 禁用动态类型转换 + relaxColumnCount: true, // 允许列数不匹配 + complete: (results) => { + // 如果解析过程中出现错误 + if (results.errors.length > 0) { + // 提取第一条错误信息用于展示 + const firstError = results.errors[0]; + // 构造更详细的错误提示 + const errorMsg = `CSV 解析失败 (行号: ${firstError.row}): ${firstError.message}`; + message.error(errorMsg); + console.error('CSV Parsing Errors:', results.errors); + setCsvData([]); + } else { + message.success(`成功解析 ${results.data.length} 条数据。`); + setCsvData(results.data); + setProcessedData([]); // 清空旧的处理结果 + } + }, + error: (error) => { + message.error('文件读取失败!'); + console.error('File Read Error:', error); + }, + }); + return false; // 阻止 antd Upload 组件的默认上传行为 + }; + + /** + * @description 核心逻辑:根据配置处理 CSV 数据并生成 Tags + */ + const handleProcessData = async () => { + // 验证是否已上传并解析了数据 + if (csvData.length === 0) { + message.warning('请先上传并成功解析一个 CSV 文件。'); + return; + } + + setIsProcessing(true); + message.loading({ content: '正在生成 Tags...', key: 'processing' }); + + try { + // 获取表单中的最新配置 + const config = await form.validateFields(); + + const { brands, fruitKeys, mintKeys, nonFlavorTokens } = config; + + // 确保品牌按长度降序排序 + const sortedBrands = [...brands].sort((a, b) => b.length - a.length); + + const dataWithTags = csvData.map((row) => { + const name = row.Name || ''; + const sku = row.SKU || ''; + try { + const tags = computeTags(name, sku, { + brands: sortedBrands, + fruitKeys, + mintKeys, + nonFlavorTokens, + }); + return { ...row, Tags: tags }; + } catch (e) { + console.error(`Failed to process row with name: ${name}`, e); + return { ...row, Tags: row.Tags || '' }; // 保留原有 Tags 或为空 + } + }); + + setProcessedData(dataWithTags); + message.success({ content: 'Tags 生成成功!现在可以下载了。', key: 'processing' }); + + } catch (error) { + message.error({ content: '处理失败,请检查配置或文件。', key: 'processing' }); + console.error('Processing Error:', error); + } finally { + setIsProcessing(false); + } + }; + + /** + * @description 将处理后的数据转换回 CSV 并触发下载 + */ + const handleDownload = () => { + // 验证是否已有处理完成的数据 + if (processedData.length === 0) { + message.warning('没有可供下载的数据。请先生成 Tags。'); + return; + } + + // 使用 Papaparse 将 JSON 对象数组转换回 CSV 字符串 + const csvString = Papa.unparse(processedData); + + // 创建 Blob 对象 + const blob = new Blob([csvString], { type: 'text/csv;charset=utf-8;' }); + + // 创建一个临时的 a 标签用于下载 + const link = document.createElement('a'); + const url = URL.createObjectURL(blob); + link.setAttribute('href', url); + link.setAttribute('download', `products_with_tags_${Date.now()}.csv`); + link.style.visibility = 'hidden'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + message.success('下载任务已开始!'); + }; + + return ( + + + {/* 左侧:配置表单 */} + + + + + + + + + + + + + {/* 右侧:文件上传与操作 */} + + + { + setFile(null); + setCsvData([]); + setProcessedData([]); + }} + > + + +
+ + +
+ +
+ +
+
+ ); +}; + +export default WpToolPage;