feat: 添加MSW模拟服务和数据源集成
refactor: 重构页面组件移除冗余Layout组件 feat: 实现WebSocket和事件总线系统 feat: 添加队列和调度系统 docs: 更新架构文档和服务映射 style: 清理重复接口定义使用数据源 chore: 更新依赖项配置 feat: 添加运行时系统和领域引导 ci: 配置ESLint边界检查规则 build: 添加Redis和WebSocket依赖 test: 添加MSW浏览器环境入口 perf: 优化数据获取逻辑使用统一数据源 fix: 修复类型定义和状态管理问题
This commit is contained in:
486
dashboard/src/pages/Settings/CostTemplateConfig.tsx
Normal file
486
dashboard/src/pages/Settings/CostTemplateConfig.tsx
Normal file
@@ -0,0 +1,486 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Card,
|
||||
Table,
|
||||
Button,
|
||||
Space,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Select,
|
||||
Switch,
|
||||
Tag,
|
||||
Badge,
|
||||
message,
|
||||
Popconfirm,
|
||||
Tooltip,
|
||||
Row,
|
||||
Col,
|
||||
Statistic,
|
||||
Divider,
|
||||
Collapse,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
CopyOutlined,
|
||||
CalculatorOutlined,
|
||||
PercentageOutlined,
|
||||
DollarOutlined,
|
||||
SettingOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { settingsDataSource, CostTemplate, CostItem } from '@/services/settingsDataSource';
|
||||
|
||||
const { Option } = Select;
|
||||
const { Panel } = Collapse;
|
||||
const { TextArea } = Input;
|
||||
|
||||
const PLATFORM_LIST = ['AMAZON', 'EBAY', 'SHOPIFY', 'SHOPEE', 'LAZADA', 'ALIBABA', 'ALL'];
|
||||
const CATEGORY_LIST = ['Electronics', 'Clothing', 'Home', 'Industrial', 'General'];
|
||||
|
||||
const COST_TYPE_CONFIG: Record<string, { color: string; text: string; icon: React.ReactNode }> = {
|
||||
FIXED: { color: 'blue', text: 'Fixed', icon: <DollarOutlined /> },
|
||||
PERCENTAGE: { color: 'green', text: 'Percentage', icon: <PercentageOutlined /> },
|
||||
PER_UNIT: { color: 'orange', text: 'Per Unit', icon: <CalculatorOutlined /> },
|
||||
};
|
||||
|
||||
const CostTemplateConfig: React.FC = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [templates, setTemplates] = useState<CostTemplate[]>([]);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [detailModalVisible, setDetailModalVisible] = useState(false);
|
||||
const [editingTemplate, setEditingTemplate] = useState<CostTemplate | null>(null);
|
||||
const [viewingTemplate, setViewingTemplate] = useState<CostTemplate | null>(null);
|
||||
const [costItems, setCostItems] = useState<CostItem[]>([]);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
useEffect(() => {
|
||||
fetchTemplates();
|
||||
}, []);
|
||||
|
||||
const fetchTemplates = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const mockTemplates: CostTemplate[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Amazon US Standard',
|
||||
platform: 'AMAZON',
|
||||
category: 'Electronics',
|
||||
description: 'Standard cost template for Amazon US electronics',
|
||||
isDefault: true,
|
||||
isActive: true,
|
||||
createdAt: '2026-01-15',
|
||||
updatedAt: '2026-03-10',
|
||||
costs: [
|
||||
{ id: 'c1', name: 'Platform Fee', type: 'PERCENTAGE', value: 15, currency: 'USD', applyTo: 'PLATFORM_FEE', description: 'Amazon referral fee' },
|
||||
{ id: 'c2', name: 'FBA Fee', type: 'PER_UNIT', value: 3.5, currency: 'USD', applyTo: 'SHIPPING', description: 'Fulfillment fee' },
|
||||
{ id: 'c3', name: 'Storage Fee', type: 'PER_UNIT', value: 0.5, currency: 'USD', applyTo: 'PRODUCT', description: 'Monthly storage' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'eBay Standard',
|
||||
platform: 'EBAY',
|
||||
category: 'General',
|
||||
description: 'Standard cost template for eBay',
|
||||
isDefault: true,
|
||||
isActive: true,
|
||||
createdAt: '2026-01-20',
|
||||
updatedAt: '2026-03-05',
|
||||
costs: [
|
||||
{ id: 'c4', name: 'Final Value Fee', type: 'PERCENTAGE', value: 12.5, currency: 'USD', applyTo: 'PLATFORM_FEE', description: 'eBay final value fee' },
|
||||
{ id: 'c5', name: 'PayPal Fee', type: 'PERCENTAGE', value: 2.9, currency: 'USD', applyTo: 'ORDER', description: 'Payment processing fee' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Shopify Standard',
|
||||
platform: 'SHOPIFY',
|
||||
category: 'General',
|
||||
description: 'Standard cost template for Shopify stores',
|
||||
isDefault: true,
|
||||
isActive: true,
|
||||
createdAt: '2026-02-01',
|
||||
updatedAt: '2026-03-15',
|
||||
costs: [
|
||||
{ id: 'c6', name: 'Transaction Fee', type: 'PERCENTAGE', value: 2.0, currency: 'USD', applyTo: 'ORDER', description: 'Shopify transaction fee' },
|
||||
{ id: 'c7', name: 'Payment Gateway', type: 'PERCENTAGE', value: 2.9, currency: 'USD', applyTo: 'ORDER', description: 'Credit card processing' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'Industrial Equipment',
|
||||
platform: 'ALL',
|
||||
category: 'Industrial',
|
||||
description: 'Cost template for industrial equipment across platforms',
|
||||
isDefault: false,
|
||||
isActive: true,
|
||||
createdAt: '2026-02-15',
|
||||
updatedAt: '2026-03-18',
|
||||
costs: [
|
||||
{ id: 'c8', name: 'Shipping Surcharge', type: 'PERCENTAGE', value: 5, currency: 'USD', applyTo: 'SHIPPING', description: 'Heavy item shipping' },
|
||||
{ id: 'c9', name: 'Insurance', type: 'PERCENTAGE', value: 1.5, currency: 'USD', applyTo: 'SHIPPING', description: 'Shipping insurance' },
|
||||
],
|
||||
},
|
||||
];
|
||||
setTemplates(mockTemplates);
|
||||
} catch (error) {
|
||||
message.error('Failed to load cost templates');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingTemplate(null);
|
||||
setCostItems([]);
|
||||
form.resetFields();
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleEdit = (record: CostTemplate) => {
|
||||
setEditingTemplate(record);
|
||||
setCostItems(record.costs);
|
||||
form.setFieldsValue(record);
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleView = (record: CostTemplate) => {
|
||||
setViewingTemplate(record);
|
||||
setDetailModalVisible(true);
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
setTemplates(templates.filter(t => t.id !== id));
|
||||
message.success('Template deleted successfully');
|
||||
};
|
||||
|
||||
const handleDuplicate = (record: CostTemplate) => {
|
||||
const newTemplate: CostTemplate = {
|
||||
...record,
|
||||
id: `${Date.now()}`,
|
||||
name: `${record.name} (Copy)`,
|
||||
isDefault: false,
|
||||
createdAt: new Date().toISOString().split('T')[0],
|
||||
updatedAt: new Date().toISOString().split('T')[0],
|
||||
};
|
||||
setTemplates([...templates, newTemplate]);
|
||||
message.success('Template duplicated successfully');
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const templateData: CostTemplate = {
|
||||
...values,
|
||||
id: editingTemplate?.id || `${Date.now()}`,
|
||||
costs: costItems,
|
||||
createdAt: editingTemplate?.createdAt || new Date().toISOString().split('T')[0],
|
||||
updatedAt: new Date().toISOString().split('T')[0],
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
if (editingTemplate) {
|
||||
setTemplates(templates.map(t => t.id === editingTemplate.id ? templateData : t));
|
||||
message.success('Template updated successfully');
|
||||
} else {
|
||||
setTemplates([...templates, templateData]);
|
||||
message.success('Template created successfully');
|
||||
}
|
||||
setModalVisible(false);
|
||||
} catch (error) {
|
||||
console.error('Form validation failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddCostItem = () => {
|
||||
const newItem: CostItem = {
|
||||
id: `temp-${Date.now()}`,
|
||||
name: '',
|
||||
type: 'FIXED',
|
||||
value: 0,
|
||||
currency: 'USD',
|
||||
applyTo: 'PRODUCT',
|
||||
description: '',
|
||||
};
|
||||
setCostItems([...costItems, newItem]);
|
||||
};
|
||||
|
||||
const handleUpdateCostItem = (id: string, field: keyof CostItem, value: any) => {
|
||||
setCostItems(costItems.map(item =>
|
||||
item.id === id ? { ...item, [field]: value } : item
|
||||
));
|
||||
};
|
||||
|
||||
const handleRemoveCostItem = (id: string) => {
|
||||
setCostItems(costItems.filter(item => item.id !== id));
|
||||
};
|
||||
|
||||
const handleSetDefault = (id: string) => {
|
||||
setTemplates(templates.map(t => ({
|
||||
...t,
|
||||
isDefault: t.id === id,
|
||||
})));
|
||||
message.success('Default template updated');
|
||||
};
|
||||
|
||||
const stats = {
|
||||
total: templates.length,
|
||||
active: templates.filter(t => t.isActive).length,
|
||||
defaults: templates.filter(t => t.isDefault).length,
|
||||
platforms: new Set(templates.map(t => t.platform)).size,
|
||||
};
|
||||
|
||||
const columns: ColumnsType<CostTemplate> = [
|
||||
{
|
||||
title: 'Name',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: 200,
|
||||
render: (text, record) => (
|
||||
<Space>
|
||||
<a onClick={() => handleView(record)}>{text}</a>
|
||||
{record.isDefault && <Tag color="gold">Default</Tag>}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Platform',
|
||||
dataIndex: 'platform',
|
||||
key: 'platform',
|
||||
width: 100,
|
||||
render: (platform) => <Tag color="blue">{platform}</Tag>,
|
||||
},
|
||||
{
|
||||
title: 'Category',
|
||||
dataIndex: 'category',
|
||||
key: 'category',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: 'Cost Items',
|
||||
key: 'costCount',
|
||||
width: 100,
|
||||
render: (_, record) => <Badge count={record.costs.length} style={{ backgroundColor: '#1890ff' }} />,
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
dataIndex: 'isActive',
|
||||
key: 'isActive',
|
||||
width: 80,
|
||||
render: (active) => <Tag color={active ? 'success' : 'default'}>{active ? 'Active' : 'Inactive'}</Tag>,
|
||||
},
|
||||
{
|
||||
title: 'Updated',
|
||||
dataIndex: 'updatedAt',
|
||||
key: 'updatedAt',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: 'Actions',
|
||||
key: 'action',
|
||||
width: 180,
|
||||
fixed: 'right',
|
||||
render: (_, record) => (
|
||||
<Space size="small">
|
||||
<Tooltip title="Edit">
|
||||
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)} />
|
||||
</Tooltip>
|
||||
<Tooltip title="Duplicate">
|
||||
<Button type="link" size="small" icon={<CopyOutlined />} onClick={() => handleDuplicate(record)} />
|
||||
</Tooltip>
|
||||
{!record.isDefault && (
|
||||
<Tooltip title="Set as Default">
|
||||
<Button type="link" size="small" onClick={() => handleSetDefault(record.id)}>
|
||||
Default
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Popconfirm title="Delete this template?" onConfirm={() => handleDelete(record.id)}>
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="cost-template-config">
|
||||
<Row gutter={16} style={{ marginBottom: 24 }}>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic title="Total Templates" value={stats.total} prefix={<SettingOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic title="Active" value={stats.active} valueStyle={{ color: '#52c41a' }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic title="Defaults" value={stats.defaults} valueStyle={{ color: '#faad14' }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic title="Platforms" value={stats.platforms} valueStyle={{ color: '#1890ff' }} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card
|
||||
title="Cost Template Configuration"
|
||||
extra={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>
|
||||
Create Template
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={templates}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
scroll={{ x: 900 }}
|
||||
pagination={{ showSizeChanger: true, showTotal: (total) => `Total ${total} templates` }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={editingTemplate ? 'Edit Cost Template' : 'Create Cost Template'}
|
||||
open={modalVisible}
|
||||
onOk={handleSubmit}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
width={800}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="name" label="Template Name" rules={[{ required: true }]}>
|
||||
<Input placeholder="Enter template name" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Form.Item name="platform" label="Platform" rules={[{ required: true }]}>
|
||||
<Select placeholder="Select platform">
|
||||
{PLATFORM_LIST.map(p => (
|
||||
<Option key={p} value={p}>{p}</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Form.Item name="category" label="Category" rules={[{ required: true }]}>
|
||||
<Select placeholder="Select category">
|
||||
{CATEGORY_LIST.map(c => (
|
||||
<Option key={c} value={c}>{c}</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item name="description" label="Description">
|
||||
<TextArea rows={2} placeholder="Enter description" />
|
||||
</Form.Item>
|
||||
<Form.Item name="isDefault" label="Set as Default" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
<Divider>Cost Items</Divider>
|
||||
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Button icon={<PlusOutlined />} onClick={handleAddCostItem}>
|
||||
Add Cost Item
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{costItems.map((item, index) => (
|
||||
<Card key={item.id} size="small" style={{ marginBottom: 8 }}>
|
||||
<Row gutter={16}>
|
||||
<Col span={6}>
|
||||
<Input
|
||||
placeholder="Name"
|
||||
value={item.name}
|
||||
onChange={(e) => handleUpdateCostItem(item.id, 'name', e.target.value)}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<Select value={item.type} onChange={(v) => handleUpdateCostItem(item.id, 'type', v)}>
|
||||
<Option value="FIXED">Fixed</Option>
|
||||
<Option value="PERCENTAGE">Percentage</Option>
|
||||
<Option value="PER_UNIT">Per Unit</Option>
|
||||
</Select>
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
placeholder="Value"
|
||||
value={item.value}
|
||||
onChange={(v) => handleUpdateCostItem(item.id, 'value', v || 0)}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<Select value={item.applyTo} onChange={(v) => handleUpdateCostItem(item.id, 'applyTo', v)}>
|
||||
<Option value="PRODUCT">Product</Option>
|
||||
<Option value="SHIPPING">Shipping</Option>
|
||||
<Option value="ORDER">Order</Option>
|
||||
<Option value="PLATFORM_FEE">Platform Fee</Option>
|
||||
</Select>
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<Button danger icon={<DeleteOutlined />} onClick={() => handleRemoveCostItem(item.id)} />
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
))}
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={`Template: ${viewingTemplate?.name}`}
|
||||
open={detailModalVisible}
|
||||
onCancel={() => setDetailModalVisible(false)}
|
||||
footer={null}
|
||||
width={700}
|
||||
>
|
||||
{viewingTemplate && (
|
||||
<>
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<p><strong>Platform:</strong> <Tag color="blue">{viewingTemplate.platform}</Tag></p>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<p><strong>Category:</strong> {viewingTemplate.category}</p>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<p><strong>Status:</strong> <Tag color={viewingTemplate.isActive ? 'success' : 'default'}>{viewingTemplate.isActive ? 'Active' : 'Inactive'}</Tag></p>
|
||||
</Col>
|
||||
</Row>
|
||||
<p><strong>Description:</strong> {viewingTemplate.description}</p>
|
||||
<Divider>Cost Items ({viewingTemplate.costs.length})</Divider>
|
||||
<Table
|
||||
dataSource={viewingTemplate.costs}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={false}
|
||||
columns={[
|
||||
{ title: 'Name', dataIndex: 'name', key: 'name' },
|
||||
{ title: 'Type', dataIndex: 'type', key: 'type', render: (t) => <Tag color={COST_TYPE_CONFIG[t].color}>{COST_TYPE_CONFIG[t].text}</Tag> },
|
||||
{ title: 'Value', dataIndex: 'value', key: 'value', render: (v, r) => r.type === 'PERCENTAGE' ? `${v}%` : `$${v}` },
|
||||
{ title: 'Apply To', dataIndex: 'applyTo', key: 'applyTo' },
|
||||
{ title: 'Description', dataIndex: 'description', key: 'description' },
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CostTemplateConfig;
|
||||
385
dashboard/src/pages/Settings/ExchangeRateConfig.tsx
Normal file
385
dashboard/src/pages/Settings/ExchangeRateConfig.tsx
Normal file
@@ -0,0 +1,385 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Card,
|
||||
Table,
|
||||
Button,
|
||||
Space,
|
||||
Modal,
|
||||
Form,
|
||||
InputNumber,
|
||||
Select,
|
||||
Tag,
|
||||
message,
|
||||
Popconfirm,
|
||||
Tooltip,
|
||||
Row,
|
||||
Col,
|
||||
Statistic,
|
||||
DatePicker,
|
||||
Alert,
|
||||
Divider,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
SyncOutlined,
|
||||
DollarOutlined,
|
||||
HistoryOutlined,
|
||||
LineChartOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import moment from 'moment';
|
||||
import { settingsDataSource, ExchangeRate } from '@/services/settingsDataSource';
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
interface RateHistory {
|
||||
id: string;
|
||||
fromCurrency: string;
|
||||
toCurrency: string;
|
||||
rate: number;
|
||||
recordedAt: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
const CURRENCY_LIST = [
|
||||
{ code: 'USD', name: 'US Dollar', symbol: '$' },
|
||||
{ code: 'CNY', name: 'Chinese Yuan', symbol: '¥' },
|
||||
{ code: 'EUR', name: 'Euro', symbol: '€' },
|
||||
{ code: 'GBP', name: 'British Pound', symbol: '£' },
|
||||
{ code: 'JPY', name: 'Japanese Yen', symbol: '¥' },
|
||||
{ code: 'AUD', name: 'Australian Dollar', symbol: 'A$' },
|
||||
{ code: 'CAD', name: 'Canadian Dollar', symbol: 'C$' },
|
||||
{ code: 'SGD', name: 'Singapore Dollar', symbol: 'S$' },
|
||||
{ code: 'HKD', name: 'Hong Kong Dollar', symbol: 'HK$' },
|
||||
{ code: 'KRW', name: 'Korean Won', symbol: '₩' },
|
||||
];
|
||||
|
||||
const SOURCE_CONFIG: Record<string, { color: string; text: string }> = {
|
||||
MANUAL: { color: 'blue', text: 'Manual' },
|
||||
AUTO: { color: 'green', text: 'Auto' },
|
||||
API: { color: 'purple', text: 'API' },
|
||||
};
|
||||
|
||||
const ExchangeRateConfig: React.FC = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [rates, setRates] = useState<ExchangeRate[]>([]);
|
||||
const [history, setHistory] = useState<RateHistory[]>([]);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [historyModalVisible, setHistoryModalVisible] = useState(false);
|
||||
const [editingRate, setEditingRate] = useState<ExchangeRate | null>(null);
|
||||
const [selectedPair, setSelectedPair] = useState<{ from: string; to: string } | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
useEffect(() => {
|
||||
fetchRates();
|
||||
}, []);
|
||||
|
||||
const fetchRates = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const mockRates: ExchangeRate[] = [
|
||||
{ id: '1', fromCurrency: 'USD', toCurrency: 'CNY', rate: 7.24, source: 'AUTO', updatedAt: '2026-03-18 10:00:00', updatedBy: 'system', effectiveDate: '2026-03-18', isActive: true },
|
||||
{ id: '2', fromCurrency: 'EUR', toCurrency: 'CNY', rate: 7.86, source: 'AUTO', updatedAt: '2026-03-18 10:00:00', updatedBy: 'system', effectiveDate: '2026-03-18', isActive: true },
|
||||
{ id: '3', fromCurrency: 'GBP', toCurrency: 'CNY', rate: 9.21, source: 'AUTO', updatedAt: '2026-03-18 10:00:00', updatedBy: 'system', effectiveDate: '2026-03-18', isActive: true },
|
||||
{ id: '4', fromCurrency: 'JPY', toCurrency: 'CNY', rate: 0.048, source: 'AUTO', updatedAt: '2026-03-18 10:00:00', updatedBy: 'system', effectiveDate: '2026-03-18', isActive: true },
|
||||
{ id: '5', fromCurrency: 'USD', toCurrency: 'EUR', rate: 0.92, source: 'MANUAL', updatedAt: '2026-03-17 15:00:00', updatedBy: 'admin', effectiveDate: '2026-03-17', isActive: true },
|
||||
{ id: '6', fromCurrency: 'AUD', toCurrency: 'CNY', rate: 4.72, source: 'API', updatedAt: '2026-03-18 09:30:00', updatedBy: 'system', effectiveDate: '2026-03-18', isActive: true },
|
||||
];
|
||||
setRates(mockRates);
|
||||
} catch (error) {
|
||||
message.error('Failed to load exchange rates');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchHistory = (from: string, to: string) => {
|
||||
const mockHistory: RateHistory[] = [
|
||||
{ id: '1', fromCurrency: from, toCurrency: to, rate: 7.24, recordedAt: '2026-03-18 10:00:00', source: 'AUTO' },
|
||||
{ id: '2', fromCurrency: from, toCurrency: to, rate: 7.22, recordedAt: '2026-03-17 10:00:00', source: 'AUTO' },
|
||||
{ id: '3', fromCurrency: from, toCurrency: to, rate: 7.20, recordedAt: '2026-03-16 10:00:00', source: 'AUTO' },
|
||||
{ id: '4', fromCurrency: from, toCurrency: to, rate: 7.18, recordedAt: '2026-03-15 10:00:00', source: 'AUTO' },
|
||||
{ id: '5', fromCurrency: from, toCurrency: to, rate: 7.15, recordedAt: '2026-03-14 10:00:00', source: 'AUTO' },
|
||||
];
|
||||
setHistory(mockHistory);
|
||||
setSelectedPair({ from, to });
|
||||
setHistoryModalVisible(true);
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingRate(null);
|
||||
form.resetFields();
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleEdit = (record: ExchangeRate) => {
|
||||
setEditingRate(record);
|
||||
form.setFieldsValue({
|
||||
...record,
|
||||
effectiveDate: moment(record.effectiveDate),
|
||||
expiryDate: record.expiryDate ? moment(record.expiryDate) : undefined,
|
||||
});
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
setRates(rates.filter(r => r.id !== id));
|
||||
message.success('Exchange rate deleted successfully');
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const rateData = {
|
||||
...values,
|
||||
effectiveDate: values.effectiveDate.format('YYYY-MM-DD'),
|
||||
expiryDate: values.expiryDate?.format('YYYY-MM-DD'),
|
||||
updatedAt: moment().format('YYYY-MM-DD HH:mm:ss'),
|
||||
updatedBy: 'admin',
|
||||
source: 'MANUAL' as const,
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
if (editingRate) {
|
||||
setRates(rates.map(r => r.id === editingRate.id ? { ...r, ...rateData } : r));
|
||||
message.success('Exchange rate updated successfully');
|
||||
} else {
|
||||
setRates([...rates, { ...rateData, id: `${Date.now()}` }]);
|
||||
message.success('Exchange rate added successfully');
|
||||
}
|
||||
setModalVisible(false);
|
||||
} catch (error) {
|
||||
console.error('Form validation failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSyncFromAPI = async () => {
|
||||
message.loading('Syncing exchange rates from API...', 2);
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
message.success('Exchange rates synced successfully');
|
||||
fetchRates();
|
||||
};
|
||||
|
||||
const handleToggleActive = (id: string, checked: boolean) => {
|
||||
setRates(rates.map(r => r.id === id ? { ...r, isActive: checked } : r));
|
||||
message.success(`Exchange rate ${checked ? 'activated' : 'deactivated'}`);
|
||||
};
|
||||
|
||||
const stats = {
|
||||
total: rates.length,
|
||||
active: rates.filter(r => r.isActive).length,
|
||||
manual: rates.filter(r => r.source === 'MANUAL').length,
|
||||
auto: rates.filter(r => r.source === 'AUTO' || r.source === 'API').length,
|
||||
};
|
||||
|
||||
const columns: ColumnsType<ExchangeRate> = [
|
||||
{
|
||||
title: 'From',
|
||||
dataIndex: 'fromCurrency',
|
||||
key: 'fromCurrency',
|
||||
width: 100,
|
||||
render: (code) => {
|
||||
const currency = CURRENCY_LIST.find(c => c.code === code);
|
||||
return <Tag>{code} ({currency?.symbol})</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'To',
|
||||
dataIndex: 'toCurrency',
|
||||
key: 'toCurrency',
|
||||
width: 100,
|
||||
render: (code) => {
|
||||
const currency = CURRENCY_LIST.find(c => c.code === code);
|
||||
return <Tag color="blue">{code} ({currency?.symbol})</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Rate',
|
||||
dataIndex: 'rate',
|
||||
key: 'rate',
|
||||
width: 120,
|
||||
render: (rate) => <span style={{ fontWeight: 'bold', fontSize: 16 }}>{rate.toFixed(4)}</span>,
|
||||
},
|
||||
{
|
||||
title: 'Source',
|
||||
dataIndex: 'source',
|
||||
key: 'source',
|
||||
width: 100,
|
||||
render: (source) => {
|
||||
const config = SOURCE_CONFIG[source];
|
||||
return <Tag color={config.color}>{config.text}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Effective Date',
|
||||
dataIndex: 'effectiveDate',
|
||||
key: 'effectiveDate',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: 'Updated',
|
||||
dataIndex: 'updatedAt',
|
||||
key: 'updatedAt',
|
||||
width: 160,
|
||||
},
|
||||
{
|
||||
title: 'Active',
|
||||
dataIndex: 'isActive',
|
||||
key: 'isActive',
|
||||
width: 80,
|
||||
render: (active, record) => (
|
||||
<Tag color={active ? 'success' : 'default'} onClick={() => handleToggleActive(record.id, !active)} style={{ cursor: 'pointer' }}>
|
||||
{active ? 'Yes' : 'No'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Actions',
|
||||
key: 'action',
|
||||
width: 150,
|
||||
fixed: 'right',
|
||||
render: (_, record) => (
|
||||
<Space size="small">
|
||||
<Tooltip title="Edit">
|
||||
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)} />
|
||||
</Tooltip>
|
||||
<Tooltip title="History">
|
||||
<Button type="link" size="small" icon={<HistoryOutlined />} onClick={() => fetchHistory(record.fromCurrency, record.toCurrency)} />
|
||||
</Tooltip>
|
||||
<Popconfirm title="Delete this rate?" onConfirm={() => handleDelete(record.id)}>
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const historyColumns: ColumnsType<RateHistory> = [
|
||||
{ title: 'Date', dataIndex: 'recordedAt', key: 'recordedAt', width: 180 },
|
||||
{ title: 'Rate', dataIndex: 'rate', key: 'rate', render: (r) => r.toFixed(4) },
|
||||
{ title: 'Source', dataIndex: 'source', key: 'source' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="exchange-rate-config">
|
||||
<Alert
|
||||
message="Exchange rates are automatically synced daily at 10:00 AM. You can also manually add or edit rates."
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
|
||||
<Row gutter={16} style={{ marginBottom: 24 }}>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic title="Total Pairs" value={stats.total} prefix={<DollarOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic title="Active" value={stats.active} valueStyle={{ color: '#52c41a' }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic title="Manual" value={stats.manual} valueStyle={{ color: '#1890ff' }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic title="Auto/API" value={stats.auto} valueStyle={{ color: '#722ed1' }} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card
|
||||
title="Exchange Rate Configuration"
|
||||
extra={
|
||||
<Space>
|
||||
<Button icon={<SyncOutlined />} onClick={handleSyncFromAPI}>
|
||||
Sync from API
|
||||
</Button>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>
|
||||
Add Rate
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={rates}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
scroll={{ x: 1000 }}
|
||||
pagination={{ showSizeChanger: true, showTotal: (total) => `Total ${total} rates` }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={editingRate ? 'Edit Exchange Rate' : 'Add Exchange Rate'}
|
||||
open={modalVisible}
|
||||
onOk={handleSubmit}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
width={500}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="fromCurrency" label="From Currency" rules={[{ required: true }]}>
|
||||
<Select placeholder="Select currency" showSearch optionFilterProp="children">
|
||||
{CURRENCY_LIST.map(c => (
|
||||
<Option key={c.code} value={c.code}>{c.code} - {c.name}</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="toCurrency" label="To Currency" rules={[{ required: true }]}>
|
||||
<Select placeholder="Select currency" showSearch optionFilterProp="children">
|
||||
{CURRENCY_LIST.map(c => (
|
||||
<Option key={c.code} value={c.code}>{c.code} - {c.name}</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item name="rate" label="Exchange Rate" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} min={0} step={0.0001} precision={4} placeholder="Enter rate" />
|
||||
</Form.Item>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="effectiveDate" label="Effective Date" rules={[{ required: true }]}>
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="expiryDate" label="Expiry Date">
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={`Rate History: ${selectedPair?.from}/${selectedPair?.to}`}
|
||||
open={historyModalVisible}
|
||||
onCancel={() => setHistoryModalVisible(false)}
|
||||
footer={null}
|
||||
width={600}
|
||||
>
|
||||
<Table
|
||||
columns={historyColumns}
|
||||
dataSource={history}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
size="small"
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExchangeRateConfig;
|
||||
446
dashboard/src/pages/Settings/PlatformAccountConfig.tsx
Normal file
446
dashboard/src/pages/Settings/PlatformAccountConfig.tsx
Normal file
@@ -0,0 +1,446 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Card,
|
||||
Table,
|
||||
Button,
|
||||
Space,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Switch,
|
||||
Tag,
|
||||
message,
|
||||
Popconfirm,
|
||||
Tooltip,
|
||||
Row,
|
||||
Col,
|
||||
Statistic,
|
||||
Badge,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
SyncOutlined,
|
||||
ApiOutlined,
|
||||
ShopOutlined,
|
||||
LinkOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { settingsDataSource, PlatformAccount } from '@/services/settingsDataSource';
|
||||
|
||||
const { Option } = Select;
|
||||
const { Password } = Input;
|
||||
|
||||
const PLATFORM_CONFIG: Record<string, { color: string; text: string; icon: React.ReactNode }> = {
|
||||
AMAZON: { color: 'orange', text: 'Amazon', icon: <ShopOutlined /> },
|
||||
EBAY: { color: 'blue', text: 'eBay', icon: <ShopOutlined /> },
|
||||
SHOPIFY: { color: 'green', text: 'Shopify', icon: <ShopOutlined /> },
|
||||
SHOPEE: { color: 'red', text: 'Shopee', icon: <ShopOutlined /> },
|
||||
LAZADA: { color: 'purple', text: 'Lazada', icon: <ShopOutlined /> },
|
||||
ALIBABA: { color: 'orange', text: 'Alibaba', icon: <ShopOutlined /> },
|
||||
};
|
||||
|
||||
const STATUS_CONFIG: Record<string, { color: string; text: string }> = {
|
||||
ACTIVE: { color: 'success', text: 'Active' },
|
||||
INACTIVE: { color: 'default', text: 'Inactive' },
|
||||
EXPIRED: { color: 'warning', text: 'Expired' },
|
||||
ERROR: { color: 'error', text: 'Error' },
|
||||
};
|
||||
|
||||
const PlatformAccountConfig: React.FC = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [accounts, setAccounts] = useState<PlatformAccount[]>([]);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [editingAccount, setEditingAccount] = useState<PlatformAccount | null>(null);
|
||||
const [testLoading, setTestLoading] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
useEffect(() => {
|
||||
fetchAccounts();
|
||||
}, []);
|
||||
|
||||
const fetchAccounts = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const mockAccounts: PlatformAccount[] = [
|
||||
{
|
||||
id: '1',
|
||||
platform: 'AMAZON',
|
||||
accountName: 'US Store',
|
||||
shopId: 'AMZ-US-001',
|
||||
shopName: 'Amazon US Store',
|
||||
region: 'US',
|
||||
status: 'ACTIVE',
|
||||
apiConnected: true,
|
||||
lastSync: '2026-03-18 10:30:00',
|
||||
tokenExpiry: '2026-06-18',
|
||||
autoSync: true,
|
||||
syncInterval: 30,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
platform: 'EBAY',
|
||||
accountName: 'eBay Outlet',
|
||||
shopId: 'EB-001',
|
||||
shopName: 'eBay Outlet Store',
|
||||
region: 'US',
|
||||
status: 'ACTIVE',
|
||||
apiConnected: true,
|
||||
lastSync: '2026-03-18 09:15:00',
|
||||
tokenExpiry: '2026-05-15',
|
||||
autoSync: true,
|
||||
syncInterval: 60,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
platform: 'SHOPIFY',
|
||||
accountName: 'Brand Store',
|
||||
shopId: 'SH-BRAND-001',
|
||||
shopName: 'Shopify Brand Store',
|
||||
region: 'US',
|
||||
status: 'ACTIVE',
|
||||
apiConnected: true,
|
||||
lastSync: '2026-03-18 08:00:00',
|
||||
tokenExpiry: '2027-03-18',
|
||||
autoSync: false,
|
||||
syncInterval: 120,
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
platform: 'SHOPEE',
|
||||
accountName: 'Shopee Mall',
|
||||
shopId: 'SP-MALL-001',
|
||||
shopName: 'Shopee Mall Store',
|
||||
region: 'SG',
|
||||
status: 'EXPIRED',
|
||||
apiConnected: false,
|
||||
lastSync: '2026-03-10 14:00:00',
|
||||
tokenExpiry: '2026-03-15',
|
||||
autoSync: true,
|
||||
syncInterval: 30,
|
||||
},
|
||||
];
|
||||
setAccounts(mockAccounts);
|
||||
} catch (error) {
|
||||
message.error('Failed to load platform accounts');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingAccount(null);
|
||||
form.resetFields();
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleEdit = (record: PlatformAccount) => {
|
||||
setEditingAccount(record);
|
||||
form.setFieldsValue(record);
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
setAccounts(accounts.filter(a => a.id !== id));
|
||||
message.success('Account deleted successfully');
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
if (editingAccount) {
|
||||
setAccounts(accounts.map(a =>
|
||||
a.id === editingAccount.id ? { ...a, ...values } : a
|
||||
));
|
||||
message.success('Account updated successfully');
|
||||
} else {
|
||||
const newAccount: PlatformAccount = {
|
||||
id: `${Date.now()}`,
|
||||
...values,
|
||||
status: 'INACTIVE',
|
||||
apiConnected: false,
|
||||
lastSync: '-',
|
||||
tokenExpiry: '-',
|
||||
};
|
||||
setAccounts([...accounts, newAccount]);
|
||||
message.success('Account added successfully');
|
||||
}
|
||||
setModalVisible(false);
|
||||
} catch (error) {
|
||||
console.error('Form validation failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
setTestLoading(true);
|
||||
try {
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
message.success('API connection test successful');
|
||||
} catch (error) {
|
||||
message.error('API connection test failed');
|
||||
} finally {
|
||||
setTestLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSync = async (id: string) => {
|
||||
message.loading('Starting sync...', 1);
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
setAccounts(accounts.map(a =>
|
||||
a.id === id ? { ...a, lastSync: new Date().toISOString().replace('T', ' ').slice(0, 19) } : a
|
||||
));
|
||||
message.success('Sync completed');
|
||||
};
|
||||
|
||||
const handleToggleAutoSync = (id: string, checked: boolean) => {
|
||||
setAccounts(accounts.map(a =>
|
||||
a.id === id ? { ...a, autoSync: checked } : a
|
||||
));
|
||||
message.success(`Auto sync ${checked ? 'enabled' : 'disabled'}`);
|
||||
};
|
||||
|
||||
const stats = {
|
||||
total: accounts.length,
|
||||
active: accounts.filter(a => a.status === 'ACTIVE').length,
|
||||
expired: accounts.filter(a => a.status === 'EXPIRED').length,
|
||||
error: accounts.filter(a => a.status === 'ERROR').length,
|
||||
};
|
||||
|
||||
const columns: ColumnsType<PlatformAccount> = [
|
||||
{
|
||||
title: 'Platform',
|
||||
dataIndex: 'platform',
|
||||
key: 'platform',
|
||||
width: 120,
|
||||
render: (platform) => {
|
||||
const config = PLATFORM_CONFIG[platform];
|
||||
return (
|
||||
<Tag color={config.color} icon={config.icon}>
|
||||
{config.text}
|
||||
</Tag>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Account Name',
|
||||
dataIndex: 'accountName',
|
||||
key: 'accountName',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: 'Shop ID',
|
||||
dataIndex: 'shopId',
|
||||
key: 'shopId',
|
||||
width: 130,
|
||||
},
|
||||
{
|
||||
title: 'Region',
|
||||
dataIndex: 'region',
|
||||
key: 'region',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
render: (status) => {
|
||||
const config = STATUS_CONFIG[status];
|
||||
return <Tag color={config.color}>{config.text}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'API',
|
||||
dataIndex: 'apiConnected',
|
||||
key: 'apiConnected',
|
||||
width: 80,
|
||||
render: (connected) => (
|
||||
<Tag color={connected ? 'success' : 'error'} icon={connected ? <CheckCircleOutlined /> : <CloseCircleOutlined />}>
|
||||
{connected ? 'Connected' : 'Disconnected'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Last Sync',
|
||||
dataIndex: 'lastSync',
|
||||
key: 'lastSync',
|
||||
width: 160,
|
||||
},
|
||||
{
|
||||
title: 'Token Expiry',
|
||||
dataIndex: 'tokenExpiry',
|
||||
key: 'tokenExpiry',
|
||||
width: 120,
|
||||
render: (date) => {
|
||||
const isExpiringSoon = date !== '-' && new Date(date) < new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
|
||||
return <span style={{ color: isExpiringSoon ? '#ff4d4f' : undefined }}>{date}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Auto Sync',
|
||||
dataIndex: 'autoSync',
|
||||
key: 'autoSync',
|
||||
width: 100,
|
||||
render: (checked, record) => (
|
||||
<Switch checked={checked} onChange={(c) => handleToggleAutoSync(record.id, c)} size="small" />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Actions',
|
||||
key: 'action',
|
||||
width: 180,
|
||||
fixed: 'right',
|
||||
render: (_, record) => (
|
||||
<Space size="small">
|
||||
<Tooltip title="Edit">
|
||||
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)} />
|
||||
</Tooltip>
|
||||
<Tooltip title="Sync Now">
|
||||
<Button type="link" size="small" icon={<SyncOutlined />} onClick={() => handleSync(record.id)} />
|
||||
</Tooltip>
|
||||
<Popconfirm
|
||||
title="Delete this account?"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
>
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="platform-account-config">
|
||||
<Row gutter={16} style={{ marginBottom: 24 }}>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic title="Total Accounts" value={stats.total} prefix={<ApiOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic title="Active" value={stats.active} valueStyle={{ color: '#52c41a' }} prefix={<CheckCircleOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic title="Expired" value={stats.expired} valueStyle={{ color: '#faad14' }} prefix={<CloseCircleOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic title="Error" value={stats.error} valueStyle={{ color: '#ff4d4f' }} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card
|
||||
title="Platform Account Configuration"
|
||||
extra={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>
|
||||
Add Account
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={accounts}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
scroll={{ x: 1200 }}
|
||||
pagination={{ showSizeChanger: true, showTotal: (total) => `Total ${total} accounts` }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={editingAccount ? 'Edit Account' : 'Add Account'}
|
||||
open={modalVisible}
|
||||
onOk={handleSubmit}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
width={600}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="platform" label="Platform" rules={[{ required: true }]}>
|
||||
<Select placeholder="Select platform">
|
||||
{Object.entries(PLATFORM_CONFIG).map(([key, config]) => (
|
||||
<Option key={key} value={key}>{config.text}</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="accountName" label="Account Name" rules={[{ required: true }]}>
|
||||
<Input placeholder="Enter account name" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="shopId" label="Shop ID" rules={[{ required: true }]}>
|
||||
<Input placeholder="Enter shop ID" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="shopName" label="Shop Name" rules={[{ required: true }]}>
|
||||
<Input placeholder="Enter shop name" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="region" label="Region" rules={[{ required: true }]}>
|
||||
<Select placeholder="Select region">
|
||||
<Option value="US">United States</Option>
|
||||
<Option value="UK">United Kingdom</Option>
|
||||
<Option value="EU">Europe</Option>
|
||||
<Option value="CN">China</Option>
|
||||
<Option value="SG">Singapore</Option>
|
||||
<Option value="JP">Japan</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="syncInterval" label="Sync Interval (minutes)">
|
||||
<Select placeholder="Select interval">
|
||||
<Option value={15}>15 minutes</Option>
|
||||
<Option value={30}>30 minutes</Option>
|
||||
<Option value={60}>1 hour</Option>
|
||||
<Option value={120}>2 hours</Option>
|
||||
<Option value={360}>6 hours</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item name="autoSync" label="Auto Sync" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Card size="small" title="API Credentials" style={{ marginBottom: 16 }}>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="apiKey" label="API Key">
|
||||
<Input placeholder="Enter API key" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="apiSecret" label="API Secret">
|
||||
<Password placeholder="Enter API secret" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Button icon={<LinkOutlined />} loading={testLoading} onClick={handleTestConnection}>
|
||||
Test Connection
|
||||
</Button>
|
||||
</Card>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlatformAccountConfig;
|
||||
1169
dashboard/src/pages/Settings/SystemSettings.tsx
Normal file
1169
dashboard/src/pages/Settings/SystemSettings.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,20 +2,11 @@ import React, { useState, useEffect } from 'react';
|
||||
import { Table, Button, Input, Select, message, Card, Modal, Form } from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, ArrowLeftOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { settingsDataSource, User } from '@/services/settingsDataSource';
|
||||
|
||||
const { Option } = Select;
|
||||
const { Search } = Input;
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
status: 'active' | 'inactive';
|
||||
createdAt: string;
|
||||
lastLogin: string;
|
||||
}
|
||||
|
||||
const UserManagement: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
@@ -34,30 +25,15 @@ const UserManagement: React.FC = () => {
|
||||
|
||||
const fetchUsers = async () => {
|
||||
setLoading(true);
|
||||
// 模拟API调用
|
||||
setTimeout(() => {
|
||||
const mockUsers: User[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'John Doe',
|
||||
email: 'john.doe@example.com',
|
||||
role: 'ADMIN',
|
||||
status: 'active',
|
||||
createdAt: '2026-01-01',
|
||||
lastLogin: '2026-03-17',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Jane Smith',
|
||||
email: 'jane.smith@example.com',
|
||||
role: 'MANAGER',
|
||||
status: 'active',
|
||||
createdAt: '2026-01-02',
|
||||
lastLogin: '2026-03-16',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Bob Johnson',
|
||||
try {
|
||||
const data = await settingsDataSource.fetchUsers();
|
||||
setUsers(data);
|
||||
} catch (error) {
|
||||
message.error('Failed to load users');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
email: 'bob.johnson@example.com',
|
||||
role: 'OPERATOR',
|
||||
status: 'active',
|
||||
|
||||
531
dashboard/src/pages/Settings/WinNodeConfig.tsx
Normal file
531
dashboard/src/pages/Settings/WinNodeConfig.tsx
Normal file
@@ -0,0 +1,531 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Card,
|
||||
Table,
|
||||
Button,
|
||||
Space,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Switch,
|
||||
Tag,
|
||||
message,
|
||||
Popconfirm,
|
||||
Tooltip,
|
||||
Row,
|
||||
Col,
|
||||
Statistic,
|
||||
Badge,
|
||||
Progress,
|
||||
Descriptions,
|
||||
Divider,
|
||||
Alert,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
DesktopOutlined,
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
SyncOutlined,
|
||||
SettingOutlined,
|
||||
GlobalOutlined,
|
||||
SafetyOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { settingsDataSource, WinNode } from '@/services/settingsDataSource';
|
||||
|
||||
const { Option } = Select;
|
||||
const { Password } = Input;
|
||||
|
||||
const STATUS_CONFIG: Record<string, { color: string; text: string; icon: React.ReactNode }> = {
|
||||
ONLINE: { color: 'success', text: 'Online', icon: <CheckCircleOutlined /> },
|
||||
OFFLINE: { color: 'default', text: 'Offline', icon: <CloseCircleOutlined /> },
|
||||
BUSY: { color: 'processing', text: 'Busy', icon: <SyncOutlined spin /> },
|
||||
ERROR: { color: 'error', text: 'Error', icon: <CloseCircleOutlined /> },
|
||||
};
|
||||
|
||||
const FINGERPRINT_POLICIES = [
|
||||
'STANDARD',
|
||||
'STEALTH',
|
||||
'RANDOM',
|
||||
'CUSTOM',
|
||||
];
|
||||
|
||||
const WinNodeConfig: React.FC = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [nodes, setNodes] = useState<WinNode[]>([]);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [detailModalVisible, setDetailModalVisible] = useState(false);
|
||||
const [editingNode, setEditingNode] = useState<WinNode | null>(null);
|
||||
const [viewingNode, setViewingNode] = useState<WinNode | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
useEffect(() => {
|
||||
fetchNodes();
|
||||
const interval = setInterval(fetchNodes, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const fetchNodes = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const mockNodes: WinNode[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'WinNode-US-01',
|
||||
host: '192.168.1.101',
|
||||
port: 9222,
|
||||
status: 'ONLINE',
|
||||
shopId: 'SHOP_001',
|
||||
shopName: 'Amazon US Store',
|
||||
profileDir: 'C:\\Profiles\\AMZ_US_01',
|
||||
proxy: 'us-proxy.example.com:8080',
|
||||
fingerprintPolicy: 'STEALTH',
|
||||
maxConcurrent: 3,
|
||||
currentTasks: 1,
|
||||
cpuUsage: 35,
|
||||
memoryUsage: 42,
|
||||
lastHeartbeat: '2026-03-18 10:30:00',
|
||||
autoRestart: true,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'WinNode-US-02',
|
||||
host: '192.168.1.102',
|
||||
port: 9222,
|
||||
status: 'BUSY',
|
||||
shopId: 'SHOP_001',
|
||||
shopName: 'Amazon US Store',
|
||||
profileDir: 'C:\\Profiles\\AMZ_US_02',
|
||||
proxy: 'us-proxy.example.com:8080',
|
||||
fingerprintPolicy: 'STEALTH',
|
||||
maxConcurrent: 3,
|
||||
currentTasks: 3,
|
||||
cpuUsage: 78,
|
||||
memoryUsage: 65,
|
||||
lastHeartbeat: '2026-03-18 10:30:05',
|
||||
autoRestart: true,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'WinNode-EU-01',
|
||||
host: '192.168.1.103',
|
||||
port: 9222,
|
||||
status: 'ONLINE',
|
||||
shopId: 'SHOP_002',
|
||||
shopName: 'eBay Outlet',
|
||||
profileDir: 'C:\\Profiles\\EB_EU_01',
|
||||
proxy: 'eu-proxy.example.com:8080',
|
||||
fingerprintPolicy: 'STANDARD',
|
||||
maxConcurrent: 2,
|
||||
currentTasks: 0,
|
||||
cpuUsage: 12,
|
||||
memoryUsage: 28,
|
||||
lastHeartbeat: '2026-03-18 10:30:02',
|
||||
autoRestart: false,
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'WinNode-ASIA-01',
|
||||
host: '192.168.1.104',
|
||||
port: 9222,
|
||||
status: 'OFFLINE',
|
||||
shopId: 'SHOP_003',
|
||||
shopName: 'Shopee Mall',
|
||||
profileDir: 'C:\\Profiles\\SP_ASIA_01',
|
||||
proxy: 'sg-proxy.example.com:8080',
|
||||
fingerprintPolicy: 'RANDOM',
|
||||
maxConcurrent: 2,
|
||||
currentTasks: 0,
|
||||
cpuUsage: 0,
|
||||
memoryUsage: 0,
|
||||
lastHeartbeat: '2026-03-17 18:00:00',
|
||||
autoRestart: true,
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
name: 'WinNode-US-03',
|
||||
host: '192.168.1.105',
|
||||
port: 9222,
|
||||
status: 'ERROR',
|
||||
shopId: 'SHOP_004',
|
||||
shopName: 'Shopify Brand',
|
||||
profileDir: 'C:\\Profiles\\SF_US_01',
|
||||
proxy: 'us-proxy2.example.com:8080',
|
||||
fingerprintPolicy: 'STEALTH',
|
||||
maxConcurrent: 3,
|
||||
currentTasks: 0,
|
||||
cpuUsage: 0,
|
||||
memoryUsage: 0,
|
||||
lastHeartbeat: '2026-03-18 08:15:00',
|
||||
autoRestart: true,
|
||||
},
|
||||
];
|
||||
setNodes(mockNodes);
|
||||
} catch (error) {
|
||||
message.error('Failed to load WinNodes');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingNode(null);
|
||||
form.resetFields();
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleEdit = (record: WinNode) => {
|
||||
setEditingNode(record);
|
||||
form.setFieldsValue(record);
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleView = (record: WinNode) => {
|
||||
setViewingNode(record);
|
||||
setDetailModalVisible(true);
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
setNodes(nodes.filter(n => n.id !== id));
|
||||
message.success('WinNode deleted successfully');
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
if (editingNode) {
|
||||
setNodes(nodes.map(n => n.id === editingNode.id ? { ...n, ...values } : n));
|
||||
message.success('WinNode updated successfully');
|
||||
} else {
|
||||
const newNode: WinNode = {
|
||||
...values,
|
||||
id: `${Date.now()}`,
|
||||
status: 'OFFLINE',
|
||||
currentTasks: 0,
|
||||
cpuUsage: 0,
|
||||
memoryUsage: 0,
|
||||
lastHeartbeat: '-',
|
||||
};
|
||||
setNodes([...nodes, newNode]);
|
||||
message.success('WinNode added successfully');
|
||||
}
|
||||
setModalVisible(false);
|
||||
} catch (error) {
|
||||
console.error('Form validation failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestart = async (id: string) => {
|
||||
message.loading('Restarting node...', 2);
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
setNodes(nodes.map(n =>
|
||||
n.id === id ? { ...n, status: 'ONLINE', lastHeartbeat: new Date().toISOString().replace('T', ' ').slice(0, 19) } : n
|
||||
));
|
||||
message.success('Node restarted successfully');
|
||||
};
|
||||
|
||||
const handleTestConnection = async (id: string) => {
|
||||
message.loading('Testing connection...', 1.5);
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
message.success('Connection test successful');
|
||||
};
|
||||
|
||||
const handleToggleAutoRestart = (id: string, checked: boolean) => {
|
||||
setNodes(nodes.map(n => n.id === id ? { ...n, autoRestart: checked } : n));
|
||||
message.success(`Auto restart ${checked ? 'enabled' : 'disabled'}`);
|
||||
};
|
||||
|
||||
const stats = {
|
||||
total: nodes.length,
|
||||
online: nodes.filter(n => n.status === 'ONLINE').length,
|
||||
busy: nodes.filter(n => n.status === 'BUSY').length,
|
||||
offline: nodes.filter(n => n.status === 'OFFLINE' || n.status === 'ERROR').length,
|
||||
totalTasks: nodes.reduce((sum, n) => sum + n.currentTasks, 0),
|
||||
};
|
||||
|
||||
const columns: ColumnsType<WinNode> = [
|
||||
{
|
||||
title: 'Name',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: 150,
|
||||
render: (text, record) => (
|
||||
<a onClick={() => handleView(record)}>
|
||||
<DesktopOutlined style={{ marginRight: 8 }} />
|
||||
{text}
|
||||
</a>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
render: (status) => {
|
||||
const config = STATUS_CONFIG[status];
|
||||
return <Tag color={config.color} icon={config.icon}>{config.text}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Shop',
|
||||
dataIndex: 'shopName',
|
||||
key: 'shopName',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: 'Host:Port',
|
||||
key: 'hostPort',
|
||||
width: 150,
|
||||
render: (_, record) => `${record.host}:${record.port}`,
|
||||
},
|
||||
{
|
||||
title: 'Tasks',
|
||||
key: 'tasks',
|
||||
width: 100,
|
||||
render: (_, record) => (
|
||||
<Badge
|
||||
count={`${record.currentTasks}/${record.maxConcurrent}`}
|
||||
style={{ backgroundColor: record.currentTasks >= record.maxConcurrent ? '#ff4d4f' : '#1890ff' }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'CPU',
|
||||
dataIndex: 'cpuUsage',
|
||||
key: 'cpuUsage',
|
||||
width: 100,
|
||||
render: (usage) => (
|
||||
<Progress
|
||||
percent={usage}
|
||||
size="small"
|
||||
status={usage > 80 ? 'exception' : 'active'}
|
||||
format={(p) => `${p}%`}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Memory',
|
||||
dataIndex: 'memoryUsage',
|
||||
key: 'memoryUsage',
|
||||
width: 100,
|
||||
render: (usage) => (
|
||||
<Progress
|
||||
percent={usage}
|
||||
size="small"
|
||||
status={usage > 80 ? 'exception' : 'active'}
|
||||
format={(p) => `${p}%`}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Last Heartbeat',
|
||||
dataIndex: 'lastHeartbeat',
|
||||
key: 'lastHeartbeat',
|
||||
width: 160,
|
||||
},
|
||||
{
|
||||
title: 'Actions',
|
||||
key: 'action',
|
||||
width: 180,
|
||||
fixed: 'right',
|
||||
render: (_, record) => (
|
||||
<Space size="small">
|
||||
<Tooltip title="View">
|
||||
<Button type="link" size="small" onClick={() => handleView(record)}>View</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title="Edit">
|
||||
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)} />
|
||||
</Tooltip>
|
||||
<Tooltip title="Restart">
|
||||
<Button type="link" size="small" icon={<SyncOutlined />} onClick={() => handleRestart(record.id)} />
|
||||
</Tooltip>
|
||||
<Popconfirm title="Delete this node?" onConfirm={() => handleDelete(record.id)}>
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="winnode-config">
|
||||
<Alert
|
||||
message="WinNodes are Windows-based execution nodes for browser automation tasks. Each node is isolated per shop with unique profile and proxy settings."
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
|
||||
<Row gutter={16} style={{ marginBottom: 24 }}>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic title="Total Nodes" value={stats.total} prefix={<DesktopOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic title="Online" value={stats.online} valueStyle={{ color: '#52c41a' }} prefix={<CheckCircleOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic title="Busy" value={stats.busy} valueStyle={{ color: '#1890ff' }} prefix={<SyncOutlined spin />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic title="Offline/Error" value={stats.offline} valueStyle={{ color: '#ff4d4f' }} prefix={<CloseCircleOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card
|
||||
title="WinNode Configuration"
|
||||
extra={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>
|
||||
Add Node
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={nodes}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
scroll={{ x: 1200 }}
|
||||
pagination={{ showSizeChanger: true, showTotal: (total) => `Total ${total} nodes` }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={editingNode ? 'Edit WinNode' : 'Add WinNode'}
|
||||
open={modalVisible}
|
||||
onOk={handleSubmit}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
width={700}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="name" label="Node Name" rules={[{ required: true }]}>
|
||||
<Input placeholder="Enter node name" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Form.Item name="host" label="Host" rules={[{ required: true }]}>
|
||||
<Input placeholder="IP address" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Form.Item name="port" label="Port" rules={[{ required: true }]}>
|
||||
<Input type="number" placeholder="Port" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="shopId" label="Shop ID" rules={[{ required: true }]}>
|
||||
<Input placeholder="Enter shop ID" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="shopName" label="Shop Name" rules={[{ required: true }]}>
|
||||
<Input placeholder="Enter shop name" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="profileDir" label="Profile Directory" rules={[{ required: true }]}>
|
||||
<Input placeholder="C:\Profiles\..." />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="proxy" label="Proxy" rules={[{ required: true }]}>
|
||||
<Input placeholder="proxy.example.com:8080" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<Form.Item name="fingerprintPolicy" label="Fingerprint Policy" rules={[{ required: true }]}>
|
||||
<Select placeholder="Select policy">
|
||||
{FINGERPRINT_POLICIES.map(p => (
|
||||
<Option key={p} value={p}>{p}</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item name="maxConcurrent" label="Max Concurrent Tasks" rules={[{ required: true }]}>
|
||||
<Input type="number" min={1} max={10} placeholder="1-10" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item name="autoRestart" label="Auto Restart" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={`WinNode: ${viewingNode?.name}`}
|
||||
open={detailModalVisible}
|
||||
onCancel={() => setDetailModalVisible(false)}
|
||||
footer={[
|
||||
<Button key="test" onClick={() => viewingNode && handleTestConnection(viewingNode.id)}>
|
||||
Test Connection
|
||||
</Button>,
|
||||
<Button key="restart" type="primary" onClick={() => viewingNode && handleRestart(viewingNode.id)}>
|
||||
Restart
|
||||
</Button>,
|
||||
]}
|
||||
width={700}
|
||||
>
|
||||
{viewingNode && (
|
||||
<>
|
||||
<Descriptions bordered column={2}>
|
||||
<Descriptions.Item label="Status">
|
||||
<Tag color={STATUS_CONFIG[viewingNode.status].color} icon={STATUS_CONFIG[viewingNode.status].icon}>
|
||||
{STATUS_CONFIG[viewingNode.status].text}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Host:Port">{viewingNode.host}:{viewingNode.port}</Descriptions.Item>
|
||||
<Descriptions.Item label="Shop">{viewingNode.shopName}</Descriptions.Item>
|
||||
<Descriptions.Item label="Shop ID">{viewingNode.shopId}</Descriptions.Item>
|
||||
<Descriptions.Item label="Profile Dir" span={2}>{viewingNode.profileDir}</Descriptions.Item>
|
||||
<Descriptions.Item label="Proxy">{viewingNode.proxy}</Descriptions.Item>
|
||||
<Descriptions.Item label="Fingerprint Policy">{viewingNode.fingerprintPolicy}</Descriptions.Item>
|
||||
<Descriptions.Item label="Max Concurrent">{viewingNode.maxConcurrent}</Descriptions.Item>
|
||||
<Descriptions.Item label="Current Tasks">{viewingNode.currentTasks}</Descriptions.Item>
|
||||
<Descriptions.Item label="Auto Restart">
|
||||
<Switch checked={viewingNode.autoRestart} onChange={(c) => handleToggleAutoRestart(viewingNode.id, c)} size="small" />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Last Heartbeat">{viewingNode.lastHeartbeat}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<Divider>Resource Usage</Divider>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<p>CPU Usage</p>
|
||||
<Progress percent={viewingNode.cpuUsage} status={viewingNode.cpuUsage > 80 ? 'exception' : 'active'} />
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<p>Memory Usage</p>
|
||||
<Progress percent={viewingNode.memoryUsage} status={viewingNode.memoryUsage > 80 ? 'exception' : 'active'} />
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WinNodeConfig;
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user