From ce23b66885b384d3f45d2a758fa1744a51130a30 Mon Sep 17 00:00:00 2001 From: tikkhun Date: Tue, 2 Dec 2025 10:55:08 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E4=BA=A7=E5=93=81):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E5=95=86=E5=93=81=E5=90=8C=E6=AD=A5=E7=8A=B6=E6=80=81=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E5=92=8C=E5=A2=9E=E5=BC=BAWordPress=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refactor(WpTool): 优化标签生成逻辑并支持更多属性分类 feat(Sync): 新增商品同步状态展示页面 feat(Area): 在站点和仓库管理中添加区域支持 style(Area): 在国家/地区选择器中显示代码 --- src/app.tsx | 5 - src/pages/Area/List/index.tsx | 2 +- src/pages/Product/Sync/index.tsx | 208 ++++++++++++++++++++++ src/pages/Product/WpTool/index.tsx | 257 +++++++++++++++++++--------- src/pages/Site/List/index.tsx | 53 ++++++ src/pages/Stock/Warehouse/index.tsx | 79 ++++++++- 6 files changed, 513 insertions(+), 91 deletions(-) diff --git a/src/app.tsx b/src/app.tsx index 2307ffd..abc29b2 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -57,11 +57,6 @@ export const layout = (): ProLayoutProps => { locale: false, }, menuDataRender: (menuData) => { - menuData.unshift({ - path: '/area', - name: '区域管理', - icon: , - }); return menuData; }, layout: 'mix', diff --git a/src/pages/Area/List/index.tsx b/src/pages/Area/List/index.tsx index a4b5152..f7d10a7 100644 --- a/src/pages/Area/List/index.tsx +++ b/src/pages/Area/List/index.tsx @@ -181,7 +181,7 @@ const AreaList: React.FC = () => { ({ label: c.name, value: c.code }))} + options={countries.map(c => ({ label: `${c.name}(${c.code})`, value: c.code }))} placeholder="请选择国家/地区" rules={[{ required: true, message: '国家/地区为必填项' }]} showSearch diff --git a/src/pages/Product/Sync/index.tsx b/src/pages/Product/Sync/index.tsx index e69de29..8c1f478 100644 --- a/src/pages/Product/Sync/index.tsx +++ b/src/pages/Product/Sync/index.tsx @@ -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; +} + +// 定义基础商品信息接口 +interface ProductBase { + sku: string; + name: string; + attributes: Record; + wpProducts: Record; +} + +// 定义API响应接口 +interface ApiResponse { + data: T[]; + success: boolean; + message?: string; +} + +// 模拟API请求函数 +const getSites = async (): Promise> => { + return request('/api/sites', { + method: 'GET', + }); +}; + +const getWPProducts = async (): Promise> => { + return request('/api/wp-products', { + method: 'GET', + }); +}; + +const ProductSyncPage: React.FC = () => { + const [loading, setLoading] = useState(true); + const [sites, setSites] = useState([]); + const [products, setProducts] = useState([]); + const [wpProducts, setWpProducts] = useState([]); + + // 从 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(); + + 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[] => { + const columns: ProColumns[] = [ + { + title: '商品 SKU', + dataIndex: 'sku', + key: 'sku', + width: 150, + fixed: 'left', + }, + { + title: '商品信息', + dataIndex: 'name', + key: 'name', + width: 200, + fixed: 'left', + render: (name: string, record: ProductBase) => ( +
+
{name}
+
+ {Object.entries(record.attributes || {}) + .map(([key, value]) => `${key}: ${value}`) + .join(', ')} +
+
+ ), + }, + ]; + + // 为每个站点生成列 + sites.forEach((site: Site) => { + const siteColumn: ProColumns = { + title: site.name, + key: `site_${site.id}`, + width: 250, + render: (_, record: ProductBase) => { + const wpProduct = record.wpProducts[site.id]; + if (!wpProduct) { + return ; + } + return ( +
+
SKU: {wpProduct.sku}
+
价格: {wpProduct.price}
+
库存: {wpProduct.stockQuantity}
+
状态: {wpProduct.status === 'publish' ? '已发布' : '草稿'}
+
+ ); + }, + }; + columns.push(siteColumn); + }); + + return columns; + }; + + return ( + + {loading ? ( + + ) : ( + + 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="暂无商品数据" + /> + )} + + ); +}; + +export default ProductSyncPage; \ No newline at end of file diff --git a/src/pages/Product/WpTool/index.tsx b/src/pages/Product/WpTool/index.tsx index b1d4860..1336553 100644 --- a/src/pages/Product/WpTool/index.tsx +++ b/src/pages/Product/WpTool/index.tsx @@ -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(); + 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') { - tags.push(t.charAt(0).toUpperCase() + t.slice(1)); + // 检查是否在 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') { - tags.push('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(); 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) => { - 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 [brands, fruitKeys, mintKeys, nonFlavorTokens] = await Promise.all([ - fetchDictItems('brand'), - fetchDictItems('fruit'), - fetchDictItems('mint'), - fetchDictItems('non_flavor_tokens'), - ]); - setConfig({ brands, fruitKeys, mintKeys, nonFlavorTokens }); - form.setFieldsValue({ brands, fruitKeys, mintKeys, nonFlavorTokens }); + try { + // 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 [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(); @@ -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 ( @@ -376,22 +451,37 @@ const WpToolPage: React.FC = () => { placeholder="请输入关键词,按回车确认" /> + + + + - @@ -418,11 +508,12 @@ const WpToolPage: React.FC = () => { diff --git a/src/pages/Site/List/index.tsx b/src/pages/Site/List/index.tsx index 9883df1..acbfea7 100644 --- a/src/pages/Site/List/index.tsx +++ b/src/pages/Site/List/index.tsx @@ -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 全球; + } + return ( + + {row.areas.map((area) => ( + {area.name} + ))} + + ); + }, + }, { 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 = () => { /> {/* 是否禁用 */} + {/* 区域选择 */} + { + 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 []; + } + }} + /> { const { message } = App.useApp(); const actionRef = useRef(); @@ -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 全球; + } + return ( + + {record.areas.map((area: any) => ( + {area.name} + ))} + + ); + }, + }, { title: '创建时间', dataIndex: 'createdAt', @@ -160,6 +184,30 @@ const CreateForm: React.FC<{ placeholder="请输入联系电话" rules={[{ required: true, message: '请输入联系电话' }]} /> + { + 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 []; + } + }} + /> ); @@ -173,7 +221,10 @@ const UpdateForm: React.FC<{ return ( title="编辑" - initialValues={initialValues} + initialValues={{ + ...initialValues, + areas: ((initialValues as any).areas?.map((area: any) => area.code) ?? []), + }} trigger={