Files
makemd/dashboard/src/pages/Product/ProductDetail.tsx
wurenzhi aa2cf560c6 feat: 添加汇率服务和缓存服务,优化数据源和日志服务
refactor: 重构数据源工厂和类型定义,提升代码可维护性

fix: 修复类型转换和状态机文档中的错误

docs: 更新服务架构文档,添加新的服务闭环流程

test: 添加汇率服务单元测试

chore: 清理无用代码和注释,优化代码结构
2026-03-19 14:19:01 +08:00

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;