feat: 添加产品工具, 重构产品 #31
|
|
@ -85,6 +85,11 @@ export default defineConfig({
|
||||||
path: '/product/wp_list',
|
path: '/product/wp_list',
|
||||||
component: './Product/WpList',
|
component: './Product/WpList',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'WP工具箱',
|
||||||
|
path: '/product/wp_tool',
|
||||||
|
component: './Product/WpTool',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
"@ant-design/icons": "^5.0.1",
|
"@ant-design/icons": "^5.0.1",
|
||||||
"@ant-design/pro-components": "^2.4.4",
|
"@ant-design/pro-components": "^2.4.4",
|
||||||
"@fingerprintjs/fingerprintjs": "^4.6.2",
|
"@fingerprintjs/fingerprintjs": "^4.6.2",
|
||||||
|
|
||||||
"@umijs/max": "^4.4.4",
|
"@umijs/max": "^4.4.4",
|
||||||
"@umijs/max-plugin-openapi": "^2.0.3",
|
"@umijs/max-plugin-openapi": "^2.0.3",
|
||||||
"@umijs/plugin-openapi": "^1.3.3",
|
"@umijs/plugin-openapi": "^1.3.3",
|
||||||
|
|
@ -24,6 +25,7 @@
|
||||||
"echarts": "^5.6.0",
|
"echarts": "^5.6.0",
|
||||||
"echarts-for-react": "^3.0.2",
|
"echarts-for-react": "^3.0.2",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
|
"papaparse": "^5.5.3",
|
||||||
"print-js": "^1.6.0",
|
"print-js": "^1.6.0",
|
||||||
"react-phone-input-2": "^2.15.1",
|
"react-phone-input-2": "^2.15.1",
|
||||||
"react-toastify": "^11.0.5",
|
"react-toastify": "^11.0.5",
|
||||||
|
|
@ -32,6 +34,7 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.0.33",
|
"@types/react": "^18.0.33",
|
||||||
"@types/react-dom": "^18.0.11",
|
"@types/react-dom": "^18.0.11",
|
||||||
|
"@types/papaparse": "^5.5.0",
|
||||||
"code-inspector-plugin": "^1.2.10",
|
"code-inspector-plugin": "^1.2.10",
|
||||||
"husky": "^9",
|
"husky": "^9",
|
||||||
"lint-staged": "^13.2.0",
|
"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