refactor: 重构数据源工厂和类型定义,提升代码可维护性 fix: 修复类型转换和状态机文档中的错误 docs: 更新服务架构文档,添加新的服务闭环流程 test: 添加汇率服务单元测试 chore: 清理无用代码和注释,优化代码结构
406 lines
13 KiB
TypeScript
406 lines
13 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import {
|
|
Card,
|
|
Descriptions,
|
|
Button,
|
|
Space,
|
|
Tag,
|
|
Image,
|
|
Divider,
|
|
Row,
|
|
Col,
|
|
Tabs,
|
|
Table,
|
|
Modal,
|
|
message,
|
|
Spin,
|
|
Typography,
|
|
Tooltip,
|
|
Badge,
|
|
} from 'antd';
|
|
import {
|
|
ShoppingCartOutlined,
|
|
EditOutlined,
|
|
DeleteOutlined,
|
|
CopyOutlined,
|
|
HistoryOutlined,
|
|
ShopOutlined,
|
|
TagOutlined,
|
|
DollarOutlined,
|
|
BoxPlotOutlined,
|
|
FileImageOutlined,
|
|
CheckCircleOutlined,
|
|
CloseCircleOutlined,
|
|
SyncOutlined,
|
|
} from '@ant-design/icons';
|
|
import type { ColumnsType } from 'antd/es/table';
|
|
import { productDataSource, Product } from '@/services/productDataSource';
|
|
|
|
const { Title, Text } = Typography;
|
|
const { TabPane } = Tabs;
|
|
|
|
interface ProductDetail {
|
|
id: string;
|
|
productId: string;
|
|
platform: string;
|
|
name: string;
|
|
description: string;
|
|
category: string;
|
|
brand: string;
|
|
status: 'DRAFT' | 'PENDING' | 'ACTIVE' | 'INACTIVE' | 'SUSPENDED';
|
|
price: number;
|
|
costPrice: number;
|
|
currency: string;
|
|
stock: number;
|
|
sku: string;
|
|
images: string[];
|
|
attributes: Record<string, string>;
|
|
variants: ProductVariant[];
|
|
complianceStatus: 'COMPLIANT' | 'NON_COMPLIANT' | 'PENDING_REVIEW';
|
|
certificates: string[];
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
publishedAt?: string;
|
|
}
|
|
|
|
interface ProductVariant {
|
|
id: string;
|
|
sku: string;
|
|
name: string;
|
|
price: number;
|
|
stock: number;
|
|
attributes: Record<string, string>;
|
|
}
|
|
|
|
interface PriceHistory {
|
|
id: string;
|
|
price: number;
|
|
currency: string;
|
|
changedAt: string;
|
|
changedBy: string;
|
|
reason: string;
|
|
}
|
|
|
|
interface StockHistory {
|
|
id: string;
|
|
quantity: number;
|
|
type: 'IN' | 'OUT';
|
|
reason: string;
|
|
createdAt: string;
|
|
}
|
|
|
|
const MOCK_PRODUCT: ProductDetail = {
|
|
id: '1',
|
|
productId: 'P-2026-001',
|
|
platform: 'Amazon',
|
|
name: 'Industrial Temperature Sensor Pro',
|
|
description: 'High-precision industrial temperature sensor with digital output. Suitable for manufacturing environments with temperature range -40°C to +125°C.',
|
|
category: 'Industrial Automation > Sensors',
|
|
brand: 'TechPro',
|
|
status: 'ACTIVE',
|
|
price: 89.99,
|
|
costPrice: 45.00,
|
|
currency: 'USD',
|
|
stock: 256,
|
|
sku: 'TP-TEMP-001',
|
|
images: [
|
|
'https://via.placeholder.com/400x400?text=Product+1',
|
|
'https://via.placeholder.com/400x400?text=Product+2',
|
|
'https://via.placeholder.com/400x400?text=Product+3',
|
|
],
|
|
attributes: {
|
|
'Temperature Range': '-40°C to +125°C',
|
|
'Accuracy': '±0.5°C',
|
|
'Output Type': 'Digital (I2C)',
|
|
'Power Supply': '3.3V - 5V DC',
|
|
'Housing Material': 'Stainless Steel 316',
|
|
'IP Rating': 'IP67',
|
|
},
|
|
variants: [
|
|
{ id: 'v1', sku: 'TP-TEMP-001-A', name: 'Standard Cable 1m', price: 89.99, stock: 150, attributes: { 'Cable Length': '1m' } },
|
|
{ id: 'v2', sku: 'TP-TEMP-001-B', name: 'Extended Cable 3m', price: 99.99, stock: 80, attributes: { 'Cable Length': '3m' } },
|
|
{ id: 'v3', sku: 'TP-TEMP-001-C', name: 'Extended Cable 5m', price: 109.99, stock: 26, attributes: { 'Cable Length': '5m' } },
|
|
],
|
|
complianceStatus: 'COMPLIANT',
|
|
certificates: ['CE', 'FCC', 'RoHS'],
|
|
createdAt: '2025-12-15 10:00:00',
|
|
updatedAt: '2026-03-18 14:30:00',
|
|
publishedAt: '2025-12-20 09:00:00',
|
|
};
|
|
|
|
const MOCK_PRICE_HISTORY: PriceHistory[] = [
|
|
{ id: '1', price: 89.99, currency: 'USD', changedAt: '2026-03-18 14:30:00', changedBy: 'System', reason: 'Market price adjustment' },
|
|
{ id: '2', price: 84.99, currency: 'USD', changedAt: '2026-02-01 10:00:00', changedBy: 'Admin', reason: 'Promotional pricing' },
|
|
{ id: '3', price: 94.99, currency: 'USD', changedAt: '2025-12-20 09:00:00', changedBy: 'Admin', reason: 'Initial listing price' },
|
|
];
|
|
|
|
const MOCK_STOCK_HISTORY: StockHistory[] = [
|
|
{ id: '1', quantity: 50, type: 'IN', reason: 'Restock from supplier', createdAt: '2026-03-15 08:00:00' },
|
|
{ id: '2', quantity: 25, type: 'OUT', reason: 'Order #ORD-2026-1234', createdAt: '2026-03-14 16:30:00' },
|
|
{ id: '3', quantity: 100, type: 'IN', reason: 'Initial stock', createdAt: '2025-12-20 09:00:00' },
|
|
];
|
|
|
|
const STATUS_CONFIG: Record<string, { color: string; text: string }> = {
|
|
DRAFT: { color: 'default', text: 'Draft' },
|
|
PENDING: { color: 'processing', text: 'Pending Review' },
|
|
ACTIVE: { color: 'success', text: 'Active' },
|
|
INACTIVE: { color: 'warning', text: 'Inactive' },
|
|
SUSPENDED: { color: 'error', text: 'Suspended' },
|
|
};
|
|
|
|
const COMPLIANCE_CONFIG: Record<string, { color: string; icon: React.ReactNode }> = {
|
|
COMPLIANT: { color: 'success', icon: <CheckCircleOutlined /> },
|
|
NON_COMPLIANT: { color: 'error', icon: <CloseCircleOutlined /> },
|
|
PENDING_REVIEW: { color: 'warning', icon: <SyncOutlined spin /> },
|
|
};
|
|
|
|
export const ProductDetail: React.FC = () => {
|
|
const [loading, setLoading] = useState(true);
|
|
const [product, setProduct] = useState<ProductDetail | null>(null);
|
|
const [priceHistory, setPriceHistory] = useState<PriceHistory[]>([]);
|
|
const [stockHistory, setStockHistory] = useState<StockHistory[]>([]);
|
|
const [editModalVisible, setEditModalVisible] = useState(false);
|
|
|
|
useEffect(() => {
|
|
fetchProductDetail();
|
|
}, []);
|
|
|
|
const fetchProductDetail = async () => {
|
|
setLoading(true);
|
|
await new Promise(resolve => setTimeout(resolve, 800));
|
|
setProduct(MOCK_PRODUCT);
|
|
setPriceHistory(MOCK_PRICE_HISTORY);
|
|
setStockHistory(MOCK_STOCK_HISTORY);
|
|
setLoading(false);
|
|
};
|
|
|
|
const handleEdit = () => {
|
|
setEditModalVisible(true);
|
|
};
|
|
|
|
const handleDelete = () => {
|
|
Modal.confirm({
|
|
title: 'Delete Product',
|
|
content: 'Are you sure you want to delete this product? This action cannot be undone.',
|
|
okText: 'Delete',
|
|
okType: 'danger',
|
|
onOk: () => {
|
|
message.success('Product deleted successfully');
|
|
},
|
|
});
|
|
};
|
|
|
|
const handleDuplicate = () => {
|
|
message.success('Product duplicated. Redirecting to edit page...');
|
|
};
|
|
|
|
const handlePublish = () => {
|
|
Modal.confirm({
|
|
title: 'Publish Product',
|
|
content: 'Publish this product to all connected platforms?',
|
|
onOk: () => {
|
|
message.success('Product published successfully');
|
|
},
|
|
});
|
|
};
|
|
|
|
const priceColumns: ColumnsType<PriceHistory> = [
|
|
{ title: 'Price', dataIndex: 'price', key: 'price', render: (v, r) => `${r.currency} ${v.toFixed(2)}` },
|
|
{ title: 'Changed At', dataIndex: 'changedAt', key: 'changedAt' },
|
|
{ title: 'Changed By', dataIndex: 'changedBy', key: 'changedBy' },
|
|
{ title: 'Reason', dataIndex: 'reason', key: 'reason' },
|
|
];
|
|
|
|
const stockColumns: ColumnsType<StockHistory> = [
|
|
{
|
|
title: 'Type',
|
|
dataIndex: 'type',
|
|
key: 'type',
|
|
render: (type: string) => (
|
|
<Tag color={type === 'IN' ? 'green' : 'red'}>
|
|
{type === 'IN' ? 'Stock In' : 'Stock Out'}
|
|
</Tag>
|
|
)
|
|
},
|
|
{ title: 'Quantity', dataIndex: 'quantity', key: 'quantity' },
|
|
{ title: 'Reason', dataIndex: 'reason', key: 'reason' },
|
|
{ title: 'Date', dataIndex: 'createdAt', key: 'createdAt' },
|
|
];
|
|
|
|
const variantColumns: ColumnsType<ProductVariant> = [
|
|
{ title: 'SKU', dataIndex: 'sku', key: 'sku' },
|
|
{ title: 'Name', dataIndex: 'name', key: 'name' },
|
|
{ title: 'Price', dataIndex: 'price', key: 'price', render: (v) => `USD ${v.toFixed(2)}` },
|
|
{
|
|
title: 'Stock',
|
|
dataIndex: 'stock',
|
|
key: 'stock',
|
|
render: (stock: number) => (
|
|
<Badge
|
|
count={stock}
|
|
showZero
|
|
style={{ backgroundColor: stock > 50 ? '#52c41a' : stock > 10 ? '#faad14' : '#ff4d4f' }}
|
|
/>
|
|
)
|
|
},
|
|
];
|
|
|
|
if (loading) {
|
|
return (
|
|
<div style={{ textAlign: 'center', padding: '100px 0' }}>
|
|
<Spin size="large" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!product) {
|
|
return <div>Product not found</div>;
|
|
}
|
|
|
|
const profitMargin = ((product.price - product.costPrice) / product.price * 100).toFixed(1);
|
|
const statusConfig = STATUS_CONFIG[product.status];
|
|
const complianceConfig = COMPLIANCE_CONFIG[product.complianceStatus];
|
|
|
|
return (
|
|
<div className="product-detail-page">
|
|
<Card>
|
|
<Row gutter={24}>
|
|
<Col span={8}>
|
|
<Image.PreviewGroup>
|
|
<Image
|
|
src={product.images[0]}
|
|
alt={product.name}
|
|
style={{ width: '100%', borderRadius: 8 }}
|
|
/>
|
|
<div style={{ display: 'none' }}>
|
|
{product.images.slice(1).map((img, idx) => (
|
|
<Image key={idx} src={img} />
|
|
))}
|
|
</div>
|
|
</Image.PreviewGroup>
|
|
<Space style={{ marginTop: 16, width: '100%' }} wrap>
|
|
{product.images.map((img, idx) => (
|
|
<Image
|
|
key={idx}
|
|
src={img}
|
|
width={60}
|
|
height={60}
|
|
style={{ borderRadius: 4, objectFit: 'cover', cursor: 'pointer' }}
|
|
/>
|
|
))}
|
|
</Space>
|
|
</Col>
|
|
<Col span={16}>
|
|
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
|
<div>
|
|
<Space>
|
|
<Tag color="blue">{product.platform}</Tag>
|
|
<Tag color={statusConfig.color}>{statusConfig.text}</Tag>
|
|
<Tag color={complianceConfig.color} icon={complianceConfig.icon}>
|
|
{product.complianceStatus.replace('_', ' ')}
|
|
</Tag>
|
|
</Space>
|
|
<Title level={3} style={{ marginTop: 8, marginBottom: 0 }}>{product.name}</Title>
|
|
<Text type="secondary">SKU: {product.sku} | ID: {product.productId}</Text>
|
|
</div>
|
|
|
|
<Descriptions column={2} size="small">
|
|
<Descriptions.Item label="Category">{product.category}</Descriptions.Item>
|
|
<Descriptions.Item label="Brand">{product.brand}</Descriptions.Item>
|
|
<Descriptions.Item label={<><DollarOutlined /> Price</>}>
|
|
<Text strong style={{ fontSize: 18 }}>{product.currency} {product.price.toFixed(2)}</Text>
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label="Cost Price">
|
|
{product.currency} {product.costPrice.toFixed(2)}
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label="Profit Margin">
|
|
<Tag color={parseFloat(profitMargin) >= 30 ? 'green' : parseFloat(profitMargin) >= 15 ? 'orange' : 'red'}>
|
|
{profitMargin}%
|
|
</Tag>
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label={<><BoxPlotOutlined /> Stock</>}>
|
|
<Badge
|
|
count={product.stock}
|
|
showZero
|
|
style={{ backgroundColor: product.stock > 50 ? '#52c41a' : product.stock > 10 ? '#faad14' : '#ff4d4f' }}
|
|
/>
|
|
</Descriptions.Item>
|
|
</Descriptions>
|
|
|
|
<div>
|
|
<Text strong>Certificates: </Text>
|
|
{product.certificates.map(cert => (
|
|
<Tag key={cert} icon={<CheckCircleOutlined />} color="green">{cert}</Tag>
|
|
))}
|
|
</div>
|
|
|
|
<Divider style={{ margin: '12px 0' }} />
|
|
|
|
<Space>
|
|
<Button type="primary" icon={<EditOutlined />} onClick={handleEdit}>Edit</Button>
|
|
<Button icon={<CopyOutlined />} onClick={handleDuplicate}>Duplicate</Button>
|
|
<Button icon={<ShopOutlined />} onClick={handlePublish}>Publish</Button>
|
|
<Button danger icon={<DeleteOutlined />} onClick={handleDelete}>Delete</Button>
|
|
</Space>
|
|
</Space>
|
|
</Col>
|
|
</Row>
|
|
</Card>
|
|
|
|
<Card style={{ marginTop: 16 }}>
|
|
<Tabs defaultActiveKey="description">
|
|
<TabPane tab="Description" key="description">
|
|
<Text style={{ whiteSpace: 'pre-wrap' }}>{product.description}</Text>
|
|
</TabPane>
|
|
<TabPane tab="Attributes" key="attributes">
|
|
<Descriptions column={2} bordered size="small">
|
|
{Object.entries(product.attributes).map(([key, value]) => (
|
|
<Descriptions.Item key={key} label={key}>{value}</Descriptions.Item>
|
|
))}
|
|
</Descriptions>
|
|
</TabPane>
|
|
<TabPane tab="Variants" key="variants">
|
|
<Table
|
|
columns={variantColumns}
|
|
dataSource={product.variants}
|
|
rowKey="id"
|
|
pagination={false}
|
|
size="small"
|
|
/>
|
|
</TabPane>
|
|
<TabPane tab={<><HistoryOutlined /> Price History</>} key="priceHistory">
|
|
<Table
|
|
columns={priceColumns}
|
|
dataSource={priceHistory}
|
|
rowKey="id"
|
|
pagination={{ pageSize: 5 }}
|
|
size="small"
|
|
/>
|
|
</TabPane>
|
|
<TabPane tab={<><BoxPlotOutlined /> Stock History</>} key="stockHistory">
|
|
<Table
|
|
columns={stockColumns}
|
|
dataSource={stockHistory}
|
|
rowKey="id"
|
|
pagination={{ pageSize: 5 }}
|
|
size="small"
|
|
/>
|
|
</TabPane>
|
|
</Tabs>
|
|
</Card>
|
|
|
|
<Modal
|
|
title="Edit Product"
|
|
open={editModalVisible}
|
|
onCancel={() => setEditModalVisible(false)}
|
|
footer={null}
|
|
width={800}
|
|
>
|
|
<p>Edit form will be implemented here...</p>
|
|
</Modal>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ProductDetail;
|