feat(product): 添加WP工具箱页面用于处理产品CSV生成标签
新增WP工具箱功能页面,包含CSV文件上传、解析和处理逻辑,能够根据产品名称和配置生成标准化的标签。添加了papaparse依赖用于CSV处理,并实现了完整的标签生成算法和下载功能。
This commit is contained in:
parent
5fdbce88cd
commit
867b7f74d4
|
|
@ -85,6 +85,11 @@ export default defineConfig({
|
|||
path: '/product/wp_list',
|
||||
component: './Product/WpList',
|
||||
},
|
||||
{
|
||||
name: 'WP工具箱',
|
||||
path: '/product/wp_tool',
|
||||
component: './Product/WpTool',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<string>();
|
||||
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<File | null>(null); // 上传的文件
|
||||
const [csvData, setCsvData] = useState<any[]>([]); // 解析后的 CSV 数据
|
||||
const [processedData, setProcessedData] = useState<any[]>([]); // 处理后待下载的数据
|
||||
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 (
|
||||
<PageContainer title="WordPress 产品工具">
|
||||
<Row gutter={[16, 16]}>
|
||||
{/* 左侧:配置表单 */}
|
||||
<Col xs={24} md={10}>
|
||||
<Card title="1. 配置映射规则">
|
||||
<ProForm
|
||||
form={form}
|
||||
initialValues={DEFAULT_CONFIG}
|
||||
onFinish={handleProcessData}
|
||||
submitter={false}
|
||||
>
|
||||
<ProFormSelect
|
||||
name="brands"
|
||||
label="品牌列表"
|
||||
mode="tags"
|
||||
placeholder="请输入品牌,按回车确认"
|
||||
rules={[{ required: true, message: '至少需要一个品牌' }]}
|
||||
tooltip="按品牌名称长度倒序匹配,请将较长的品牌(如 WHITE FOX)放在前面。"
|
||||
/>
|
||||
<ProFormSelect
|
||||
name="fruitKeys"
|
||||
label="水果关键词"
|
||||
mode="tags"
|
||||
placeholder="请输入关键词,按回车确认"
|
||||
/>
|
||||
<ProFormSelect
|
||||
name="mintKeys"
|
||||
label="薄荷关键词"
|
||||
mode="tags"
|
||||
placeholder="请输入关键词,按回车确认"
|
||||
/>
|
||||
<ProFormSelect
|
||||
name="nonFlavorTokens"
|
||||
label="非口味关键词"
|
||||
mode="tags"
|
||||
placeholder="请输入关键词,按回车确认"
|
||||
tooltip="这些词将从口味中剔除,例如 slim, mini, dry 等。"
|
||||
/>
|
||||
</ProForm>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => form.submit()}
|
||||
loading={isProcessing}
|
||||
disabled={csvData.length === 0}
|
||||
style={{ marginTop: 24 }}
|
||||
>
|
||||
生成 Tags
|
||||
</Button>
|
||||
</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={handleDownload}
|
||||
disabled={processedData.length === 0 || isProcessing}
|
||||
style={{ marginTop: '20px' }}
|
||||
>
|
||||
下载处理后的 CSV
|
||||
</Button>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default WpToolPage;
|
||||
Loading…
Reference in New Issue