forked from yoone/WEB
715 lines
22 KiB
TypeScript
715 lines
22 KiB
TypeScript
import { productcontrollerGetproductlist } from '@/servers/api/product';
|
||
import { templatecontrollerGettemplatebyname } from '@/servers/api/template';
|
||
import { EditOutlined, SyncOutlined } from '@ant-design/icons';
|
||
import {
|
||
ActionType,
|
||
ModalForm,
|
||
ProColumns,
|
||
ProFormText,
|
||
ProTable,
|
||
} from '@ant-design/pro-components';
|
||
import { request } from '@umijs/max';
|
||
import { Button, Card, Spin, Tag, message, Select, Progress, Modal } from 'antd';
|
||
import React, { useEffect, useRef, useState } from 'react';
|
||
import EditForm from '../List/EditForm';
|
||
|
||
// 定义站点接口
|
||
interface Site {
|
||
id: string;
|
||
name: string;
|
||
skuPrefix?: string;
|
||
isDisabled?: boolean;
|
||
}
|
||
|
||
// 定义WordPress商品接口
|
||
interface WpProduct {
|
||
id?: number;
|
||
externalProductId?: string;
|
||
sku: string;
|
||
name: string;
|
||
price: string;
|
||
regular_price?: string;
|
||
sale_price?: string;
|
||
stock_quantity: number;
|
||
stockQuantity?: number;
|
||
status: string;
|
||
attributes?: any[];
|
||
constitution?: { sku: string; quantity: number }[];
|
||
}
|
||
|
||
// 扩展本地产品接口,包含对应的 WP 产品信息
|
||
interface ProductWithWP extends API.Product {
|
||
wpProducts: Record<string, WpProduct>;
|
||
attributes?: any[];
|
||
siteSkus?: Array<{
|
||
siteSku: string;
|
||
[key: string]: any;
|
||
}>;
|
||
}
|
||
|
||
// 定义API响应接口
|
||
interface ApiResponse<T> {
|
||
data: T[];
|
||
success: boolean;
|
||
message?: string;
|
||
}
|
||
|
||
// 模拟API请求函数
|
||
const getSites = async (): Promise<ApiResponse<Site>> => {
|
||
const res = await request('/site/list', {
|
||
method: 'GET',
|
||
params: {
|
||
current: 1,
|
||
pageSize: 1000,
|
||
},
|
||
});
|
||
return {
|
||
data: res.data?.items || [],
|
||
success: res.success,
|
||
message: res.message,
|
||
};
|
||
};
|
||
|
||
const getWPProducts = async (): Promise<ApiResponse<WpProduct>> => {
|
||
return request('/product/wp-products', {
|
||
method: 'GET',
|
||
});
|
||
};
|
||
|
||
const ProductSyncPage: React.FC = () => {
|
||
const [sites, setSites] = useState<Site[]>([]);
|
||
// 存储所有 WP 产品,用于查找匹配。 Key: SKU (包含前缀)
|
||
const [wpProductMap, setWpProductMap] = useState<Map<string, WpProduct>>(
|
||
new Map(),
|
||
);
|
||
const [skuTemplate, setSkuTemplate] = useState<string>('');
|
||
const [initialLoading, setInitialLoading] = useState(true);
|
||
const actionRef = useRef<ActionType>();
|
||
const [selectedSiteId, setSelectedSiteId] = useState<string>('');
|
||
const [batchSyncModalVisible, setBatchSyncModalVisible] = useState(false);
|
||
const [syncProgress, setSyncProgress] = useState(0);
|
||
const [syncing, setSyncing] = useState(false);
|
||
const [syncResults, setSyncResults] = useState<{ success: number; failed: number; errors: string[] }>({ success: 0, failed: 0, errors: [] });
|
||
|
||
// 初始化数据:获取站点和所有 WP 产品
|
||
useEffect(() => {
|
||
const fetchData = async () => {
|
||
try {
|
||
setInitialLoading(true);
|
||
// 获取所有站点
|
||
const sitesResponse = await getSites();
|
||
const rawSiteList = sitesResponse.data || [];
|
||
// 过滤掉已禁用的站点
|
||
const siteList: Site[] = rawSiteList.filter((site) => !site.isDisabled);
|
||
setSites(siteList);
|
||
|
||
// 获取所有 WordPress 商品
|
||
const wpProductsResponse = await getWPProducts();
|
||
const wpProductList: WpProduct[] = wpProductsResponse.data || [];
|
||
|
||
// 构建 WP 产品 Map,Key 为 SKU
|
||
const map = new Map<string, WpProduct>();
|
||
wpProductList.forEach((p) => {
|
||
if (p.sku) {
|
||
map.set(p.sku, p);
|
||
}
|
||
});
|
||
setWpProductMap(map);
|
||
|
||
// 获取 SKU 模板
|
||
try {
|
||
const templateRes = await templatecontrollerGettemplatebyname({
|
||
name: 'site.product.sku',
|
||
});
|
||
if (templateRes && templateRes.value) {
|
||
setSkuTemplate(templateRes.value);
|
||
}
|
||
} catch (e) {
|
||
console.log('Template site.product.sku not found, using default.');
|
||
}
|
||
} catch (error) {
|
||
message.error('获取基础数据失败,请重试');
|
||
console.error('Error fetching data:', error);
|
||
} finally {
|
||
setInitialLoading(false);
|
||
}
|
||
};
|
||
|
||
fetchData();
|
||
}, []);
|
||
|
||
// 同步产品到站点
|
||
const syncProductToSite = async (
|
||
values: any,
|
||
record: ProductWithWP,
|
||
site: Site,
|
||
wpProductId?: string,
|
||
) => {
|
||
try {
|
||
const hide = message.loading('正在同步...', 0);
|
||
const data = {
|
||
name: record.name,
|
||
sku: values.sku,
|
||
regular_price: record.price?.toString(),
|
||
sale_price: record.promotionPrice?.toString(),
|
||
type: record.type === 'bundle' ? 'simple' : record.type,
|
||
description: record.description,
|
||
status: 'publish',
|
||
stock_status: 'instock',
|
||
manage_stock: false,
|
||
};
|
||
|
||
let res;
|
||
if (wpProductId) {
|
||
res = await request(`/site-api/${site.id}/products/${wpProductId}`, {
|
||
method: 'PUT',
|
||
data,
|
||
});
|
||
} else {
|
||
res = await request(`/site-api/${site.id}/products`, {
|
||
method: 'POST',
|
||
data,
|
||
});
|
||
}
|
||
|
||
console.log('res', res);
|
||
if (!res.success) {
|
||
hide();
|
||
throw new Error(res.message || '同步失败');
|
||
}
|
||
// 更新本地缓存 Map,避免刷新
|
||
setWpProductMap((prev) => {
|
||
const newMap = new Map(prev);
|
||
if (res.data && typeof res.data === 'object') {
|
||
newMap.set(values.sku, res.data as WpProduct);
|
||
}
|
||
return newMap;
|
||
});
|
||
|
||
hide();
|
||
message.success('同步成功');
|
||
return true;
|
||
} catch (error: any) {
|
||
message.error('同步失败: ' + (error.message || error.toString()));
|
||
return false;
|
||
} finally {
|
||
}
|
||
};
|
||
|
||
// 批量同步产品到指定站点
|
||
const batchSyncProducts = async () => {
|
||
if (!selectedSiteId) {
|
||
message.error('请选择要同步到的站点');
|
||
return;
|
||
}
|
||
|
||
const targetSite = sites.find(site => site.id === selectedSiteId);
|
||
if (!targetSite) {
|
||
message.error('选择的站点不存在');
|
||
return;
|
||
}
|
||
|
||
setSyncing(true);
|
||
setSyncProgress(0);
|
||
setSyncResults({ success: 0, failed: 0, errors: [] });
|
||
|
||
try {
|
||
// 获取所有产品
|
||
const { data, success } = await productcontrollerGetproductlist({
|
||
current: 1,
|
||
pageSize: 10000, // 获取所有产品
|
||
} as any);
|
||
|
||
if (!success || !data?.items) {
|
||
message.error('获取产品列表失败');
|
||
return;
|
||
}
|
||
|
||
const products = data.items as ProductWithWP[];
|
||
const totalProducts = products.length;
|
||
let processed = 0;
|
||
let successCount = 0;
|
||
let failedCount = 0;
|
||
const errors: string[] = [];
|
||
|
||
// 逐个同步产品
|
||
for (const product of products) {
|
||
try {
|
||
// 获取该产品在目标站点的SKU
|
||
let siteProductSku = '';
|
||
if (product.siteSkus && product.siteSkus.length > 0) {
|
||
const siteSkuInfo = product.siteSkus.find((sku: any) => {
|
||
return sku.siteSku && sku.siteSku.includes(targetSite.skuPrefix || targetSite.name);
|
||
});
|
||
if (siteSkuInfo) {
|
||
siteProductSku = siteSkuInfo.siteSku;
|
||
}
|
||
}
|
||
|
||
// 如果没有找到实际的siteSku,则根据模板生成
|
||
const expectedSku = siteProductSku || (
|
||
skuTemplate
|
||
? renderSku(skuTemplate, { site: targetSite, product })
|
||
: `${targetSite.skuPrefix || ''}-${product.sku}`
|
||
);
|
||
|
||
// 检查是否已存在
|
||
const existingProduct = wpProductMap.get(expectedSku);
|
||
|
||
// 准备同步数据
|
||
const syncData = {
|
||
name: product.name,
|
||
sku: expectedSku,
|
||
regular_price: product.price?.toString(),
|
||
sale_price: product.promotionPrice?.toString(),
|
||
type: product.type === 'bundle' ? 'simple' : product.type,
|
||
description: product.description,
|
||
status: 'publish',
|
||
stock_status: 'instock',
|
||
manage_stock: false,
|
||
};
|
||
|
||
let res;
|
||
if (existingProduct?.externalProductId) {
|
||
// 更新现有产品
|
||
res = await request(`/site-api/${targetSite.id}/products/${existingProduct.externalProductId}`, {
|
||
method: 'PUT',
|
||
data: syncData,
|
||
});
|
||
} else {
|
||
// 创建新产品
|
||
res = await request(`/site-api/${targetSite.id}/products`, {
|
||
method: 'POST',
|
||
data: syncData,
|
||
});
|
||
}
|
||
|
||
console.log('res', res);
|
||
|
||
if (res.success) {
|
||
successCount++;
|
||
// 更新本地缓存
|
||
setWpProductMap((prev) => {
|
||
const newMap = new Map(prev);
|
||
if (res.data && typeof res.data === 'object') {
|
||
newMap.set(expectedSku, res.data as WpProduct);
|
||
}
|
||
return newMap;
|
||
});
|
||
} else {
|
||
failedCount++;
|
||
errors.push(`产品 ${product.sku}: ${res.message || '同步失败'}`);
|
||
}
|
||
|
||
} catch (error: any) {
|
||
failedCount++;
|
||
errors.push(`产品 ${product.sku}: ${error.message || '未知错误'}`);
|
||
}
|
||
|
||
processed++;
|
||
setSyncProgress(Math.round((processed / totalProducts) * 100));
|
||
}
|
||
|
||
setSyncResults({ success: successCount, failed: failedCount, errors });
|
||
|
||
if (failedCount === 0) {
|
||
message.success(`批量同步完成,成功同步 ${successCount} 个产品`);
|
||
} else {
|
||
message.warning(`批量同步完成,成功 ${successCount} 个,失败 ${failedCount} 个`);
|
||
}
|
||
|
||
// 刷新表格
|
||
actionRef.current?.reload();
|
||
|
||
} catch (error: any) {
|
||
message.error('批量同步失败: ' + (error.message || error.toString()));
|
||
} finally {
|
||
setSyncing(false);
|
||
}
|
||
};
|
||
|
||
// 简单的模板渲染函数
|
||
const renderSku = (template: string, data: any) => {
|
||
if (!template) return '';
|
||
// 支持 <%= it.path %> (Eta) 和 {{ path }} (Mustache/Handlebars)
|
||
return template.replace(
|
||
/<%=\s*it\.([\w.]+)\s*%>|\{\{\s*([\w.]+)\s*\}\}/g,
|
||
(_, p1, p2) => {
|
||
const path = p1 || p2;
|
||
const keys = path.split('.');
|
||
let value = data;
|
||
for (const key of keys) {
|
||
value = value?.[key];
|
||
}
|
||
return value === undefined || value === null ? '' : String(value);
|
||
},
|
||
);
|
||
};
|
||
|
||
// 生成表格列配置
|
||
const generateColumns = (): ProColumns<ProductWithWP>[] => {
|
||
const columns: ProColumns<ProductWithWP>[] = [
|
||
{
|
||
title: 'SKU',
|
||
dataIndex: 'sku',
|
||
key: 'sku',
|
||
width: 150,
|
||
fixed: 'left',
|
||
copyable: true,
|
||
},
|
||
{
|
||
title: '商品信息',
|
||
key: 'profile',
|
||
width: 300,
|
||
fixed: 'left',
|
||
render: (_, record) => (
|
||
<div>
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'center',
|
||
marginBottom: 4,
|
||
}}
|
||
>
|
||
<div style={{ fontWeight: 'bold', fontSize: 14 }}>
|
||
{record.name}
|
||
</div>
|
||
<EditForm
|
||
record={record}
|
||
tableRef={actionRef}
|
||
trigger={
|
||
<EditOutlined
|
||
style={{
|
||
cursor: 'pointer',
|
||
fontSize: 16,
|
||
color: '#1890ff',
|
||
}}
|
||
/>
|
||
}
|
||
/>
|
||
</div>
|
||
<div style={{ fontSize: 12, color: '#666' }}>
|
||
<span style={{ marginRight: 8 }}>价格: {record.price}</span>
|
||
{record.promotionPrice && (
|
||
<span style={{ color: 'red' }}>
|
||
促销价: {record.promotionPrice}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* 属性 */}
|
||
<div style={{ marginTop: 4 }}>
|
||
{record.attributes?.map((attr: any, idx: number) => (
|
||
<Tag
|
||
key={idx}
|
||
style={{ fontSize: 10, marginRight: 4, marginBottom: 2 }}
|
||
>
|
||
{attr.dict?.name || attr.name}: {attr.name}
|
||
</Tag>
|
||
))}
|
||
</div>
|
||
|
||
{/* 组成 (如果是 Bundle) */}
|
||
{record.type === 'bundle' &&
|
||
record.components &&
|
||
record.components.length > 0 && (
|
||
<div
|
||
style={{
|
||
marginTop: 8,
|
||
fontSize: 12,
|
||
background: '#f5f5f5',
|
||
padding: 4,
|
||
borderRadius: 4,
|
||
}}
|
||
>
|
||
<div style={{ fontWeight: 'bold', marginBottom: 2 }}>
|
||
Components:
|
||
</div>
|
||
{record.components.map((comp: any, idx: number) => (
|
||
<div key={idx}>
|
||
{comp.sku} × {comp.quantity}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
),
|
||
},
|
||
];
|
||
|
||
// 为每个站点生成列
|
||
sites.forEach((site: Site) => {
|
||
const siteColumn: ProColumns<ProductWithWP> = {
|
||
title: site.name,
|
||
key: `site_${site.id}`,
|
||
hideInSearch: true,
|
||
width: 220,
|
||
render: (_, record) => {
|
||
// 首先查找该产品在该站点的实际SKU
|
||
let siteProductSku = '';
|
||
if (record.siteSkus && record.siteSkus.length > 0) {
|
||
// 根据站点名称匹配对应的siteSku
|
||
const siteSkuInfo = record.siteSkus.find((sku: any) => {
|
||
// 这里假设可以根据站点名称或其他标识来匹配
|
||
// 如果需要更精确的匹配逻辑,可以根据实际需求调整
|
||
return sku.siteSku && sku.siteSku.includes(site.skuPrefix || site.name);
|
||
});
|
||
if (siteSkuInfo) {
|
||
siteProductSku = siteSkuInfo.siteSku;
|
||
}
|
||
}
|
||
|
||
// 如果没有找到实际的siteSku,则根据模板或默认规则生成期望的SKU
|
||
const expectedSku = siteProductSku || (
|
||
skuTemplate
|
||
? renderSku(skuTemplate, { site, product: record })
|
||
: `${site.skuPrefix || ''}-${record.sku}`
|
||
);
|
||
|
||
// 尝试用确定的SKU获取WP产品
|
||
let wpProduct = wpProductMap.get(expectedSku);
|
||
|
||
// 如果根据实际SKU没找到,再尝试用模板生成的SKU查找
|
||
if (!wpProduct && siteProductSku && skuTemplate) {
|
||
const templateSku = renderSku(skuTemplate, { site, product: record });
|
||
wpProduct = wpProductMap.get(templateSku);
|
||
}
|
||
|
||
if (!wpProduct) {
|
||
return (
|
||
<ModalForm
|
||
title="同步产品"
|
||
trigger={
|
||
<Button type="link" icon={<SyncOutlined />}>
|
||
同步到站点
|
||
</Button>
|
||
}
|
||
width={400}
|
||
onFinish={async (values) => {
|
||
return await syncProductToSite(values, record, site);
|
||
}}
|
||
initialValues={{
|
||
sku: siteProductSku || (
|
||
skuTemplate
|
||
? renderSku(skuTemplate, { site, product: record })
|
||
: `${site.skuPrefix || ''}-${record.sku}`
|
||
),
|
||
}}
|
||
>
|
||
<ProFormText
|
||
name="sku"
|
||
label="商店 SKU"
|
||
placeholder="请输入商店 SKU"
|
||
rules={[{ required: true, message: '请输入 SKU' }]}
|
||
/>
|
||
</ModalForm>
|
||
);
|
||
}
|
||
return (
|
||
<div style={{ fontSize: 12 }}>
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'start',
|
||
}}
|
||
>
|
||
<div style={{ fontWeight: 'bold' }}>{wpProduct.sku}</div>
|
||
<ModalForm
|
||
title="更新同步"
|
||
trigger={
|
||
<Button
|
||
type="link"
|
||
size="small"
|
||
icon={<SyncOutlined spin={false} />}
|
||
></Button>
|
||
}
|
||
width={400}
|
||
onFinish={async (values) => {
|
||
return await syncProductToSite(
|
||
values,
|
||
record,
|
||
site,
|
||
wpProduct.externalProductId,
|
||
);
|
||
}}
|
||
initialValues={{
|
||
sku: wpProduct.sku,
|
||
}}
|
||
>
|
||
<ProFormText
|
||
name="sku"
|
||
label="商店 SKU"
|
||
placeholder="请输入商店 SKU"
|
||
rules={[{ required: true, message: '请输入 SKU' }]}
|
||
disabled
|
||
/>
|
||
<div style={{ marginBottom: 16, color: '#666' }}>
|
||
确定要将本地产品数据更新到站点吗?
|
||
</div>
|
||
</ModalForm>
|
||
</div>
|
||
<div>Price: {wpProduct.regular_price ?? wpProduct.price}</div>
|
||
{wpProduct.sale_price && (
|
||
<div style={{ color: 'red' }}>Sale: {wpProduct.sale_price}</div>
|
||
)}
|
||
<div>
|
||
Stock: {wpProduct.stock_quantity ?? wpProduct.stockQuantity}
|
||
</div>
|
||
<div style={{ marginTop: 2 }}>
|
||
Status:{' '}
|
||
{wpProduct.status === 'publish' ? (
|
||
<Tag color="green">Published</Tag>
|
||
) : (
|
||
<Tag>{wpProduct.status}</Tag>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
},
|
||
};
|
||
columns.push(siteColumn);
|
||
});
|
||
|
||
return columns;
|
||
};
|
||
|
||
if (initialLoading) {
|
||
return (
|
||
<Card title="商品同步状态" className="product-sync-card">
|
||
<Spin
|
||
size="large"
|
||
style={{ display: 'flex', justifyContent: 'center', padding: 40 }}
|
||
/>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<Card
|
||
title="商品同步状态"
|
||
className="product-sync-card"
|
||
extra={
|
||
<div style={{ display: 'flex', gap: 8 }}>
|
||
<Select
|
||
style={{ width: 200 }}
|
||
placeholder="选择目标站点"
|
||
value={selectedSiteId}
|
||
onChange={setSelectedSiteId}
|
||
options={sites.map(site => ({
|
||
label: site.name,
|
||
value: site.id,
|
||
}))}
|
||
/>
|
||
<Button
|
||
type="primary"
|
||
icon={<SyncOutlined />}
|
||
onClick={() => setBatchSyncModalVisible(true)}
|
||
disabled={!selectedSiteId || sites.length === 0}
|
||
>
|
||
批量同步
|
||
</Button>
|
||
</div>
|
||
}
|
||
>
|
||
<ProTable<ProductWithWP>
|
||
columns={generateColumns()}
|
||
actionRef={actionRef}
|
||
rowKey="id"
|
||
request={async (params, sort, filter) => {
|
||
// 调用本地获取产品列表 API
|
||
const { data, success } = await productcontrollerGetproductlist({
|
||
...params,
|
||
current: params.current,
|
||
pageSize: params.pageSize,
|
||
// 传递搜索参数
|
||
keyword: params.keyword, // 假设 ProTable 的 search 表单会传递 keyword 或其他字段
|
||
sku: (params as any).sku,
|
||
name: (params as any).name,
|
||
} as any);
|
||
|
||
// 返回给 ProTable
|
||
return {
|
||
data: (data?.items || []) as ProductWithWP[],
|
||
success,
|
||
total: data?.total || 0,
|
||
};
|
||
}}
|
||
pagination={{
|
||
pageSize: 10,
|
||
showSizeChanger: true,
|
||
}}
|
||
scroll={{ x: 'max-content' }}
|
||
search={{
|
||
labelWidth: 'auto',
|
||
}}
|
||
options={{
|
||
density: true,
|
||
fullScreen: true,
|
||
}}
|
||
dateFormatter="string"
|
||
/>
|
||
|
||
{/* 批量同步模态框 */}
|
||
<Modal
|
||
title="批量同步产品"
|
||
open={batchSyncModalVisible}
|
||
onCancel={() => !syncing && setBatchSyncModalVisible(false)}
|
||
footer={null}
|
||
closable={!syncing}
|
||
maskClosable={!syncing}
|
||
>
|
||
<div style={{ marginBottom: 16 }}>
|
||
<p>目标站点:<strong>{sites.find(s => s.id === selectedSiteId)?.name}</strong></p>
|
||
<p>此操作将同步所有库存产品到指定站点,请确认是否继续?</p>
|
||
</div>
|
||
|
||
{syncing && (
|
||
<div style={{ marginBottom: 16 }}>
|
||
<div style={{ marginBottom: 8 }}>同步进度:</div>
|
||
<Progress percent={syncProgress} status="active" />
|
||
<div style={{ marginTop: 8, fontSize: 12, color: '#666' }}>
|
||
成功:{syncResults.success} | 失败:{syncResults.failed}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{syncResults.errors.length > 0 && (
|
||
<div style={{ marginBottom: 16, maxHeight: 200, overflow: 'auto' }}>
|
||
<div style={{ marginBottom: 8, color: '#ff4d4f' }}>错误详情:</div>
|
||
{syncResults.errors.slice(0, 10).map((error, index) => (
|
||
<div key={index} style={{ fontSize: 12, color: '#666', marginBottom: 4 }}>
|
||
{error}
|
||
</div>
|
||
))}
|
||
{syncResults.errors.length > 10 && (
|
||
<div style={{ fontSize: 12, color: '#999' }}>...还有 {syncResults.errors.length - 10} 个错误</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<div style={{ textAlign: 'right' }}>
|
||
<Button
|
||
onClick={() => setBatchSyncModalVisible(false)}
|
||
disabled={syncing}
|
||
style={{ marginRight: 8 }}
|
||
>
|
||
取消
|
||
</Button>
|
||
<Button
|
||
type="primary"
|
||
onClick={batchSyncProducts}
|
||
loading={syncing}
|
||
disabled={syncing}
|
||
>
|
||
{syncing ? '同步中...' : '开始同步'}
|
||
</Button>
|
||
</div>
|
||
</Modal>
|
||
</Card>
|
||
);
|
||
};
|
||
|
||
export default ProductSyncPage;
|