feat: 添加产品工具, 重构产品 #31

Closed
zksu wants to merge 37 commits from (deleted):main into main
3 changed files with 390 additions and 0 deletions
Showing only changes of commit 867b7f74d4 - Show all commits

View File

@ -85,6 +85,11 @@ export default defineConfig({
path: '/product/wp_list',
component: './Product/WpList',
},
{
name: 'WP工具箱',
path: '/product/wp_tool',
component: './Product/WpTool',
},
],
},
{

View File

@ -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",

View File

@ -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;