feat(产品): 添加商品同步状态页面和增强WordPress工具功能
refactor(WpTool): 优化标签生成逻辑并支持更多属性分类 feat(Sync): 新增商品同步状态展示页面 feat(Area): 在站点和仓库管理中添加区域支持 style(Area): 在国家/地区选择器中显示代码
This commit is contained in:
parent
accb93bf16
commit
ce23b66885
|
|
@ -57,11 +57,6 @@ export const layout = (): ProLayoutProps => {
|
||||||
locale: false,
|
locale: false,
|
||||||
},
|
},
|
||||||
menuDataRender: (menuData) => {
|
menuDataRender: (menuData) => {
|
||||||
menuData.unshift({
|
|
||||||
path: '/area',
|
|
||||||
name: '区域管理',
|
|
||||||
icon: <GlobalOutlined />,
|
|
||||||
});
|
|
||||||
return menuData;
|
return menuData;
|
||||||
},
|
},
|
||||||
layout: 'mix',
|
layout: 'mix',
|
||||||
|
|
|
||||||
|
|
@ -181,7 +181,7 @@ const AreaList: React.FC = () => {
|
||||||
<ProFormSelect
|
<ProFormSelect
|
||||||
name="code"
|
name="code"
|
||||||
label="国家/地区"
|
label="国家/地区"
|
||||||
options={countries.map(c => ({ label: c.name, value: c.code }))}
|
options={countries.map(c => ({ label: `${c.name}(${c.code})`, value: c.code }))}
|
||||||
placeholder="请选择国家/地区"
|
placeholder="请选择国家/地区"
|
||||||
rules={[{ required: true, message: '国家/地区为必填项' }]}
|
rules={[{ required: true, message: '国家/地区为必填项' }]}
|
||||||
showSearch
|
showSearch
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,208 @@
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { ProTable, ProColumns } from '@ant-design/pro-components';
|
||||||
|
import { Card, Spin, Empty, message } from 'antd';
|
||||||
|
import request from '@@/plugin-request/request';
|
||||||
|
|
||||||
|
// 定义站点接口
|
||||||
|
interface Site {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
prefix?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 定义WordPress商品接口
|
||||||
|
interface WpProduct {
|
||||||
|
sku: string;
|
||||||
|
name: string;
|
||||||
|
price: string;
|
||||||
|
stockQuantity: number;
|
||||||
|
status: string;
|
||||||
|
attributes?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 定义基础商品信息接口
|
||||||
|
interface ProductBase {
|
||||||
|
sku: string;
|
||||||
|
name: string;
|
||||||
|
attributes: Record<string, any>;
|
||||||
|
wpProducts: Record<string, WpProduct>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 定义API响应接口
|
||||||
|
interface ApiResponse<T> {
|
||||||
|
data: T[];
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模拟API请求函数
|
||||||
|
const getSites = async (): Promise<ApiResponse<Site>> => {
|
||||||
|
return request('/api/sites', {
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getWPProducts = async (): Promise<ApiResponse<WpProduct>> => {
|
||||||
|
return request('/api/wp-products', {
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const ProductSyncPage: React.FC = () => {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [sites, setSites] = useState<Site[]>([]);
|
||||||
|
const [products, setProducts] = useState<ProductBase[]>([]);
|
||||||
|
const [wpProducts, setWpProducts] = useState<WpProduct[]>([]);
|
||||||
|
|
||||||
|
// 从 SKU 中去除站点前缀
|
||||||
|
const removeSitePrefix = (sku: string, sitePrefixes: string[]): string => {
|
||||||
|
for (const prefix of sitePrefixes) {
|
||||||
|
if (prefix && sku.startsWith(prefix)) {
|
||||||
|
return sku.substring(prefix.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sku;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 初始化数据
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
// 获取所有站点
|
||||||
|
const sitesResponse = await getSites();
|
||||||
|
const siteList: Site[] = sitesResponse.data || [];
|
||||||
|
setSites(siteList);
|
||||||
|
|
||||||
|
// 获取所有 WordPress 商品
|
||||||
|
const wpProductsResponse = await getWPProducts();
|
||||||
|
const wpProductList: WpProduct[] = wpProductsResponse.data || [];
|
||||||
|
setWpProducts(wpProductList);
|
||||||
|
|
||||||
|
// 提取所有站点前缀
|
||||||
|
const sitePrefixes = siteList.map(site => site.prefix || '');
|
||||||
|
|
||||||
|
// 按基础 SKU 分组商品
|
||||||
|
const productMap = new Map<string, ProductBase>();
|
||||||
|
|
||||||
|
wpProductList.forEach((wpProduct: WpProduct) => {
|
||||||
|
// 去除前缀获取基础 SKU
|
||||||
|
const baseSku = removeSitePrefix(wpProduct.sku, sitePrefixes);
|
||||||
|
|
||||||
|
if (!productMap.has(baseSku)) {
|
||||||
|
productMap.set(baseSku, {
|
||||||
|
sku: baseSku,
|
||||||
|
name: wpProduct.name,
|
||||||
|
attributes: wpProduct.attributes || {},
|
||||||
|
wpProducts: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查找对应的站点
|
||||||
|
const site = siteList.find((s: Site) => s.prefix && wpProduct.sku.startsWith(s.prefix));
|
||||||
|
if (site) {
|
||||||
|
const product = productMap.get(baseSku);
|
||||||
|
if (product) {
|
||||||
|
product.wpProducts[site.id] = wpProduct;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 转换为数组
|
||||||
|
setProducts(Array.from(productMap.values()));
|
||||||
|
} catch (error) {
|
||||||
|
message.error('获取数据失败,请重试');
|
||||||
|
console.error('Error fetching data:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 生成表格列配置
|
||||||
|
const generateColumns = (): ProColumns<ProductBase>[] => {
|
||||||
|
const columns: ProColumns<ProductBase>[] = [
|
||||||
|
{
|
||||||
|
title: '商品 SKU',
|
||||||
|
dataIndex: 'sku',
|
||||||
|
key: 'sku',
|
||||||
|
width: 150,
|
||||||
|
fixed: 'left',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '商品信息',
|
||||||
|
dataIndex: 'name',
|
||||||
|
key: 'name',
|
||||||
|
width: 200,
|
||||||
|
fixed: 'left',
|
||||||
|
render: (name: string, record: ProductBase) => (
|
||||||
|
<div>
|
||||||
|
<div>{name}</div>
|
||||||
|
<div style={{ marginTop: 4, fontSize: 12, color: '#666' }}>
|
||||||
|
{Object.entries(record.attributes || {})
|
||||||
|
.map(([key, value]) => `${key}: ${value}`)
|
||||||
|
.join(', ')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 为每个站点生成列
|
||||||
|
sites.forEach((site: Site) => {
|
||||||
|
const siteColumn: ProColumns<ProductBase> = {
|
||||||
|
title: site.name,
|
||||||
|
key: `site_${site.id}`,
|
||||||
|
width: 250,
|
||||||
|
render: (_, record: ProductBase) => {
|
||||||
|
const wpProduct = record.wpProducts[site.id];
|
||||||
|
if (!wpProduct) {
|
||||||
|
return <Empty description="未同步" image={Empty.PRESENTED_IMAGE_SIMPLE} />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div>SKU: {wpProduct.sku}</div>
|
||||||
|
<div>价格: {wpProduct.price}</div>
|
||||||
|
<div>库存: {wpProduct.stockQuantity}</div>
|
||||||
|
<div>状态: {wpProduct.status === 'publish' ? '已发布' : '草稿'}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
columns.push(siteColumn);
|
||||||
|
});
|
||||||
|
|
||||||
|
return columns;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card title="商品同步状态" className="product-sync-card">
|
||||||
|
{loading ? (
|
||||||
|
<Spin size="large" style={{ display: 'flex', justifyContent: 'center', padding: 40 }} />
|
||||||
|
) : (
|
||||||
|
<ProTable<ProductBase>
|
||||||
|
columns={generateColumns()}
|
||||||
|
dataSource={products}
|
||||||
|
rowKey="sku"
|
||||||
|
pagination={{
|
||||||
|
pageSize: 10,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showTotal: (total) => `共 ${total} 个商品`,
|
||||||
|
}}
|
||||||
|
scroll={{ x: 'max-content' }}
|
||||||
|
search={{
|
||||||
|
labelWidth: 'auto',
|
||||||
|
}}
|
||||||
|
options={{
|
||||||
|
density: true,
|
||||||
|
}}
|
||||||
|
emptyText="暂无商品数据"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProductSyncPage;
|
||||||
|
|
@ -8,13 +8,18 @@ import { Button, Card, Col, Input, message, Row, Upload } from 'antd';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import * as XLSX from 'xlsx';
|
import * as XLSX from 'xlsx';
|
||||||
import { request } from '@umijs/max';
|
import { request } from '@umijs/max';
|
||||||
|
import { attributes } from '../Attribute/consts';
|
||||||
|
|
||||||
// 定义配置接口
|
// 定义配置接口
|
||||||
interface TagConfig {
|
interface TagConfig {
|
||||||
brands: string[];
|
brands: string[];
|
||||||
fruitKeys: string[];
|
fruitKeys: string[];
|
||||||
mintKeys: string[];
|
mintKeys: string[];
|
||||||
nonFlavorTokens: string[];
|
flavorKeys: string[];
|
||||||
|
strengthKeys: string[];
|
||||||
|
sizeKeys: string[];
|
||||||
|
humidityKeys: string[];
|
||||||
|
categoryKeys: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 移植 Python 脚本中的核心函数
|
// 移植 Python 脚本中的核心函数
|
||||||
|
|
@ -33,9 +38,12 @@ const parseName = (
|
||||||
const mgMatch = nm.match(/(\d+)\s*MG/i);
|
const mgMatch = nm.match(/(\d+)\s*MG/i);
|
||||||
const mg = mgMatch ? mgMatch[1] : '';
|
const mg = mgMatch ? mgMatch[1] : '';
|
||||||
|
|
||||||
|
// 确保品牌按长度降序排序,避免部分匹配(如匹配到 VELO 而不是 VELO MAX)
|
||||||
|
// 这一步其实应该在传入 brands 之前就做好了,但这里再保险一下
|
||||||
|
// 实际调用时 sortedBrands 已经排好序了
|
||||||
for (const b of brands) {
|
for (const b of brands) {
|
||||||
if (nm.toUpperCase().startsWith(b.toUpperCase())) {
|
if (nm.toUpperCase().startsWith(b.toUpperCase())) {
|
||||||
const brand = b.toUpperCase();
|
const brand = b; // 使用字典中的原始大小写
|
||||||
const start = b.length;
|
const start = b.length;
|
||||||
const end = mgMatch ? mgMatch.index : nm.length;
|
const end = mgMatch ? mgMatch.index : nm.length;
|
||||||
let flavorPart = nm.substring(start, end);
|
let flavorPart = nm.substring(start, end);
|
||||||
|
|
@ -45,7 +53,7 @@ const parseName = (
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const firstWord = nm.split(' ')[0]?.toUpperCase() || '';
|
const firstWord = nm.split(' ')[0] || '';
|
||||||
const brand = firstWord;
|
const brand = firstWord;
|
||||||
const end = mgMatch ? mgMatch.index : nm.length;
|
const end = mgMatch ? mgMatch.index : nm.length;
|
||||||
const flavorPart = nm.substring(brand.length, end).trim();
|
const flavorPart = nm.substring(brand.length, end).trim();
|
||||||
|
|
@ -86,10 +94,10 @@ const classifyExtraTags = (
|
||||||
const tokens = splitFlavorTokens(flavorPart);
|
const tokens = splitFlavorTokens(flavorPart);
|
||||||
const fLower = flavorPart.toLowerCase();
|
const fLower = flavorPart.toLowerCase();
|
||||||
const isFruit =
|
const isFruit =
|
||||||
fruitKeys.some((key) => fLower.includes(key)) ||
|
fruitKeys.some((key) => fLower.includes(key.toLowerCase())) ||
|
||||||
tokens.some((t) => fruitKeys.includes(t));
|
tokens.some((t) => fruitKeys.map(k => k.toLowerCase()).includes(t));
|
||||||
const isMint =
|
const isMint =
|
||||||
mintKeys.some((key) => fLower.includes(key)) || tokens.includes('mint');
|
mintKeys.some((key) => fLower.includes(key.toLowerCase())) || tokens.includes('mint');
|
||||||
|
|
||||||
const extras: string[] = [];
|
const extras: string[] = [];
|
||||||
if (isFruit) extras.push('Fruit');
|
if (isFruit) extras.push('Fruit');
|
||||||
|
|
@ -97,6 +105,23 @@ const classifyExtraTags = (
|
||||||
return extras;
|
return extras;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 在文本中匹配属性关键词
|
||||||
|
*/
|
||||||
|
const matchAttributes = (text: string, keys: string[]): string[] => {
|
||||||
|
const matched = new Set<string>();
|
||||||
|
for (const key of keys) {
|
||||||
|
// 使用单词边界匹配,避免部分匹配
|
||||||
|
// 转义正则特殊字符
|
||||||
|
const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
const regex = new RegExp(`\\b${escapedKey}\\b`, 'i');
|
||||||
|
if (regex.test(text)) {
|
||||||
|
matched.add(key.charAt(0).toUpperCase() + key.slice(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(matched);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description 计算最终的 Tags 字符串
|
* @description 计算最终的 Tags 字符串
|
||||||
*/
|
*/
|
||||||
|
|
@ -104,9 +129,15 @@ const computeTags = (name: string, sku: string, config: TagConfig): string => {
|
||||||
const [brand, flavorPart, mg, dryness] = parseName(name, config.brands);
|
const [brand, flavorPart, mg, dryness] = parseName(name, config.brands);
|
||||||
const tokens = splitFlavorTokens(flavorPart);
|
const tokens = splitFlavorTokens(flavorPart);
|
||||||
|
|
||||||
|
// 白名单模式:只保留在 flavorKeys 中的 token
|
||||||
|
// 且对比时忽略大小写
|
||||||
|
const flavorKeysLower = config.flavorKeys.map(k => k.toLowerCase());
|
||||||
|
|
||||||
const tokensForFlavor = tokens.filter(
|
const tokensForFlavor = tokens.filter(
|
||||||
(t) => !config.nonFlavorTokens.includes(t),
|
(t) => flavorKeysLower.includes(t.toLowerCase()),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 将匹配到的 token 转为首字母大写
|
||||||
const flavorTag = tokensForFlavor
|
const flavorTag = tokensForFlavor
|
||||||
.map((t) => t.charAt(0).toUpperCase() + t.slice(1))
|
.map((t) => t.charAt(0).toUpperCase() + t.slice(1))
|
||||||
.join('');
|
.join('');
|
||||||
|
|
@ -115,30 +146,42 @@ const computeTags = (name: string, sku: string, config: TagConfig): string => {
|
||||||
if (brand) tags.push(brand);
|
if (brand) tags.push(brand);
|
||||||
if (flavorTag) tags.push(flavorTag);
|
if (flavorTag) tags.push(flavorTag);
|
||||||
|
|
||||||
|
// 添加额外的口味描述词
|
||||||
for (const t of tokensForFlavor) {
|
for (const t of tokensForFlavor) {
|
||||||
if (config.fruitKeys.includes(t) && t !== 'fruit') {
|
// 检查是否在 fruitKeys 中 (忽略大小写)
|
||||||
|
const isFruitKey = config.fruitKeys.some(k => k.toLowerCase() === t.toLowerCase());
|
||||||
|
if (isFruitKey && t.toLowerCase() !== 'fruit') {
|
||||||
tags.push(t.charAt(0).toUpperCase() + t.slice(1));
|
tags.push(t.charAt(0).toUpperCase() + t.slice(1));
|
||||||
}
|
}
|
||||||
if (t === 'mint') {
|
if (t.toLowerCase() === 'mint') {
|
||||||
tags.push('Mint');
|
tags.push('Mint');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tokens.includes('slim') || /\bslim\b/i.test(name)) {
|
// 匹配 Size (Slim, Mini etc.)
|
||||||
tags.push('Slim');
|
tags.push(...matchAttributes(name, config.sizeKeys));
|
||||||
}
|
|
||||||
if (tokens.includes('mini') || /\bmini\b/i.test(name)) {
|
// 匹配 Humidity (Dry, Moist etc.)
|
||||||
tags.push('Mini');
|
tags.push(...matchAttributes(name, config.humidityKeys));
|
||||||
}
|
|
||||||
if (tokens.includes('dry') || /\bdry\b/i.test(name)) {
|
// 匹配 Category
|
||||||
tags.push('Dry');
|
tags.push(...matchAttributes(name, config.categoryKeys));
|
||||||
}
|
|
||||||
|
// 匹配 Strength (Qualitative like "Strong" or exact matches in dict)
|
||||||
|
tags.push(...matchAttributes(name, config.strengthKeys));
|
||||||
|
|
||||||
|
// 保留原有的 Mix Pack 逻辑
|
||||||
if (/mix/i.test(name) || (sku && /mix/i.test(sku))) {
|
if (/mix/i.test(name) || (sku && /mix/i.test(sku))) {
|
||||||
tags.push('Mix Pack');
|
tags.push('Mix Pack');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 保留原有的 MG 提取逻辑 (Regex is robust for "6MG", "6 MG")
|
||||||
if (mg) {
|
if (mg) {
|
||||||
tags.push(`${mg} mg`);
|
tags.push(`${mg} mg`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 保留原有的 dryness 提取逻辑 (从括号中提取)
|
||||||
|
// 如果 dict 匹配已经覆盖了,去重时会处理
|
||||||
if (dryness) {
|
if (dryness) {
|
||||||
if (/moist/i.test(dryness)) {
|
if (/moist/i.test(dryness)) {
|
||||||
tags.push('Moisture');
|
tags.push('Moisture');
|
||||||
|
|
@ -154,6 +197,8 @@ const computeTags = (name: string, sku: string, config: TagConfig): string => {
|
||||||
// 去重并保留顺序
|
// 去重并保留顺序
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const finalTags = tags.filter((t) => {
|
const finalTags = tags.filter((t) => {
|
||||||
|
// 简单的去重,忽略大小写差异? 或者完全匹配
|
||||||
|
// 这里使用完全匹配,因为前面已经做了一些格式化
|
||||||
if (t && !seen.has(t)) {
|
if (t && !seen.has(t)) {
|
||||||
seen.add(t);
|
seen.add(t);
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -178,32 +223,58 @@ const WpToolPage: React.FC = () => {
|
||||||
brands: [],
|
brands: [],
|
||||||
fruitKeys: [],
|
fruitKeys: [],
|
||||||
mintKeys: [],
|
mintKeys: [],
|
||||||
nonFlavorTokens: [],
|
flavorKeys: [],
|
||||||
|
strengthKeys: [],
|
||||||
|
sizeKeys: [],
|
||||||
|
humidityKeys: [],
|
||||||
|
categoryKeys: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
// 在组件加载时获取字典数据
|
// 在组件加载时获取字典数据
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchDictItems = async (name: string) => {
|
const fetchAllConfigs = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await request('/api/dict/items-by-name', {
|
// 1. 获取所有字典列表以找到对应的 ID
|
||||||
params: { name },
|
const dictList = await request('/dict/list');
|
||||||
});
|
|
||||||
return response.data.map((item: any) => item.name);
|
// 2. 根据字典名称获取字典项
|
||||||
} catch (error) {
|
const getItems = async (dictName: string) => {
|
||||||
message.error(`获取字典 ${name} 失败`);
|
const dict = dictList.find((d: any) => d.name === dictName);
|
||||||
|
if (!dict) {
|
||||||
|
console.warn(`Dictionary ${dictName} not found`);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
const res = await request('/dict/items', { params: { dictId: dict.id } });
|
||||||
|
return res.map((item: any) => item.name);
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchAllConfigs = async () => {
|
const [brands, fruitKeys, mintKeys, flavorKeys, strengthKeys, sizeKeys, humidityKeys, categoryKeys] = await Promise.all([
|
||||||
const [brands, fruitKeys, mintKeys, nonFlavorTokens] = await Promise.all([
|
getItems('brand'),
|
||||||
fetchDictItems('brand'),
|
getItems('fruit'), // 假设字典名为 fruit
|
||||||
fetchDictItems('fruit'),
|
getItems('mint'), // 假设字典名为 mint
|
||||||
fetchDictItems('mint'),
|
getItems('flavor'), // 假设字典名为 flavor
|
||||||
fetchDictItems('non_flavor_tokens'),
|
getItems('strength'),
|
||||||
|
getItems('size'),
|
||||||
|
getItems('humidity'),
|
||||||
|
getItems('category'),
|
||||||
]);
|
]);
|
||||||
setConfig({ brands, fruitKeys, mintKeys, nonFlavorTokens });
|
|
||||||
form.setFieldsValue({ brands, fruitKeys, mintKeys, nonFlavorTokens });
|
const newConfig = {
|
||||||
|
brands,
|
||||||
|
fruitKeys,
|
||||||
|
mintKeys,
|
||||||
|
flavorKeys,
|
||||||
|
strengthKeys,
|
||||||
|
sizeKeys,
|
||||||
|
humidityKeys,
|
||||||
|
categoryKeys
|
||||||
|
};
|
||||||
|
setConfig(newConfig);
|
||||||
|
form.setFieldsValue(newConfig);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch configs:', error);
|
||||||
|
message.error('获取字典配置失败');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchAllConfigs();
|
fetchAllConfigs();
|
||||||
|
|
@ -264,6 +335,26 @@ const WpToolPage: React.FC = () => {
|
||||||
return false; // 阻止 antd Upload 组件的默认上传行为
|
return false; // 阻止 antd Upload 组件的默认上传行为
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 将数据转换回 CSV 并触发下载
|
||||||
|
*/
|
||||||
|
const downloadData = (data: any[]) => {
|
||||||
|
if (data.length === 0) return;
|
||||||
|
|
||||||
|
// 创建一个新的工作簿
|
||||||
|
const workbook = XLSX.utils.book_new();
|
||||||
|
// 将 JSON 数据转换为工作表
|
||||||
|
const worksheet = XLSX.utils.json_to_sheet(data);
|
||||||
|
// 将工作表添加到工作簿
|
||||||
|
XLSX.utils.book_append_sheet(workbook, worksheet, 'Products with Tags');
|
||||||
|
|
||||||
|
// 生成文件名并触发下载
|
||||||
|
const fileName = `products_with_tags_${Date.now()}.xlsx`;
|
||||||
|
XLSX.writeFile(workbook, fileName);
|
||||||
|
|
||||||
|
message.success('下载任务已开始!');
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description 核心逻辑:根据配置处理 CSV 数据并生成 Tags
|
* @description 核心逻辑:根据配置处理 CSV 数据并生成 Tags
|
||||||
*/
|
*/
|
||||||
|
|
@ -281,7 +372,7 @@ const WpToolPage: React.FC = () => {
|
||||||
// 获取表单中的最新配置
|
// 获取表单中的最新配置
|
||||||
const config = await form.validateFields();
|
const config = await form.validateFields();
|
||||||
|
|
||||||
const { brands, fruitKeys, mintKeys, nonFlavorTokens } = config;
|
const { brands, fruitKeys, mintKeys, flavorKeys, strengthKeys, sizeKeys, humidityKeys, categoryKeys } = config;
|
||||||
|
|
||||||
// 确保品牌按长度降序排序
|
// 确保品牌按长度降序排序
|
||||||
const sortedBrands = [...brands].sort((a, b) => b.length - a.length);
|
const sortedBrands = [...brands].sort((a, b) => b.length - a.length);
|
||||||
|
|
@ -294,7 +385,11 @@ const WpToolPage: React.FC = () => {
|
||||||
brands: sortedBrands,
|
brands: sortedBrands,
|
||||||
fruitKeys,
|
fruitKeys,
|
||||||
mintKeys,
|
mintKeys,
|
||||||
nonFlavorTokens,
|
flavorKeys,
|
||||||
|
strengthKeys,
|
||||||
|
sizeKeys,
|
||||||
|
humidityKeys,
|
||||||
|
categoryKeys,
|
||||||
});
|
});
|
||||||
return { ...row, Tags: tags };
|
return { ...row, Tags: tags };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -305,9 +400,13 @@ const WpToolPage: React.FC = () => {
|
||||||
|
|
||||||
setProcessedData(dataWithTags);
|
setProcessedData(dataWithTags);
|
||||||
message.success({
|
message.success({
|
||||||
content: 'Tags 生成成功!现在可以下载了。',
|
content: 'Tags 生成成功!正在自动下载...',
|
||||||
key: 'processing',
|
key: 'processing',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 自动下载
|
||||||
|
downloadData(dataWithTags);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error({
|
message.error({
|
||||||
content: '处理失败,请检查配置或文件。',
|
content: '处理失败,请检查配置或文件。',
|
||||||
|
|
@ -319,30 +418,6 @@ const WpToolPage: React.FC = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* @description 将处理后的数据转换回 CSV 并触发下载
|
|
||||||
*/
|
|
||||||
const handleDownload = () => {
|
|
||||||
// 验证是否已有处理完成的数据
|
|
||||||
if (processedData.length === 0) {
|
|
||||||
message.warning('没有可供下载的数据。请先生成 Tags。');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建一个新的工作簿
|
|
||||||
const workbook = XLSX.utils.book_new();
|
|
||||||
// 将 JSON 数据转换为工作表
|
|
||||||
const worksheet = XLSX.utils.json_to_sheet(processedData);
|
|
||||||
// 将工作表添加到工作簿
|
|
||||||
XLSX.utils.book_append_sheet(workbook, worksheet, 'Products with Tags');
|
|
||||||
|
|
||||||
// 生成文件名并触发下载
|
|
||||||
const fileName = `products_with_tags_${Date.now()}.xlsx`;
|
|
||||||
XLSX.writeFile(workbook, fileName);
|
|
||||||
|
|
||||||
message.success('下载任务已开始!');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer title="WordPress 产品工具">
|
<PageContainer title="WordPress 产品工具">
|
||||||
<Row gutter={[16, 16]}>
|
<Row gutter={[16, 16]}>
|
||||||
|
|
@ -376,22 +451,37 @@ const WpToolPage: React.FC = () => {
|
||||||
placeholder="请输入关键词,按回车确认"
|
placeholder="请输入关键词,按回车确认"
|
||||||
/>
|
/>
|
||||||
<ProFormSelect
|
<ProFormSelect
|
||||||
name="nonFlavorTokens"
|
name="flavorKeys"
|
||||||
label="非口味关键词"
|
label="口味白名单"
|
||||||
|
mode="tags"
|
||||||
|
placeholder="请输入关键词,按回车确认"
|
||||||
|
tooltip="只有在白名单中的词才会被识别为口味。"
|
||||||
|
/>
|
||||||
|
<ProFormSelect
|
||||||
|
name="strengthKeys"
|
||||||
|
label="强度关键词"
|
||||||
|
mode="tags"
|
||||||
|
placeholder="请输入关键词,按回车确认"
|
||||||
|
/>
|
||||||
|
<ProFormSelect
|
||||||
|
name="sizeKeys"
|
||||||
|
label="尺寸关键词"
|
||||||
|
mode="tags"
|
||||||
|
placeholder="请输入关键词,按回车确认"
|
||||||
|
/>
|
||||||
|
<ProFormSelect
|
||||||
|
name="humidityKeys"
|
||||||
|
label="湿度关键词"
|
||||||
|
mode="tags"
|
||||||
|
placeholder="请输入关键词,按回车确认"
|
||||||
|
/>
|
||||||
|
<ProFormSelect
|
||||||
|
name="categoryKeys"
|
||||||
|
label="分类关键词"
|
||||||
mode="tags"
|
mode="tags"
|
||||||
placeholder="请输入关键词,按回车确认"
|
placeholder="请输入关键词,按回车确认"
|
||||||
tooltip="这些词将从口味中剔除,例如 slim, mini, dry 等。"
|
|
||||||
/>
|
/>
|
||||||
</ProForm>
|
</ProForm>
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
onClick={() => form.submit()}
|
|
||||||
loading={isProcessing}
|
|
||||||
disabled={csvData.length === 0}
|
|
||||||
style={{ marginTop: 24 }}
|
|
||||||
>
|
|
||||||
生成 Tags
|
|
||||||
</Button>
|
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
|
|
@ -418,11 +508,12 @@ const WpToolPage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
onClick={handleDownload}
|
onClick={handleProcessData}
|
||||||
disabled={processedData.length === 0 || isProcessing}
|
disabled={csvData.length === 0 || isProcessing}
|
||||||
|
loading={isProcessing}
|
||||||
style={{ marginTop: '20px' }}
|
style={{ marginTop: '20px' }}
|
||||||
>
|
>
|
||||||
下载处理后的 CSV
|
生成并下载 Tags
|
||||||
</Button>
|
</Button>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,12 @@ import { request } from '@umijs/max';
|
||||||
import { Button, message, Popconfirm, Space, Tag } from 'antd';
|
import { Button, message, Popconfirm, Space, Tag } from 'antd';
|
||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
// 区域数据项类型
|
||||||
|
interface AreaItem {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
// 站点数据项类型(前端不包含密钥字段,后端列表不返回密钥)
|
// 站点数据项类型(前端不包含密钥字段,后端列表不返回密钥)
|
||||||
interface SiteItem {
|
interface SiteItem {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -20,6 +26,7 @@ interface SiteItem {
|
||||||
type?: 'woocommerce' | 'shopyy';
|
type?: 'woocommerce' | 'shopyy';
|
||||||
skuPrefix?: string;
|
skuPrefix?: string;
|
||||||
isDisabled: number;
|
isDisabled: number;
|
||||||
|
areas?: AreaItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建/更新表单的值类型,包含可选的密钥字段
|
// 创建/更新表单的值类型,包含可选的密钥字段
|
||||||
|
|
@ -31,6 +38,7 @@ interface SiteFormValues {
|
||||||
consumerKey?: string; // WooCommerce REST API 的 consumer key
|
consumerKey?: string; // WooCommerce REST API 的 consumer key
|
||||||
consumerSecret?: string; // WooCommerce REST API 的 consumer secret
|
consumerSecret?: string; // WooCommerce REST API 的 consumer secret
|
||||||
skuPrefix?: string;
|
skuPrefix?: string;
|
||||||
|
areas?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const SiteList: React.FC = () => {
|
const SiteList: React.FC = () => {
|
||||||
|
|
@ -50,6 +58,7 @@ const SiteList: React.FC = () => {
|
||||||
isDisabled: !!editing.isDisabled,
|
isDisabled: !!editing.isDisabled,
|
||||||
consumerKey: undefined,
|
consumerKey: undefined,
|
||||||
consumerSecret: undefined,
|
consumerSecret: undefined,
|
||||||
|
areas: editing.areas?.map((area) => area.code) ?? [],
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
formRef.current?.setFieldsValue({
|
formRef.current?.setFieldsValue({
|
||||||
|
|
@ -91,6 +100,24 @@ const SiteList: React.FC = () => {
|
||||||
{ label: 'Shopyy', value: 'shopyy' },
|
{ label: 'Shopyy', value: 'shopyy' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: '区域',
|
||||||
|
dataIndex: 'areas',
|
||||||
|
width: 200,
|
||||||
|
hideInSearch: true,
|
||||||
|
render: (_, row) => {
|
||||||
|
if (!row.areas || row.areas.length === 0) {
|
||||||
|
return <Tag color="blue">全球</Tag>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Space wrap>
|
||||||
|
{row.areas.map((area) => (
|
||||||
|
<Tag key={area.code}>{area.name}</Tag>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
dataIndex: 'isDisabled',
|
dataIndex: 'isDisabled',
|
||||||
|
|
@ -184,6 +211,7 @@ const SiteList: React.FC = () => {
|
||||||
? { isDisabled: values.isDisabled }
|
? { isDisabled: values.isDisabled }
|
||||||
: {}),
|
: {}),
|
||||||
...(values.skuPrefix ? { skuPrefix: values.skuPrefix } : {}),
|
...(values.skuPrefix ? { skuPrefix: values.skuPrefix } : {}),
|
||||||
|
areas: values.areas ?? [],
|
||||||
};
|
};
|
||||||
// 仅当输入了新密钥时才提交,未输入则保持原本值
|
// 仅当输入了新密钥时才提交,未输入则保持原本值
|
||||||
if (values.consumerKey && values.consumerKey.trim()) {
|
if (values.consumerKey && values.consumerKey.trim()) {
|
||||||
|
|
@ -210,6 +238,7 @@ const SiteList: React.FC = () => {
|
||||||
consumerKey: values.consumerKey,
|
consumerKey: values.consumerKey,
|
||||||
consumerSecret: values.consumerSecret,
|
consumerSecret: values.consumerSecret,
|
||||||
skuPrefix: values.skuPrefix,
|
skuPrefix: values.skuPrefix,
|
||||||
|
areas: values.areas ?? [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -282,6 +311,30 @@ const SiteList: React.FC = () => {
|
||||||
/>
|
/>
|
||||||
{/* 是否禁用 */}
|
{/* 是否禁用 */}
|
||||||
<ProFormSwitch name="isDisabled" label="禁用" />
|
<ProFormSwitch name="isDisabled" label="禁用" />
|
||||||
|
{/* 区域选择 */}
|
||||||
|
<ProFormSelect
|
||||||
|
name="areas"
|
||||||
|
label="区域"
|
||||||
|
mode="multiple"
|
||||||
|
placeholder="留空表示全球"
|
||||||
|
request={async () => {
|
||||||
|
try {
|
||||||
|
const resp = await request('/area', {
|
||||||
|
method: 'GET',
|
||||||
|
params: { pageSize: 1000 },
|
||||||
|
});
|
||||||
|
if (resp.success) {
|
||||||
|
return resp.data.list.map((area: AreaItem) => ({
|
||||||
|
label: area.name,
|
||||||
|
value: area.code,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
} catch (e) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<ProFormText
|
<ProFormText
|
||||||
name="skuPrefix"
|
name="skuPrefix"
|
||||||
label="SKU 前缀"
|
label="SKU 前缀"
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,20 @@ import {
|
||||||
PageContainer,
|
PageContainer,
|
||||||
ProColumns,
|
ProColumns,
|
||||||
ProForm,
|
ProForm,
|
||||||
|
ProFormSelect,
|
||||||
ProFormText,
|
ProFormText,
|
||||||
ProTable,
|
ProTable,
|
||||||
} from '@ant-design/pro-components';
|
} from '@ant-design/pro-components';
|
||||||
import { App, Button, Divider, Popconfirm } from 'antd';
|
import { request } from '@umijs/max';
|
||||||
|
import { App, Button, Divider, Popconfirm, Space, Tag } from 'antd';
|
||||||
import { useRef } from 'react';
|
import { useRef } from 'react';
|
||||||
|
|
||||||
|
// 区域数据项类型
|
||||||
|
interface AreaItem {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
const ListPage: React.FC = () => {
|
const ListPage: React.FC = () => {
|
||||||
const { message } = App.useApp();
|
const { message } = App.useApp();
|
||||||
const actionRef = useRef<ActionType>();
|
const actionRef = useRef<ActionType>();
|
||||||
|
|
@ -37,6 +45,22 @@ const ListPage: React.FC = () => {
|
||||||
title: '联系电话',
|
title: '联系电话',
|
||||||
dataIndex: 'contactPhone',
|
dataIndex: 'contactPhone',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: '区域',
|
||||||
|
dataIndex: 'areas',
|
||||||
|
render: (_, record: any) => {
|
||||||
|
if (!record.areas || record.areas.length === 0) {
|
||||||
|
return <Tag color="blue">全球</Tag>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Space wrap>
|
||||||
|
{record.areas.map((area: any) => (
|
||||||
|
<Tag key={area.code}>{area.name}</Tag>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '创建时间',
|
title: '创建时间',
|
||||||
dataIndex: 'createdAt',
|
dataIndex: 'createdAt',
|
||||||
|
|
@ -160,6 +184,30 @@ const CreateForm: React.FC<{
|
||||||
placeholder="请输入联系电话"
|
placeholder="请输入联系电话"
|
||||||
rules={[{ required: true, message: '请输入联系电话' }]}
|
rules={[{ required: true, message: '请输入联系电话' }]}
|
||||||
/>
|
/>
|
||||||
|
<ProFormSelect
|
||||||
|
name="areas"
|
||||||
|
label="区域"
|
||||||
|
width="lg"
|
||||||
|
mode="multiple"
|
||||||
|
placeholder="留空表示全球"
|
||||||
|
request={async () => {
|
||||||
|
try {
|
||||||
|
const resp = await request('/area', {
|
||||||
|
method: 'GET',
|
||||||
|
params: { pageSize: 1000 },
|
||||||
|
});
|
||||||
|
if (resp.success) {
|
||||||
|
return resp.data.list.map((area: AreaItem) => ({
|
||||||
|
label: area.name,
|
||||||
|
value: area.code,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
} catch (e) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</ProForm.Group>
|
</ProForm.Group>
|
||||||
</DrawerForm>
|
</DrawerForm>
|
||||||
);
|
);
|
||||||
|
|
@ -173,7 +221,10 @@ const UpdateForm: React.FC<{
|
||||||
return (
|
return (
|
||||||
<DrawerForm<API.UpdateStockPointDTO>
|
<DrawerForm<API.UpdateStockPointDTO>
|
||||||
title="编辑"
|
title="编辑"
|
||||||
initialValues={initialValues}
|
initialValues={{
|
||||||
|
...initialValues,
|
||||||
|
areas: ((initialValues as any).areas?.map((area: any) => area.code) ?? []),
|
||||||
|
}}
|
||||||
trigger={
|
trigger={
|
||||||
<Button type="primary">
|
<Button type="primary">
|
||||||
<EditOutlined />
|
<EditOutlined />
|
||||||
|
|
@ -231,6 +282,30 @@ const UpdateForm: React.FC<{
|
||||||
placeholder="请输入联系电话"
|
placeholder="请输入联系电话"
|
||||||
rules={[{ required: true, message: '请输入联系电话' }]}
|
rules={[{ required: true, message: '请输入联系电话' }]}
|
||||||
/>
|
/>
|
||||||
|
<ProFormSelect
|
||||||
|
name="areas"
|
||||||
|
label="区域"
|
||||||
|
width="lg"
|
||||||
|
mode="multiple"
|
||||||
|
placeholder="留空表示全球"
|
||||||
|
request={async () => {
|
||||||
|
try {
|
||||||
|
const resp = await request('/area', {
|
||||||
|
method: 'GET',
|
||||||
|
params: { pageSize: 1000 },
|
||||||
|
});
|
||||||
|
if (resp.success) {
|
||||||
|
return resp.data.list.map((area: AreaItem) => ({
|
||||||
|
label: area.name,
|
||||||
|
value: area.code,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
} catch (e) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</ProForm.Group>
|
</ProForm.Group>
|
||||||
</DrawerForm>
|
</DrawerForm>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue