feat: 添加汇率服务和缓存服务,优化数据源和日志服务
refactor: 重构数据源工厂和类型定义,提升代码可维护性 fix: 修复类型转换和状态机文档中的错误 docs: 更新服务架构文档,添加新的服务闭环流程 test: 添加汇率服务单元测试 chore: 清理无用代码和注释,优化代码结构
This commit is contained in:
553
dashboard/src/services/arbitrageDataSource.ts
Normal file
553
dashboard/src/services/arbitrageDataSource.ts
Normal file
@@ -0,0 +1,553 @@
|
||||
/**
|
||||
* [MOCK-ARBITRAGE] 跨平台套利数据源抽象层
|
||||
* 通过环境变量自动切换Mock/真实API
|
||||
* AI注意: 这是唯一入口,业务代码必须调用此层
|
||||
*
|
||||
* @module services/arbitrageDataSource
|
||||
* @author AI-Agent-1
|
||||
* @created 2026-03-19
|
||||
*/
|
||||
|
||||
export interface ProfitSnapshot {
|
||||
netProfit: number;
|
||||
profitRate: number;
|
||||
roi: number;
|
||||
platformFee: number;
|
||||
logisticsCost: number;
|
||||
taxCost: number;
|
||||
exchangeRate: number;
|
||||
}
|
||||
|
||||
export interface ArbitrageOpportunity {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
product_id: string;
|
||||
product_name: string;
|
||||
source_platform: string;
|
||||
source_url: string;
|
||||
source_price: number;
|
||||
source_currency: string;
|
||||
target_platform: string;
|
||||
target_price: number;
|
||||
target_currency: string;
|
||||
profit_snapshot: ProfitSnapshot;
|
||||
risk_level: 'LOW' | 'MEDIUM' | 'HIGH' | 'BLOCK';
|
||||
opportunity_score: number;
|
||||
status: 'PENDING' | 'APPROVED' | 'REJECTED' | 'EXECUTING' | 'EXECUTED' | 'FAILED';
|
||||
execution_mode: 'AUTO' | 'MANUAL';
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
executed_at?: string;
|
||||
error_message?: string;
|
||||
}
|
||||
|
||||
export interface ArbitrageExecution {
|
||||
id: string;
|
||||
opportunity_id: string;
|
||||
tenant_id: string;
|
||||
status: 'STARTED' | 'PURCHASED' | 'LISTED' | 'SOLD' | 'COMPLETED' | 'FAILED';
|
||||
purchase_order_id?: string;
|
||||
listing_id?: string;
|
||||
sale_order_id?: string;
|
||||
actual_purchase_price?: number;
|
||||
actual_sale_price?: number;
|
||||
actual_profit?: number;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ArbitrageStats {
|
||||
totalOpportunities: number;
|
||||
pendingOpportunities: number;
|
||||
executedOpportunities: number;
|
||||
successRate: number;
|
||||
totalProfit: number;
|
||||
avgProfitMargin: number;
|
||||
avgROI: number;
|
||||
byPlatform: Record<string, { count: number; profit: number }>;
|
||||
byStatus: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface OpportunityQueryParams {
|
||||
status?: string;
|
||||
platform?: string;
|
||||
minScore?: number;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface CreateOpportunityParams {
|
||||
productId: string;
|
||||
productName: string;
|
||||
sourcePlatform: string;
|
||||
sourceUrl: string;
|
||||
sourcePrice: number;
|
||||
sourceCurrency: string;
|
||||
targetPlatform: string;
|
||||
targetPrice: number;
|
||||
targetCurrency: string;
|
||||
logisticsCost?: number;
|
||||
adBudget?: number;
|
||||
isB2B?: boolean;
|
||||
executionMode?: 'AUTO' | 'MANUAL';
|
||||
}
|
||||
|
||||
export interface ScanParams {
|
||||
productIds: string;
|
||||
sourcePlatform: string;
|
||||
}
|
||||
|
||||
export interface IArbitrageDataSource {
|
||||
getOpportunities(params?: OpportunityQueryParams): Promise<{ opportunities: ArbitrageOpportunity[]; total: number }>;
|
||||
getOpportunityById(id: string): Promise<ArbitrageOpportunity | null>;
|
||||
createOpportunity(params: CreateOpportunityParams): Promise<ArbitrageOpportunity>;
|
||||
approveOpportunity(id: string): Promise<ArbitrageOpportunity>;
|
||||
rejectOpportunity(id: string, reason: string): Promise<ArbitrageOpportunity>;
|
||||
executeOpportunity(id: string): Promise<ArbitrageExecution>;
|
||||
scanProducts(params: ScanParams): Promise<ArbitrageOpportunity[]>;
|
||||
getStats(): Promise<ArbitrageStats>;
|
||||
getExecutions(opportunityId: string): Promise<ArbitrageExecution[]>;
|
||||
}
|
||||
|
||||
const USE_MOCK = process.env.REACT_APP_USE_MOCK === 'true';
|
||||
|
||||
const mockOpportunities: ArbitrageOpportunity[] = [
|
||||
{
|
||||
id: 'arb_001',
|
||||
tenant_id: 'tenant_001',
|
||||
product_id: 'prod_001',
|
||||
product_name: 'Wireless Bluetooth Headphones Pro',
|
||||
source_platform: '1688',
|
||||
source_url: 'https://1688.com/item/12345',
|
||||
source_price: 35,
|
||||
source_currency: 'CNY',
|
||||
target_platform: 'Amazon',
|
||||
target_price: 29.99,
|
||||
target_currency: 'USD',
|
||||
profit_snapshot: {
|
||||
netProfit: 12.5,
|
||||
profitRate: 0.42,
|
||||
roi: 0.85,
|
||||
platformFee: 4.5,
|
||||
logisticsCost: 3.0,
|
||||
taxCost: 1.2,
|
||||
exchangeRate: 0.14
|
||||
},
|
||||
risk_level: 'LOW',
|
||||
opportunity_score: 85,
|
||||
status: 'PENDING',
|
||||
execution_mode: 'MANUAL',
|
||||
created_at: '2026-03-19T10:00:00Z',
|
||||
updated_at: '2026-03-19T10:00:00Z'
|
||||
},
|
||||
{
|
||||
id: 'arb_002',
|
||||
tenant_id: 'tenant_001',
|
||||
product_id: 'prod_002',
|
||||
product_name: 'USB-C Fast Charging Cable 3-Pack',
|
||||
source_platform: 'Taobao',
|
||||
source_url: 'https://taobao.com/item/67890',
|
||||
source_price: 15,
|
||||
source_currency: 'CNY',
|
||||
target_platform: 'Temu',
|
||||
target_price: 8.99,
|
||||
target_currency: 'USD',
|
||||
profit_snapshot: {
|
||||
netProfit: 4.2,
|
||||
profitRate: 0.35,
|
||||
roi: 0.65,
|
||||
platformFee: 1.5,
|
||||
logisticsCost: 1.0,
|
||||
taxCost: 0.5,
|
||||
exchangeRate: 0.14
|
||||
},
|
||||
risk_level: 'MEDIUM',
|
||||
opportunity_score: 72,
|
||||
status: 'APPROVED',
|
||||
execution_mode: 'MANUAL',
|
||||
created_at: '2026-03-19T09:30:00Z',
|
||||
updated_at: '2026-03-19T11:00:00Z'
|
||||
},
|
||||
{
|
||||
id: 'arb_003',
|
||||
tenant_id: 'tenant_001',
|
||||
product_id: 'prod_003',
|
||||
product_name: 'Smart Watch Series X',
|
||||
source_platform: 'Alibaba',
|
||||
source_url: 'https://alibaba.com/item/11111',
|
||||
source_price: 120,
|
||||
source_currency: 'CNY',
|
||||
target_platform: 'Amazon',
|
||||
target_price: 49.99,
|
||||
target_currency: 'USD',
|
||||
profit_snapshot: {
|
||||
netProfit: 18.5,
|
||||
profitRate: 0.37,
|
||||
roi: 0.58,
|
||||
platformFee: 7.5,
|
||||
logisticsCost: 5.0,
|
||||
taxCost: 2.0,
|
||||
exchangeRate: 0.14
|
||||
},
|
||||
risk_level: 'LOW',
|
||||
opportunity_score: 78,
|
||||
status: 'EXECUTED',
|
||||
execution_mode: 'AUTO',
|
||||
created_at: '2026-03-18T14:00:00Z',
|
||||
updated_at: '2026-03-18T18:00:00Z',
|
||||
executed_at: '2026-03-18T18:00:00Z'
|
||||
},
|
||||
{
|
||||
id: 'arb_004',
|
||||
tenant_id: 'tenant_001',
|
||||
product_id: 'prod_004',
|
||||
product_name: 'LED Desk Lamp with Wireless Charger',
|
||||
source_platform: '1688',
|
||||
source_url: 'https://1688.com/item/22222',
|
||||
source_price: 45,
|
||||
source_currency: 'CNY',
|
||||
target_platform: 'TikTok',
|
||||
target_price: 24.99,
|
||||
target_currency: 'USD',
|
||||
profit_snapshot: {
|
||||
netProfit: 5.8,
|
||||
profitRate: 0.23,
|
||||
roi: 0.32,
|
||||
platformFee: 3.5,
|
||||
logisticsCost: 2.5,
|
||||
taxCost: 1.0,
|
||||
exchangeRate: 0.14
|
||||
},
|
||||
risk_level: 'HIGH',
|
||||
opportunity_score: 55,
|
||||
status: 'PENDING',
|
||||
execution_mode: 'MANUAL',
|
||||
created_at: '2026-03-19T08:00:00Z',
|
||||
updated_at: '2026-03-19T08:00:00Z'
|
||||
},
|
||||
{
|
||||
id: 'arb_005',
|
||||
tenant_id: 'tenant_001',
|
||||
product_id: 'prod_005',
|
||||
product_name: 'Portable Bluetooth Speaker Mini',
|
||||
source_platform: 'Taobao',
|
||||
source_url: 'https://taobao.com/item/33333',
|
||||
source_price: 28,
|
||||
source_currency: 'CNY',
|
||||
target_platform: 'Shopee',
|
||||
target_price: 12.99,
|
||||
target_currency: 'USD',
|
||||
profit_snapshot: {
|
||||
netProfit: 3.2,
|
||||
profitRate: 0.25,
|
||||
roi: 0.38,
|
||||
platformFee: 2.0,
|
||||
logisticsCost: 1.5,
|
||||
taxCost: 0.5,
|
||||
exchangeRate: 0.14
|
||||
},
|
||||
risk_level: 'MEDIUM',
|
||||
opportunity_score: 62,
|
||||
status: 'REJECTED',
|
||||
execution_mode: 'MANUAL',
|
||||
created_at: '2026-03-17T16:00:00Z',
|
||||
updated_at: '2026-03-18T10:00:00Z',
|
||||
error_message: 'Profit margin below threshold'
|
||||
}
|
||||
];
|
||||
|
||||
const mockStats: ArbitrageStats = {
|
||||
totalOpportunities: 15,
|
||||
pendingOpportunities: 5,
|
||||
executedOpportunities: 8,
|
||||
successRate: 0.67,
|
||||
totalProfit: 256.8,
|
||||
avgProfitMargin: 0.32,
|
||||
avgROI: 0.56,
|
||||
byPlatform: {
|
||||
Amazon: { count: 6, profit: 120.5 },
|
||||
Temu: { count: 4, profit: 45.2 },
|
||||
TikTok: { count: 3, profit: 58.3 },
|
||||
Shopee: { count: 2, profit: 32.8 }
|
||||
},
|
||||
byStatus: {
|
||||
PENDING: 5,
|
||||
APPROVED: 2,
|
||||
EXECUTED: 6,
|
||||
REJECTED: 2
|
||||
}
|
||||
};
|
||||
|
||||
class MockArbitrageDataSource implements IArbitrageDataSource {
|
||||
private opportunities = [...mockOpportunities];
|
||||
|
||||
async getOpportunities(params?: OpportunityQueryParams): Promise<{ opportunities: ArbitrageOpportunity[]; total: number }> {
|
||||
await this.delay(300);
|
||||
let filtered = [...this.opportunities];
|
||||
|
||||
if (params?.status) {
|
||||
filtered = filtered.filter(o => o.status === params.status);
|
||||
}
|
||||
if (params?.platform) {
|
||||
filtered = filtered.filter(o => o.target_platform === params.platform);
|
||||
}
|
||||
if (params?.minScore) {
|
||||
filtered = filtered.filter(o => o.opportunity_score >= params.minScore!);
|
||||
}
|
||||
|
||||
const total = filtered.length;
|
||||
const offset = params?.offset || 0;
|
||||
const limit = params?.limit || 50;
|
||||
const opportunities = filtered.slice(offset, offset + limit);
|
||||
|
||||
return { opportunities, total };
|
||||
}
|
||||
|
||||
async getOpportunityById(id: string): Promise<ArbitrageOpportunity | null> {
|
||||
await this.delay(200);
|
||||
return this.opportunities.find(o => o.id === id) || null;
|
||||
}
|
||||
|
||||
async createOpportunity(params: CreateOpportunityParams): Promise<ArbitrageOpportunity> {
|
||||
await this.delay(500);
|
||||
const opportunity: ArbitrageOpportunity = {
|
||||
id: `arb_${Date.now()}`,
|
||||
tenant_id: 'tenant_001',
|
||||
product_id: params.productId,
|
||||
product_name: params.productName,
|
||||
source_platform: params.sourcePlatform,
|
||||
source_url: params.sourceUrl,
|
||||
source_price: params.sourcePrice,
|
||||
source_currency: params.sourceCurrency,
|
||||
target_platform: params.targetPlatform,
|
||||
target_price: params.targetPrice,
|
||||
target_currency: params.targetCurrency,
|
||||
profit_snapshot: {
|
||||
netProfit: (params.targetPrice - params.sourcePrice * 0.14) * 0.6,
|
||||
profitRate: 0.35,
|
||||
roi: 0.5,
|
||||
platformFee: params.targetPrice * 0.15,
|
||||
logisticsCost: params.logisticsCost || 3,
|
||||
taxCost: params.targetPrice * 0.05,
|
||||
exchangeRate: 0.14
|
||||
},
|
||||
risk_level: 'MEDIUM',
|
||||
opportunity_score: Math.floor(Math.random() * 30) + 60,
|
||||
status: 'PENDING',
|
||||
execution_mode: params.executionMode || 'MANUAL',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
};
|
||||
this.opportunities.unshift(opportunity);
|
||||
return opportunity;
|
||||
}
|
||||
|
||||
async approveOpportunity(id: string): Promise<ArbitrageOpportunity> {
|
||||
await this.delay(300);
|
||||
const opportunity = this.opportunities.find(o => o.id === id);
|
||||
if (!opportunity) throw new Error('Opportunity not found');
|
||||
opportunity.status = 'APPROVED';
|
||||
opportunity.updated_at = new Date().toISOString();
|
||||
return opportunity;
|
||||
}
|
||||
|
||||
async rejectOpportunity(id: string, reason: string): Promise<ArbitrageOpportunity> {
|
||||
await this.delay(300);
|
||||
const opportunity = this.opportunities.find(o => o.id === id);
|
||||
if (!opportunity) throw new Error('Opportunity not found');
|
||||
opportunity.status = 'REJECTED';
|
||||
opportunity.error_message = reason;
|
||||
opportunity.updated_at = new Date().toISOString();
|
||||
return opportunity;
|
||||
}
|
||||
|
||||
async executeOpportunity(id: string): Promise<ArbitrageExecution> {
|
||||
await this.delay(500);
|
||||
const opportunity = this.opportunities.find(o => o.id === id);
|
||||
if (!opportunity) throw new Error('Opportunity not found');
|
||||
opportunity.status = 'EXECUTING';
|
||||
opportunity.updated_at = new Date().toISOString();
|
||||
|
||||
const execution: ArbitrageExecution = {
|
||||
id: `exec_${Date.now()}`,
|
||||
opportunity_id: id,
|
||||
tenant_id: opportunity.tenant_id,
|
||||
status: 'STARTED',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
};
|
||||
return execution;
|
||||
}
|
||||
|
||||
async scanProducts(params: ScanParams): Promise<ArbitrageOpportunity[]> {
|
||||
await this.delay(1000);
|
||||
const newOpportunities: ArbitrageOpportunity[] = [];
|
||||
const productIds = params.productIds.split('\n').filter(id => id.trim());
|
||||
|
||||
for (const productId of productIds) {
|
||||
const opportunity: ArbitrageOpportunity = {
|
||||
id: `arb_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
tenant_id: 'tenant_001',
|
||||
product_id: productId.trim(),
|
||||
product_name: `Product ${productId.trim()}`,
|
||||
source_platform: params.sourcePlatform,
|
||||
source_url: `https://${params.sourcePlatform.toLowerCase()}.com/item/${productId}`,
|
||||
source_price: Math.floor(Math.random() * 100) + 10,
|
||||
source_currency: 'CNY',
|
||||
target_platform: ['Amazon', 'Temu', 'TikTok'][Math.floor(Math.random() * 3)],
|
||||
target_price: Math.floor(Math.random() * 50) + 15,
|
||||
target_currency: 'USD',
|
||||
profit_snapshot: {
|
||||
netProfit: Math.random() * 20 + 5,
|
||||
profitRate: Math.random() * 0.3 + 0.2,
|
||||
roi: Math.random() * 0.5 + 0.3,
|
||||
platformFee: Math.random() * 5 + 2,
|
||||
logisticsCost: 3,
|
||||
taxCost: 1,
|
||||
exchangeRate: 0.14
|
||||
},
|
||||
risk_level: ['LOW', 'MEDIUM', 'HIGH'][Math.floor(Math.random() * 3)] as any,
|
||||
opportunity_score: Math.floor(Math.random() * 40) + 50,
|
||||
status: 'PENDING',
|
||||
execution_mode: 'MANUAL',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
};
|
||||
newOpportunities.push(opportunity);
|
||||
this.opportunities.unshift(opportunity);
|
||||
}
|
||||
|
||||
return newOpportunities;
|
||||
}
|
||||
|
||||
async getStats(): Promise<ArbitrageStats> {
|
||||
await this.delay(200);
|
||||
return mockStats;
|
||||
}
|
||||
|
||||
async getExecutions(opportunityId: string): Promise<ArbitrageExecution[]> {
|
||||
await this.delay(200);
|
||||
return [
|
||||
{
|
||||
id: 'exec_001',
|
||||
opportunity_id: opportunityId,
|
||||
tenant_id: 'tenant_001',
|
||||
status: 'COMPLETED',
|
||||
purchase_order_id: 'po_12345',
|
||||
listing_id: 'list_67890',
|
||||
sale_order_id: 'so_11111',
|
||||
actual_purchase_price: 35,
|
||||
actual_sale_price: 29.99,
|
||||
actual_profit: 12.5,
|
||||
notes: 'Successfully completed arbitrage',
|
||||
created_at: '2026-03-18T14:00:00Z',
|
||||
updated_at: '2026-03-18T18:00:00Z'
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
|
||||
class ApiArbitrageDataSource implements IArbitrageDataSource {
|
||||
private baseUrl = '/api/v1/arbitrage';
|
||||
|
||||
async getOpportunities(params?: OpportunityQueryParams): Promise<{ opportunities: ArbitrageOpportunity[]; total: number }> {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.status) query.append('status', params.status);
|
||||
if (params?.platform) query.append('platform', params.platform);
|
||||
if (params?.minScore) query.append('minScore', params.minScore.toString());
|
||||
if (params?.limit) query.append('limit', params.limit.toString());
|
||||
if (params?.offset) query.append('offset', params.offset.toString());
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/opportunities?${query.toString()}`);
|
||||
if (!response.ok) throw new Error(`API Error: ${response.status}`);
|
||||
const result = await response.json();
|
||||
return result.data || { opportunities: [], total: 0 };
|
||||
}
|
||||
|
||||
async getOpportunityById(id: string): Promise<ArbitrageOpportunity | null> {
|
||||
const response = await fetch(`${this.baseUrl}/opportunities/${id}`);
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) return null;
|
||||
throw new Error(`API Error: ${response.status}`);
|
||||
}
|
||||
const result = await response.json();
|
||||
return result.data || null;
|
||||
}
|
||||
|
||||
async createOpportunity(params: CreateOpportunityParams): Promise<ArbitrageOpportunity> {
|
||||
const response = await fetch(`${this.baseUrl}/opportunities`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(params)
|
||||
});
|
||||
if (!response.ok) throw new Error(`API Error: ${response.status}`);
|
||||
const result = await response.json();
|
||||
return result.data;
|
||||
}
|
||||
|
||||
async approveOpportunity(id: string): Promise<ArbitrageOpportunity> {
|
||||
const response = await fetch(`${this.baseUrl}/opportunities/${id}/approve`, {
|
||||
method: 'POST'
|
||||
});
|
||||
if (!response.ok) throw new Error(`API Error: ${response.status}`);
|
||||
const result = await response.json();
|
||||
return result.data;
|
||||
}
|
||||
|
||||
async rejectOpportunity(id: string, reason: string): Promise<ArbitrageOpportunity> {
|
||||
const response = await fetch(`${this.baseUrl}/opportunities/${id}/reject`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ reason })
|
||||
});
|
||||
if (!response.ok) throw new Error(`API Error: ${response.status}`);
|
||||
const result = await response.json();
|
||||
return result.data;
|
||||
}
|
||||
|
||||
async executeOpportunity(id: string): Promise<ArbitrageExecution> {
|
||||
const response = await fetch(`${this.baseUrl}/opportunities/${id}/execute`, {
|
||||
method: 'POST'
|
||||
});
|
||||
if (!response.ok) throw new Error(`API Error: ${response.status}`);
|
||||
const result = await response.json();
|
||||
return result.data;
|
||||
}
|
||||
|
||||
async scanProducts(params: ScanParams): Promise<ArbitrageOpportunity[]> {
|
||||
const response = await fetch(`${this.baseUrl}/scan`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(params)
|
||||
});
|
||||
if (!response.ok) throw new Error(`API Error: ${response.status}`);
|
||||
const result = await response.json();
|
||||
return result.data || [];
|
||||
}
|
||||
|
||||
async getStats(): Promise<ArbitrageStats> {
|
||||
const response = await fetch(`${this.baseUrl}/stats`);
|
||||
if (!response.ok) throw new Error(`API Error: ${response.status}`);
|
||||
const result = await response.json();
|
||||
return result.data;
|
||||
}
|
||||
|
||||
async getExecutions(opportunityId: string): Promise<ArbitrageExecution[]> {
|
||||
const response = await fetch(`${this.baseUrl}/opportunities/${opportunityId}/executions`);
|
||||
if (!response.ok) throw new Error(`API Error: ${response.status}`);
|
||||
const result = await response.json();
|
||||
return result.data || [];
|
||||
}
|
||||
}
|
||||
|
||||
const arbitrageDataSource: IArbitrageDataSource = USE_MOCK
|
||||
? new MockArbitrageDataSource()
|
||||
: new ApiArbitrageDataSource();
|
||||
|
||||
export default arbitrageDataSource;
|
||||
export { arbitrageDataSource };
|
||||
520
dashboard/src/services/autoExecutionDataSource.ts
Normal file
520
dashboard/src/services/autoExecutionDataSource.ts
Normal file
@@ -0,0 +1,520 @@
|
||||
/**
|
||||
* [MOCK] AI决策自动化配置数据源
|
||||
* AI注意: 这是Mock实现,不是真实业务逻辑
|
||||
* 仅在USE_MOCK=true时启用
|
||||
*/
|
||||
|
||||
export type AutomationLevel = 'L1' | 'L2' | 'L3' | 'L4';
|
||||
export type RiskLevel = 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
|
||||
export type DecisionModule =
|
||||
| 'PRICING'
|
||||
| 'INVENTORY'
|
||||
| 'AD_OPTIMIZE'
|
||||
| 'PRODUCT_SELECT'
|
||||
| 'LOGISTICS'
|
||||
| 'RISK_CONTROL'
|
||||
| 'CUSTOMER_SERVICE'
|
||||
| 'SETTLEMENT';
|
||||
|
||||
export interface AutoExecutionConfig {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
shop_id: string;
|
||||
module: DecisionModule;
|
||||
automation_level: AutomationLevel;
|
||||
confidence_thresholds: {
|
||||
auto_execute: number;
|
||||
pending_review: number;
|
||||
auto_reject: number;
|
||||
};
|
||||
risk_limits: {
|
||||
max_amount: number;
|
||||
max_quantity: number;
|
||||
allowed_risk_levels: RiskLevel[];
|
||||
};
|
||||
time_restrictions: {
|
||||
allowed_hours: number[];
|
||||
excluded_dates: string[];
|
||||
};
|
||||
rollback_config: {
|
||||
enabled: boolean;
|
||||
max_attempts: number;
|
||||
cooldown_minutes: number;
|
||||
};
|
||||
notification_config: {
|
||||
on_execute: boolean;
|
||||
on_fail: boolean;
|
||||
on_rollback: boolean;
|
||||
channels: ('EMAIL' | 'SMS' | 'WEBHOOK')[];
|
||||
};
|
||||
statistics: {
|
||||
total_executions: number;
|
||||
success_count: number;
|
||||
fail_count: number;
|
||||
rollback_count: number;
|
||||
avg_confidence: number;
|
||||
last_execution_at?: string;
|
||||
};
|
||||
status: 'ACTIVE' | 'INACTIVE' | 'SUSPENDED';
|
||||
created_by: string;
|
||||
updated_by: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface LevelEvolutionRecord {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
shop_id: string;
|
||||
module: DecisionModule;
|
||||
from_level: AutomationLevel;
|
||||
to_level: AutomationLevel;
|
||||
reason: string;
|
||||
metrics: {
|
||||
success_rate: number;
|
||||
total_executions: number;
|
||||
avg_confidence: number;
|
||||
};
|
||||
approved_by: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface LevelCapability {
|
||||
max_risk_level: RiskLevel;
|
||||
max_amount_multiplier: number;
|
||||
requires_approval: boolean;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface IAutoExecutionDataSource {
|
||||
fetchConfigs(tenantId: string, shopId?: string): Promise<AutoExecutionConfig[]>;
|
||||
fetchConfig(tenantId: string, shopId: string, module: DecisionModule): Promise<AutoExecutionConfig>;
|
||||
updateConfig(configId: string, updates: Partial<AutoExecutionConfig>): Promise<AutoExecutionConfig>;
|
||||
upgradeLevel(tenantId: string, shopId: string, module: DecisionModule, targetLevel: AutomationLevel, reason: string): Promise<LevelEvolutionRecord>;
|
||||
downgradeLevel(tenantId: string, shopId: string, module: DecisionModule, targetLevel: AutomationLevel, reason: string): Promise<LevelEvolutionRecord>;
|
||||
fetchEvolutionHistory(tenantId: string, shopId?: string, module?: DecisionModule): Promise<LevelEvolutionRecord[]>;
|
||||
fetchLevelCapabilities(): Promise<Record<AutomationLevel, LevelCapability>>;
|
||||
fetchModuleDefaults(): Promise<Record<DecisionModule, Partial<AutoExecutionConfig>>>;
|
||||
}
|
||||
|
||||
const MODULE_NAMES: Record<DecisionModule, string> = {
|
||||
PRICING: '定价模块',
|
||||
INVENTORY: '库存模块',
|
||||
AD_OPTIMIZE: '广告优化',
|
||||
PRODUCT_SELECT: '选品模块',
|
||||
LOGISTICS: '物流模块',
|
||||
RISK_CONTROL: '风控模块',
|
||||
CUSTOMER_SERVICE: '客服模块',
|
||||
SETTLEMENT: '结算模块',
|
||||
};
|
||||
|
||||
const LEVEL_CAPABILITIES: Record<AutomationLevel, LevelCapability> = {
|
||||
L1: {
|
||||
max_risk_level: 'LOW',
|
||||
max_amount_multiplier: 0.5,
|
||||
requires_approval: true,
|
||||
description: '仅建议,所有操作需人工确认',
|
||||
},
|
||||
L2: {
|
||||
max_risk_level: 'MEDIUM',
|
||||
max_amount_multiplier: 1.0,
|
||||
requires_approval: false,
|
||||
description: '低风险操作可自动执行,中高风险需审核',
|
||||
},
|
||||
L3: {
|
||||
max_risk_level: 'HIGH',
|
||||
max_amount_multiplier: 2.0,
|
||||
requires_approval: false,
|
||||
description: '大部分操作可自动执行,仅高风险需审核',
|
||||
},
|
||||
L4: {
|
||||
max_risk_level: 'CRITICAL',
|
||||
max_amount_multiplier: 5.0,
|
||||
requires_approval: false,
|
||||
description: '全自动化,包含关键操作,需严格监控',
|
||||
},
|
||||
};
|
||||
|
||||
const MODULE_DEFAULTS: Record<DecisionModule, Partial<AutoExecutionConfig>> = {
|
||||
PRICING: {
|
||||
confidence_thresholds: { auto_execute: 0.85, pending_review: 0.60, auto_reject: 0.40 },
|
||||
risk_limits: { max_amount: 5000, max_quantity: 100, allowed_risk_levels: ['LOW', 'MEDIUM'] },
|
||||
},
|
||||
INVENTORY: {
|
||||
confidence_thresholds: { auto_execute: 0.90, pending_review: 0.70, auto_reject: 0.50 },
|
||||
risk_limits: { max_amount: 10000, max_quantity: 500, allowed_risk_levels: ['LOW', 'MEDIUM'] },
|
||||
},
|
||||
AD_OPTIMIZE: {
|
||||
confidence_thresholds: { auto_execute: 0.80, pending_review: 0.55, auto_reject: 0.35 },
|
||||
risk_limits: { max_amount: 2000, max_quantity: 50, allowed_risk_levels: ['LOW', 'MEDIUM', 'HIGH'] },
|
||||
},
|
||||
PRODUCT_SELECT: {
|
||||
confidence_thresholds: { auto_execute: 0.75, pending_review: 0.50, auto_reject: 0.30 },
|
||||
risk_limits: { max_amount: 3000, max_quantity: 200, allowed_risk_levels: ['LOW', 'MEDIUM'] },
|
||||
},
|
||||
LOGISTICS: {
|
||||
confidence_thresholds: { auto_execute: 0.88, pending_review: 0.65, auto_reject: 0.45 },
|
||||
risk_limits: { max_amount: 1000, max_quantity: 100, allowed_risk_levels: ['LOW', 'MEDIUM'] },
|
||||
},
|
||||
RISK_CONTROL: {
|
||||
confidence_thresholds: { auto_execute: 0.95, pending_review: 0.80, auto_reject: 0.60 },
|
||||
risk_limits: { max_amount: 50000, max_quantity: 1000, allowed_risk_levels: ['LOW'] },
|
||||
},
|
||||
CUSTOMER_SERVICE: {
|
||||
confidence_thresholds: { auto_execute: 0.82, pending_review: 0.60, auto_reject: 0.40 },
|
||||
risk_limits: { max_amount: 500, max_quantity: 10, allowed_risk_levels: ['LOW', 'MEDIUM'] },
|
||||
},
|
||||
SETTLEMENT: {
|
||||
confidence_thresholds: { auto_execute: 0.98, pending_review: 0.90, auto_reject: 0.70 },
|
||||
risk_limits: { max_amount: 100000, max_quantity: 100, allowed_risk_levels: ['LOW'] },
|
||||
},
|
||||
};
|
||||
|
||||
class MockAutoExecutionDataSource implements IAutoExecutionDataSource {
|
||||
private configs: AutoExecutionConfig[] = [];
|
||||
private evolutionRecords: LevelEvolutionRecord[] = [];
|
||||
|
||||
constructor() {
|
||||
this.initMockData();
|
||||
}
|
||||
|
||||
private initMockData() {
|
||||
const modules: DecisionModule[] = [
|
||||
'PRICING', 'INVENTORY', 'AD_OPTIMIZE', 'PRODUCT_SELECT',
|
||||
'LOGISTICS', 'RISK_CONTROL', 'CUSTOMER_SERVICE', 'SETTLEMENT'
|
||||
];
|
||||
|
||||
this.configs = modules.map((module, index) => {
|
||||
const defaults = MODULE_DEFAULTS[module];
|
||||
return {
|
||||
id: `AEC-${index + 1}`,
|
||||
tenant_id: 'tenant-001',
|
||||
shop_id: 'shop-001',
|
||||
module,
|
||||
automation_level: (['L1', 'L2', 'L3', 'L4'] as AutomationLevel[])[index % 4],
|
||||
confidence_thresholds: defaults.confidence_thresholds!,
|
||||
risk_limits: defaults.risk_limits!,
|
||||
time_restrictions: {
|
||||
allowed_hours: Array.from({ length: 24 }, (_, i) => i),
|
||||
excluded_dates: [],
|
||||
},
|
||||
rollback_config: {
|
||||
enabled: true,
|
||||
max_attempts: 3,
|
||||
cooldown_minutes: 30,
|
||||
},
|
||||
notification_config: {
|
||||
on_execute: true,
|
||||
on_fail: true,
|
||||
on_rollback: true,
|
||||
channels: ['EMAIL'] as ('EMAIL' | 'SMS' | 'WEBHOOK')[],
|
||||
},
|
||||
statistics: {
|
||||
total_executions: Math.floor(Math.random() * 1000) + 100,
|
||||
success_count: Math.floor(Math.random() * 900) + 90,
|
||||
fail_count: Math.floor(Math.random() * 50) + 5,
|
||||
rollback_count: Math.floor(Math.random() * 20) + 2,
|
||||
avg_confidence: 0.75 + Math.random() * 0.2,
|
||||
last_execution_at: new Date(Date.now() - Math.random() * 86400000).toISOString(),
|
||||
},
|
||||
status: 'ACTIVE',
|
||||
created_by: 'admin',
|
||||
updated_by: 'admin',
|
||||
created_at: new Date(Date.now() - Math.random() * 30 * 86400000).toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
});
|
||||
|
||||
this.evolutionRecords = [
|
||||
{
|
||||
id: 'EVO-001',
|
||||
tenant_id: 'tenant-001',
|
||||
shop_id: 'shop-001',
|
||||
module: 'PRICING',
|
||||
from_level: 'L1',
|
||||
to_level: 'L2',
|
||||
reason: '成功率达标,自动升级',
|
||||
metrics: {
|
||||
success_rate: 0.96,
|
||||
total_executions: 150,
|
||||
avg_confidence: 0.88,
|
||||
},
|
||||
approved_by: 'admin',
|
||||
created_at: new Date(Date.now() - 7 * 86400000).toISOString(),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async fetchConfigs(tenantId: string, shopId?: string): Promise<AutoExecutionConfig[]> {
|
||||
await this.delay(300);
|
||||
return this.configs.filter(c =>
|
||||
c.tenant_id === tenantId &&
|
||||
(!shopId || c.shop_id === shopId)
|
||||
);
|
||||
}
|
||||
|
||||
async fetchConfig(tenantId: string, shopId: string, module: DecisionModule): Promise<AutoExecutionConfig> {
|
||||
await this.delay(200);
|
||||
const config = this.configs.find(c =>
|
||||
c.tenant_id === tenantId &&
|
||||
c.shop_id === shopId &&
|
||||
c.module === module
|
||||
);
|
||||
if (!config) {
|
||||
const defaults = MODULE_DEFAULTS[module];
|
||||
return {
|
||||
id: `AEC-NEW-${Date.now()}`,
|
||||
tenant_id: tenantId,
|
||||
shop_id: shopId,
|
||||
module,
|
||||
automation_level: 'L1',
|
||||
confidence_thresholds: defaults.confidence_thresholds!,
|
||||
risk_limits: defaults.risk_limits!,
|
||||
time_restrictions: {
|
||||
allowed_hours: Array.from({ length: 24 }, (_, i) => i),
|
||||
excluded_dates: [],
|
||||
},
|
||||
rollback_config: {
|
||||
enabled: true,
|
||||
max_attempts: 3,
|
||||
cooldown_minutes: 30,
|
||||
},
|
||||
notification_config: {
|
||||
on_execute: true,
|
||||
on_fail: true,
|
||||
on_rollback: true,
|
||||
channels: ['EMAIL'],
|
||||
},
|
||||
statistics: {
|
||||
total_executions: 0,
|
||||
success_count: 0,
|
||||
fail_count: 0,
|
||||
rollback_count: 0,
|
||||
avg_confidence: 0,
|
||||
},
|
||||
status: 'ACTIVE',
|
||||
created_by: 'SYSTEM',
|
||||
updated_by: 'SYSTEM',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
async updateConfig(configId: string, updates: Partial<AutoExecutionConfig>): Promise<AutoExecutionConfig> {
|
||||
await this.delay(300);
|
||||
const index = this.configs.findIndex(c => c.id === configId);
|
||||
if (index === -1) {
|
||||
throw new Error('Config not found');
|
||||
}
|
||||
this.configs[index] = {
|
||||
...this.configs[index],
|
||||
...updates,
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
return this.configs[index];
|
||||
}
|
||||
|
||||
async upgradeLevel(
|
||||
tenantId: string,
|
||||
shopId: string,
|
||||
module: DecisionModule,
|
||||
targetLevel: AutomationLevel,
|
||||
reason: string
|
||||
): Promise<LevelEvolutionRecord> {
|
||||
await this.delay(500);
|
||||
const config = await this.fetchConfig(tenantId, shopId, module);
|
||||
const record: LevelEvolutionRecord = {
|
||||
id: `EVO-${Date.now()}`,
|
||||
tenant_id: tenantId,
|
||||
shop_id: shopId,
|
||||
module,
|
||||
from_level: config.automation_level,
|
||||
to_level: targetLevel,
|
||||
reason,
|
||||
metrics: {
|
||||
success_rate: config.statistics.total_executions > 0
|
||||
? config.statistics.success_count / config.statistics.total_executions
|
||||
: 0,
|
||||
total_executions: config.statistics.total_executions,
|
||||
avg_confidence: config.statistics.avg_confidence,
|
||||
},
|
||||
approved_by: 'admin',
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
this.evolutionRecords.unshift(record);
|
||||
|
||||
const index = this.configs.findIndex(c => c.module === module);
|
||||
if (index !== -1) {
|
||||
this.configs[index].automation_level = targetLevel;
|
||||
}
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
async downgradeLevel(
|
||||
tenantId: string,
|
||||
shopId: string,
|
||||
module: DecisionModule,
|
||||
targetLevel: AutomationLevel,
|
||||
reason: string
|
||||
): Promise<LevelEvolutionRecord> {
|
||||
await this.delay(500);
|
||||
const config = await this.fetchConfig(tenantId, shopId, module);
|
||||
const record: LevelEvolutionRecord = {
|
||||
id: `EVO-${Date.now()}`,
|
||||
tenant_id: tenantId,
|
||||
shop_id: shopId,
|
||||
module,
|
||||
from_level: config.automation_level,
|
||||
to_level: targetLevel,
|
||||
reason,
|
||||
metrics: {
|
||||
success_rate: config.statistics.total_executions > 0
|
||||
? config.statistics.success_count / config.statistics.total_executions
|
||||
: 0,
|
||||
total_executions: config.statistics.total_executions,
|
||||
avg_confidence: config.statistics.avg_confidence,
|
||||
},
|
||||
approved_by: 'admin',
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
this.evolutionRecords.unshift(record);
|
||||
|
||||
const index = this.configs.findIndex(c => c.module === module);
|
||||
if (index !== -1) {
|
||||
this.configs[index].automation_level = targetLevel;
|
||||
}
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
async fetchEvolutionHistory(
|
||||
tenantId: string,
|
||||
shopId?: string,
|
||||
module?: DecisionModule
|
||||
): Promise<LevelEvolutionRecord[]> {
|
||||
await this.delay(200);
|
||||
return this.evolutionRecords.filter(r =>
|
||||
r.tenant_id === tenantId &&
|
||||
(!shopId || r.shop_id === shopId) &&
|
||||
(!module || r.module === module)
|
||||
);
|
||||
}
|
||||
|
||||
async fetchLevelCapabilities(): Promise<Record<AutomationLevel, LevelCapability>> {
|
||||
await this.delay(100);
|
||||
return LEVEL_CAPABILITIES;
|
||||
}
|
||||
|
||||
async fetchModuleDefaults(): Promise<Record<DecisionModule, Partial<AutoExecutionConfig>>> {
|
||||
await this.delay(100);
|
||||
return MODULE_DEFAULTS;
|
||||
}
|
||||
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
|
||||
class ApiAutoExecutionDataSource implements IAutoExecutionDataSource {
|
||||
private baseUrl = '/api/auto-execution';
|
||||
|
||||
async fetchConfigs(tenantId: string, shopId?: string): Promise<AutoExecutionConfig[]> {
|
||||
const params = new URLSearchParams({ tenant_id: tenantId });
|
||||
if (shopId) params.append('shop_id', shopId);
|
||||
const response = await fetch(`${this.baseUrl}/configs?${params}`);
|
||||
if (!response.ok) throw new Error('Failed to fetch configs');
|
||||
const data = await response.json();
|
||||
return data.data;
|
||||
}
|
||||
|
||||
async fetchConfig(tenantId: string, shopId: string, module: DecisionModule): Promise<AutoExecutionConfig> {
|
||||
const params = new URLSearchParams({ tenant_id: tenantId, shop_id: shopId });
|
||||
const response = await fetch(`${this.baseUrl}/config/${module}?${params}`);
|
||||
if (!response.ok) throw new Error('Failed to fetch config');
|
||||
const data = await response.json();
|
||||
return data.data;
|
||||
}
|
||||
|
||||
async updateConfig(configId: string, updates: Partial<AutoExecutionConfig>): Promise<AutoExecutionConfig> {
|
||||
const response = await fetch(`${this.baseUrl}/config/${configId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to update config');
|
||||
const data = await response.json();
|
||||
return data.data;
|
||||
}
|
||||
|
||||
async upgradeLevel(
|
||||
tenantId: string,
|
||||
shopId: string,
|
||||
module: DecisionModule,
|
||||
targetLevel: AutomationLevel,
|
||||
reason: string
|
||||
): Promise<LevelEvolutionRecord> {
|
||||
const response = await fetch(`${this.baseUrl}/level/upgrade`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tenant_id: tenantId, shop_id: shopId, module, target_level: targetLevel, reason }),
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to upgrade level');
|
||||
const data = await response.json();
|
||||
return data.data;
|
||||
}
|
||||
|
||||
async downgradeLevel(
|
||||
tenantId: string,
|
||||
shopId: string,
|
||||
module: DecisionModule,
|
||||
targetLevel: AutomationLevel,
|
||||
reason: string
|
||||
): Promise<LevelEvolutionRecord> {
|
||||
const response = await fetch(`${this.baseUrl}/level/downgrade`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tenant_id: tenantId, shop_id: shopId, module, target_level: targetLevel, reason }),
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to downgrade level');
|
||||
const data = await response.json();
|
||||
return data.data;
|
||||
}
|
||||
|
||||
async fetchEvolutionHistory(
|
||||
tenantId: string,
|
||||
shopId?: string,
|
||||
module?: DecisionModule
|
||||
): Promise<LevelEvolutionRecord[]> {
|
||||
const params = new URLSearchParams({ tenant_id: tenantId });
|
||||
if (shopId) params.append('shop_id', shopId);
|
||||
if (module) params.append('module', module);
|
||||
const response = await fetch(`${this.baseUrl}/evolution-history?${params}`);
|
||||
if (!response.ok) throw new Error('Failed to fetch evolution history');
|
||||
const data = await response.json();
|
||||
return data.data;
|
||||
}
|
||||
|
||||
async fetchLevelCapabilities(): Promise<Record<AutomationLevel, LevelCapability>> {
|
||||
const response = await fetch(`${this.baseUrl}/level-capabilities`);
|
||||
if (!response.ok) throw new Error('Failed to fetch level capabilities');
|
||||
const data = await response.json();
|
||||
return data.data;
|
||||
}
|
||||
|
||||
async fetchModuleDefaults(): Promise<Record<DecisionModule, Partial<AutoExecutionConfig>>> {
|
||||
const response = await fetch(`${this.baseUrl}/module-defaults`);
|
||||
if (!response.ok) throw new Error('Failed to fetch module defaults');
|
||||
const data = await response.json();
|
||||
return data.data;
|
||||
}
|
||||
}
|
||||
|
||||
const useMock = process.env.REACT_APP_USE_MOCK === 'true';
|
||||
export const autoExecutionDataSource: IAutoExecutionDataSource = useMock
|
||||
? new MockAutoExecutionDataSource()
|
||||
: new ApiAutoExecutionDataSource();
|
||||
|
||||
export { MODULE_NAMES, LEVEL_CAPABILITIES, MODULE_DEFAULTS };
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
import { Certificate } from '@/types/certificate';
|
||||
import { IDataSource, CertificateQueryParams } from '@/types/datasource';
|
||||
import { BaseDataSource, BaseMockDataSource, DataSourceFactory } from './dataSourceFactory';
|
||||
|
||||
// ============================================
|
||||
// 真实API实现
|
||||
@@ -19,104 +20,9 @@ import { IDataSource, CertificateQueryParams } from '@/types/datasource';
|
||||
* 证书API数据源
|
||||
* 调用真实后端API
|
||||
*/
|
||||
class ApiCertificateDataSource implements IDataSource<Certificate, CertificateQueryParams> {
|
||||
private baseUrl = '/api/v1/certificate';
|
||||
|
||||
async list(params?: CertificateQueryParams): Promise<Certificate[]> {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.status) query.append('status', params.status);
|
||||
if (params?.type) query.append('type', params.type);
|
||||
if (params?.keyword) query.append('keyword', params.keyword);
|
||||
if (params?.page) query.append('page', params.page.toString());
|
||||
if (params?.pageSize) query.append('pageSize', params.pageSize.toString());
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/certificates?${query.toString()}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API Error: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return result.data || [];
|
||||
}
|
||||
|
||||
async detail(id: string): Promise<Certificate | null> {
|
||||
const response = await fetch(`${this.baseUrl}/certificates/${id}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) return null;
|
||||
throw new Error(`API Error: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return result.data || null;
|
||||
}
|
||||
|
||||
async create(data: Partial<Certificate>): Promise<Certificate> {
|
||||
const response = await fetch(`${this.baseUrl}/certificates`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API Error: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// 创建成功后获取完整数据
|
||||
if (result.data?.id) {
|
||||
const created = await this.detail(result.data.id);
|
||||
if (created) return created;
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
|
||||
async update(id: string, data: Partial<Certificate>): Promise<Certificate> {
|
||||
const response = await fetch(`${this.baseUrl}/certificates/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API Error: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// 更新成功后获取完整数据
|
||||
const updated = await this.detail(id);
|
||||
if (updated) return updated;
|
||||
|
||||
return result.data;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
const response = await fetch(`${this.baseUrl}/certificates/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API Error: ${response.status}`);
|
||||
}
|
||||
class ApiCertificateDataSource extends BaseDataSource<Certificate, CertificateQueryParams> {
|
||||
constructor() {
|
||||
super('/api/v1/certificate/certificates');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -126,7 +32,7 @@ class ApiCertificateDataSource implements IDataSource<Certificate, CertificateQu
|
||||
* @param approvedBy 审核人
|
||||
*/
|
||||
async updateStatus(id: string, status: string, approvedBy?: string): Promise<Certificate> {
|
||||
const response = await fetch(`${this.baseUrl}/certificates/${id}/status`, {
|
||||
const response = await fetch(`${this.baseUrl}/${id}/status`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -157,13 +63,12 @@ class ApiCertificateDataSource implements IDataSource<Certificate, CertificateQu
|
||||
* AI注意: 这是Mock实现,不是真实业务逻辑
|
||||
* 仅在REACT_APP_USE_MOCK=true时启用
|
||||
*/
|
||||
class MockCertificateDataSource implements IDataSource<Certificate, CertificateQueryParams> {
|
||||
/** Mock标记 */
|
||||
readonly __MOCK__ = true as const;
|
||||
class MockCertificateDataSource extends BaseMockDataSource<Certificate, CertificateQueryParams> {
|
||||
/** Mock数据源名称 */
|
||||
readonly __MOCK_NAME__ = 'MockCertificateDataSource';
|
||||
|
||||
private mockData: Certificate[] = [
|
||||
/** Mock数据 */
|
||||
protected mockData: Certificate[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'CE认证证书',
|
||||
@@ -248,89 +153,12 @@ class MockCertificateDataSource implements IDataSource<Certificate, CertificateQ
|
||||
},
|
||||
];
|
||||
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async list(params?: CertificateQueryParams): Promise<Certificate[]> {
|
||||
// 模拟网络延迟
|
||||
await this.delay(300);
|
||||
|
||||
let result = [...this.mockData];
|
||||
|
||||
// 状态筛选
|
||||
if (params?.status) {
|
||||
result = result.filter(item => item.status === params.status);
|
||||
}
|
||||
|
||||
// 类型筛选
|
||||
if (params?.type) {
|
||||
result = result.filter(item => item.type === params.type);
|
||||
}
|
||||
|
||||
// 关键词搜索
|
||||
if (params?.keyword) {
|
||||
const keyword = params.keyword.toLowerCase();
|
||||
result = result.filter(
|
||||
item =>
|
||||
item.name.toLowerCase().includes(keyword) ||
|
||||
item.productName?.toLowerCase().includes(keyword) ||
|
||||
item.notes?.toLowerCase().includes(keyword)
|
||||
);
|
||||
}
|
||||
|
||||
// 分页
|
||||
const page = params?.page || 1;
|
||||
const pageSize = params?.pageSize || 10;
|
||||
const start = (page - 1) * pageSize;
|
||||
const end = start + pageSize;
|
||||
|
||||
return result.slice(start, end);
|
||||
}
|
||||
|
||||
async detail(id: string): Promise<Certificate | null> {
|
||||
await this.delay(200);
|
||||
return this.mockData.find(item => item.id === id) || null;
|
||||
}
|
||||
|
||||
async create(data: Partial<Certificate>): Promise<Certificate> {
|
||||
await this.delay(500);
|
||||
|
||||
const newCert: Certificate = {
|
||||
id: `${Date.now()}`,
|
||||
name: data.name || '',
|
||||
type: data.type || 'OTHER',
|
||||
status: 'PENDING',
|
||||
fileUrl: data.fileUrl || '/files/uploaded.pdf',
|
||||
fileName: data.fileName || 'uploaded.pdf',
|
||||
uploadDate: new Date().toISOString().split('T')[0],
|
||||
expiryDate: data.expiryDate || '',
|
||||
productId: data.productId,
|
||||
productName: data.productName,
|
||||
notes: data.notes,
|
||||
};
|
||||
|
||||
this.mockData.unshift(newCert);
|
||||
return newCert;
|
||||
}
|
||||
|
||||
async update(id: string, data: Partial<Certificate>): Promise<Certificate> {
|
||||
await this.delay(300);
|
||||
|
||||
const index = this.mockData.findIndex(item => item.id === id);
|
||||
if (index === -1) {
|
||||
throw new Error('Certificate not found');
|
||||
}
|
||||
|
||||
this.mockData[index] = { ...this.mockData[index], ...data };
|
||||
return this.mockData[index];
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.delay(200);
|
||||
this.mockData = this.mockData.filter(item => item.id !== id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新证书状态(审核)
|
||||
* @param id 证书ID
|
||||
* @param status 新状态
|
||||
* @param approvedBy 审核人
|
||||
*/
|
||||
async updateStatus(id: string, status: string, approvedBy?: string): Promise<Certificate> {
|
||||
await this.delay(300);
|
||||
|
||||
@@ -354,40 +182,22 @@ class MockCertificateDataSource implements IDataSource<Certificate, CertificateQ
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 导出数据源实例 (环境变量控制)
|
||||
// 导出数据源
|
||||
// ============================================
|
||||
|
||||
const useMock = process.env.REACT_APP_USE_MOCK === 'true';
|
||||
|
||||
/**
|
||||
* 证书数据源实例
|
||||
* 根据环境变量自动切换Mock/真实API
|
||||
*
|
||||
* 使用示例:
|
||||
* ```typescript
|
||||
* import { certificateDataSource } from '@/services/certificateDataSource';
|
||||
*
|
||||
* // 查询列表
|
||||
* const certificates = await certificateDataSource.list({ status: 'APPROVED' });
|
||||
*
|
||||
* // 获取详情
|
||||
* const cert = await certificateDataSource.detail('1');
|
||||
*
|
||||
* // 创建
|
||||
* const newCert = await certificateDataSource.create({ name: '新证书', ... });
|
||||
* ```
|
||||
*/
|
||||
export const certificateDataSource: IDataSource<Certificate, CertificateQueryParams> & {
|
||||
updateStatus?(id: string, status: string, approvedBy?: string): Promise<Certificate>;
|
||||
} = useMock ? new MockCertificateDataSource() : new ApiCertificateDataSource();
|
||||
export const certificateDataSource = DataSourceFactory.createWithMethods<
|
||||
Certificate,
|
||||
CertificateQueryParams,
|
||||
{
|
||||
updateStatus(id: string, status: string, approvedBy?: string): Promise<Certificate>;
|
||||
}
|
||||
>({
|
||||
apiDataSource: ApiCertificateDataSource,
|
||||
mockDataSource: MockCertificateDataSource,
|
||||
});
|
||||
|
||||
/**
|
||||
* Mock状态标记
|
||||
* 用于调试和开发环境识别
|
||||
*/
|
||||
export const __MOCK__ = useMock;
|
||||
|
||||
/**
|
||||
* 当前数据源类型
|
||||
*/
|
||||
export const __DATA_SOURCE_TYPE__ = useMock ? 'mock' : 'api';
|
||||
export { __MOCK__, __DATA_SOURCE_TYPE__ } from './dataSourceFactory';
|
||||
|
||||
184
dashboard/src/services/dataSourceFactory.test.ts
Normal file
184
dashboard/src/services/dataSourceFactory.test.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* DataSourceFactory 单元测试
|
||||
* 测试 DataSource 工厂模式的核心功能
|
||||
*/
|
||||
|
||||
import { DataSourceFactory, BaseDataSource, BaseMockDataSource } from '../src/services/dataSourceFactory';
|
||||
|
||||
// 测试接口
|
||||
interface TestItem {
|
||||
id: string;
|
||||
name: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
interface TestQueryParams {
|
||||
name?: string;
|
||||
value?: number;
|
||||
}
|
||||
|
||||
// 测试 API DataSource
|
||||
class TestApiDataSource extends BaseDataSource<TestItem, TestQueryParams> {
|
||||
constructor() {
|
||||
super('/api/test');
|
||||
}
|
||||
|
||||
// 自定义方法
|
||||
async customMethod(): Promise<string> {
|
||||
return 'API custom method';
|
||||
}
|
||||
}
|
||||
|
||||
// 测试 Mock DataSource
|
||||
class TestMockDataSource extends BaseMockDataSource<TestItem, TestQueryParams> {
|
||||
readonly __MOCK_NAME__ = 'TestMockDataSource';
|
||||
protected mockData: TestItem[] = [
|
||||
{ id: '1', name: 'Test 1', value: 100 },
|
||||
{ id: '2', name: 'Test 2', value: 200 },
|
||||
];
|
||||
|
||||
// 自定义方法
|
||||
async customMethod(): Promise<string> {
|
||||
return 'Mock custom method';
|
||||
}
|
||||
}
|
||||
|
||||
describe('DataSourceFactory', () => {
|
||||
describe('create', () => {
|
||||
it('should create DataSource instance', () => {
|
||||
const dataSource = DataSourceFactory.create(
|
||||
TestApiDataSource,
|
||||
TestMockDataSource
|
||||
);
|
||||
expect(dataSource).toBeDefined();
|
||||
expect(typeof dataSource.list).toBe('function');
|
||||
expect(typeof dataSource.detail).toBe('function');
|
||||
expect(typeof dataSource.create).toBe('function');
|
||||
expect(typeof dataSource.update).toBe('function');
|
||||
expect(typeof dataSource.delete).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createWithMethods', () => {
|
||||
it('should create DataSource instance with custom methods', () => {
|
||||
const dataSource = DataSourceFactory.createWithMethods<
|
||||
TestItem,
|
||||
TestQueryParams,
|
||||
{
|
||||
customMethod(): Promise<string>;
|
||||
}
|
||||
>({
|
||||
apiDataSource: TestApiDataSource,
|
||||
mockDataSource: TestMockDataSource,
|
||||
});
|
||||
|
||||
expect(dataSource).toBeDefined();
|
||||
expect(typeof dataSource.list).toBe('function');
|
||||
expect(typeof dataSource.customMethod).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('BaseDataSource', () => {
|
||||
let dataSource: TestApiDataSource;
|
||||
|
||||
beforeEach(() => {
|
||||
dataSource = new TestApiDataSource();
|
||||
});
|
||||
|
||||
describe('buildQueryParams', () => {
|
||||
it('should build query params from object', () => {
|
||||
const params = { name: 'test', value: 100, page: 1 };
|
||||
const query = dataSource['buildQueryParams'](params);
|
||||
expect(query).toBe('name=test&value=100&page=1');
|
||||
});
|
||||
|
||||
it('should handle undefined params', () => {
|
||||
const query = dataSource['buildQueryParams'](undefined);
|
||||
expect(query).toBe('');
|
||||
});
|
||||
|
||||
it('should handle null values', () => {
|
||||
const params = { name: 'test', value: null };
|
||||
const query = dataSource['buildQueryParams'](params);
|
||||
expect(query).toBe('name=test');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('BaseMockDataSource', () => {
|
||||
let dataSource: TestMockDataSource;
|
||||
|
||||
beforeEach(() => {
|
||||
dataSource = new TestMockDataSource();
|
||||
});
|
||||
|
||||
describe('list', () => {
|
||||
it('should return mock data', async () => {
|
||||
const items = await dataSource.list();
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items[0].id).toBe('1');
|
||||
expect(items[1].id).toBe('2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('detail', () => {
|
||||
it('should return item by id', async () => {
|
||||
const item = await dataSource.detail('1');
|
||||
expect(item).toBeDefined();
|
||||
expect(item?.id).toBe('1');
|
||||
expect(item?.name).toBe('Test 1');
|
||||
});
|
||||
|
||||
it('should return null for non-existent id', async () => {
|
||||
const item = await dataSource.detail('999');
|
||||
expect(item).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should create new item', async () => {
|
||||
const newItem = await dataSource.create({ name: 'Test 3', value: 300 });
|
||||
expect(newItem).toBeDefined();
|
||||
expect(newItem.id).toBeDefined();
|
||||
expect(newItem.name).toBe('Test 3');
|
||||
expect(newItem.value).toBe(300);
|
||||
|
||||
// Verify item was added to mock data
|
||||
const items = await dataSource.list();
|
||||
expect(items).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should update existing item', async () => {
|
||||
const updatedItem = await dataSource.update('1', { name: 'Updated Test 1', value: 150 });
|
||||
expect(updatedItem).toBeDefined();
|
||||
expect(updatedItem.id).toBe('1');
|
||||
expect(updatedItem.name).toBe('Updated Test 1');
|
||||
expect(updatedItem.value).toBe(150);
|
||||
});
|
||||
|
||||
it('should throw error for non-existent id', async () => {
|
||||
await expect(dataSource.update('999', { name: 'Test' })).rejects.toThrow('Item not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should delete item', async () => {
|
||||
await dataSource.delete('1');
|
||||
const items = await dataSource.list();
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].id).toBe('2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('delay', () => {
|
||||
it('should delay execution', async () => {
|
||||
const startTime = Date.now();
|
||||
await dataSource['delay'](100);
|
||||
const endTime = Date.now();
|
||||
expect(endTime - startTime).toBeGreaterThanOrEqual(100);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
238
dashboard/src/services/dataSourceFactory.ts
Normal file
238
dashboard/src/services/dataSourceFactory.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* 统一 DataSource 工厂模式
|
||||
* 消除前端数据源重复代码,提供统一的创建和管理机制
|
||||
*/
|
||||
|
||||
import { IDataSource } from '@/types/datasource';
|
||||
|
||||
// 环境变量判断
|
||||
const useMock = process.env.REACT_APP_USE_MOCK === 'true';
|
||||
|
||||
/**
|
||||
* 基础 DataSource 抽象类
|
||||
* 提供通用的 CRUD 方法实现
|
||||
*/
|
||||
export abstract class BaseDataSource<T, P = any> implements IDataSource<T, P> {
|
||||
protected baseUrl: string;
|
||||
|
||||
constructor(baseUrl: string) {
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
async list(params?: P): Promise<T[]> {
|
||||
const query = this.buildQueryParams(params);
|
||||
const response = await fetch(`${this.baseUrl}?${query}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API Error: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return result.data || [];
|
||||
}
|
||||
|
||||
async detail(id: string): Promise<T | null> {
|
||||
const response = await fetch(`${this.baseUrl}/${id}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) return null;
|
||||
throw new Error(`API Error: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return result.data || null;
|
||||
}
|
||||
|
||||
async create(data: Partial<T>): Promise<T> {
|
||||
const response = await fetch(this.baseUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API Error: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// 创建成功后获取完整数据
|
||||
if (result.data?.id) {
|
||||
const created = await this.detail(result.data.id);
|
||||
if (created) return created;
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
|
||||
async update(id: string, data: Partial<T>): Promise<T> {
|
||||
const response = await fetch(`${this.baseUrl}/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API Error: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// 更新成功后获取完整数据
|
||||
const updated = await this.detail(id);
|
||||
if (updated) return updated;
|
||||
|
||||
return result.data;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
const response = await fetch(`${this.baseUrl}/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API Error: ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建查询参数
|
||||
*/
|
||||
protected buildQueryParams(params?: P): string {
|
||||
const query = new URLSearchParams();
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
query.append(key, String(value));
|
||||
}
|
||||
});
|
||||
}
|
||||
return query.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟延迟(用于 Mock 实现)
|
||||
*/
|
||||
protected delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 基础 Mock DataSource 类
|
||||
*/
|
||||
export abstract class BaseMockDataSource<T, P = any> implements IDataSource<T, P> {
|
||||
/** Mock标记 */
|
||||
readonly __MOCK__ = true as const;
|
||||
/** Mock数据源名称 */
|
||||
abstract readonly __MOCK_NAME__: string;
|
||||
/** Mock数据 */
|
||||
protected abstract mockData: T[];
|
||||
|
||||
async list(params?: P): Promise<T[]> {
|
||||
await this.delay(200);
|
||||
return this.mockData;
|
||||
}
|
||||
|
||||
async detail(id: string): Promise<T | null> {
|
||||
await this.delay(100);
|
||||
return this.mockData.find(item => (item as any).id === id) || null;
|
||||
}
|
||||
|
||||
async create(data: Partial<T>): Promise<T> {
|
||||
await this.delay(300);
|
||||
|
||||
const newItem = {
|
||||
id: `${Date.now()}`,
|
||||
...data,
|
||||
} as T;
|
||||
|
||||
this.mockData.unshift(newItem);
|
||||
return newItem;
|
||||
}
|
||||
|
||||
async update(id: string, data: Partial<T>): Promise<T> {
|
||||
await this.delay(300);
|
||||
|
||||
const index = this.mockData.findIndex(item => (item as any).id === id);
|
||||
if (index === -1) {
|
||||
throw new Error('Item not found');
|
||||
}
|
||||
|
||||
this.mockData[index] = { ...this.mockData[index], ...data };
|
||||
return this.mockData[index];
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.delay(200);
|
||||
this.mockData = this.mockData.filter(item => (item as any).id !== id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟延迟
|
||||
*/
|
||||
protected delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DataSource 工厂类
|
||||
* 负责根据环境变量创建相应的 DataSource 实例
|
||||
*/
|
||||
export class DataSourceFactory {
|
||||
/**
|
||||
* 创建 DataSource 实例
|
||||
* @param apiDataSource API实现类
|
||||
* @param mockDataSource Mock实现类
|
||||
* @returns DataSource 实例
|
||||
*/
|
||||
static create<T, P = any>(
|
||||
apiDataSource: new () => IDataSource<T, P>,
|
||||
mockDataSource: new () => IDataSource<T, P>
|
||||
): IDataSource<T, P> {
|
||||
return useMock ? new mockDataSource() : new apiDataSource();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建带有自定义方法的 DataSource 实例
|
||||
* @param apiDataSource API实现类
|
||||
* @param mockDataSource Mock实现类
|
||||
* @returns DataSource 实例
|
||||
*/
|
||||
static createWithMethods<T, P = any, M extends Record<string, any>>({
|
||||
apiDataSource,
|
||||
mockDataSource,
|
||||
}: {
|
||||
apiDataSource: new () => IDataSource<T, P> & M;
|
||||
mockDataSource: new () => IDataSource<T, P> & M;
|
||||
}): IDataSource<T, P> & M {
|
||||
return useMock ? new mockDataSource() : new apiDataSource();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock状态标记
|
||||
* 用于调试和开发环境识别
|
||||
*/
|
||||
export const __MOCK__ = useMock;
|
||||
|
||||
/**
|
||||
* 当前数据源类型
|
||||
*/
|
||||
export const __DATA_SOURCE_TYPE__ = useMock ? 'mock' : 'api';
|
||||
573
dashboard/src/services/dynamicPricingDataSource.ts
Normal file
573
dashboard/src/services/dynamicPricingDataSource.ts
Normal file
@@ -0,0 +1,573 @@
|
||||
/**
|
||||
* [MOCK] 动态定价数据源
|
||||
* AI注意: 这是Mock实现,不是真实业务逻辑
|
||||
* 仅在USE_MOCK=true时启用
|
||||
*/
|
||||
|
||||
export type PricingStrategy = 'GAME_THEORY' | 'COMPETITION' | 'DEMAND_BASED' | 'COST_PLUS' | 'DYNAMIC';
|
||||
export type PriceAdjustmentDirection = 'INCREASE' | 'DECREASE' | 'MAINTAIN';
|
||||
export type PricingDecisionStatus = 'PENDING' | 'EXECUTED' | 'REJECTED' | 'EXPIRED';
|
||||
|
||||
export interface PricingConfig {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
shop_id: string;
|
||||
product_id: string;
|
||||
base_price: number;
|
||||
min_price: number;
|
||||
max_price: number;
|
||||
cost_price: number;
|
||||
strategy: PricingStrategy;
|
||||
strategy_params: {
|
||||
game_theory?: {
|
||||
competitor_weight: number;
|
||||
market_share_target: number;
|
||||
profit_priority: number;
|
||||
};
|
||||
competition?: {
|
||||
price_position: 'LOWEST' | 'BELOW_AVG' | 'AVG' | 'ABOVE_AVG' | 'HIGHEST';
|
||||
max_diff_percent: number;
|
||||
follow_leader: boolean;
|
||||
};
|
||||
demand_based?: {
|
||||
elasticity_threshold: number;
|
||||
demand_sensitivity: number;
|
||||
seasonal_factor: number;
|
||||
};
|
||||
};
|
||||
auto_adjust: boolean;
|
||||
adjust_frequency: 'HOURLY' | 'DAILY' | 'WEEKLY';
|
||||
max_adjust_percent: number;
|
||||
cooldown_hours: number;
|
||||
min_profit_margin: number;
|
||||
min_profit_amount: number;
|
||||
enabled: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface PricingDecision {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
shop_id: string;
|
||||
product_id: string;
|
||||
product_name?: string;
|
||||
current_price: number;
|
||||
suggested_price: number;
|
||||
price_change: number;
|
||||
change_percent: number;
|
||||
adjustment_direction: PriceAdjustmentDirection;
|
||||
strategy_used: PricingStrategy;
|
||||
decision_factors: {
|
||||
competitor_prices: number[];
|
||||
market_avg_price: number;
|
||||
demand_level: 'LOW' | 'MEDIUM' | 'HIGH';
|
||||
inventory_level: number;
|
||||
sales_velocity: number;
|
||||
profit_margin: number;
|
||||
};
|
||||
predicted_impact: {
|
||||
revenue_change: number;
|
||||
profit_change: number;
|
||||
sales_change: number;
|
||||
confidence: number;
|
||||
};
|
||||
status: PricingDecisionStatus;
|
||||
reviewed_by?: string;
|
||||
reviewed_at?: string;
|
||||
review_notes?: string;
|
||||
executed_at?: string;
|
||||
execution_result?: {
|
||||
success: boolean;
|
||||
actual_price?: number;
|
||||
error?: string;
|
||||
};
|
||||
created_at: string;
|
||||
expires_at: string;
|
||||
}
|
||||
|
||||
export interface CompetitorPriceRecord {
|
||||
id: string;
|
||||
product_id: string;
|
||||
competitor_shop_id: string;
|
||||
competitor_name: string;
|
||||
competitor_platform: string;
|
||||
price: number;
|
||||
shipping_cost: number;
|
||||
total_price: number;
|
||||
stock_status: 'IN_STOCK' | 'LOW_STOCK' | 'OUT_OF_STOCK';
|
||||
rating: number;
|
||||
review_count: number;
|
||||
collected_at: string;
|
||||
}
|
||||
|
||||
export interface CompetitorAnalysis {
|
||||
product_id: string;
|
||||
analysis_date: string;
|
||||
price_stats: {
|
||||
min_price: number;
|
||||
max_price: number;
|
||||
avg_price: number;
|
||||
median_price: number;
|
||||
our_price: number;
|
||||
our_rank: number;
|
||||
total_competitors: number;
|
||||
};
|
||||
competitor_rankings: Array<{
|
||||
shop_id: string;
|
||||
shop_name: string;
|
||||
price: number;
|
||||
rank: number;
|
||||
price_diff: number;
|
||||
rating: number;
|
||||
review_count: number;
|
||||
}>;
|
||||
market_trend: {
|
||||
price_trend: 'RISING' | 'FALLING' | 'STABLE';
|
||||
competition_level: 'HIGH' | 'MEDIUM' | 'LOW';
|
||||
recommended_action: 'INCREASE' | 'DECREASE' | 'MAINTAIN';
|
||||
};
|
||||
alerts: Array<{
|
||||
type: 'PRICE_DROP' | 'OUT_OF_STOCK' | 'NEW_COMPETITOR' | 'PROMOTION';
|
||||
severity: 'HIGH' | 'MEDIUM' | 'LOW';
|
||||
message: string;
|
||||
competitor_shop_id?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface PricingABTest {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
product_id: string;
|
||||
test_name: string;
|
||||
control_price: number;
|
||||
variant_price: number;
|
||||
traffic_split: number;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
results?: {
|
||||
control: { impressions: number; clicks: number; conversions: number; revenue: number };
|
||||
variant: { impressions: number; clicks: number; conversions: number; revenue: number };
|
||||
winner?: 'CONTROL' | 'VARIANT' | 'INCONCLUSIVE';
|
||||
confidence?: number;
|
||||
};
|
||||
status: 'RUNNING' | 'COMPLETED' | 'PAUSED';
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface IDynamicPricingDataSource {
|
||||
generatePricingDecision(productId: string, strategy?: PricingStrategy): Promise<PricingDecision>;
|
||||
fetchPendingDecisions(limit?: number): Promise<PricingDecision[]>;
|
||||
executePricingDecision(decisionId: string): Promise<{ success: boolean; message: string }>;
|
||||
rejectPricingDecision(decisionId: string, reason: string): Promise<{ success: boolean }>;
|
||||
fetchPricingConfig(productId: string): Promise<PricingConfig | null>;
|
||||
updatePricingConfig(productId: string, config: Partial<PricingConfig>): Promise<PricingConfig>;
|
||||
fetchCompetitorPrices(productId: string): Promise<CompetitorPriceRecord[]>;
|
||||
analyzeCompetitorPrices(productId: string, ourPrice: number): Promise<CompetitorAnalysis>;
|
||||
createABTest(params: {
|
||||
productId: string;
|
||||
testName: string;
|
||||
controlPrice: number;
|
||||
variantPrice: number;
|
||||
trafficSplit: number;
|
||||
durationDays: number;
|
||||
}): Promise<PricingABTest>;
|
||||
fetchABTests(productId?: string): Promise<PricingABTest[]>;
|
||||
}
|
||||
|
||||
const STRATEGY_NAMES: Record<PricingStrategy, string> = {
|
||||
GAME_THEORY: '博弈定价',
|
||||
COMPETITION: '竞争定价',
|
||||
DEMAND_BASED: '需求定价',
|
||||
COST_PLUS: '成本加成',
|
||||
DYNAMIC: '动态定价',
|
||||
};
|
||||
|
||||
const PLATFORM_NAMES: Record<string, string> = {
|
||||
AMAZON: '亚马逊',
|
||||
EBAY: 'eBay',
|
||||
SHOPEE: 'Shopee',
|
||||
TIKTOK: 'TikTok',
|
||||
TEMU: 'Temu',
|
||||
};
|
||||
|
||||
class MockDynamicPricingDataSource implements IDynamicPricingDataSource {
|
||||
private decisions: PricingDecision[] = [];
|
||||
private configs: Map<string, PricingConfig> = new Map();
|
||||
private abTests: PricingABTest[] = [];
|
||||
|
||||
constructor() {
|
||||
this.initMockData();
|
||||
}
|
||||
|
||||
private initMockData() {
|
||||
const products = [
|
||||
{ id: 'prod-001', name: '无线蓝牙耳机' },
|
||||
{ id: 'prod-002', name: '智能手表' },
|
||||
{ id: 'prod-003', name: '便携充电宝' },
|
||||
{ id: 'prod-004', name: '手机壳' },
|
||||
{ id: 'prod-005', name: '数据线' },
|
||||
];
|
||||
|
||||
const strategies: PricingStrategy[] = ['GAME_THEORY', 'COMPETITION', 'DEMAND_BASED', 'DYNAMIC'];
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const product = products[i % products.length];
|
||||
const strategy = strategies[i % strategies.length];
|
||||
const currentPrice = 50 + Math.random() * 100;
|
||||
const changePercent = (Math.random() - 0.5) * 20;
|
||||
const suggestedPrice = currentPrice * (1 + changePercent / 100);
|
||||
|
||||
this.decisions.push({
|
||||
id: `PD-${i + 1}`,
|
||||
tenant_id: 'tenant-001',
|
||||
shop_id: 'shop-001',
|
||||
product_id: product.id,
|
||||
product_name: product.name,
|
||||
current_price: currentPrice,
|
||||
suggested_price: suggestedPrice,
|
||||
price_change: suggestedPrice - currentPrice,
|
||||
change_percent: changePercent,
|
||||
adjustment_direction: changePercent > 1 ? 'INCREASE' : changePercent < -1 ? 'DECREASE' : 'MAINTAIN',
|
||||
strategy_used: strategy,
|
||||
decision_factors: {
|
||||
competitor_prices: Array.from({ length: 5 }, () => currentPrice * (0.8 + Math.random() * 0.4)),
|
||||
market_avg_price: currentPrice * (0.95 + Math.random() * 0.1),
|
||||
demand_level: Math.random() > 0.6 ? 'HIGH' : Math.random() > 0.3 ? 'MEDIUM' : 'LOW',
|
||||
inventory_level: Math.floor(Math.random() * 200),
|
||||
sales_velocity: Math.random() * 10,
|
||||
profit_margin: 15 + Math.random() * 20,
|
||||
},
|
||||
predicted_impact: {
|
||||
revenue_change: (Math.random() - 0.3) * 500,
|
||||
profit_change: (Math.random() - 0.3) * 100,
|
||||
sales_change: (Math.random() - 0.5) * 20,
|
||||
confidence: 0.6 + Math.random() * 0.35,
|
||||
},
|
||||
status: i < 3 ? 'PENDING' : i < 6 ? 'EXECUTED' : 'REJECTED',
|
||||
created_at: new Date(Date.now() - i * 3600000).toISOString(),
|
||||
expires_at: new Date(Date.now() + (24 - i) * 3600000).toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async generatePricingDecision(productId: string, strategy?: PricingStrategy): Promise<PricingDecision> {
|
||||
await this.delay(1000);
|
||||
const currentPrice = 50 + Math.random() * 100;
|
||||
const changePercent = (Math.random() - 0.5) * 15;
|
||||
const suggestedPrice = currentPrice * (1 + changePercent / 100);
|
||||
|
||||
const decision: PricingDecision = {
|
||||
id: `PD-${Date.now()}`,
|
||||
tenant_id: 'tenant-001',
|
||||
shop_id: 'shop-001',
|
||||
product_id: productId,
|
||||
product_name: '商品-' + productId,
|
||||
current_price: currentPrice,
|
||||
suggested_price: suggestedPrice,
|
||||
price_change: suggestedPrice - currentPrice,
|
||||
change_percent: changePercent,
|
||||
adjustment_direction: changePercent > 1 ? 'INCREASE' : changePercent < -1 ? 'DECREASE' : 'MAINTAIN',
|
||||
strategy_used: strategy || 'DYNAMIC',
|
||||
decision_factors: {
|
||||
competitor_prices: Array.from({ length: 5 }, () => currentPrice * (0.8 + Math.random() * 0.4)),
|
||||
market_avg_price: currentPrice * (0.95 + Math.random() * 0.1),
|
||||
demand_level: Math.random() > 0.5 ? 'HIGH' : 'MEDIUM',
|
||||
inventory_level: Math.floor(Math.random() * 200),
|
||||
sales_velocity: Math.random() * 10,
|
||||
profit_margin: 15 + Math.random() * 20,
|
||||
},
|
||||
predicted_impact: {
|
||||
revenue_change: (Math.random() - 0.3) * 500,
|
||||
profit_change: (Math.random() - 0.3) * 100,
|
||||
sales_change: (Math.random() - 0.5) * 20,
|
||||
confidence: 0.7 + Math.random() * 0.25,
|
||||
},
|
||||
status: 'PENDING',
|
||||
created_at: new Date().toISOString(),
|
||||
expires_at: new Date(Date.now() + 24 * 3600000).toISOString(),
|
||||
};
|
||||
|
||||
this.decisions.unshift(decision);
|
||||
return decision;
|
||||
}
|
||||
|
||||
async fetchPendingDecisions(limit: number = 20): Promise<PricingDecision[]> {
|
||||
await this.delay(300);
|
||||
return this.decisions.filter(d => d.status === 'PENDING').slice(0, limit);
|
||||
}
|
||||
|
||||
async executePricingDecision(decisionId: string): Promise<{ success: boolean; message: string }> {
|
||||
await this.delay(500);
|
||||
const decision = this.decisions.find(d => d.id === decisionId);
|
||||
if (!decision) {
|
||||
throw new Error('决策不存在');
|
||||
}
|
||||
decision.status = 'EXECUTED';
|
||||
decision.executed_at = new Date().toISOString();
|
||||
decision.execution_result = { success: true, actual_price: decision.suggested_price };
|
||||
return { success: true, message: '价格调整成功' };
|
||||
}
|
||||
|
||||
async rejectPricingDecision(decisionId: string, reason: string): Promise<{ success: boolean }> {
|
||||
await this.delay(300);
|
||||
const decision = this.decisions.find(d => d.id === decisionId);
|
||||
if (!decision) {
|
||||
throw new Error('决策不存在');
|
||||
}
|
||||
decision.status = 'REJECTED';
|
||||
decision.review_notes = reason;
|
||||
decision.reviewed_at = new Date().toISOString();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async fetchPricingConfig(productId: string): Promise<PricingConfig | null> {
|
||||
await this.delay(200);
|
||||
if (this.configs.has(productId)) {
|
||||
return this.configs.get(productId) || null;
|
||||
}
|
||||
const config: PricingConfig = {
|
||||
id: `PC-${productId}`,
|
||||
tenant_id: 'tenant-001',
|
||||
shop_id: 'shop-001',
|
||||
product_id: productId,
|
||||
base_price: 100,
|
||||
min_price: 70,
|
||||
max_price: 150,
|
||||
cost_price: 50,
|
||||
strategy: 'DYNAMIC',
|
||||
strategy_params: {
|
||||
game_theory: { competitor_weight: 0.3, market_share_target: 0.2, profit_priority: 0.7 },
|
||||
competition: { price_position: 'AVG', max_diff_percent: 10, follow_leader: false },
|
||||
demand_based: { elasticity_threshold: 1.5, demand_sensitivity: 0.5, seasonal_factor: 1 },
|
||||
},
|
||||
auto_adjust: false,
|
||||
adjust_frequency: 'DAILY',
|
||||
max_adjust_percent: 10,
|
||||
cooldown_hours: 24,
|
||||
min_profit_margin: 15,
|
||||
min_profit_amount: 0,
|
||||
enabled: true,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
this.configs.set(productId, config);
|
||||
return config;
|
||||
}
|
||||
|
||||
async updatePricingConfig(productId: string, updates: Partial<PricingConfig>): Promise<PricingConfig> {
|
||||
await this.delay(300);
|
||||
const config = await this.fetchPricingConfig(productId);
|
||||
const updated = { ...config!, ...updates, updated_at: new Date().toISOString() };
|
||||
this.configs.set(productId, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
async fetchCompetitorPrices(productId: string): Promise<CompetitorPriceRecord[]> {
|
||||
await this.delay(400);
|
||||
const platforms = ['AMAZON', 'EBAY', 'SHOPEE', 'TIKTOK', 'TEMU'];
|
||||
return platforms.map((platform, index) => ({
|
||||
id: `CP-${productId}-${index}`,
|
||||
product_id: productId,
|
||||
competitor_shop_id: `COMP-${platform}-${index}`,
|
||||
competitor_name: `${PLATFORM_NAMES[platform]}店铺${index + 1}`,
|
||||
competitor_platform: platform,
|
||||
price: 80 + Math.random() * 40,
|
||||
shipping_cost: Math.random() * 10,
|
||||
total_price: 85 + Math.random() * 45,
|
||||
stock_status: Math.random() > 0.2 ? 'IN_STOCK' : Math.random() > 0.5 ? 'LOW_STOCK' : 'OUT_OF_STOCK',
|
||||
rating: 3.5 + Math.random() * 1.5,
|
||||
review_count: Math.floor(Math.random() * 500),
|
||||
collected_at: new Date(Date.now() - Math.random() * 3600000).toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
async analyzeCompetitorPrices(productId: string, ourPrice: number): Promise<CompetitorAnalysis> {
|
||||
await this.delay(500);
|
||||
const prices = await this.fetchCompetitorPrices(productId);
|
||||
const allPrices = prices.map(p => p.total_price);
|
||||
const minPrice = Math.min(...allPrices);
|
||||
const maxPrice = Math.max(...allPrices);
|
||||
const avgPrice = allPrices.reduce((a, b) => a + b, 0) / allPrices.length;
|
||||
|
||||
return {
|
||||
product_id: productId,
|
||||
analysis_date: new Date().toISOString(),
|
||||
price_stats: {
|
||||
min_price: minPrice,
|
||||
max_price: maxPrice,
|
||||
avg_price: avgPrice,
|
||||
median_price: allPrices.sort((a, b) => a - b)[Math.floor(allPrices.length / 2)],
|
||||
our_price: ourPrice,
|
||||
our_rank: [...allPrices, ourPrice].sort((a, b) => a - b).indexOf(ourPrice) + 1,
|
||||
total_competitors: prices.length,
|
||||
},
|
||||
competitor_rankings: prices.map((p, i) => ({
|
||||
shop_id: p.competitor_shop_id,
|
||||
shop_name: p.competitor_name,
|
||||
price: p.total_price,
|
||||
rank: i + 1,
|
||||
price_diff: p.total_price - ourPrice,
|
||||
rating: p.rating,
|
||||
review_count: p.review_count,
|
||||
})),
|
||||
market_trend: {
|
||||
price_trend: Math.random() > 0.6 ? 'RISING' : Math.random() > 0.3 ? 'STABLE' : 'FALLING',
|
||||
competition_level: prices.length > 4 ? 'HIGH' : prices.length > 2 ? 'MEDIUM' : 'LOW',
|
||||
recommended_action: ourPrice > avgPrice * 1.1 ? 'DECREASE' : ourPrice < avgPrice * 0.9 ? 'INCREASE' : 'MAINTAIN',
|
||||
},
|
||||
alerts: [
|
||||
{ type: 'PRICE_DROP', severity: 'HIGH', message: '发现竞争对手价格低于我们15%', competitor_shop_id: prices[0].competitor_shop_id },
|
||||
{ type: 'PROMOTION', severity: 'MEDIUM', message: '2个竞争对手正在进行促销活动' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async createABTest(params: {
|
||||
productId: string;
|
||||
testName: string;
|
||||
controlPrice: number;
|
||||
variantPrice: number;
|
||||
trafficSplit: number;
|
||||
durationDays: number;
|
||||
}): Promise<PricingABTest> {
|
||||
await this.delay(500);
|
||||
const test: PricingABTest = {
|
||||
id: `ABT-${Date.now()}`,
|
||||
tenant_id: 'tenant-001',
|
||||
product_id: params.productId,
|
||||
test_name: params.testName,
|
||||
control_price: params.controlPrice,
|
||||
variant_price: params.variantPrice,
|
||||
traffic_split: params.trafficSplit,
|
||||
start_date: new Date().toISOString(),
|
||||
end_date: new Date(Date.now() + params.durationDays * 86400000).toISOString(),
|
||||
status: 'RUNNING',
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
this.abTests.push(test);
|
||||
return test;
|
||||
}
|
||||
|
||||
async fetchABTests(productId?: string): Promise<PricingABTest[]> {
|
||||
await this.delay(300);
|
||||
let tests = this.abTests;
|
||||
if (productId) {
|
||||
tests = tests.filter(t => t.product_id === productId);
|
||||
}
|
||||
return tests;
|
||||
}
|
||||
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
|
||||
class ApiDynamicPricingDataSource implements IDynamicPricingDataSource {
|
||||
private baseUrl = '/api/pricing';
|
||||
|
||||
async generatePricingDecision(productId: string, strategy?: PricingStrategy): Promise<PricingDecision> {
|
||||
const response = await fetch(`${this.baseUrl}/decisions/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ product_id: productId, strategy }),
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to generate pricing decision');
|
||||
const data = await response.json();
|
||||
return data.data;
|
||||
}
|
||||
|
||||
async fetchPendingDecisions(limit?: number): Promise<PricingDecision[]> {
|
||||
const params = new URLSearchParams({ limit: (limit || 20).toString() });
|
||||
const response = await fetch(`${this.baseUrl}/decisions/pending?${params}`);
|
||||
if (!response.ok) throw new Error('Failed to fetch pending decisions');
|
||||
const data = await response.json();
|
||||
return data.data;
|
||||
}
|
||||
|
||||
async executePricingDecision(decisionId: string): Promise<{ success: boolean; message: string }> {
|
||||
const response = await fetch(`${this.baseUrl}/decisions/${decisionId}/execute`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to execute pricing decision');
|
||||
const data = await response.json();
|
||||
return data;
|
||||
}
|
||||
|
||||
async rejectPricingDecision(decisionId: string, reason: string): Promise<{ success: boolean }> {
|
||||
const response = await fetch(`${this.baseUrl}/decisions/${decisionId}/reject`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ reason }),
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to reject pricing decision');
|
||||
const data = await response.json();
|
||||
return data;
|
||||
}
|
||||
|
||||
async fetchPricingConfig(productId: string): Promise<PricingConfig | null> {
|
||||
const response = await fetch(`${this.baseUrl}/config/${productId}`);
|
||||
if (!response.ok) return null;
|
||||
const data = await response.json();
|
||||
return data.data;
|
||||
}
|
||||
|
||||
async updatePricingConfig(productId: string, config: Partial<PricingConfig>): Promise<PricingConfig> {
|
||||
const response = await fetch(`${this.baseUrl}/config/${productId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to update pricing config');
|
||||
const data = await response.json();
|
||||
return data.data;
|
||||
}
|
||||
|
||||
async fetchCompetitorPrices(productId: string): Promise<CompetitorPriceRecord[]> {
|
||||
const response = await fetch(`${this.baseUrl}/competitors/${productId}`);
|
||||
if (!response.ok) throw new Error('Failed to fetch competitor prices');
|
||||
const data = await response.json();
|
||||
return data.data;
|
||||
}
|
||||
|
||||
async analyzeCompetitorPrices(productId: string, ourPrice: number): Promise<CompetitorAnalysis> {
|
||||
const response = await fetch(`${this.baseUrl}/competitors/${productId}/analyze?our_price=${ourPrice}`);
|
||||
if (!response.ok) throw new Error('Failed to analyze competitor prices');
|
||||
const data = await response.json();
|
||||
return data.data;
|
||||
}
|
||||
|
||||
async createABTest(params: {
|
||||
productId: string;
|
||||
testName: string;
|
||||
controlPrice: number;
|
||||
variantPrice: number;
|
||||
trafficSplit: number;
|
||||
durationDays: number;
|
||||
}): Promise<PricingABTest> {
|
||||
const response = await fetch(`${this.baseUrl}/ab-tests`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to create A/B test');
|
||||
const data = await response.json();
|
||||
return data.data;
|
||||
}
|
||||
|
||||
async fetchABTests(productId?: string): Promise<PricingABTest[]> {
|
||||
const params = productId ? `?product_id=${productId}` : '';
|
||||
const response = await fetch(`${this.baseUrl}/ab-tests${params}`);
|
||||
if (!response.ok) throw new Error('Failed to fetch A/B tests');
|
||||
const data = await response.json();
|
||||
return data.data;
|
||||
}
|
||||
}
|
||||
|
||||
const useMock = process.env.REACT_APP_USE_MOCK === 'true';
|
||||
export const dynamicPricingDataSource: IDynamicPricingDataSource = useMock
|
||||
? new MockDynamicPricingDataSource()
|
||||
: new ApiDynamicPricingDataSource();
|
||||
|
||||
export { STRATEGY_NAMES, PLATFORM_NAMES };
|
||||
@@ -4,6 +4,8 @@
|
||||
* 仅在USE_MOCK=true时启用
|
||||
*/
|
||||
|
||||
import { BaseDataSource, BaseMockDataSource, DataSourceFactory } from './dataSourceFactory';
|
||||
|
||||
export interface LeaderboardEntry {
|
||||
rank: number;
|
||||
tenant_id: string;
|
||||
@@ -36,7 +38,31 @@ export interface ILeaderboardDataSource {
|
||||
fetchMyRank(period: 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'ALL_TIME'): Promise<MyRank>;
|
||||
}
|
||||
|
||||
class MockLeaderboardDataSource implements ILeaderboardDataSource {
|
||||
class ApiLeaderboardDataSource extends BaseDataSource<any, any> implements ILeaderboardDataSource {
|
||||
constructor() {
|
||||
super('/api/leaderboard');
|
||||
}
|
||||
|
||||
async fetchLeaderboard(period: 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'ALL_TIME'): Promise<LeaderboardData> {
|
||||
const response = await fetch(`${this.baseUrl}?period=${period}`);
|
||||
if (!response.ok) throw new Error('Failed to fetch leaderboard');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async fetchMyRank(period: 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'ALL_TIME'): Promise<MyRank> {
|
||||
const response = await fetch(`${this.baseUrl}/my-rank?period=${period}`);
|
||||
if (!response.ok) throw new Error('Failed to fetch my rank');
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
|
||||
class MockLeaderboardDataSource extends BaseMockDataSource<any, any> implements ILeaderboardDataSource {
|
||||
/** Mock数据源名称 */
|
||||
readonly __MOCK_NAME__ = 'MockLeaderboardDataSource';
|
||||
|
||||
/** Mock数据 */
|
||||
protected mockData: any[] = [];
|
||||
|
||||
private generateRankings(count: number, type: 'revenue' | 'roi' | 'growth'): LeaderboardEntry[] {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
rank: i + 1,
|
||||
@@ -54,7 +80,7 @@ class MockLeaderboardDataSource implements ILeaderboardDataSource {
|
||||
}
|
||||
|
||||
async fetchLeaderboard(period: 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'ALL_TIME'): Promise<LeaderboardData> {
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
await this.delay(500);
|
||||
return {
|
||||
revenue: { rankings: this.generateRankings(10, 'revenue'), total: 100 },
|
||||
roi: { rankings: this.generateRankings(10, 'roi'), total: 100 },
|
||||
@@ -65,7 +91,7 @@ class MockLeaderboardDataSource implements ILeaderboardDataSource {
|
||||
}
|
||||
|
||||
async fetchMyRank(period: 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'ALL_TIME'): Promise<MyRank> {
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
await this.delay(300);
|
||||
return {
|
||||
revenue: { rank: 15, percentile: 85 },
|
||||
roi: { rank: 8, percentile: 92 },
|
||||
@@ -75,23 +101,17 @@ class MockLeaderboardDataSource implements ILeaderboardDataSource {
|
||||
}
|
||||
}
|
||||
|
||||
class ApiLeaderboardDataSource implements ILeaderboardDataSource {
|
||||
private baseUrl = '/api/leaderboard';
|
||||
export const leaderboardDataSource = DataSourceFactory.createWithMethods<
|
||||
any,
|
||||
any,
|
||||
ILeaderboardDataSource
|
||||
>({
|
||||
apiDataSource: ApiLeaderboardDataSource,
|
||||
mockDataSource: MockLeaderboardDataSource,
|
||||
});
|
||||
|
||||
async fetchLeaderboard(period: 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'ALL_TIME'): Promise<LeaderboardData> {
|
||||
const response = await fetch(`${this.baseUrl}?period=${period}`);
|
||||
if (!response.ok) throw new Error('Failed to fetch leaderboard');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async fetchMyRank(period: 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'ALL_TIME'): Promise<MyRank> {
|
||||
const response = await fetch(`${this.baseUrl}/my-rank?period=${period}`);
|
||||
if (!response.ok) throw new Error('Failed to fetch my rank');
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
|
||||
const useMock = process.env.REACT_APP_USE_MOCK === 'true';
|
||||
export const leaderboardDataSource: ILeaderboardDataSource = useMock
|
||||
? new MockLeaderboardDataSource()
|
||||
: new ApiLeaderboardDataSource();
|
||||
/**
|
||||
* Mock状态标记
|
||||
* 用于调试和开发环境识别
|
||||
*/
|
||||
export { __MOCK__, __DATA_SOURCE_TYPE__ } from './dataSourceFactory';
|
||||
|
||||
223
dashboard/src/services/returnDataSource.ts
Normal file
223
dashboard/src/services/returnDataSource.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* [MOCK] 退货管理数据源
|
||||
* AI注意: 这是Mock实现,不是真实业务逻辑
|
||||
* 仅在USE_MOCK=true时启用
|
||||
*/
|
||||
|
||||
export interface SKUData {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
shop_id: string;
|
||||
sku_id: string;
|
||||
sku_name: string;
|
||||
product_name: string;
|
||||
return_rate: number;
|
||||
return_count: number;
|
||||
sales_count: number;
|
||||
return_reasons: { reason: string; count: number }[];
|
||||
status: 'NORMAL' | 'WARNING' | 'CRITICAL';
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ReturnData {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
shop_id: string;
|
||||
sku_id: string;
|
||||
sku_name: string;
|
||||
product_name: string;
|
||||
order_id: string;
|
||||
return_reason: string;
|
||||
return_type: 'REFUND' | 'EXCHANGE' | 'RETURN';
|
||||
status: 'PENDING' | 'APPROVED' | 'PROCESSING' | 'COMPLETED' | 'REJECTED';
|
||||
amount: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ReturnTrend {
|
||||
date: string;
|
||||
returnCount: number;
|
||||
returnRate: number;
|
||||
refundAmount: number;
|
||||
}
|
||||
|
||||
export interface IReturnDataSource {
|
||||
fetchSKUData(params?: { status?: string; shop_id?: string }): Promise<SKUData[]>;
|
||||
updateSKUStatus(id: string, status: 'NORMAL' | 'WARNING' | 'CRITICAL'): Promise<SKUData>;
|
||||
|
||||
fetchReturns(params?: { status?: string; shop_id?: string; sku_id?: string }): Promise<ReturnData[]>;
|
||||
processReturn(id: string, action: 'APPROVE' | 'REJECT'): Promise<ReturnData>;
|
||||
|
||||
fetchReturnTrends(params?: { start_date?: string; end_date?: string }): Promise<ReturnTrend[]>;
|
||||
}
|
||||
|
||||
class MockReturnDataSource implements IReturnDataSource {
|
||||
private skuData: SKUData[] = [
|
||||
{
|
||||
id: '1',
|
||||
tenant_id: 'tenant_001',
|
||||
shop_id: 'shop_001',
|
||||
sku_id: 'SKU-001',
|
||||
sku_name: 'Product A - Blue',
|
||||
product_name: 'Product A',
|
||||
return_rate: 15.5,
|
||||
return_count: 155,
|
||||
sales_count: 1000,
|
||||
return_reasons: [
|
||||
{ reason: 'Wrong size', count: 50 },
|
||||
{ reason: 'Defective', count: 40 },
|
||||
{ reason: 'Not as described', count: 35 },
|
||||
{ reason: 'Changed mind', count: 30 },
|
||||
],
|
||||
status: 'WARNING',
|
||||
createdAt: '2026-03-01',
|
||||
updatedAt: '2026-03-15',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
tenant_id: 'tenant_001',
|
||||
shop_id: 'shop_001',
|
||||
sku_id: 'SKU-002',
|
||||
sku_name: 'Product B - Red',
|
||||
product_name: 'Product B',
|
||||
return_rate: 5.2,
|
||||
return_count: 52,
|
||||
sales_count: 1000,
|
||||
return_reasons: [
|
||||
{ reason: 'Changed mind', count: 30 },
|
||||
{ reason: 'Defective', count: 22 },
|
||||
],
|
||||
status: 'NORMAL',
|
||||
createdAt: '2026-03-01',
|
||||
updatedAt: '2026-03-15',
|
||||
},
|
||||
];
|
||||
|
||||
private returns: ReturnData[] = [
|
||||
{
|
||||
id: '1',
|
||||
tenant_id: 'tenant_001',
|
||||
shop_id: 'shop_001',
|
||||
sku_id: 'SKU-001',
|
||||
sku_name: 'Product A - Blue',
|
||||
product_name: 'Product A',
|
||||
order_id: 'ORD-2026-001',
|
||||
return_reason: 'Wrong size',
|
||||
return_type: 'EXCHANGE',
|
||||
status: 'PENDING',
|
||||
amount: 99.99,
|
||||
created_at: '2026-03-15',
|
||||
updated_at: '2026-03-15',
|
||||
},
|
||||
];
|
||||
|
||||
private trends: ReturnTrend[] = [
|
||||
{ date: '2026-03-10', returnCount: 12, returnRate: 3.5, refundAmount: 1200 },
|
||||
{ date: '2026-03-11', returnCount: 15, returnRate: 4.2, refundAmount: 1500 },
|
||||
{ date: '2026-03-12', returnCount: 10, returnRate: 2.8, refundAmount: 1000 },
|
||||
{ date: '2026-03-13', returnCount: 18, returnRate: 5.1, refundAmount: 1800 },
|
||||
{ date: '2026-03-14', returnCount: 14, returnRate: 3.9, refundAmount: 1400 },
|
||||
{ date: '2026-03-15', returnCount: 20, returnRate: 5.6, refundAmount: 2000 },
|
||||
];
|
||||
|
||||
async fetchSKUData(params?: { status?: string; shop_id?: string }): Promise<SKUData[]> {
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
let result = [...this.skuData];
|
||||
if (params?.status) {
|
||||
result = result.filter(s => s.status === params.status);
|
||||
}
|
||||
if (params?.shop_id) {
|
||||
result = result.filter(s => s.shop_id === params.shop_id);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async updateSKUStatus(id: string, status: 'NORMAL' | 'WARNING' | 'CRITICAL'): Promise<SKUData> {
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
const index = this.skuData.findIndex(s => s.id === id);
|
||||
if (index === -1) throw new Error('SKU not found');
|
||||
this.skuData[index] = { ...this.skuData[index], status, updatedAt: new Date().toISOString().split('T')[0] };
|
||||
return this.skuData[index];
|
||||
}
|
||||
|
||||
async fetchReturns(params?: { status?: string; shop_id?: string; sku_id?: string }): Promise<ReturnData[]> {
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
let result = [...this.returns];
|
||||
if (params?.status) {
|
||||
result = result.filter(r => r.status === params.status);
|
||||
}
|
||||
if (params?.shop_id) {
|
||||
result = result.filter(r => r.shop_id === params.shop_id);
|
||||
}
|
||||
if (params?.sku_id) {
|
||||
result = result.filter(r => r.sku_id === params.sku_id);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async processReturn(id: string, action: 'APPROVE' | 'REJECT'): Promise<ReturnData> {
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
const index = this.returns.findIndex(r => r.id === id);
|
||||
if (index === -1) throw new Error('Return not found');
|
||||
this.returns[index] = {
|
||||
...this.returns[index],
|
||||
status: action === 'APPROVE' ? 'APPROVED' : 'REJECTED',
|
||||
updated_at: new Date().toISOString().split('T')[0],
|
||||
};
|
||||
return this.returns[index];
|
||||
}
|
||||
|
||||
async fetchReturnTrends(params?: { start_date?: string; end_date?: string }): Promise<ReturnTrend[]> {
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
return [...this.trends];
|
||||
}
|
||||
}
|
||||
|
||||
class ApiReturnDataSource implements IReturnDataSource {
|
||||
private baseUrl = '/api/returns';
|
||||
|
||||
async fetchSKUData(params?: { status?: string; shop_id?: string }): Promise<SKUData[]> {
|
||||
const response = await fetch(`${this.baseUrl}/sku?${new URLSearchParams(params as any)}`);
|
||||
if (!response.ok) throw new Error('Failed to fetch SKU data');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async updateSKUStatus(id: string, status: 'NORMAL' | 'WARNING' | 'CRITICAL'): Promise<SKUData> {
|
||||
const response = await fetch(`${this.baseUrl}/sku/${id}/status`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to update SKU status');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async fetchReturns(params?: { status?: string; shop_id?: string; sku_id?: string }): Promise<ReturnData[]> {
|
||||
const response = await fetch(`${this.baseUrl}?${new URLSearchParams(params as any)}`);
|
||||
if (!response.ok) throw new Error('Failed to fetch returns');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async processReturn(id: string, action: 'APPROVE' | 'REJECT'): Promise<ReturnData> {
|
||||
const response = await fetch(`${this.baseUrl}/${id}/process`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action }),
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to process return');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async fetchReturnTrends(params?: { start_date?: string; end_date?: string }): Promise<ReturnTrend[]> {
|
||||
const response = await fetch(`${this.baseUrl}/trends?${new URLSearchParams(params as any)}`);
|
||||
if (!response.ok) throw new Error('Failed to fetch return trends');
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
|
||||
const useMock = process.env.REACT_APP_USE_MOCK === 'true';
|
||||
export const returnDataSource: IReturnDataSource = useMock
|
||||
? new MockReturnDataSource()
|
||||
: new ApiReturnDataSource();
|
||||
394
dashboard/src/services/shopReportDataSource.ts
Normal file
394
dashboard/src/services/shopReportDataSource.ts
Normal file
@@ -0,0 +1,394 @@
|
||||
/**
|
||||
* [MOCK] 多店铺报表数据源
|
||||
* AI注意: 这是Mock实现,不是真实业务逻辑
|
||||
* 仅在USE_MOCK=true时启用
|
||||
*/
|
||||
|
||||
export type ReportType = 'SALES' | 'PROFIT' | 'INVENTORY' | 'ORDER' | 'AD' | 'REFUND';
|
||||
export type TimeDimension = 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'QUARTERLY' | 'YEARLY';
|
||||
|
||||
export interface ShopInfo {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
name: string;
|
||||
platform: string;
|
||||
status: 'ACTIVE' | 'INACTIVE' | 'SUSPENDED';
|
||||
region: string;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export interface AggregatedReport {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
report_type: ReportType;
|
||||
time_dimension: TimeDimension;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
summary: {
|
||||
total_shops: number;
|
||||
active_shops: number;
|
||||
total_revenue: number;
|
||||
total_cost: number;
|
||||
total_profit: number;
|
||||
total_orders: number;
|
||||
total_quantity: number;
|
||||
avg_order_value: number;
|
||||
profit_margin: number;
|
||||
};
|
||||
shop_breakdown: Array<{
|
||||
shop_id: string;
|
||||
shop_name: string;
|
||||
platform: string;
|
||||
revenue: number;
|
||||
cost: number;
|
||||
profit: number;
|
||||
orders: number;
|
||||
quantity: number;
|
||||
avg_order_value: number;
|
||||
profit_margin: number;
|
||||
contribution_rate: number;
|
||||
}>;
|
||||
time_series: Array<{
|
||||
date: string;
|
||||
revenue: number;
|
||||
profit: number;
|
||||
orders: number;
|
||||
shop_count: number;
|
||||
}>;
|
||||
platform_breakdown: Array<{
|
||||
platform: string;
|
||||
revenue: number;
|
||||
profit: number;
|
||||
orders: number;
|
||||
shop_count: number;
|
||||
}>;
|
||||
rankings: {
|
||||
top_revenue_shops: Array<{ shop_id: string; shop_name: string; value: number }>;
|
||||
top_profit_shops: Array<{ shop_id: string; shop_name: string; value: number }>;
|
||||
top_growth_shops: Array<{ shop_id: string; shop_name: string; growth_rate: number }>;
|
||||
};
|
||||
comparison: {
|
||||
revenue_growth: number;
|
||||
profit_growth: number;
|
||||
order_growth: number;
|
||||
shop_growth: number;
|
||||
};
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ReportSubscription {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
user_id: string;
|
||||
report_type: ReportType;
|
||||
time_dimension: TimeDimension;
|
||||
frequency: 'DAILY' | 'WEEKLY' | 'MONTHLY';
|
||||
recipients: string[];
|
||||
enabled: boolean;
|
||||
last_sent_at?: string;
|
||||
next_send_at?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface IShopReportDataSource {
|
||||
generateReport(params: {
|
||||
tenant_id: string;
|
||||
report_type: ReportType;
|
||||
time_dimension: TimeDimension;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
shop_ids?: string[];
|
||||
platforms?: string[];
|
||||
}): Promise<AggregatedReport>;
|
||||
fetchHistoricalReports(tenantId: string, reportType?: ReportType, limit?: number): Promise<AggregatedReport[]>;
|
||||
fetchReportById(reportId: string): Promise<AggregatedReport | null>;
|
||||
createSubscription(params: {
|
||||
tenant_id: string;
|
||||
report_type: ReportType;
|
||||
time_dimension: TimeDimension;
|
||||
frequency: 'DAILY' | 'WEEKLY' | 'MONTHLY';
|
||||
recipients: string[];
|
||||
}): Promise<ReportSubscription>;
|
||||
fetchUserSubscriptions(tenantId: string): Promise<ReportSubscription[]>;
|
||||
}
|
||||
|
||||
const REPORT_TYPE_NAMES: Record<ReportType, string> = {
|
||||
SALES: '销售报表',
|
||||
PROFIT: '利润报表',
|
||||
INVENTORY: '库存报表',
|
||||
ORDER: '订单报表',
|
||||
AD: '广告报表',
|
||||
REFUND: '退款报表',
|
||||
};
|
||||
|
||||
const PLATFORM_NAMES: Record<string, string> = {
|
||||
AMAZON: '亚马逊',
|
||||
EBAY: 'eBay',
|
||||
SHOPEE: 'Shopee',
|
||||
TIKTOK: 'TikTok',
|
||||
TEMU: 'Temu',
|
||||
LAZADA: 'Lazada',
|
||||
};
|
||||
|
||||
class MockShopReportDataSource implements IShopReportDataSource {
|
||||
private reports: AggregatedReport[] = [];
|
||||
private subscriptions: ReportSubscription[] = [];
|
||||
|
||||
constructor() {
|
||||
this.initMockData();
|
||||
}
|
||||
|
||||
private initMockData() {
|
||||
const shops = [
|
||||
{ id: 'shop-001', name: '店铺A', platform: 'AMAZON' },
|
||||
{ id: 'shop-002', name: '店铺B', platform: 'EBAY' },
|
||||
{ id: 'shop-003', name: '店铺C', platform: 'SHOPEE' },
|
||||
{ id: 'shop-004', name: '店铺D', platform: 'AMAZON' },
|
||||
{ id: 'shop-005', name: '店铺E', platform: 'TIKTOK' },
|
||||
];
|
||||
|
||||
// 生成模拟报表
|
||||
const reportTypes: ReportType[] = ['SALES', 'PROFIT', 'ORDER'];
|
||||
const timeDimensions: TimeDimension[] = ['DAILY', 'WEEKLY', 'MONTHLY'];
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const reportType = reportTypes[i % reportTypes.length];
|
||||
const timeDimension = timeDimensions[i % timeDimensions.length];
|
||||
const totalRevenue = 50000 + Math.random() * 100000;
|
||||
const totalProfit = totalRevenue * (0.15 + Math.random() * 0.15);
|
||||
|
||||
const shopBreakdown = shops.map((shop, index) => {
|
||||
const shopRevenue = totalRevenue / shops.length * (0.8 + Math.random() * 0.4);
|
||||
const shopProfit = shopRevenue * (0.1 + Math.random() * 0.2);
|
||||
return {
|
||||
shop_id: shop.id,
|
||||
shop_name: shop.name,
|
||||
platform: shop.platform,
|
||||
revenue: shopRevenue,
|
||||
cost: shopRevenue - shopProfit,
|
||||
profit: shopProfit,
|
||||
orders: Math.floor(100 + Math.random() * 500),
|
||||
quantity: Math.floor(500 + Math.random() * 2000),
|
||||
avg_order_value: shopRevenue / (100 + Math.random() * 500),
|
||||
profit_margin: (shopProfit / shopRevenue) * 100,
|
||||
contribution_rate: (shopRevenue / totalRevenue) * 100,
|
||||
};
|
||||
}).sort((a, b) => b.revenue - a.revenue);
|
||||
|
||||
const timeSeries = Array.from({ length: 7 }, (_, day) => ({
|
||||
date: new Date(Date.now() - (6 - day) * 86400000).toISOString().split('T')[0],
|
||||
revenue: totalRevenue / 7 * (0.8 + Math.random() * 0.4),
|
||||
profit: totalProfit / 7 * (0.8 + Math.random() * 0.4),
|
||||
orders: Math.floor((100 + Math.random() * 500) / 7),
|
||||
shop_count: shops.length,
|
||||
}));
|
||||
|
||||
const platformBreakdown = Object.entries(
|
||||
shops.reduce((acc, shop) => {
|
||||
if (!acc[shop.platform]) {
|
||||
acc[shop.platform] = { revenue: 0, profit: 0, orders: 0, shop_count: 0 };
|
||||
}
|
||||
const shopData = shopBreakdown.find(s => s.shop_id === shop.id);
|
||||
if (shopData) {
|
||||
acc[shop.platform].revenue += shopData.revenue;
|
||||
acc[shop.platform].profit += shopData.profit;
|
||||
acc[shop.platform].orders += shopData.orders;
|
||||
acc[shop.platform].shop_count += 1;
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, any>)
|
||||
).map(([platform, data]) => ({ platform, ...data }));
|
||||
|
||||
this.reports.push({
|
||||
id: `RPT-${i + 1}`,
|
||||
tenant_id: 'tenant-001',
|
||||
report_type: reportType,
|
||||
time_dimension: timeDimension,
|
||||
start_date: new Date(Date.now() - 7 * 86400000).toISOString().split('T')[0],
|
||||
end_date: new Date().toISOString().split('T')[0],
|
||||
summary: {
|
||||
total_shops: shops.length,
|
||||
active_shops: shops.length,
|
||||
total_revenue: totalRevenue,
|
||||
total_cost: totalRevenue - totalProfit,
|
||||
total_profit: totalProfit,
|
||||
total_orders: shopBreakdown.reduce((sum, s) => sum + s.orders, 0),
|
||||
total_quantity: shopBreakdown.reduce((sum, s) => sum + s.quantity, 0),
|
||||
avg_order_value: totalRevenue / shopBreakdown.reduce((sum, s) => sum + s.orders, 0),
|
||||
profit_margin: (totalProfit / totalRevenue) * 100,
|
||||
},
|
||||
shop_breakdown: shopBreakdown,
|
||||
time_series: timeSeries,
|
||||
platform_breakdown: platformBreakdown,
|
||||
rankings: {
|
||||
top_revenue_shops: shopBreakdown.slice(0, 3).map(s => ({
|
||||
shop_id: s.shop_id,
|
||||
shop_name: s.shop_name,
|
||||
value: s.revenue,
|
||||
})),
|
||||
top_profit_shops: shopBreakdown.slice(0, 3).map(s => ({
|
||||
shop_id: s.shop_id,
|
||||
shop_name: s.shop_name,
|
||||
value: s.profit,
|
||||
})),
|
||||
top_growth_shops: shopBreakdown.slice(0, 3).map((s, i) => ({
|
||||
shop_id: s.shop_id,
|
||||
shop_name: s.shop_name,
|
||||
growth_rate: 15 + Math.random() * 20 - i * 5,
|
||||
})),
|
||||
},
|
||||
comparison: {
|
||||
revenue_growth: 12.5 + Math.random() * 10,
|
||||
profit_growth: 8.3 + Math.random() * 8,
|
||||
order_growth: 5.7 + Math.random() * 5,
|
||||
shop_growth: 2.1 + Math.random() * 3,
|
||||
},
|
||||
created_at: new Date(Date.now() - i * 86400000).toISOString(),
|
||||
updated_at: new Date(Date.now() - i * 86400000).toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async generateReport(params: {
|
||||
tenant_id: string;
|
||||
report_type: ReportType;
|
||||
time_dimension: TimeDimension;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
shop_ids?: string[];
|
||||
platforms?: string[];
|
||||
}): Promise<AggregatedReport> {
|
||||
await this.delay(1000);
|
||||
const report = this.reports[0];
|
||||
return {
|
||||
...report,
|
||||
id: `RPT-${Date.now()}`,
|
||||
report_type: params.report_type,
|
||||
time_dimension: params.time_dimension,
|
||||
start_date: params.start_date,
|
||||
end_date: params.end_date,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async fetchHistoricalReports(tenantId: string, reportType?: ReportType, limit: number = 10): Promise<AggregatedReport[]> {
|
||||
await this.delay(300);
|
||||
let reports = this.reports.filter(r => r.tenant_id === tenantId);
|
||||
if (reportType) {
|
||||
reports = reports.filter(r => r.report_type === reportType);
|
||||
}
|
||||
return reports.slice(0, limit);
|
||||
}
|
||||
|
||||
async fetchReportById(reportId: string): Promise<AggregatedReport | null> {
|
||||
await this.delay(200);
|
||||
return this.reports.find(r => r.id === reportId) || null;
|
||||
}
|
||||
|
||||
async createSubscription(params: {
|
||||
tenant_id: string;
|
||||
report_type: ReportType;
|
||||
time_dimension: TimeDimension;
|
||||
frequency: 'DAILY' | 'WEEKLY' | 'MONTHLY';
|
||||
recipients: string[];
|
||||
}): Promise<ReportSubscription> {
|
||||
await this.delay(300);
|
||||
const subscription: ReportSubscription = {
|
||||
id: `SUB-${Date.now()}`,
|
||||
tenant_id: params.tenant_id,
|
||||
user_id: 'user-001',
|
||||
report_type: params.report_type,
|
||||
time_dimension: params.time_dimension,
|
||||
frequency: params.frequency,
|
||||
recipients: params.recipients,
|
||||
enabled: true,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
this.subscriptions.push(subscription);
|
||||
return subscription;
|
||||
}
|
||||
|
||||
async fetchUserSubscriptions(tenantId: string): Promise<ReportSubscription[]> {
|
||||
await this.delay(200);
|
||||
return this.subscriptions.filter(s => s.tenant_id === tenantId);
|
||||
}
|
||||
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
|
||||
class ApiShopReportDataSource implements IShopReportDataSource {
|
||||
private baseUrl = '/api/shop-reports';
|
||||
|
||||
async generateReport(params: {
|
||||
tenant_id: string;
|
||||
report_type: ReportType;
|
||||
time_dimension: TimeDimension;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
shop_ids?: string[];
|
||||
platforms?: string[];
|
||||
}): Promise<AggregatedReport> {
|
||||
const response = await fetch(`${this.baseUrl}/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to generate report');
|
||||
const data = await response.json();
|
||||
return data.data;
|
||||
}
|
||||
|
||||
async fetchHistoricalReports(tenantId: string, reportType?: ReportType, limit?: number): Promise<AggregatedReport[]> {
|
||||
const params = new URLSearchParams({ tenant_id: tenantId });
|
||||
if (reportType) params.append('report_type', reportType);
|
||||
if (limit) params.append('limit', limit.toString());
|
||||
const response = await fetch(`${this.baseUrl}/history?${params}`);
|
||||
if (!response.ok) throw new Error('Failed to fetch historical reports');
|
||||
const data = await response.json();
|
||||
return data.data;
|
||||
}
|
||||
|
||||
async fetchReportById(reportId: string): Promise<AggregatedReport | null> {
|
||||
const response = await fetch(`${this.baseUrl}/${reportId}`);
|
||||
if (!response.ok) throw new Error('Failed to fetch report');
|
||||
const data = await response.json();
|
||||
return data.data;
|
||||
}
|
||||
|
||||
async createSubscription(params: {
|
||||
tenant_id: string;
|
||||
report_type: ReportType;
|
||||
time_dimension: TimeDimension;
|
||||
frequency: 'DAILY' | 'WEEKLY' | 'MONTHLY';
|
||||
recipients: string[];
|
||||
}): Promise<ReportSubscription> {
|
||||
const response = await fetch(`${this.baseUrl}/subscriptions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to create subscription');
|
||||
const data = await response.json();
|
||||
return data.data;
|
||||
}
|
||||
|
||||
async fetchUserSubscriptions(tenantId: string): Promise<ReportSubscription[]> {
|
||||
const response = await fetch(`${this.baseUrl}/subscriptions/my?tenant_id=${tenantId}`);
|
||||
if (!response.ok) throw new Error('Failed to fetch subscriptions');
|
||||
const data = await response.json();
|
||||
return data.data;
|
||||
}
|
||||
}
|
||||
|
||||
const useMock = process.env.REACT_APP_USE_MOCK === 'true';
|
||||
export const shopReportDataSource: IShopReportDataSource = useMock
|
||||
? new MockShopReportDataSource()
|
||||
: new ApiShopReportDataSource();
|
||||
|
||||
export { REPORT_TYPE_NAMES, PLATFORM_NAMES };
|
||||
237
dashboard/src/services/userAssetDataSource.ts
Normal file
237
dashboard/src/services/userAssetDataSource.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* [MOCK] 用户资产数据源
|
||||
* AI注意: 这是Mock实现,不是真实业务逻辑
|
||||
* 仅在USE_MOCK=true时启用
|
||||
*/
|
||||
|
||||
export type MemberLevel = 'BRONZE' | 'SILVER' | 'GOLD' | 'PLATINUM' | 'DIAMOND';
|
||||
|
||||
export interface UserAsset {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
userName: string;
|
||||
email: string;
|
||||
memberLevel: MemberLevel;
|
||||
points: number;
|
||||
totalSpent: number;
|
||||
availableBalance: number;
|
||||
frozenBalance: number;
|
||||
totalOrders: number;
|
||||
joinDate: string;
|
||||
lastActiveDate: string;
|
||||
status: 'ACTIVE' | 'INACTIVE' | 'SUSPENDED';
|
||||
}
|
||||
|
||||
export interface MemberLevelConfig {
|
||||
id: string;
|
||||
level: MemberLevel;
|
||||
name: string;
|
||||
minSpent: number;
|
||||
discount: number;
|
||||
pointsMultiplier: number;
|
||||
benefits: string[];
|
||||
color: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export interface PointsRecord {
|
||||
id: string;
|
||||
userId: string;
|
||||
userName: string;
|
||||
type: 'EARN' | 'REDEEM' | 'EXPIRE' | 'ADJUST';
|
||||
amount: number;
|
||||
balance: number;
|
||||
source: string;
|
||||
description: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface IUserAssetDataSource {
|
||||
fetchUserAssets(params?: { memberLevel?: string; status?: string; search?: string }): Promise<UserAsset[]>;
|
||||
updateUserAsset(id: string, data: Partial<UserAsset>): Promise<UserAsset>;
|
||||
|
||||
fetchMemberLevelConfigs(): Promise<MemberLevelConfig[]>;
|
||||
updateMemberLevelConfig(id: string, data: Partial<MemberLevelConfig>): Promise<MemberLevelConfig>;
|
||||
|
||||
fetchPointsRecords(params?: { userId?: string; type?: string }): Promise<PointsRecord[]>;
|
||||
adjustPoints(userId: string, amount: number, reason: string): Promise<PointsRecord>;
|
||||
}
|
||||
|
||||
class MockUserAssetDataSource implements IUserAssetDataSource {
|
||||
private userAssets: UserAsset[] = [
|
||||
{
|
||||
id: '1',
|
||||
tenantId: 'tenant_001',
|
||||
userId: 'user_001',
|
||||
userName: 'John Doe',
|
||||
email: 'john@example.com',
|
||||
memberLevel: 'GOLD',
|
||||
points: 5000,
|
||||
totalSpent: 15000,
|
||||
availableBalance: 500,
|
||||
frozenBalance: 100,
|
||||
totalOrders: 25,
|
||||
joinDate: '2025-01-15',
|
||||
lastActiveDate: '2026-03-18',
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
tenantId: 'tenant_001',
|
||||
userId: 'user_002',
|
||||
userName: 'Jane Smith',
|
||||
email: 'jane@example.com',
|
||||
memberLevel: 'PLATINUM',
|
||||
points: 15000,
|
||||
totalSpent: 50000,
|
||||
availableBalance: 1200,
|
||||
frozenBalance: 0,
|
||||
totalOrders: 85,
|
||||
joinDate: '2024-06-20',
|
||||
lastActiveDate: '2026-03-19',
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
];
|
||||
|
||||
private memberLevelConfigs: MemberLevelConfig[] = [
|
||||
{ id: '1', level: 'BRONZE', name: 'Bronze', minSpent: 0, discount: 0, pointsMultiplier: 1, benefits: ['Basic support'], color: '#CD7F32', icon: '🥉' },
|
||||
{ id: '2', level: 'SILVER', name: 'Silver', minSpent: 1000, discount: 5, pointsMultiplier: 1.2, benefits: ['Priority support', '5% discount'], color: '#C0C0C0', icon: '🥈' },
|
||||
{ id: '3', level: 'GOLD', name: 'Gold', minSpent: 5000, discount: 10, pointsMultiplier: 1.5, benefits: ['VIP support', '10% discount', 'Free shipping'], color: '#FFD700', icon: '🥇' },
|
||||
{ id: '4', level: 'PLATINUM', name: 'Platinum', minSpent: 20000, discount: 15, pointsMultiplier: 2, benefits: ['Dedicated support', '15% discount', 'Free express shipping', 'Birthday gift'], color: '#E5E4E2', icon: '💎' },
|
||||
{ id: '5', level: 'DIAMOND', name: 'Diamond', minSpent: 50000, discount: 20, pointsMultiplier: 3, benefits: ['Personal account manager', '20% discount', 'Exclusive events'], color: '#B9F2FF', icon: '💠' },
|
||||
];
|
||||
|
||||
private pointsRecords: PointsRecord[] = [
|
||||
{ id: '1', userId: 'user_001', userName: 'John Doe', type: 'EARN', amount: 100, balance: 5000, source: 'Purchase', description: 'Order #ORD-001', createdAt: '2026-03-15' },
|
||||
{ id: '2', userId: 'user_001', userName: 'John Doe', type: 'REDEEM', amount: -50, balance: 4950, source: 'Redemption', description: 'Coupon redemption', createdAt: '2026-03-16' },
|
||||
];
|
||||
|
||||
async fetchUserAssets(params?: { memberLevel?: string; status?: string; search?: string }): Promise<UserAsset[]> {
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
let result = [...this.userAssets];
|
||||
if (params?.memberLevel) {
|
||||
result = result.filter(u => u.memberLevel === params.memberLevel);
|
||||
}
|
||||
if (params?.status) {
|
||||
result = result.filter(u => u.status === params.status);
|
||||
}
|
||||
if (params?.search) {
|
||||
result = result.filter(u => u.userName.toLowerCase().includes(params.search!.toLowerCase()) || u.email.toLowerCase().includes(params.search!.toLowerCase()));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async updateUserAsset(id: string, data: Partial<UserAsset>): Promise<UserAsset> {
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
const index = this.userAssets.findIndex(u => u.id === id);
|
||||
if (index === -1) throw new Error('User asset not found');
|
||||
this.userAssets[index] = { ...this.userAssets[index], ...data };
|
||||
return this.userAssets[index];
|
||||
}
|
||||
|
||||
async fetchMemberLevelConfigs(): Promise<MemberLevelConfig[]> {
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
return [...this.memberLevelConfigs];
|
||||
}
|
||||
|
||||
async updateMemberLevelConfig(id: string, data: Partial<MemberLevelConfig>): Promise<MemberLevelConfig> {
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
const index = this.memberLevelConfigs.findIndex(c => c.id === id);
|
||||
if (index === -1) throw new Error('Config not found');
|
||||
this.memberLevelConfigs[index] = { ...this.memberLevelConfigs[index], ...data };
|
||||
return this.memberLevelConfigs[index];
|
||||
}
|
||||
|
||||
async fetchPointsRecords(params?: { userId?: string; type?: string }): Promise<PointsRecord[]> {
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
let result = [...this.pointsRecords];
|
||||
if (params?.userId) {
|
||||
result = result.filter(r => r.userId === params.userId);
|
||||
}
|
||||
if (params?.type) {
|
||||
result = result.filter(r => r.type === params.type);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async adjustPoints(userId: string, amount: number, reason: string): Promise<PointsRecord> {
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
const user = this.userAssets.find(u => u.userId === userId);
|
||||
if (!user) throw new Error('User not found');
|
||||
|
||||
const newBalance = user.points + amount;
|
||||
const record: PointsRecord = {
|
||||
id: `${Date.now()}`,
|
||||
userId,
|
||||
userName: user.userName,
|
||||
type: amount > 0 ? 'EARN' : 'REDEEM',
|
||||
amount,
|
||||
balance: newBalance,
|
||||
source: 'Manual Adjustment',
|
||||
description: reason,
|
||||
createdAt: new Date().toISOString().split('T')[0],
|
||||
};
|
||||
|
||||
user.points = newBalance;
|
||||
this.pointsRecords.push(record);
|
||||
return record;
|
||||
}
|
||||
}
|
||||
|
||||
class ApiUserAssetDataSource implements IUserAssetDataSource {
|
||||
private baseUrl = '/api/user-assets';
|
||||
|
||||
async fetchUserAssets(params?: { memberLevel?: string; status?: string; search?: string }): Promise<UserAsset[]> {
|
||||
const response = await fetch(`${this.baseUrl}?${new URLSearchParams(params as any)}`);
|
||||
if (!response.ok) throw new Error('Failed to fetch user assets');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async updateUserAsset(id: string, data: Partial<UserAsset>): Promise<UserAsset> {
|
||||
const response = await fetch(`${this.baseUrl}/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to update user asset');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async fetchMemberLevelConfigs(): Promise<MemberLevelConfig[]> {
|
||||
const response = await fetch(`${this.baseUrl}/member-levels`);
|
||||
if (!response.ok) throw new Error('Failed to fetch member level configs');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async updateMemberLevelConfig(id: string, data: Partial<MemberLevelConfig>): Promise<MemberLevelConfig> {
|
||||
const response = await fetch(`${this.baseUrl}/member-levels/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to update member level config');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async fetchPointsRecords(params?: { userId?: string; type?: string }): Promise<PointsRecord[]> {
|
||||
const response = await fetch(`${this.baseUrl}/points?${new URLSearchParams(params as any)}`);
|
||||
if (!response.ok) throw new Error('Failed to fetch points records');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async adjustPoints(userId: string, amount: number, reason: string): Promise<PointsRecord> {
|
||||
const response = await fetch(`${this.baseUrl}/points/adjust`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ userId, amount, reason }),
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to adjust points');
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
|
||||
const useMock = process.env.REACT_APP_USE_MOCK === 'true';
|
||||
export const userAssetDataSource: IUserAssetDataSource = useMock
|
||||
? new MockUserAssetDataSource()
|
||||
: new ApiUserAssetDataSource();
|
||||
Reference in New Issue
Block a user