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,
|
||||
},
|
||||
menuDataRender: (menuData) => {
|
||||
menuData.unshift({
|
||||
path: '/area',
|
||||
name: '区域管理',
|
||||
icon: <GlobalOutlined />,
|
||||
});
|
||||
return menuData;
|
||||
},
|
||||
layout: 'mix',
|
||||
|
|
|
|||
|
|
@ -181,7 +181,7 @@ const AreaList: React.FC = () => {
|
|||
<ProFormSelect
|
||||
name="code"
|
||||
label="国家/地区"
|
||||
options={countries.map(c => ({ label: c.name, value: c.code }))}
|
||||
options={countries.map(c => ({ label: `${c.name}(${c.code})`, value: c.code }))}
|
||||
placeholder="请选择国家/地区"
|
||||
rules={[{ required: true, message: '国家/地区为必填项' }]}
|
||||
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 * as XLSX from 'xlsx';
|
||||
import { request } from '@umijs/max';
|
||||
import { attributes } from '../Attribute/consts';
|
||||
|
||||
// 定义配置接口
|
||||
interface TagConfig {
|
||||
brands: string[];
|
||||
fruitKeys: string[];
|
||||
mintKeys: string[];
|
||||
nonFlavorTokens: string[];
|
||||
flavorKeys: string[];
|
||||
strengthKeys: string[];
|
||||
sizeKeys: string[];
|
||||
humidityKeys: string[];
|
||||
categoryKeys: string[];
|
||||
}
|
||||
|
||||
// 移植 Python 脚本中的核心函数
|
||||
|
|
@ -33,9 +38,12 @@ const parseName = (
|
|||
const mgMatch = nm.match(/(\d+)\s*MG/i);
|
||||
const mg = mgMatch ? mgMatch[1] : '';
|
||||
|
||||
// 确保品牌按长度降序排序,避免部分匹配(如匹配到 VELO 而不是 VELO MAX)
|
||||
// 这一步其实应该在传入 brands 之前就做好了,但这里再保险一下
|
||||
// 实际调用时 sortedBrands 已经排好序了
|
||||
for (const b of brands) {
|
||||
if (nm.toUpperCase().startsWith(b.toUpperCase())) {
|
||||
const brand = b.toUpperCase();
|
||||
const brand = b; // 使用字典中的原始大小写
|
||||
const start = b.length;
|
||||
const end = mgMatch ? mgMatch.index : nm.length;
|
||||
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 end = mgMatch ? mgMatch.index : nm.length;
|
||||
const flavorPart = nm.substring(brand.length, end).trim();
|
||||
|
|
@ -86,10 +94,10 @@ const classifyExtraTags = (
|
|||
const tokens = splitFlavorTokens(flavorPart);
|
||||
const fLower = flavorPart.toLowerCase();
|
||||
const isFruit =
|
||||
fruitKeys.some((key) => fLower.includes(key)) ||
|
||||
tokens.some((t) => fruitKeys.includes(t));
|
||||
fruitKeys.some((key) => fLower.includes(key.toLowerCase())) ||
|
||||
tokens.some((t) => fruitKeys.map(k => k.toLowerCase()).includes(t));
|
||||
const isMint =
|
||||
mintKeys.some((key) => fLower.includes(key)) || tokens.includes('mint');
|
||||
mintKeys.some((key) => fLower.includes(key.toLowerCase())) || tokens.includes('mint');
|
||||
|
||||
const extras: string[] = [];
|
||||
if (isFruit) extras.push('Fruit');
|
||||
|
|
@ -97,6 +105,23 @@ const classifyExtraTags = (
|
|||
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 字符串
|
||||
*/
|
||||
|
|
@ -104,9 +129,15 @@ const computeTags = (name: string, sku: string, config: TagConfig): string => {
|
|||
const [brand, flavorPart, mg, dryness] = parseName(name, config.brands);
|
||||
const tokens = splitFlavorTokens(flavorPart);
|
||||
|
||||
// 白名单模式:只保留在 flavorKeys 中的 token
|
||||
// 且对比时忽略大小写
|
||||
const flavorKeysLower = config.flavorKeys.map(k => k.toLowerCase());
|
||||
|
||||
const tokensForFlavor = tokens.filter(
|
||||
(t) => !config.nonFlavorTokens.includes(t),
|
||||
(t) => flavorKeysLower.includes(t.toLowerCase()),
|
||||
);
|
||||
|
||||
// 将匹配到的 token 转为首字母大写
|
||||
const flavorTag = tokensForFlavor
|
||||
.map((t) => t.charAt(0).toUpperCase() + t.slice(1))
|
||||
.join('');
|
||||
|
|
@ -115,30 +146,42 @@ const computeTags = (name: string, sku: string, config: TagConfig): string => {
|
|||
if (brand) tags.push(brand);
|
||||
if (flavorTag) tags.push(flavorTag);
|
||||
|
||||
// 添加额外的口味描述词
|
||||
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));
|
||||
}
|
||||
if (t === 'mint') {
|
||||
if (t.toLowerCase() === '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');
|
||||
}
|
||||
// 匹配 Size (Slim, Mini etc.)
|
||||
tags.push(...matchAttributes(name, config.sizeKeys));
|
||||
|
||||
// 匹配 Humidity (Dry, Moist etc.)
|
||||
tags.push(...matchAttributes(name, config.humidityKeys));
|
||||
|
||||
// 匹配 Category
|
||||
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))) {
|
||||
tags.push('Mix Pack');
|
||||
}
|
||||
|
||||
// 保留原有的 MG 提取逻辑 (Regex is robust for "6MG", "6 MG")
|
||||
if (mg) {
|
||||
tags.push(`${mg} mg`);
|
||||
}
|
||||
|
||||
// 保留原有的 dryness 提取逻辑 (从括号中提取)
|
||||
// 如果 dict 匹配已经覆盖了,去重时会处理
|
||||
if (dryness) {
|
||||
if (/moist/i.test(dryness)) {
|
||||
tags.push('Moisture');
|
||||
|
|
@ -154,6 +197,8 @@ const computeTags = (name: string, sku: string, config: TagConfig): string => {
|
|||
// 去重并保留顺序
|
||||
const seen = new Set<string>();
|
||||
const finalTags = tags.filter((t) => {
|
||||
// 简单的去重,忽略大小写差异? 或者完全匹配
|
||||
// 这里使用完全匹配,因为前面已经做了一些格式化
|
||||
if (t && !seen.has(t)) {
|
||||
seen.add(t);
|
||||
return true;
|
||||
|
|
@ -178,32 +223,58 @@ const WpToolPage: React.FC = () => {
|
|||
brands: [],
|
||||
fruitKeys: [],
|
||||
mintKeys: [],
|
||||
nonFlavorTokens: [],
|
||||
flavorKeys: [],
|
||||
strengthKeys: [],
|
||||
sizeKeys: [],
|
||||
humidityKeys: [],
|
||||
categoryKeys: [],
|
||||
});
|
||||
|
||||
// 在组件加载时获取字典数据
|
||||
useEffect(() => {
|
||||
const fetchDictItems = async (name: string) => {
|
||||
const fetchAllConfigs = async () => {
|
||||
try {
|
||||
const response = await request('/api/dict/items-by-name', {
|
||||
params: { name },
|
||||
});
|
||||
return response.data.map((item: any) => item.name);
|
||||
} catch (error) {
|
||||
message.error(`获取字典 ${name} 失败`);
|
||||
// 1. 获取所有字典列表以找到对应的 ID
|
||||
const dictList = await request('/dict/list');
|
||||
|
||||
// 2. 根据字典名称获取字典项
|
||||
const getItems = async (dictName: string) => {
|
||||
const dict = dictList.find((d: any) => d.name === dictName);
|
||||
if (!dict) {
|
||||
console.warn(`Dictionary ${dictName} not found`);
|
||||
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, nonFlavorTokens] = await Promise.all([
|
||||
fetchDictItems('brand'),
|
||||
fetchDictItems('fruit'),
|
||||
fetchDictItems('mint'),
|
||||
fetchDictItems('non_flavor_tokens'),
|
||||
const [brands, fruitKeys, mintKeys, flavorKeys, strengthKeys, sizeKeys, humidityKeys, categoryKeys] = await Promise.all([
|
||||
getItems('brand'),
|
||||
getItems('fruit'), // 假设字典名为 fruit
|
||||
getItems('mint'), // 假设字典名为 mint
|
||||
getItems('flavor'), // 假设字典名为 flavor
|
||||
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();
|
||||
|
|
@ -264,6 +335,26 @@ const WpToolPage: React.FC = () => {
|
|||
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
|
||||
*/
|
||||
|
|
@ -281,7 +372,7 @@ const WpToolPage: React.FC = () => {
|
|||
// 获取表单中的最新配置
|
||||
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);
|
||||
|
|
@ -294,7 +385,11 @@ const WpToolPage: React.FC = () => {
|
|||
brands: sortedBrands,
|
||||
fruitKeys,
|
||||
mintKeys,
|
||||
nonFlavorTokens,
|
||||
flavorKeys,
|
||||
strengthKeys,
|
||||
sizeKeys,
|
||||
humidityKeys,
|
||||
categoryKeys,
|
||||
});
|
||||
return { ...row, Tags: tags };
|
||||
} catch (e) {
|
||||
|
|
@ -305,9 +400,13 @@ const WpToolPage: React.FC = () => {
|
|||
|
||||
setProcessedData(dataWithTags);
|
||||
message.success({
|
||||
content: 'Tags 生成成功!现在可以下载了。',
|
||||
content: 'Tags 生成成功!正在自动下载...',
|
||||
key: 'processing',
|
||||
});
|
||||
|
||||
// 自动下载
|
||||
downloadData(dataWithTags);
|
||||
|
||||
} catch (error) {
|
||||
message.error({
|
||||
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 (
|
||||
<PageContainer title="WordPress 产品工具">
|
||||
<Row gutter={[16, 16]}>
|
||||
|
|
@ -376,22 +451,37 @@ const WpToolPage: React.FC = () => {
|
|||
placeholder="请输入关键词,按回车确认"
|
||||
/>
|
||||
<ProFormSelect
|
||||
name="nonFlavorTokens"
|
||||
label="非口味关键词"
|
||||
name="flavorKeys"
|
||||
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"
|
||||
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>
|
||||
|
||||
|
|
@ -418,11 +508,12 @@ const WpToolPage: React.FC = () => {
|
|||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleDownload}
|
||||
disabled={processedData.length === 0 || isProcessing}
|
||||
onClick={handleProcessData}
|
||||
disabled={csvData.length === 0 || isProcessing}
|
||||
loading={isProcessing}
|
||||
style={{ marginTop: '20px' }}
|
||||
>
|
||||
下载处理后的 CSV
|
||||
生成并下载 Tags
|
||||
</Button>
|
||||
</Card>
|
||||
</Col>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,12 @@ import { request } from '@umijs/max';
|
|||
import { Button, message, Popconfirm, Space, Tag } from 'antd';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
// 区域数据项类型
|
||||
interface AreaItem {
|
||||
code: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// 站点数据项类型(前端不包含密钥字段,后端列表不返回密钥)
|
||||
interface SiteItem {
|
||||
id: number;
|
||||
|
|
@ -20,6 +26,7 @@ interface SiteItem {
|
|||
type?: 'woocommerce' | 'shopyy';
|
||||
skuPrefix?: string;
|
||||
isDisabled: number;
|
||||
areas?: AreaItem[];
|
||||
}
|
||||
|
||||
// 创建/更新表单的值类型,包含可选的密钥字段
|
||||
|
|
@ -31,6 +38,7 @@ interface SiteFormValues {
|
|||
consumerKey?: string; // WooCommerce REST API 的 consumer key
|
||||
consumerSecret?: string; // WooCommerce REST API 的 consumer secret
|
||||
skuPrefix?: string;
|
||||
areas?: string[];
|
||||
}
|
||||
|
||||
const SiteList: React.FC = () => {
|
||||
|
|
@ -50,6 +58,7 @@ const SiteList: React.FC = () => {
|
|||
isDisabled: !!editing.isDisabled,
|
||||
consumerKey: undefined,
|
||||
consumerSecret: undefined,
|
||||
areas: editing.areas?.map((area) => area.code) ?? [],
|
||||
});
|
||||
} else {
|
||||
formRef.current?.setFieldsValue({
|
||||
|
|
@ -91,6 +100,24 @@ const SiteList: React.FC = () => {
|
|||
{ 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: '状态',
|
||||
dataIndex: 'isDisabled',
|
||||
|
|
@ -184,6 +211,7 @@ const SiteList: React.FC = () => {
|
|||
? { isDisabled: values.isDisabled }
|
||||
: {}),
|
||||
...(values.skuPrefix ? { skuPrefix: values.skuPrefix } : {}),
|
||||
areas: values.areas ?? [],
|
||||
};
|
||||
// 仅当输入了新密钥时才提交,未输入则保持原本值
|
||||
if (values.consumerKey && values.consumerKey.trim()) {
|
||||
|
|
@ -210,6 +238,7 @@ const SiteList: React.FC = () => {
|
|||
consumerKey: values.consumerKey,
|
||||
consumerSecret: values.consumerSecret,
|
||||
skuPrefix: values.skuPrefix,
|
||||
areas: values.areas ?? [],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -282,6 +311,30 @@ const SiteList: React.FC = () => {
|
|||
/>
|
||||
{/* 是否禁用 */}
|
||||
<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
|
||||
name="skuPrefix"
|
||||
label="SKU 前缀"
|
||||
|
|
|
|||
|
|
@ -11,12 +11,20 @@ import {
|
|||
PageContainer,
|
||||
ProColumns,
|
||||
ProForm,
|
||||
ProFormSelect,
|
||||
ProFormText,
|
||||
ProTable,
|
||||
} 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';
|
||||
|
||||
// 区域数据项类型
|
||||
interface AreaItem {
|
||||
code: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const ListPage: React.FC = () => {
|
||||
const { message } = App.useApp();
|
||||
const actionRef = useRef<ActionType>();
|
||||
|
|
@ -37,6 +45,22 @@ const ListPage: React.FC = () => {
|
|||
title: '联系电话',
|
||||
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: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
|
|
@ -160,6 +184,30 @@ const CreateForm: React.FC<{
|
|||
placeholder="请输入联系电话"
|
||||
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>
|
||||
</DrawerForm>
|
||||
);
|
||||
|
|
@ -173,7 +221,10 @@ const UpdateForm: React.FC<{
|
|||
return (
|
||||
<DrawerForm<API.UpdateStockPointDTO>
|
||||
title="编辑"
|
||||
initialValues={initialValues}
|
||||
initialValues={{
|
||||
...initialValues,
|
||||
areas: ((initialValues as any).areas?.map((area: any) => area.code) ?? []),
|
||||
}}
|
||||
trigger={
|
||||
<Button type="primary">
|
||||
<EditOutlined />
|
||||
|
|
@ -231,6 +282,30 @@ const UpdateForm: React.FC<{
|
|||
placeholder="请输入联系电话"
|
||||
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>
|
||||
</DrawerForm>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in New Issue