505 lines
14 KiB
TypeScript
505 lines
14 KiB
TypeScript
import { showBatchOperationResult } from '@/utils/showResult';
|
||
import {
|
||
productcontrollerBatchsynctosite,
|
||
productcontrollerGetproductlist,
|
||
productcontrollerSynctosite,
|
||
} from '@/servers/api/product';
|
||
import { EditOutlined, SyncOutlined } from '@ant-design/icons';
|
||
import { ActionType, ProColumns, ProTable } from '@ant-design/pro-components';
|
||
import { request } from '@umijs/max';
|
||
import {
|
||
Button,
|
||
Card,
|
||
message,
|
||
Modal,
|
||
Progress,
|
||
Select,
|
||
Spin,
|
||
Tag,
|
||
} from 'antd';
|
||
import React, { useEffect, useRef, useState } from 'react';
|
||
import EditForm from '../List/EditForm';
|
||
import SiteProductCell from './SiteProductCell';
|
||
|
||
// 定义站点接口
|
||
interface Site {
|
||
id: number;
|
||
name: string;
|
||
skuPrefix?: string;
|
||
isDisabled?: boolean;
|
||
}
|
||
|
||
// 定义本地产品接口(与后端 Product 实体匹配)
|
||
interface SiteProduct {
|
||
id: number;
|
||
sku: string;
|
||
name: string;
|
||
nameCn: string;
|
||
shortDescription?: string;
|
||
description?: string;
|
||
price: number;
|
||
promotionPrice: number;
|
||
type: string;
|
||
categoryId?: number;
|
||
category?: any;
|
||
attributes?: any[];
|
||
components?: any[];
|
||
siteSkus: string[];
|
||
source: number;
|
||
createdAt: Date;
|
||
updatedAt: Date;
|
||
}
|
||
|
||
// 定义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 ProductSyncPage: React.FC = () => {
|
||
const [sites, setSites] = useState<Site[]>([]);
|
||
|
||
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: [] });
|
||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||
const [selectedRows, setSelectedRows] = useState<SiteProduct[]>([]);
|
||
// 初始化加载站点列表
|
||
useEffect(() => {
|
||
const initializeData = async () => {
|
||
try {
|
||
// 获取站点列表
|
||
const sitesRes = await getSites();
|
||
if (sitesRes.success && sitesRes.data.length > 0) {
|
||
setSites(sitesRes.data);
|
||
}
|
||
} catch (error) {
|
||
console.error('初始化数据失败:', error);
|
||
message.error('初始化数据失败');
|
||
} finally {
|
||
setInitialLoading(false);
|
||
}
|
||
};
|
||
|
||
initializeData();
|
||
}, []);
|
||
|
||
const syncProductToSite = async (
|
||
values: any,
|
||
record: SiteProduct,
|
||
site: Site,
|
||
siteProductId?: string,
|
||
) => {
|
||
try {
|
||
const hide = message.loading('正在同步...', 0);
|
||
|
||
// 使用 productcontrollerSynctosite API 同步产品到站点
|
||
const res = await productcontrollerSynctosite({
|
||
productId: Number(record.id),
|
||
siteId: Number(site.id),
|
||
} as any);
|
||
|
||
if (!res.success) {
|
||
hide();
|
||
throw new Error(res.message || '同步失败');
|
||
}
|
||
|
||
hide();
|
||
message.success('同步成功');
|
||
return true;
|
||
} catch (error: any) {
|
||
message.error('同步失败: ' + (error.message || error.toString()));
|
||
return false;
|
||
}
|
||
};
|
||
|
||
// 批量同步产品到指定站点
|
||
const batchSyncProducts = async (productsToSync?: SiteProduct[]) => {
|
||
if (!selectedSiteId) {
|
||
message.error('请选择要同步到的站点');
|
||
return;
|
||
}
|
||
|
||
const targetSite = sites.find((site) => site.id === selectedSiteId);
|
||
if (!targetSite) {
|
||
message.error('选择的站点不存在');
|
||
return;
|
||
}
|
||
|
||
// 如果没有传入产品列表,则使用选中的产品
|
||
let products = productsToSync || selectedRows;
|
||
|
||
// 如果既没有传入产品也没有选中产品,则同步所有产品
|
||
if (!products || products.length === 0) {
|
||
try {
|
||
const { data, success } = await productcontrollerGetproductlist({
|
||
current: 1,
|
||
pageSize: 10000, // 获取所有产品
|
||
} as any);
|
||
|
||
if (!success || !data?.items) {
|
||
message.error('获取产品列表失败');
|
||
return;
|
||
}
|
||
products = data.items as SiteProduct[];
|
||
} catch (error) {
|
||
message.error('获取产品列表失败');
|
||
return;
|
||
}
|
||
}
|
||
|
||
setSyncing(true);
|
||
setSyncProgress(0);
|
||
setSyncResults({ success: 0, failed: 0, errors: [] });
|
||
|
||
try {
|
||
// 使用 productcontrollerBatchsynctosite API 批量同步
|
||
const productIds = products.map((product) => Number(product.id));
|
||
|
||
// 更新进度为50%,表示正在处理
|
||
setSyncProgress(50);
|
||
|
||
const res = await productcontrollerBatchsynctosite({
|
||
productIds: productIds,
|
||
siteId: Number(targetSite.id),
|
||
} as any);
|
||
|
||
if (res.success) {
|
||
const syncedCount = res.data?.synced || 0;
|
||
const errors = res.data?.errors || [];
|
||
|
||
// 更新进度为100%,表示完成
|
||
setSyncProgress(100);
|
||
|
||
setSyncResults({
|
||
success: syncedCount,
|
||
failed: errors.length,
|
||
errors: errors.map((err: any) => err.error || '未知错误'),
|
||
});
|
||
|
||
if (errors.length === 0) {
|
||
message.success(`批量同步完成,成功同步 ${syncedCount} 个产品`);
|
||
} else {
|
||
message.warning(
|
||
`批量同步完成,成功 ${syncedCount} 个,失败 ${errors.length} 个`,
|
||
);
|
||
}
|
||
|
||
// 刷新表格
|
||
actionRef.current?.reload();
|
||
} else {
|
||
throw new Error(res.message || '批量同步失败');
|
||
}
|
||
} catch (error: any) {
|
||
message.error('批量同步失败: ' + (error.message || error.toString()));
|
||
} finally {
|
||
setSyncing(false);
|
||
}
|
||
};
|
||
|
||
// 生成表格列配置
|
||
const generateColumns = (): ProColumns<Site>[] => {
|
||
const columns: ProColumns<SiteProduct>[] = [
|
||
{
|
||
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<SiteProduct> = {
|
||
title: site.name,
|
||
key: `site_${site.id}`,
|
||
hideInSearch: true,
|
||
width: 220,
|
||
render: (_, record) => {
|
||
return (
|
||
<SiteProductCell
|
||
product={record}
|
||
site={site}
|
||
onSyncSuccess={() => {
|
||
// 同步成功后刷新表格
|
||
actionRef.current?.reload();
|
||
}}
|
||
/>
|
||
);
|
||
},
|
||
};
|
||
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">
|
||
<ProTable<SiteProduct>
|
||
columns={generateColumns()}
|
||
actionRef={actionRef}
|
||
rowKey="id"
|
||
rowSelection={{
|
||
selectedRowKeys,
|
||
onChange: (keys, rows) => {
|
||
setSelectedRowKeys(keys);
|
||
setSelectedRows(rows);
|
||
},
|
||
}}
|
||
toolBarRender={() => [
|
||
<Select
|
||
key="site-select"
|
||
style={{ width: 200 }}
|
||
placeholder="选择目标站点"
|
||
value={selectedSiteId}
|
||
onChange={setSelectedSiteId}
|
||
options={sites.map((site) => ({
|
||
label: site.name,
|
||
value: site.id,
|
||
}))}
|
||
/>,
|
||
<Button
|
||
key="batch-sync"
|
||
type="primary"
|
||
icon={<SyncOutlined />}
|
||
onClick={() => {
|
||
if (!selectedSiteId) {
|
||
message.warning('请先选择目标站点');
|
||
return;
|
||
}
|
||
setBatchSyncModalVisible(true);
|
||
}}
|
||
disabled={!selectedSiteId || sites.length === 0}
|
||
>
|
||
批量同步
|
||
</Button>,
|
||
]}
|
||
request={async (params, sort, filter) => {
|
||
// 调用本地获取产品列表 API
|
||
const response = 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);
|
||
console.log('result', response);
|
||
// 返回给 ProTable
|
||
return {
|
||
data: response.data?.items || [],
|
||
success: response.success,
|
||
total: response.data?.total || 0,
|
||
};
|
||
}}
|
||
pagination={{
|
||
pageSize: 10,
|
||
showSizeChanger: true,
|
||
showQuickJumper: 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>
|
||
{selectedRows.length > 0 ? (
|
||
<p>
|
||
已选择 <strong>{selectedRows.length}</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;
|