feat(产品): 添加商品同步状态页面和增强WordPress工具功能

refactor(WpTool): 优化标签生成逻辑并支持更多属性分类
feat(Sync): 新增商品同步状态展示页面
feat(Area): 在站点和仓库管理中添加区域支持
style(Area): 在国家/地区选择器中显示代码
This commit is contained in:
tikkhun 2025-12-02 10:55:08 +08:00
parent accb93bf16
commit ce23b66885
6 changed files with 513 additions and 91 deletions

View File

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

View File

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

View File

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

View File

@ -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 中 (忽略大小写)
tags.push(t.charAt(0).toUpperCase() + t.slice(1)); 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'); 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) => {
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} 失败`);
return [];
}
};
const fetchAllConfigs = async () => { const fetchAllConfigs = async () => {
const [brands, fruitKeys, mintKeys, nonFlavorTokens] = await Promise.all([ try {
fetchDictItems('brand'), // 1. 获取所有字典列表以找到对应的 ID
fetchDictItems('fruit'), const dictList = await request('/dict/list');
fetchDictItems('mint'),
fetchDictItems('non_flavor_tokens'), // 2. 根据字典名称获取字典项
]); const getItems = async (dictName: string) => {
setConfig({ brands, fruitKeys, mintKeys, nonFlavorTokens }); const dict = dictList.find((d: any) => d.name === dictName);
form.setFieldsValue({ brands, fruitKeys, mintKeys, nonFlavorTokens }); 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 [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'),
]);
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>

View File

@ -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 前缀"

View File

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