feat: 前端适配后端新接口
适配后端统一模型 ID、协议字段、UUID 自动生成和结构化错误响应: - 类型定义:Provider 新增 protocol 字段,Model 新增 unifiedId,CreateModelInput 移除 id - API 客户端:提取结构化错误响应中的错误码 - 供应商管理:添加协议选择下拉框和表格列 - 模型管理:移除 ID 输入,显示统一模型 ID(只读) - Hooks:错误码映射为友好中文消息 - 测试:所有组件测试通过,mock 数据适配新字段 - 文档:更新 README 说明协议字段和统一模型 ID
This commit is contained in:
@@ -8,7 +8,7 @@ AI 网关管理前端,提供供应商配置和用量统计界面。
|
|||||||
- **构建工具**: Vite
|
- **构建工具**: Vite
|
||||||
- **语言**: TypeScript (strict mode)
|
- **语言**: TypeScript (strict mode)
|
||||||
- **框架**: React
|
- **框架**: React
|
||||||
- **UI 组件库**: Ant Design 5
|
- **UI 组件库**: TDesign
|
||||||
- **路由**: React Router v7
|
- **路由**: React Router v7
|
||||||
- **数据获取**: TanStack Query v5
|
- **数据获取**: TanStack Query v5
|
||||||
- **样式**: SCSS Modules(禁止使用纯 CSS)
|
- **样式**: SCSS Modules(禁止使用纯 CSS)
|
||||||
@@ -102,18 +102,21 @@ bun run test:e2e
|
|||||||
|
|
||||||
### 供应商管理
|
### 供应商管理
|
||||||
|
|
||||||
- 查看供应商列表(Ant Design Table)
|
- 查看供应商列表(TDesign Table)
|
||||||
- 添加新供应商(Modal Form)
|
- 添加新供应商(Modal Form)
|
||||||
- 编辑供应商配置
|
- 编辑供应商配置
|
||||||
- 删除供应商(Popconfirm 确认)
|
- 删除供应商(Popconfirm 确认)
|
||||||
- API Key 脱敏显示
|
- API Key 脱敏显示
|
||||||
- 启用/禁用状态标签
|
- 启用/禁用状态标签
|
||||||
|
- **协议字段**:支持 OpenAI 和 Anthropic 协议选择
|
||||||
|
|
||||||
### 模型管理
|
### 模型管理
|
||||||
|
|
||||||
- 展开供应商行查看关联模型
|
- 展开供应商行查看关联模型
|
||||||
- 添加/编辑/删除模型
|
- 添加/编辑/删除模型
|
||||||
- 按供应商筛选模型
|
- 按供应商筛选模型
|
||||||
|
- **统一模型 ID**:显示格式为 `provider_id/model_name`,用于跨协议模型识别
|
||||||
|
- **UUID 自动生成**:创建模型时后端自动生成 UUID,无需手动输入 ID
|
||||||
|
|
||||||
### 用量统计
|
### 用量统计
|
||||||
|
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ describe('request', () => {
|
|||||||
|
|
||||||
it('parses JSON and converts snake_case keys to camelCase on success', async () => {
|
it('parses JSON and converts snake_case keys to camelCase on success', async () => {
|
||||||
mswServer.use(
|
mswServer.use(
|
||||||
http.get('/api/test', () => {
|
http.get('http://localhost:3000/api/test', () => {
|
||||||
return HttpResponse.json({
|
return HttpResponse.json({
|
||||||
id: '1',
|
id: '1',
|
||||||
created_at: '2025-01-01',
|
created_at: '2025-01-01',
|
||||||
@@ -110,7 +110,7 @@ describe('request', () => {
|
|||||||
|
|
||||||
it('throws ApiError with status and message on HTTP error', async () => {
|
it('throws ApiError with status and message on HTTP error', async () => {
|
||||||
mswServer.use(
|
mswServer.use(
|
||||||
http.get('/api/test', () => {
|
http.get('http://localhost:3000/api/test', () => {
|
||||||
return HttpResponse.json(
|
return HttpResponse.json(
|
||||||
{ message: 'Not found' },
|
{ message: 'Not found' },
|
||||||
{ status: 404 },
|
{ status: 404 },
|
||||||
@@ -131,9 +131,9 @@ describe('request', () => {
|
|||||||
|
|
||||||
it('throws ApiError with default message when error body has no message', async () => {
|
it('throws ApiError with default message when error body has no message', async () => {
|
||||||
mswServer.use(
|
mswServer.use(
|
||||||
http.get('/api/test', () => {
|
http.get('http://localhost:3000/api/test', () => {
|
||||||
return HttpResponse.json(
|
return HttpResponse.json(
|
||||||
{ error: 'something' },
|
{ details: 'something' },
|
||||||
{ status: 500 },
|
{ status: 500 },
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
@@ -149,9 +149,30 @@ describe('request', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('throws ApiError with code field when error response includes code', async () => {
|
||||||
|
mswServer.use(
|
||||||
|
http.get('http://localhost:3000/api/test', () => {
|
||||||
|
return HttpResponse.json(
|
||||||
|
{ error: 'Model not found', code: 'MODEL_NOT_FOUND' },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await request('GET', '/api/test');
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(ApiError);
|
||||||
|
const apiError = error as ApiError;
|
||||||
|
expect(apiError.status).toBe(404);
|
||||||
|
expect(apiError.message).toBe('Model not found');
|
||||||
|
expect(apiError.code).toBe('MODEL_NOT_FOUND');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('throws Error on network failure', async () => {
|
it('throws Error on network failure', async () => {
|
||||||
mswServer.use(
|
mswServer.use(
|
||||||
http.get('/api/test', () => {
|
http.get('http://localhost:3000/api/test', () => {
|
||||||
return HttpResponse.error();
|
return HttpResponse.error();
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -161,7 +182,7 @@ describe('request', () => {
|
|||||||
|
|
||||||
it('returns undefined for 204 No Content', async () => {
|
it('returns undefined for 204 No Content', async () => {
|
||||||
mswServer.use(
|
mswServer.use(
|
||||||
http.delete('/api/test/1', () => {
|
http.delete('http://localhost:3000/api/test/1', () => {
|
||||||
return new HttpResponse(null, { status: 204 });
|
return new HttpResponse(null, { status: 204 });
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -174,7 +195,7 @@ describe('request', () => {
|
|||||||
let receivedBody: Record<string, unknown> | null = null;
|
let receivedBody: Record<string, unknown> | null = null;
|
||||||
|
|
||||||
mswServer.use(
|
mswServer.use(
|
||||||
http.post('/api/test', async ({ request }) => {
|
http.post('http://localhost:3000/api/test', async ({ request }) => {
|
||||||
receivedBody = (await request.json()) as Record<string, unknown>;
|
receivedBody = (await request.json()) as Record<string, unknown>;
|
||||||
return HttpResponse.json({ id: '1' });
|
return HttpResponse.json({ id: '1' });
|
||||||
}),
|
}),
|
||||||
@@ -195,7 +216,7 @@ describe('request', () => {
|
|||||||
let contentType: string | null = null;
|
let contentType: string | null = null;
|
||||||
|
|
||||||
mswServer.use(
|
mswServer.use(
|
||||||
http.post('/api/test', async ({ request }) => {
|
http.post('http://localhost:3000/api/test', async ({ request }) => {
|
||||||
contentType = request.headers.get('Content-Type');
|
contentType = request.headers.get('Content-Type');
|
||||||
return HttpResponse.json({ id: '1' });
|
return HttpResponse.json({ id: '1' });
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const mockModels = [
|
|||||||
id: 'gpt-4',
|
id: 'gpt-4',
|
||||||
provider_id: 'prov-1',
|
provider_id: 'prov-1',
|
||||||
model_name: 'gpt-4',
|
model_name: 'gpt-4',
|
||||||
|
unified_id: 'prov-1/gpt-4',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
created_at: '2025-01-01T00:00:00Z',
|
created_at: '2025-01-01T00:00:00Z',
|
||||||
},
|
},
|
||||||
@@ -15,6 +16,7 @@ const mockModels = [
|
|||||||
id: 'claude-3',
|
id: 'claude-3',
|
||||||
provider_id: 'prov-2',
|
provider_id: 'prov-2',
|
||||||
model_name: 'claude-3',
|
model_name: 'claude-3',
|
||||||
|
unified_id: 'prov-2/claude-3',
|
||||||
enabled: false,
|
enabled: false,
|
||||||
created_at: '2025-01-02T00:00:00Z',
|
created_at: '2025-01-02T00:00:00Z',
|
||||||
},
|
},
|
||||||
@@ -30,7 +32,7 @@ describe('models API', () => {
|
|||||||
describe('listModels', () => {
|
describe('listModels', () => {
|
||||||
it('returns array of Model objects with camelCase keys', async () => {
|
it('returns array of Model objects with camelCase keys', async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/models', () => {
|
http.get('http://localhost:3000/api/models', () => {
|
||||||
return HttpResponse.json(mockModels);
|
return HttpResponse.json(mockModels);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -42,6 +44,7 @@ describe('models API', () => {
|
|||||||
id: 'gpt-4',
|
id: 'gpt-4',
|
||||||
providerId: 'prov-1',
|
providerId: 'prov-1',
|
||||||
modelName: 'gpt-4',
|
modelName: 'gpt-4',
|
||||||
|
unifiedId: 'prov-1/gpt-4',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
createdAt: '2025-01-01T00:00:00Z',
|
createdAt: '2025-01-01T00:00:00Z',
|
||||||
},
|
},
|
||||||
@@ -49,6 +52,7 @@ describe('models API', () => {
|
|||||||
id: 'claude-3',
|
id: 'claude-3',
|
||||||
providerId: 'prov-2',
|
providerId: 'prov-2',
|
||||||
modelName: 'claude-3',
|
modelName: 'claude-3',
|
||||||
|
unifiedId: 'prov-2/claude-3',
|
||||||
enabled: false,
|
enabled: false,
|
||||||
createdAt: '2025-01-02T00:00:00Z',
|
createdAt: '2025-01-02T00:00:00Z',
|
||||||
},
|
},
|
||||||
@@ -59,7 +63,7 @@ describe('models API', () => {
|
|||||||
let receivedUrl: string | null = null;
|
let receivedUrl: string | null = null;
|
||||||
|
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/models', ({ request }) => {
|
http.get('http://localhost:3000/api/models', ({ request }) => {
|
||||||
receivedUrl = request.url;
|
receivedUrl = request.url;
|
||||||
return HttpResponse.json([mockModels[0]]);
|
return HttpResponse.json([mockModels[0]]);
|
||||||
}),
|
}),
|
||||||
@@ -79,7 +83,7 @@ describe('models API', () => {
|
|||||||
let receivedBody: Record<string, unknown> | null = null;
|
let receivedBody: Record<string, unknown> | null = null;
|
||||||
|
|
||||||
server.use(
|
server.use(
|
||||||
http.post('/api/models', async ({ request }) => {
|
http.post('http://localhost:3000/api/models', async ({ request }) => {
|
||||||
receivedMethod = request.method;
|
receivedMethod = request.method;
|
||||||
receivedBody = (await request.json()) as Record<string, unknown>;
|
receivedBody = (await request.json()) as Record<string, unknown>;
|
||||||
return HttpResponse.json(mockModels[0]);
|
return HttpResponse.json(mockModels[0]);
|
||||||
@@ -87,7 +91,6 @@ describe('models API', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const input = {
|
const input = {
|
||||||
id: 'gpt-4',
|
|
||||||
providerId: 'prov-1',
|
providerId: 'prov-1',
|
||||||
modelName: 'gpt-4',
|
modelName: 'gpt-4',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -97,7 +100,6 @@ describe('models API', () => {
|
|||||||
|
|
||||||
expect(receivedMethod).toBe('POST');
|
expect(receivedMethod).toBe('POST');
|
||||||
expect(receivedBody).toEqual({
|
expect(receivedBody).toEqual({
|
||||||
id: 'gpt-4',
|
|
||||||
provider_id: 'prov-1',
|
provider_id: 'prov-1',
|
||||||
model_name: 'gpt-4',
|
model_name: 'gpt-4',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -105,6 +107,7 @@ describe('models API', () => {
|
|||||||
expect(result.id).toBe('gpt-4');
|
expect(result.id).toBe('gpt-4');
|
||||||
expect(result.providerId).toBe('prov-1');
|
expect(result.providerId).toBe('prov-1');
|
||||||
expect(result.modelName).toBe('gpt-4');
|
expect(result.modelName).toBe('gpt-4');
|
||||||
|
expect(result.unifiedId).toBe('prov-1/gpt-4');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -115,7 +118,7 @@ describe('models API', () => {
|
|||||||
let receivedBody: Record<string, unknown> | null = null;
|
let receivedBody: Record<string, unknown> | null = null;
|
||||||
|
|
||||||
server.use(
|
server.use(
|
||||||
http.put('/api/models/:id', async ({ request }) => {
|
http.put('http://localhost:3000/api/models/:id', async ({ request }) => {
|
||||||
receivedMethod = request.method;
|
receivedMethod = request.method;
|
||||||
receivedUrl = new URL(request.url).pathname;
|
receivedUrl = new URL(request.url).pathname;
|
||||||
receivedBody = (await request.json()) as Record<string, unknown>;
|
receivedBody = (await request.json()) as Record<string, unknown>;
|
||||||
@@ -149,7 +152,7 @@ describe('models API', () => {
|
|||||||
let receivedUrl: string | null = null;
|
let receivedUrl: string | null = null;
|
||||||
|
|
||||||
server.use(
|
server.use(
|
||||||
http.delete('/api/models/:id', ({ request }) => {
|
http.delete('http://localhost:3000/api/models/:id', ({ request }) => {
|
||||||
receivedMethod = request.method;
|
receivedMethod = request.method;
|
||||||
receivedUrl = new URL(request.url).pathname;
|
receivedUrl = new URL(request.url).pathname;
|
||||||
return new HttpResponse(null, { status: 204 });
|
return new HttpResponse(null, { status: 204 });
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const mockProviders = [
|
|||||||
name: 'OpenAI',
|
name: 'OpenAI',
|
||||||
api_key: 'sk-xxx',
|
api_key: 'sk-xxx',
|
||||||
base_url: 'https://api.openai.com',
|
base_url: 'https://api.openai.com',
|
||||||
|
protocol: 'openai',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
created_at: '2025-01-01T00:00:00Z',
|
created_at: '2025-01-01T00:00:00Z',
|
||||||
updated_at: '2025-01-01T00:00:00Z',
|
updated_at: '2025-01-01T00:00:00Z',
|
||||||
@@ -18,6 +19,7 @@ const mockProviders = [
|
|||||||
name: 'Anthropic',
|
name: 'Anthropic',
|
||||||
api_key: 'sk-yyy',
|
api_key: 'sk-yyy',
|
||||||
base_url: 'https://api.anthropic.com',
|
base_url: 'https://api.anthropic.com',
|
||||||
|
protocol: 'anthropic',
|
||||||
enabled: false,
|
enabled: false,
|
||||||
created_at: '2025-01-02T00:00:00Z',
|
created_at: '2025-01-02T00:00:00Z',
|
||||||
updated_at: '2025-01-02T00:00:00Z',
|
updated_at: '2025-01-02T00:00:00Z',
|
||||||
@@ -34,7 +36,7 @@ describe('providers API', () => {
|
|||||||
describe('listProviders', () => {
|
describe('listProviders', () => {
|
||||||
it('returns array of Provider objects with camelCase keys', async () => {
|
it('returns array of Provider objects with camelCase keys', async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/providers', () => {
|
http.get('http://localhost:3000/api/providers', () => {
|
||||||
return HttpResponse.json(mockProviders);
|
return HttpResponse.json(mockProviders);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -47,6 +49,7 @@ describe('providers API', () => {
|
|||||||
name: 'OpenAI',
|
name: 'OpenAI',
|
||||||
apiKey: 'sk-xxx',
|
apiKey: 'sk-xxx',
|
||||||
baseUrl: 'https://api.openai.com',
|
baseUrl: 'https://api.openai.com',
|
||||||
|
protocol: 'openai',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
createdAt: '2025-01-01T00:00:00Z',
|
createdAt: '2025-01-01T00:00:00Z',
|
||||||
updatedAt: '2025-01-01T00:00:00Z',
|
updatedAt: '2025-01-01T00:00:00Z',
|
||||||
@@ -56,6 +59,7 @@ describe('providers API', () => {
|
|||||||
name: 'Anthropic',
|
name: 'Anthropic',
|
||||||
apiKey: 'sk-yyy',
|
apiKey: 'sk-yyy',
|
||||||
baseUrl: 'https://api.anthropic.com',
|
baseUrl: 'https://api.anthropic.com',
|
||||||
|
protocol: 'anthropic',
|
||||||
enabled: false,
|
enabled: false,
|
||||||
createdAt: '2025-01-02T00:00:00Z',
|
createdAt: '2025-01-02T00:00:00Z',
|
||||||
updatedAt: '2025-01-02T00:00:00Z',
|
updatedAt: '2025-01-02T00:00:00Z',
|
||||||
@@ -70,7 +74,7 @@ describe('providers API', () => {
|
|||||||
let receivedBody: Record<string, unknown> | null = null;
|
let receivedBody: Record<string, unknown> | null = null;
|
||||||
|
|
||||||
server.use(
|
server.use(
|
||||||
http.post('/api/providers', async ({ request }) => {
|
http.post('http://localhost:3000/api/providers', async ({ request }) => {
|
||||||
receivedMethod = request.method;
|
receivedMethod = request.method;
|
||||||
receivedBody = (await request.json()) as Record<string, unknown>;
|
receivedBody = (await request.json()) as Record<string, unknown>;
|
||||||
return HttpResponse.json(mockProviders[0]);
|
return HttpResponse.json(mockProviders[0]);
|
||||||
@@ -100,6 +104,7 @@ describe('providers API', () => {
|
|||||||
name: 'OpenAI',
|
name: 'OpenAI',
|
||||||
apiKey: 'sk-xxx',
|
apiKey: 'sk-xxx',
|
||||||
baseUrl: 'https://api.openai.com',
|
baseUrl: 'https://api.openai.com',
|
||||||
|
protocol: 'openai',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
createdAt: '2025-01-01T00:00:00Z',
|
createdAt: '2025-01-01T00:00:00Z',
|
||||||
updatedAt: '2025-01-01T00:00:00Z',
|
updatedAt: '2025-01-01T00:00:00Z',
|
||||||
@@ -114,7 +119,7 @@ describe('providers API', () => {
|
|||||||
let receivedBody: Record<string, unknown> | null = null;
|
let receivedBody: Record<string, unknown> | null = null;
|
||||||
|
|
||||||
server.use(
|
server.use(
|
||||||
http.put('/api/providers/:id', async ({ request, params }) => {
|
http.put('http://localhost:3000/api/providers/:id', async ({ request, params }) => {
|
||||||
receivedMethod = request.method;
|
receivedMethod = request.method;
|
||||||
receivedUrl = new URL(request.url).pathname;
|
receivedUrl = new URL(request.url).pathname;
|
||||||
receivedBody = (await request.json()) as Record<string, unknown>;
|
receivedBody = (await request.json()) as Record<string, unknown>;
|
||||||
@@ -148,7 +153,7 @@ describe('providers API', () => {
|
|||||||
let receivedUrl: string | null = null;
|
let receivedUrl: string | null = null;
|
||||||
|
|
||||||
server.use(
|
server.use(
|
||||||
http.delete('/api/providers/:id', ({ request, params }) => {
|
http.delete('http://localhost:3000/api/providers/:id', ({ request, params }) => {
|
||||||
receivedMethod = request.method;
|
receivedMethod = request.method;
|
||||||
receivedUrl = new URL(request.url).pathname;
|
receivedUrl = new URL(request.url).pathname;
|
||||||
return new HttpResponse(null, { status: 204 });
|
return new HttpResponse(null, { status: 204 });
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ describe('stats API', () => {
|
|||||||
let receivedUrl: string | null = null;
|
let receivedUrl: string | null = null;
|
||||||
|
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/stats', ({ request }) => {
|
http.get('http://localhost:3000/api/stats', ({ request }) => {
|
||||||
receivedUrl = request.url;
|
receivedUrl = request.url;
|
||||||
return HttpResponse.json(mockStats);
|
return HttpResponse.json(mockStats);
|
||||||
}),
|
}),
|
||||||
@@ -63,7 +63,7 @@ describe('stats API', () => {
|
|||||||
let receivedUrl: string | null = null;
|
let receivedUrl: string | null = null;
|
||||||
|
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/stats', ({ request }) => {
|
http.get('http://localhost:3000/api/stats', ({ request }) => {
|
||||||
receivedUrl = request.url;
|
receivedUrl = request.url;
|
||||||
return HttpResponse.json([]);
|
return HttpResponse.json([]);
|
||||||
}),
|
}),
|
||||||
@@ -86,7 +86,7 @@ describe('stats API', () => {
|
|||||||
let receivedUrl: string | null = null;
|
let receivedUrl: string | null = null;
|
||||||
|
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/stats', ({ request }) => {
|
http.get('http://localhost:3000/api/stats', ({ request }) => {
|
||||||
receivedUrl = request.url;
|
receivedUrl = request.url;
|
||||||
return HttpResponse.json([]);
|
return HttpResponse.json([]);
|
||||||
}),
|
}),
|
||||||
@@ -104,7 +104,7 @@ describe('stats API', () => {
|
|||||||
|
|
||||||
it('returns UsageStats array with camelCase keys', async () => {
|
it('returns UsageStats array with camelCase keys', async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/stats', () => {
|
http.get('http://localhost:3000/api/stats', () => {
|
||||||
return HttpResponse.json(mockStats);
|
return HttpResponse.json(mockStats);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const mockProviders: Provider[] = [
|
|||||||
name: 'OpenAI',
|
name: 'OpenAI',
|
||||||
apiKey: 'sk-test',
|
apiKey: 'sk-test',
|
||||||
baseUrl: 'https://api.openai.com/v1',
|
baseUrl: 'https://api.openai.com/v1',
|
||||||
|
protocol: 'openai',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
createdAt: '2024-01-01T00:00:00Z',
|
createdAt: '2024-01-01T00:00:00Z',
|
||||||
updatedAt: '2024-01-01T00:00:00Z',
|
updatedAt: '2024-01-01T00:00:00Z',
|
||||||
@@ -19,6 +20,7 @@ const mockProviders: Provider[] = [
|
|||||||
name: 'Anthropic',
|
name: 'Anthropic',
|
||||||
apiKey: 'sk-ant-test',
|
apiKey: 'sk-ant-test',
|
||||||
baseUrl: 'https://api.anthropic.com',
|
baseUrl: 'https://api.anthropic.com',
|
||||||
|
protocol: 'anthropic',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
createdAt: '2024-01-02T00:00:00Z',
|
createdAt: '2024-01-02T00:00:00Z',
|
||||||
updatedAt: '2024-01-02T00:00:00Z',
|
updatedAt: '2024-01-02T00:00:00Z',
|
||||||
@@ -31,6 +33,7 @@ const mockModel: Model = {
|
|||||||
modelName: 'gpt-4o',
|
modelName: 'gpt-4o',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
createdAt: '2024-01-01T00:00:00Z',
|
createdAt: '2024-01-01T00:00:00Z',
|
||||||
|
unifiedId: 'openai/gpt-4o',
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
@@ -57,7 +60,6 @@ describe('ModelForm', () => {
|
|||||||
|
|
||||||
const dialog = getDialog();
|
const dialog = getDialog();
|
||||||
expect(within(dialog).getByText('添加模型')).toBeInTheDocument();
|
expect(within(dialog).getByText('添加模型')).toBeInTheDocument();
|
||||||
expect(within(dialog).getByText('ID')).toBeInTheDocument();
|
|
||||||
expect(within(dialog).getByText('供应商')).toBeInTheDocument();
|
expect(within(dialog).getByText('供应商')).toBeInTheDocument();
|
||||||
expect(within(dialog).getByText('模型名称')).toBeInTheDocument();
|
expect(within(dialog).getByText('模型名称')).toBeInTheDocument();
|
||||||
expect(within(dialog).getByText('启用')).toBeInTheDocument();
|
expect(within(dialog).getByText('启用')).toBeInTheDocument();
|
||||||
@@ -85,8 +87,7 @@ describe('ModelForm', () => {
|
|||||||
const okButton = within(dialog).getByRole('button', { name: /保/ });
|
const okButton = within(dialog).getByRole('button', { name: /保/ });
|
||||||
await user.click(okButton);
|
await user.click(okButton);
|
||||||
|
|
||||||
expect(await screen.findByText('请输入模型 ID')).toBeInTheDocument();
|
expect(await screen.findByText('请选择供应商')).toBeInTheDocument();
|
||||||
expect(screen.getByText('请选择供应商')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('请输入模型名称')).toBeInTheDocument();
|
expect(screen.getByText('请输入模型名称')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -96,15 +97,12 @@ describe('ModelForm', () => {
|
|||||||
render(<ModelForm {...defaultProps} onSave={onSave} />);
|
render(<ModelForm {...defaultProps} onSave={onSave} />);
|
||||||
|
|
||||||
const dialog = getDialog();
|
const dialog = getDialog();
|
||||||
// There are two inputs with placeholder "例如: gpt-4o": ID field (index 0) and model name (index 1)
|
// Only one input with placeholder "例如: gpt-4o" for model name
|
||||||
const inputs = within(dialog).getAllByPlaceholderText('例如: gpt-4o');
|
const modelNameInput = within(dialog).getByPlaceholderText('例如: gpt-4o');
|
||||||
|
|
||||||
// Type into the ID field
|
|
||||||
await user.clear(inputs[0]);
|
|
||||||
await user.type(inputs[0], 'gpt-4o-mini');
|
|
||||||
// Type into the model name field
|
// Type into the model name field
|
||||||
await user.clear(inputs[1]);
|
await user.clear(modelNameInput);
|
||||||
await user.type(inputs[1], 'gpt-4o-mini');
|
await user.type(modelNameInput, 'gpt-4o-mini');
|
||||||
|
|
||||||
const okButton = within(dialog).getByRole('button', { name: /保/ });
|
const okButton = within(dialog).getByRole('button', { name: /保/ });
|
||||||
await user.click(okButton);
|
await user.click(okButton);
|
||||||
@@ -113,7 +111,6 @@ describe('ModelForm', () => {
|
|||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(onSave).toHaveBeenCalledWith(
|
expect(onSave).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: 'gpt-4o-mini',
|
|
||||||
providerId: 'openai',
|
providerId: 'openai',
|
||||||
modelName: 'gpt-4o-mini',
|
modelName: 'gpt-4o-mini',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -128,12 +125,11 @@ describe('ModelForm', () => {
|
|||||||
const dialog = getDialog();
|
const dialog = getDialog();
|
||||||
expect(within(dialog).getByText('编辑模型')).toBeInTheDocument();
|
expect(within(dialog).getByText('编辑模型')).toBeInTheDocument();
|
||||||
|
|
||||||
const inputs = within(dialog).getAllByPlaceholderText('例如: gpt-4o');
|
// Check that unified ID field is displayed
|
||||||
const idInput = inputs[0] as HTMLInputElement;
|
expect(within(dialog).getByText('统一模型 ID')).toBeInTheDocument();
|
||||||
expect(idInput.value).toBe('gpt-4o');
|
|
||||||
expect(idInput).toBeDisabled();
|
|
||||||
|
|
||||||
const modelNameInput = inputs[1] as HTMLInputElement;
|
// Check model name input
|
||||||
|
const modelNameInput = within(dialog).getByPlaceholderText('例如: gpt-4o') as HTMLInputElement;
|
||||||
expect(modelNameInput.value).toBe('gpt-4o');
|
expect(modelNameInput.value).toBe('gpt-4o');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
123
frontend/src/__tests__/components/ModelTable.test.tsx
Normal file
123
frontend/src/__tests__/components/ModelTable.test.tsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { ModelTable } from '@/pages/Providers/ModelTable';
|
||||||
|
import type { Model } from '@/types';
|
||||||
|
|
||||||
|
const mockModels: Model[] = [
|
||||||
|
{
|
||||||
|
id: 'model-1',
|
||||||
|
providerId: 'openai',
|
||||||
|
modelName: 'gpt-4o',
|
||||||
|
enabled: true,
|
||||||
|
createdAt: '2024-01-01T00:00:00Z',
|
||||||
|
unifiedId: 'openai/gpt-4o',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'model-2',
|
||||||
|
providerId: 'openai',
|
||||||
|
modelName: 'gpt-3.5-turbo',
|
||||||
|
enabled: false,
|
||||||
|
createdAt: '2024-01-02T00:00:00Z',
|
||||||
|
unifiedId: 'openai/gpt-3.5-turbo',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockMutate = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('@/hooks/useModels', () => ({
|
||||||
|
useModels: vi.fn((providerId: string) => {
|
||||||
|
if (providerId === 'openai') {
|
||||||
|
return { data: mockModels, isLoading: false };
|
||||||
|
}
|
||||||
|
return { data: [], isLoading: false };
|
||||||
|
}),
|
||||||
|
useDeleteModel: vi.fn(() => ({ mutate: mockMutate })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
providerId: 'openai',
|
||||||
|
onAdd: vi.fn(),
|
||||||
|
onEdit: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('ModelTable', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockMutate.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders model list with unified ID and model name', () => {
|
||||||
|
render(<ModelTable {...defaultProps} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/关联模型/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('openai/gpt-4o')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('openai/gpt-3.5-turbo')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('gpt-4o')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('gpt-3.5-turbo')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders status tags correctly', () => {
|
||||||
|
render(<ModelTable {...defaultProps} />);
|
||||||
|
|
||||||
|
const enabledTags = screen.getAllByText('启用');
|
||||||
|
const disabledTags = screen.getAllByText('禁用');
|
||||||
|
expect(enabledTags.length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(disabledTags.length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onAdd when clicking "添加模型" button', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onAdd = vi.fn();
|
||||||
|
render(<ModelTable {...defaultProps} onAdd={onAdd} />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: '添加模型' }));
|
||||||
|
expect(onAdd).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onEdit with correct model when clicking "编辑"', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onEdit = vi.fn();
|
||||||
|
render(<ModelTable {...defaultProps} onEdit={onEdit} />);
|
||||||
|
|
||||||
|
const editButtons = screen.getAllByRole('button', { name: /编 ?辑/ });
|
||||||
|
await user.click(editButtons[0]);
|
||||||
|
|
||||||
|
expect(onEdit).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onEdit).toHaveBeenCalledWith(mockModels[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls deleteModel.mutate with correct model ID when delete is confirmed', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(<ModelTable {...defaultProps} />);
|
||||||
|
|
||||||
|
// Find and click the delete button for the first row
|
||||||
|
const deleteButtons = screen.getAllByRole('button', { name: '删除' });
|
||||||
|
await user.click(deleteButtons[0]);
|
||||||
|
|
||||||
|
// TDesign Popconfirm renders confirmation popup with "确定" button
|
||||||
|
const confirmButton = await screen.findByRole('button', { name: '确定' });
|
||||||
|
await user.click(confirmButton);
|
||||||
|
|
||||||
|
// Assert that deleteModel.mutate was called with the correct model ID
|
||||||
|
expect(mockMutate).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockMutate).toHaveBeenCalledWith('model-1');
|
||||||
|
}, 10000);
|
||||||
|
|
||||||
|
it('shows custom empty text when models list is empty', () => {
|
||||||
|
render(<ModelTable providerId="anthropic" />);
|
||||||
|
expect(screen.getByText('暂无模型,点击上方按钮添加')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render add button when onAdd is not provided', () => {
|
||||||
|
render(<ModelTable providerId="openai" />);
|
||||||
|
|
||||||
|
expect(screen.queryByRole('button', { name: '添加模型' })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render edit button when onEdit is not provided', () => {
|
||||||
|
render(<ModelTable providerId="openai" onAdd={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(screen.queryByRole('button', { name: /编 ?辑/ })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,6 +9,7 @@ const mockProvider: Provider = {
|
|||||||
name: 'OpenAI',
|
name: 'OpenAI',
|
||||||
apiKey: 'sk-old-key',
|
apiKey: 'sk-old-key',
|
||||||
baseUrl: 'https://api.openai.com/v1',
|
baseUrl: 'https://api.openai.com/v1',
|
||||||
|
protocol: 'openai',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
createdAt: '2024-01-01T00:00:00Z',
|
createdAt: '2024-01-01T00:00:00Z',
|
||||||
updatedAt: '2024-01-01T00:00:00Z',
|
updatedAt: '2024-01-01T00:00:00Z',
|
||||||
@@ -40,6 +41,7 @@ describe('ProviderForm', () => {
|
|||||||
expect(within(dialog).getByText('名称')).toBeInTheDocument();
|
expect(within(dialog).getByText('名称')).toBeInTheDocument();
|
||||||
expect(within(dialog).getByText('API Key')).toBeInTheDocument();
|
expect(within(dialog).getByText('API Key')).toBeInTheDocument();
|
||||||
expect(within(dialog).getByText('Base URL')).toBeInTheDocument();
|
expect(within(dialog).getByText('Base URL')).toBeInTheDocument();
|
||||||
|
expect(within(dialog).getByText('协议')).toBeInTheDocument();
|
||||||
expect(within(dialog).getByText('启用')).toBeInTheDocument();
|
expect(within(dialog).getByText('启用')).toBeInTheDocument();
|
||||||
expect(within(dialog).getByPlaceholderText('例如: openai')).toBeInTheDocument();
|
expect(within(dialog).getByPlaceholderText('例如: openai')).toBeInTheDocument();
|
||||||
expect(within(dialog).getByPlaceholderText('例如: OpenAI')).toBeInTheDocument();
|
expect(within(dialog).getByPlaceholderText('例如: OpenAI')).toBeInTheDocument();
|
||||||
@@ -154,4 +156,41 @@ describe('ProviderForm', () => {
|
|||||||
expect(screen.getByText('请输入有效的 URL')).toBeInTheDocument();
|
expect(screen.getByText('请输入有效的 URL')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
}, 15000);
|
}, 15000);
|
||||||
|
|
||||||
|
it('renders protocol select field with default value', () => {
|
||||||
|
render(<ProviderForm {...defaultProps} />);
|
||||||
|
|
||||||
|
const dialog = getDialog();
|
||||||
|
expect(within(dialog).getByText('协议')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes protocol field in form submission', async () => {
|
||||||
|
const onSave = vi.fn();
|
||||||
|
render(<ProviderForm {...defaultProps} onSave={onSave} />);
|
||||||
|
|
||||||
|
const dialog = getDialog();
|
||||||
|
|
||||||
|
// Get form instance and set values directly
|
||||||
|
const idInput = within(dialog).getByPlaceholderText('例如: openai') as HTMLInputElement;
|
||||||
|
const nameInput = within(dialog).getByPlaceholderText('例如: OpenAI') as HTMLInputElement;
|
||||||
|
const apiKeyInput = within(dialog).getByPlaceholderText('sk-...') as HTMLInputElement;
|
||||||
|
const baseUrlInput = within(dialog).getByPlaceholderText('例如: https://api.openai.com/v1') as HTMLInputElement;
|
||||||
|
|
||||||
|
// Simulate user input by directly setting values
|
||||||
|
fireEvent.change(idInput, { target: { value: 'test-provider' } });
|
||||||
|
fireEvent.change(nameInput, { target: { value: 'Test Provider' } });
|
||||||
|
fireEvent.change(apiKeyInput, { target: { value: 'sk-test-key' } });
|
||||||
|
fireEvent.change(baseUrlInput, { target: { value: 'https://api.test.com/v1' } });
|
||||||
|
|
||||||
|
const okButton = within(dialog).getByRole('button', { name: /保/ });
|
||||||
|
fireEvent.click(okButton);
|
||||||
|
|
||||||
|
// Wait for the onSave to be called
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(onSave).toHaveBeenCalled();
|
||||||
|
// Verify that the saved data includes a protocol field
|
||||||
|
const savedData = onSave.mock.calls[0][0];
|
||||||
|
expect(savedData).toHaveProperty('protocol');
|
||||||
|
}, { timeout: 5000 });
|
||||||
|
}, 10000);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { ProviderTable } from '@/pages/Providers/ProviderTable';
|
|||||||
import type { Provider } from '@/types';
|
import type { Provider } from '@/types';
|
||||||
|
|
||||||
const mockModelsData = [
|
const mockModelsData = [
|
||||||
{ id: 'model-1', providerId: 'openai', modelName: 'gpt-4o', enabled: true },
|
{ id: 'model-1', providerId: 'openai', modelName: 'gpt-4o', enabled: true, unifiedId: 'openai/gpt-4o' },
|
||||||
{ id: 'model-2', providerId: 'openai', modelName: 'gpt-3.5-turbo', enabled: false },
|
{ id: 'model-2', providerId: 'openai', modelName: 'gpt-3.5-turbo', enabled: false, unifiedId: 'openai/gpt-3.5-turbo' },
|
||||||
];
|
];
|
||||||
|
|
||||||
vi.mock('@/hooks/useModels', () => ({
|
vi.mock('@/hooks/useModels', () => ({
|
||||||
@@ -20,6 +20,7 @@ const mockProviders: Provider[] = [
|
|||||||
name: 'OpenAI',
|
name: 'OpenAI',
|
||||||
apiKey: 'sk-abcdefgh12345678',
|
apiKey: 'sk-abcdefgh12345678',
|
||||||
baseUrl: 'https://api.openai.com/v1',
|
baseUrl: 'https://api.openai.com/v1',
|
||||||
|
protocol: 'openai',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
createdAt: '2024-01-01T00:00:00Z',
|
createdAt: '2024-01-01T00:00:00Z',
|
||||||
updatedAt: '2024-01-01T00:00:00Z',
|
updatedAt: '2024-01-01T00:00:00Z',
|
||||||
@@ -29,6 +30,7 @@ const mockProviders: Provider[] = [
|
|||||||
name: 'Anthropic',
|
name: 'Anthropic',
|
||||||
apiKey: 'sk-ant-test',
|
apiKey: 'sk-ant-test',
|
||||||
baseUrl: 'https://api.anthropic.com',
|
baseUrl: 'https://api.anthropic.com',
|
||||||
|
protocol: 'anthropic',
|
||||||
enabled: false,
|
enabled: false,
|
||||||
createdAt: '2024-01-02T00:00:00Z',
|
createdAt: '2024-01-02T00:00:00Z',
|
||||||
updatedAt: '2024-01-02T00:00:00Z',
|
updatedAt: '2024-01-02T00:00:00Z',
|
||||||
@@ -51,11 +53,12 @@ describe('ProviderTable', () => {
|
|||||||
|
|
||||||
expect(screen.getByText('供应商列表')).toBeInTheDocument();
|
expect(screen.getByText('供应商列表')).toBeInTheDocument();
|
||||||
|
|
||||||
expect(screen.getByText('OpenAI')).toBeInTheDocument();
|
// Check that provider names appear (they will appear in both name column and potentially protocol column)
|
||||||
|
expect(screen.getAllByText('OpenAI').length).toBeGreaterThan(0);
|
||||||
expect(screen.getByText('https://api.openai.com/v1')).toBeInTheDocument();
|
expect(screen.getByText('https://api.openai.com/v1')).toBeInTheDocument();
|
||||||
expect(screen.getByText('****5678')).toBeInTheDocument();
|
expect(screen.getByText('****5678')).toBeInTheDocument();
|
||||||
|
|
||||||
expect(screen.getByText('Anthropic')).toBeInTheDocument();
|
expect(screen.getAllByText('Anthropic').length).toBeGreaterThan(0);
|
||||||
expect(screen.getByText('https://api.anthropic.com')).toBeInTheDocument();
|
expect(screen.getByText('https://api.anthropic.com')).toBeInTheDocument();
|
||||||
expect(screen.getByText('****test')).toBeInTheDocument();
|
expect(screen.getByText('****test')).toBeInTheDocument();
|
||||||
|
|
||||||
@@ -163,4 +166,36 @@ describe('ProviderTable', () => {
|
|||||||
render(<ProviderTable {...defaultProps} providers={[]} />);
|
render(<ProviderTable {...defaultProps} providers={[]} />);
|
||||||
expect(screen.getByText('暂无供应商,点击上方按钮添加')).toBeInTheDocument();
|
expect(screen.getByText('暂无供应商,点击上方按钮添加')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders protocol column with correct tags', () => {
|
||||||
|
const { container } = render(<ProviderTable {...defaultProps} />);
|
||||||
|
|
||||||
|
// Check that protocol tags are displayed in the table
|
||||||
|
const protocolCells = container.querySelectorAll('[data-colkey="protocol"]');
|
||||||
|
expect(protocolCells.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Verify protocol tags exist
|
||||||
|
const tags = container.querySelectorAll('.t-tag');
|
||||||
|
expect(tags.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays protocol tag for each provider', () => {
|
||||||
|
const singleProvider: Provider[] = [
|
||||||
|
{
|
||||||
|
id: 'test',
|
||||||
|
name: 'Test Provider',
|
||||||
|
apiKey: 'test-key',
|
||||||
|
baseUrl: 'https://test.com',
|
||||||
|
protocol: 'openai',
|
||||||
|
enabled: true,
|
||||||
|
createdAt: '2024-01-01T00:00:00Z',
|
||||||
|
updatedAt: '2024-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const { container } = render(<ProviderTable {...defaultProps} providers={singleProvider} />);
|
||||||
|
|
||||||
|
// Should display protocol column
|
||||||
|
const protocolCell = container.querySelector('[data-colkey="protocol"]');
|
||||||
|
expect(protocolCell).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,21 +5,21 @@ import type { UsageStats } from '@/types';
|
|||||||
|
|
||||||
const mockStats: UsageStats[] = [
|
const mockStats: UsageStats[] = [
|
||||||
{
|
{
|
||||||
id: '1',
|
id: 1,
|
||||||
providerId: 'openai',
|
providerId: 'openai',
|
||||||
modelName: 'gpt-4o',
|
modelName: 'gpt-4o',
|
||||||
requestCount: 100,
|
requestCount: 100,
|
||||||
date: '2024-01-01',
|
date: '2024-01-01',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '2',
|
id: 2,
|
||||||
providerId: 'openai',
|
providerId: 'openai',
|
||||||
modelName: 'gpt-3.5-turbo',
|
modelName: 'gpt-3.5-turbo',
|
||||||
requestCount: 200,
|
requestCount: 200,
|
||||||
date: '2024-01-01',
|
date: '2024-01-01',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '3',
|
id: 3,
|
||||||
providerId: 'anthropic',
|
providerId: 'anthropic',
|
||||||
modelName: 'claude-3',
|
modelName: 'claude-3',
|
||||||
requestCount: 150,
|
requestCount: 150,
|
||||||
@@ -73,7 +73,7 @@ describe('StatCards', () => {
|
|||||||
const statsWithToday: UsageStats[] = [
|
const statsWithToday: UsageStats[] = [
|
||||||
...mockStats,
|
...mockStats,
|
||||||
{
|
{
|
||||||
id: '4',
|
id: 4,
|
||||||
providerId: 'openai',
|
providerId: 'openai',
|
||||||
modelName: 'gpt-4o',
|
modelName: 'gpt-4o',
|
||||||
requestCount: 50,
|
requestCount: 50,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const mockProviders: Provider[] = [
|
|||||||
name: 'OpenAI',
|
name: 'OpenAI',
|
||||||
apiKey: 'sk-test',
|
apiKey: 'sk-test',
|
||||||
baseUrl: 'https://api.openai.com/v1',
|
baseUrl: 'https://api.openai.com/v1',
|
||||||
|
protocol: 'openai',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
createdAt: '2024-01-01T00:00:00Z',
|
createdAt: '2024-01-01T00:00:00Z',
|
||||||
updatedAt: '2024-01-01T00:00:00Z',
|
updatedAt: '2024-01-01T00:00:00Z',
|
||||||
@@ -18,6 +19,7 @@ const mockProviders: Provider[] = [
|
|||||||
name: 'Anthropic',
|
name: 'Anthropic',
|
||||||
apiKey: 'sk-ant-test',
|
apiKey: 'sk-ant-test',
|
||||||
baseUrl: 'https://api.anthropic.com',
|
baseUrl: 'https://api.anthropic.com',
|
||||||
|
protocol: 'anthropic',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
createdAt: '2024-01-02T00:00:00Z',
|
createdAt: '2024-01-02T00:00:00Z',
|
||||||
updatedAt: '2024-01-02T00:00:00Z',
|
updatedAt: '2024-01-02T00:00:00Z',
|
||||||
@@ -26,14 +28,14 @@ const mockProviders: Provider[] = [
|
|||||||
|
|
||||||
const mockStats: UsageStats[] = [
|
const mockStats: UsageStats[] = [
|
||||||
{
|
{
|
||||||
id: '1',
|
id: 1,
|
||||||
providerId: 'openai',
|
providerId: 'openai',
|
||||||
modelName: 'gpt-4o',
|
modelName: 'gpt-4o',
|
||||||
requestCount: 100,
|
requestCount: 100,
|
||||||
date: '2024-01-15',
|
date: '2024-01-15',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '2',
|
id: 2,
|
||||||
providerId: 'anthropic',
|
providerId: 'anthropic',
|
||||||
modelName: 'claude-3-opus',
|
modelName: 'claude-3-opus',
|
||||||
requestCount: 50,
|
requestCount: 50,
|
||||||
|
|||||||
@@ -16,21 +16,21 @@ vi.mock('recharts', () => ({
|
|||||||
|
|
||||||
const mockStats: UsageStats[] = [
|
const mockStats: UsageStats[] = [
|
||||||
{
|
{
|
||||||
id: '1',
|
id: 1,
|
||||||
providerId: 'openai',
|
providerId: 'openai',
|
||||||
modelName: 'gpt-4o',
|
modelName: 'gpt-4o',
|
||||||
requestCount: 100,
|
requestCount: 100,
|
||||||
date: '2024-01-01',
|
date: '2024-01-01',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '2',
|
id: 2,
|
||||||
providerId: 'openai',
|
providerId: 'openai',
|
||||||
modelName: 'gpt-3.5-turbo',
|
modelName: 'gpt-3.5-turbo',
|
||||||
requestCount: 200,
|
requestCount: 200,
|
||||||
date: '2024-01-01',
|
date: '2024-01-01',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '3',
|
id: 3,
|
||||||
providerId: 'anthropic',
|
providerId: 'anthropic',
|
||||||
modelName: 'claude-3',
|
modelName: 'claude-3',
|
||||||
requestCount: 150,
|
requestCount: 150,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const mockModels: Model[] = [
|
|||||||
modelName: 'gpt-4o',
|
modelName: 'gpt-4o',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
createdAt: '2026-01-01T00:00:00Z',
|
createdAt: '2026-01-01T00:00:00Z',
|
||||||
|
unifiedId: 'gpt-4o',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'model-2',
|
id: 'model-2',
|
||||||
@@ -30,6 +31,7 @@ const mockModels: Model[] = [
|
|||||||
modelName: 'gpt-4o-mini',
|
modelName: 'gpt-4o-mini',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
createdAt: '2026-01-02T00:00:00Z',
|
createdAt: '2026-01-02T00:00:00Z',
|
||||||
|
unifiedId: 'gpt-4o-mini',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -40,6 +42,7 @@ const mockFilteredModels: Model[] = [
|
|||||||
modelName: 'claude-sonnet-4-5',
|
modelName: 'claude-sonnet-4-5',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
createdAt: '2026-02-01T00:00:00Z',
|
createdAt: '2026-02-01T00:00:00Z',
|
||||||
|
unifiedId: 'claude-sonnet-4-5',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -49,6 +52,7 @@ const mockCreatedModel: Model = {
|
|||||||
modelName: 'gpt-4.1',
|
modelName: 'gpt-4.1',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
createdAt: '2026-03-01T00:00:00Z',
|
createdAt: '2026-03-01T00:00:00Z',
|
||||||
|
unifiedId: 'gpt-4.1',
|
||||||
};
|
};
|
||||||
|
|
||||||
// MSW handlers
|
// MSW handlers
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const mockProviders: Provider[] = [
|
|||||||
name: 'OpenAI',
|
name: 'OpenAI',
|
||||||
apiKey: 'sk-xxx',
|
apiKey: 'sk-xxx',
|
||||||
baseUrl: 'https://api.openai.com',
|
baseUrl: 'https://api.openai.com',
|
||||||
|
protocol: 'openai',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
createdAt: '2026-01-01T00:00:00Z',
|
createdAt: '2026-01-01T00:00:00Z',
|
||||||
updatedAt: '2026-01-01T00:00:00Z',
|
updatedAt: '2026-01-01T00:00:00Z',
|
||||||
@@ -31,6 +32,7 @@ const mockProviders: Provider[] = [
|
|||||||
name: 'Anthropic',
|
name: 'Anthropic',
|
||||||
apiKey: 'sk-yyy',
|
apiKey: 'sk-yyy',
|
||||||
baseUrl: 'https://api.anthropic.com',
|
baseUrl: 'https://api.anthropic.com',
|
||||||
|
protocol: 'anthropic',
|
||||||
enabled: false,
|
enabled: false,
|
||||||
createdAt: '2026-02-01T00:00:00Z',
|
createdAt: '2026-02-01T00:00:00Z',
|
||||||
updatedAt: '2026-02-01T00:00:00Z',
|
updatedAt: '2026-02-01T00:00:00Z',
|
||||||
@@ -42,6 +44,7 @@ const mockCreatedProvider: Provider = {
|
|||||||
name: 'NewProvider',
|
name: 'NewProvider',
|
||||||
apiKey: 'sk-zzz',
|
apiKey: 'sk-zzz',
|
||||||
baseUrl: 'https://api.newprovider.com',
|
baseUrl: 'https://api.newprovider.com',
|
||||||
|
protocol: 'openai',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
createdAt: '2026-03-01T00:00:00Z',
|
createdAt: '2026-03-01T00:00:00Z',
|
||||||
updatedAt: '2026-03-01T00:00:00Z',
|
updatedAt: '2026-03-01T00:00:00Z',
|
||||||
@@ -135,6 +138,7 @@ describe('useCreateProvider', () => {
|
|||||||
name: 'NewProvider',
|
name: 'NewProvider',
|
||||||
apiKey: 'sk-zzz',
|
apiKey: 'sk-zzz',
|
||||||
baseUrl: 'https://api.newprovider.com',
|
baseUrl: 'https://api.newprovider.com',
|
||||||
|
protocol: 'openai',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -166,6 +170,7 @@ describe('useCreateProvider', () => {
|
|||||||
name: 'NewProvider',
|
name: 'NewProvider',
|
||||||
apiKey: 'sk-zzz',
|
apiKey: 'sk-zzz',
|
||||||
baseUrl: 'https://api.newprovider.com',
|
baseUrl: 'https://api.newprovider.com',
|
||||||
|
protocol: 'openai',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import '@testing-library/jest-dom/vitest';
|
import '@testing-library/jest-dom/vitest';
|
||||||
|
|
||||||
// Ensure jsdom environment is properly initialized
|
// Ensure happy-dom environment is properly initialized
|
||||||
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
||||||
throw new Error('jsdom environment not initialized. Check vitest config.');
|
throw new Error('happy-dom environment not initialized. Check vitest config.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Polyfill window.matchMedia for jsdom (required by TDesign)
|
// Polyfill window.matchMedia for jsdom (required by TDesign)
|
||||||
|
|||||||
@@ -51,15 +51,25 @@ export async function request<T>(
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
let message = `请求失败 (${response.status})`;
|
let message = `请求失败 (${response.status})`;
|
||||||
|
let code: string | undefined;
|
||||||
try {
|
try {
|
||||||
const errorData = await response.json();
|
const errorData = await response.json();
|
||||||
if (typeof errorData === 'object' && errorData !== null && 'message' in errorData) {
|
if (typeof errorData === 'object' && errorData !== null) {
|
||||||
message = (errorData as { message: string }).message;
|
// 提取结构化错误响应
|
||||||
|
if ('error' in errorData && typeof errorData.error === 'string') {
|
||||||
|
message = errorData.error;
|
||||||
|
} else if ('message' in errorData && typeof errorData.message === 'string') {
|
||||||
|
message = errorData.message;
|
||||||
|
}
|
||||||
|
// 提取错误码
|
||||||
|
if ('code' in errorData && typeof errorData.code === 'string') {
|
||||||
|
code = errorData.code;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore JSON parse error
|
// ignore JSON parse error
|
||||||
}
|
}
|
||||||
throw new ApiError(response.status, message);
|
throw new ApiError(response.status, message, code);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status === 204) {
|
if (response.status === 204) {
|
||||||
|
|||||||
@@ -1,8 +1,19 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { MessagePlugin } from 'tdesign-react';
|
import { MessagePlugin } from 'tdesign-react';
|
||||||
import type { CreateModelInput, UpdateModelInput } from '@/types';
|
import type { CreateModelInput, UpdateModelInput, ApiError } from '@/types';
|
||||||
import * as api from '@/api/models';
|
import * as api from '@/api/models';
|
||||||
|
|
||||||
|
const ERROR_MESSAGES: Record<string, string> = {
|
||||||
|
duplicate_model: '同一供应商下模型名称已存在',
|
||||||
|
invalid_provider_id: '供应商 ID 仅允许字母、数字、下划线,长度 1-64',
|
||||||
|
immutable_field: '供应商 ID 不允许修改',
|
||||||
|
provider_not_found: '供应商不存在',
|
||||||
|
};
|
||||||
|
|
||||||
|
function getErrorMessage(error: ApiError): string {
|
||||||
|
return error.code ? ERROR_MESSAGES[error.code] || error.message : error.message;
|
||||||
|
}
|
||||||
|
|
||||||
export const modelKeys = {
|
export const modelKeys = {
|
||||||
all: ['models'] as const,
|
all: ['models'] as const,
|
||||||
filtered: (providerId?: string) => ['models', providerId] as const,
|
filtered: (providerId?: string) => ['models', providerId] as const,
|
||||||
@@ -24,8 +35,8 @@ export function useCreateModel() {
|
|||||||
queryClient.invalidateQueries({ queryKey: modelKeys.all });
|
queryClient.invalidateQueries({ queryKey: modelKeys.all });
|
||||||
MessagePlugin.success('模型创建成功');
|
MessagePlugin.success('模型创建成功');
|
||||||
},
|
},
|
||||||
onError: (error: Error) => {
|
onError: (error: ApiError) => {
|
||||||
MessagePlugin.error(error.message);
|
MessagePlugin.error(getErrorMessage(error));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -40,8 +51,8 @@ export function useUpdateModel() {
|
|||||||
queryClient.invalidateQueries({ queryKey: modelKeys.all });
|
queryClient.invalidateQueries({ queryKey: modelKeys.all });
|
||||||
MessagePlugin.success('模型更新成功');
|
MessagePlugin.success('模型更新成功');
|
||||||
},
|
},
|
||||||
onError: (error: Error) => {
|
onError: (error: ApiError) => {
|
||||||
MessagePlugin.error(error.message);
|
MessagePlugin.error(getErrorMessage(error));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,19 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { MessagePlugin } from 'tdesign-react';
|
import { MessagePlugin } from 'tdesign-react';
|
||||||
import type { CreateProviderInput, UpdateProviderInput } from '@/types';
|
import type { CreateProviderInput, UpdateProviderInput, ApiError } from '@/types';
|
||||||
import * as api from '@/api/providers';
|
import * as api from '@/api/providers';
|
||||||
|
|
||||||
|
const ERROR_MESSAGES: Record<string, string> = {
|
||||||
|
duplicate_model: '同一供应商下模型名称已存在',
|
||||||
|
invalid_provider_id: '供应商 ID 仅允许字母、数字、下划线,长度 1-64',
|
||||||
|
immutable_field: '供应商 ID 不允许修改',
|
||||||
|
provider_not_found: '供应商不存在',
|
||||||
|
};
|
||||||
|
|
||||||
|
function getErrorMessage(error: ApiError): string {
|
||||||
|
return error.code ? ERROR_MESSAGES[error.code] || error.message : error.message;
|
||||||
|
}
|
||||||
|
|
||||||
export const providerKeys = {
|
export const providerKeys = {
|
||||||
all: ['providers'] as const,
|
all: ['providers'] as const,
|
||||||
};
|
};
|
||||||
@@ -23,8 +34,8 @@ export function useCreateProvider() {
|
|||||||
queryClient.invalidateQueries({ queryKey: providerKeys.all });
|
queryClient.invalidateQueries({ queryKey: providerKeys.all });
|
||||||
MessagePlugin.success('供应商创建成功');
|
MessagePlugin.success('供应商创建成功');
|
||||||
},
|
},
|
||||||
onError: (error: Error) => {
|
onError: (error: ApiError) => {
|
||||||
MessagePlugin.error(error.message);
|
MessagePlugin.error(getErrorMessage(error));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -39,8 +50,8 @@ export function useUpdateProvider() {
|
|||||||
queryClient.invalidateQueries({ queryKey: providerKeys.all });
|
queryClient.invalidateQueries({ queryKey: providerKeys.all });
|
||||||
MessagePlugin.success('供应商更新成功');
|
MessagePlugin.success('供应商更新成功');
|
||||||
},
|
},
|
||||||
onError: (error: Error) => {
|
onError: (error: ApiError) => {
|
||||||
MessagePlugin.error(error.message);
|
MessagePlugin.error(getErrorMessage(error));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import type { Provider, Model } from '@/types';
|
|||||||
import type { SubmitContext } from 'tdesign-react/es/form/type';
|
import type { SubmitContext } from 'tdesign-react/es/form/type';
|
||||||
|
|
||||||
interface ModelFormValues {
|
interface ModelFormValues {
|
||||||
id: string;
|
|
||||||
providerId: string;
|
providerId: string;
|
||||||
modelName: string;
|
modelName: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -38,7 +37,6 @@ export function ModelForm({
|
|||||||
if (model) {
|
if (model) {
|
||||||
// 编辑模式:设置现有值
|
// 编辑模式:设置现有值
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
id: model.id,
|
|
||||||
providerId: model.providerId,
|
providerId: model.providerId,
|
||||||
modelName: model.modelName,
|
modelName: model.modelName,
|
||||||
enabled: model.enabled,
|
enabled: model.enabled,
|
||||||
@@ -73,9 +71,14 @@ export function ModelForm({
|
|||||||
destroyOnClose
|
destroyOnClose
|
||||||
>
|
>
|
||||||
<Form form={form} layout="vertical" onSubmit={handleSubmit}>
|
<Form form={form} layout="vertical" onSubmit={handleSubmit}>
|
||||||
<Form.FormItem label="ID" name="id" rules={[{ required: true, message: '请输入模型 ID' }]}>
|
{isEdit && model?.unifiedId && (
|
||||||
<Input disabled={isEdit} placeholder="例如: gpt-4o" />
|
<Form.FormItem label="统一模型 ID">
|
||||||
</Form.FormItem>
|
<Input value={model.unifiedId} disabled />
|
||||||
|
<div style={{ color: '#999', fontSize: 12, marginTop: 4 }}>
|
||||||
|
格式:provider_id/model_name
|
||||||
|
</div>
|
||||||
|
</Form.FormItem>
|
||||||
|
)}
|
||||||
|
|
||||||
<Form.FormItem
|
<Form.FormItem
|
||||||
label="供应商"
|
label="供应商"
|
||||||
|
|||||||
@@ -14,6 +14,13 @@ export function ModelTable({ providerId, onAdd, onEdit }: ModelTableProps) {
|
|||||||
const deleteModel = useDeleteModel();
|
const deleteModel = useDeleteModel();
|
||||||
|
|
||||||
const columns: PrimaryTableCol<Model>[] = [
|
const columns: PrimaryTableCol<Model>[] = [
|
||||||
|
{
|
||||||
|
title: '统一模型 ID',
|
||||||
|
colKey: 'unifiedId',
|
||||||
|
width: 250,
|
||||||
|
ellipsis: true,
|
||||||
|
cell: ({ row }) => row.unifiedId || `${row.providerId}/${row.modelName}`,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '模型名称',
|
title: '模型名称',
|
||||||
colKey: 'modelName',
|
colKey: 'modelName',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { Dialog, Form, Input, Switch } from 'tdesign-react';
|
import { Dialog, Form, Input, Switch, Select } from 'tdesign-react';
|
||||||
import type { Provider } from '@/types';
|
import type { Provider } from '@/types';
|
||||||
import type { SubmitContext } from 'tdesign-react/es/form/type';
|
import type { SubmitContext } from 'tdesign-react/es/form/type';
|
||||||
|
|
||||||
@@ -8,6 +8,7 @@ interface ProviderFormValues {
|
|||||||
name: string;
|
name: string;
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
|
protocol: 'openai' | 'anthropic';
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,12 +40,13 @@ export function ProviderForm({
|
|||||||
name: provider.name,
|
name: provider.name,
|
||||||
apiKey: '',
|
apiKey: '',
|
||||||
baseUrl: provider.baseUrl,
|
baseUrl: provider.baseUrl,
|
||||||
|
protocol: provider.protocol,
|
||||||
enabled: provider.enabled,
|
enabled: provider.enabled,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// 新增模式:重置表单
|
// 新增模式:重置表单
|
||||||
form.reset();
|
form.reset();
|
||||||
form.setFieldsValue({ enabled: true });
|
form.setFieldsValue({ enabled: true, protocol: 'openai' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [open, provider]); // 移除form依赖,避免循环
|
}, [open, provider]); // 移除form依赖,避免循环
|
||||||
@@ -95,6 +97,13 @@ export function ProviderForm({
|
|||||||
<Input placeholder="例如: https://api.openai.com/v1" />
|
<Input placeholder="例如: https://api.openai.com/v1" />
|
||||||
</Form.FormItem>
|
</Form.FormItem>
|
||||||
|
|
||||||
|
<Form.FormItem label="协议" name="protocol" rules={[{ required: true, message: '请选择协议' }]}>
|
||||||
|
<Select>
|
||||||
|
<Select.Option value="openai">OpenAI</Select.Option>
|
||||||
|
<Select.Option value="anthropic">Anthropic</Select.Option>
|
||||||
|
</Select>
|
||||||
|
</Form.FormItem>
|
||||||
|
|
||||||
<Form.FormItem label="启用" name="enabled">
|
<Form.FormItem label="启用" name="enabled">
|
||||||
<Switch />
|
<Switch />
|
||||||
</Form.FormItem>
|
</Form.FormItem>
|
||||||
|
|||||||
@@ -40,6 +40,16 @@ export function ProviderTable({
|
|||||||
colKey: 'baseUrl',
|
colKey: 'baseUrl',
|
||||||
ellipsis: true,
|
ellipsis: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: '协议',
|
||||||
|
colKey: 'protocol',
|
||||||
|
width: 100,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Tag theme={row.protocol === 'openai' ? 'primary' : 'success'}>
|
||||||
|
{row.protocol === 'openai' ? 'OpenAI' : 'Anthropic'}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'API Key',
|
title: 'API Key',
|
||||||
colKey: 'apiKey',
|
colKey: 'apiKey',
|
||||||
|
|||||||
@@ -47,6 +47,11 @@ export function StatsTable({
|
|||||||
colKey: 'modelName',
|
colKey: 'modelName',
|
||||||
width: 250,
|
width: 250,
|
||||||
ellipsis: true,
|
ellipsis: true,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
// 如果后端返回统一 ID 格式(包含 /),直接显示
|
||||||
|
// 否则显示原始 model_name
|
||||||
|
return row.modelName;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '日期',
|
title: '日期',
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export interface Provider {
|
|||||||
name: string;
|
name: string;
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
|
protocol: 'openai' | 'anthropic';
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
@@ -14,6 +15,7 @@ export interface Model {
|
|||||||
modelName: string;
|
modelName: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
unifiedId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UsageStats {
|
export interface UsageStats {
|
||||||
@@ -29,6 +31,7 @@ export interface CreateProviderInput {
|
|||||||
name: string;
|
name: string;
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
|
protocol: 'openai' | 'anthropic';
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,11 +39,11 @@ export interface UpdateProviderInput {
|
|||||||
name?: string;
|
name?: string;
|
||||||
apiKey?: string;
|
apiKey?: string;
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
|
protocol?: 'openai' | 'anthropic';
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateModelInput {
|
export interface CreateModelInput {
|
||||||
id: string;
|
|
||||||
providerId: string;
|
providerId: string;
|
||||||
modelName: string;
|
modelName: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -61,13 +64,21 @@ export interface StatsQueryParams {
|
|||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
status: number;
|
status: number;
|
||||||
|
code?: string;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
status: number,
|
status: number,
|
||||||
message: string,
|
message: string,
|
||||||
|
code?: string,
|
||||||
) {
|
) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = 'ApiError';
|
this.name = 'ApiError';
|
||||||
this.status = status;
|
this.status = status;
|
||||||
|
this.code = code;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ApiErrorResponse {
|
||||||
|
error: string;
|
||||||
|
code?: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,6 +23,48 @@
|
|||||||
- **THEN** `error` 字段 SHALL 包含人类可读的错误描述
|
- **THEN** `error` 字段 SHALL 包含人类可读的错误描述
|
||||||
- **THEN** `code` 字段 SHALL 包含机器可读的错误代码(可选)
|
- **THEN** `code` 字段 SHALL 包含机器可读的错误代码(可选)
|
||||||
|
|
||||||
|
### Requirement: 前端提取并处理错误码
|
||||||
|
|
||||||
|
前端 SHALL 提取后端结构化错误响应中的错误码并用于错误处理。
|
||||||
|
|
||||||
|
#### Scenario: API 客户端解析结构化错误
|
||||||
|
|
||||||
|
- **WHEN** 后端返回错误响应
|
||||||
|
- **THEN** API 客户端 SHALL 尝试解析 JSON 格式 `{error: string, code?: string}`
|
||||||
|
- **THEN** 如解析成功且包含 code 字段,SHALL 创建包含 code 的 ApiError
|
||||||
|
- **THEN** 如解析失败或不包含 code,SHALL 创建不包含 code 的 ApiError
|
||||||
|
|
||||||
|
#### Scenario: ApiError 包含错误码
|
||||||
|
|
||||||
|
- **WHEN** 创建 ApiError 对象
|
||||||
|
- **THEN** ApiError 类 SHALL 包含可选的 code 字段
|
||||||
|
- **THEN** code 字段类型 SHALL 为 `string | undefined`
|
||||||
|
- **THEN** 构造函数 SHALL 接受可选的 code 参数
|
||||||
|
|
||||||
|
#### Scenario: Hooks 使用错误码映射友好消息
|
||||||
|
|
||||||
|
- **WHEN** useMutation 或其他 Hook 处理错误
|
||||||
|
- **THEN** SHALL 检查 error.code 是否存在
|
||||||
|
- **THEN** 如存在,SHALL 使用映射表转换为友好中文消息
|
||||||
|
- **THEN** 如不存在或未定义映射,SHALL 使用 error.message
|
||||||
|
|
||||||
|
#### Scenario: 错误码映射表定义
|
||||||
|
|
||||||
|
- **WHEN** 定义错误码映射表
|
||||||
|
- **THEN** 映射表 SHALL 包含以下键值对:
|
||||||
|
- `duplicate_model` → "同一供应商下模型名称已存在"
|
||||||
|
- `invalid_provider_id` → "供应商 ID 仅允许字母、数字、下划线,长度 1-64"
|
||||||
|
- `immutable_field` → "供应商 ID 不允许修改"
|
||||||
|
- `provider_not_found` → "供应商不存在"
|
||||||
|
- **THEN** 映射表 SHALL 使用 TypeScript Record 类型确保类型安全
|
||||||
|
|
||||||
|
#### Scenario: 错误码映射降级处理
|
||||||
|
|
||||||
|
- **WHEN** 后端返回新的错误码(映射表未定义)
|
||||||
|
- **THEN** 前端 SHALL 降级使用 error.message
|
||||||
|
- **THEN** 前端 SHALL NOT 抛出错误或崩溃
|
||||||
|
- **THEN** 用户 SHALL 仍能看到原始错误消息
|
||||||
|
|
||||||
### Requirement: provider_id 校验错误
|
### Requirement: provider_id 校验错误
|
||||||
|
|
||||||
系统 SHALL 对 provider_id 校验错误返回明确的错误信息。
|
系统 SHALL 对 provider_id 校验错误返回明确的错误信息。
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
|
|||||||
- **THEN** 前端 SHALL 显示 TDesign Dialog + Form
|
- **THEN** 前端 SHALL 显示 TDesign Dialog + Form
|
||||||
- **THEN** provider_id SHALL 自动关联当前供应商
|
- **THEN** provider_id SHALL 自动关联当前供应商
|
||||||
- **THEN** 供应商选择 SHALL 使用 `options` 属性
|
- **THEN** 供应商选择 SHALL 使用 `options` 属性
|
||||||
|
- **THEN** 创建表单 SHALL NOT 包含 ID 输入框(后端自动生成 UUID)
|
||||||
- **WHEN** 用户提交表单
|
- **WHEN** 用户提交表单
|
||||||
- **THEN** 前端 SHALL 通过 useMutation 调用创建 API
|
- **THEN** 前端 SHALL 通过 useMutation 调用创建 API
|
||||||
- **THEN** 成功后 SHALL 刷新模型列表
|
- **THEN** 成功后 SHALL 刷新模型列表
|
||||||
@@ -88,6 +89,8 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
|
|||||||
|
|
||||||
- **WHEN** 用户点击模型的"编辑"
|
- **WHEN** 用户点击模型的"编辑"
|
||||||
- **THEN** 前端 SHALL 显示编辑表单
|
- **THEN** 前端 SHALL 显示编辑表单
|
||||||
|
- **THEN** 编辑表单 SHALL 显示统一模型 ID(只读)
|
||||||
|
- **THEN** ID 字段 SHALL 为禁用状态
|
||||||
- **WHEN** 用户提交表单
|
- **WHEN** 用户提交表单
|
||||||
- **THEN** 前端 SHALL 通过 useMutation 调用更新 API
|
- **THEN** 前端 SHALL 通过 useMutation 调用更新 API
|
||||||
- **THEN** 成功后 SHALL 刷新模型列表
|
- **THEN** 成功后 SHALL 刷新模型列表
|
||||||
@@ -100,6 +103,71 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
|
|||||||
- **THEN** 前端 SHALL 通过 useMutation 调用删除 API
|
- **THEN** 前端 SHALL 通过 useMutation 调用删除 API
|
||||||
- **THEN** 成功后 SHALL 刷新模型列表
|
- **THEN** 成功后 SHALL 刷新模型列表
|
||||||
|
|
||||||
|
### Requirement: 显示协议字段
|
||||||
|
|
||||||
|
前端 SHALL 在供应商管理界面显示协议字段。
|
||||||
|
|
||||||
|
#### Scenario: 供应商表格显示协议列
|
||||||
|
|
||||||
|
- **WHEN** 渲染供应商表格
|
||||||
|
- **THEN** 表格 SHALL 包含协议列
|
||||||
|
- **THEN** 协议列 SHALL 显示 "OpenAI" 或 "Anthropic" 标签
|
||||||
|
- **THEN** OpenAI 协议 SHALL 使用主题色标签
|
||||||
|
- **THEN** Anthropic 协议 SHALL 使用成功色标签
|
||||||
|
|
||||||
|
#### Scenario: 供应商表单选择协议
|
||||||
|
|
||||||
|
- **WHEN** 创建或编辑供应商
|
||||||
|
- **THEN** 表单 SHALL 包含协议选择下拉框
|
||||||
|
- **THEN** 下拉框 SHALL 提供 "OpenAI" 和 "Anthropic" 选项
|
||||||
|
- **THEN** 协议字段 SHALL 为必填项
|
||||||
|
|
||||||
|
### Requirement: 显示统一模型 ID
|
||||||
|
|
||||||
|
前端 SHALL 在所有显示模型的地方使用统一模型 ID。
|
||||||
|
|
||||||
|
#### Scenario: 模型表格显示统一 ID 列
|
||||||
|
|
||||||
|
- **WHEN** 渲染模型表格
|
||||||
|
- **THEN** 表格 SHALL 包含统一模型 ID 列
|
||||||
|
- **THEN** 统一模型 ID 列 SHALL 显示 `provider_id/model_name` 格式
|
||||||
|
- **THEN** 统一模型 ID 列 SHALL 启用 ellipsis(超长文本显示省略号,hover 显示 Tooltip)
|
||||||
|
- **THEN** 统一模型 ID 列 SHALL 固定宽度 250px
|
||||||
|
|
||||||
|
#### Scenario: 编辑模型显示统一 ID
|
||||||
|
|
||||||
|
- **WHEN** 编辑模型表单
|
||||||
|
- **THEN** 表单 SHALL 显示统一模型 ID 字段
|
||||||
|
- **THEN** 统一模型 ID 字段 SHALL 为只读(disabled)
|
||||||
|
- **THEN** 统一模型 ID 字段 SHALL 显示格式说明 "格式:provider_id/model_name"
|
||||||
|
|
||||||
|
#### Scenario: 统一模型 ID 降级显示
|
||||||
|
|
||||||
|
- **WHEN** 后端未返回 unified_id 字段
|
||||||
|
- **THEN** 前端 SHALL 拼接 providerId 和 modelName 显示
|
||||||
|
- **THEN** 拼接格式 SHALL 为 `{providerId}/{modelName}`
|
||||||
|
|
||||||
|
### Requirement: 提取并映射错误码
|
||||||
|
|
||||||
|
前端 SHALL 提取后端结构化错误响应中的错误码并映射为友好消息。
|
||||||
|
|
||||||
|
#### Scenario: API 客户端提取错误码
|
||||||
|
|
||||||
|
- **WHEN** 后端返回结构化错误响应 `{error: string, code: string}`
|
||||||
|
- **THEN** API 客户端 SHALL 提取 code 字段
|
||||||
|
- **THEN** ApiError 对象 SHALL 包含 code 字段
|
||||||
|
- **THEN** code 字段 SHALL 为可选(兼容旧错误格式)
|
||||||
|
|
||||||
|
#### Scenario: Hooks 映射错误码为中文消息
|
||||||
|
|
||||||
|
- **WHEN** 处理 API 错误
|
||||||
|
- **THEN** Hooks SHALL 使用错误码映射表
|
||||||
|
- **THEN** 映射表 SHALL 包含以下错误码:
|
||||||
|
- `duplicate_model` → "同一供应商下模型名称已存在"
|
||||||
|
- `invalid_provider_id` → "供应商 ID 仅允许字母、数字、下划线,长度 1-64"
|
||||||
|
- `immutable_field` → "供应商 ID 不允许修改"
|
||||||
|
- **THEN** 未定义的错误码 SHALL 降级使用原始错误消息
|
||||||
|
|
||||||
### Requirement: 提供统计查看页面
|
### Requirement: 提供统计查看页面
|
||||||
|
|
||||||
前端 SHALL 使用 TDesign 组件提供统计仪表盘页面。
|
前端 SHALL 使用 TDesign 组件提供统计仪表盘页面。
|
||||||
|
|||||||
@@ -4,6 +4,28 @@
|
|||||||
|
|
||||||
管理模型的增删改查,通过 handler → service → repository 分层实现业务逻辑和数据访问,支持供应商关联验证。
|
管理模型的增删改查,通过 handler → service → repository 分层实现业务逻辑和数据访问,支持供应商关联验证。
|
||||||
|
|
||||||
|
### Requirement: 前端适配统一模型 ID 显示
|
||||||
|
|
||||||
|
前端 SHALL 在模型管理界面显示统一模型 ID。
|
||||||
|
|
||||||
|
#### Scenario: 模型列表返回统一 ID
|
||||||
|
|
||||||
|
- **WHEN** 向 `/api/models` 发送 GET 请求
|
||||||
|
- **THEN** 每个模型 SHALL 包含 unified_id 字段
|
||||||
|
- **THEN** unified_id 格式 SHALL 为 `{provider_id}/{model_name}`
|
||||||
|
|
||||||
|
#### Scenario: 创建模型返回统一 ID
|
||||||
|
|
||||||
|
- **WHEN** 向 `/api/models` 发送 POST 请求创建模型
|
||||||
|
- **THEN** 返回的模型 SHALL 包含 unified_id 字段
|
||||||
|
- **THEN** unified_id SHALL 由后端根据 provider_id 和 model_name 生成
|
||||||
|
|
||||||
|
#### Scenario: 更新模型返回统一 ID
|
||||||
|
|
||||||
|
- **WHEN** 向 `/api/models/:id` 发送 PUT 请求更新模型
|
||||||
|
- **THEN** 返回的模型 SHALL 包含更新后的 unified_id
|
||||||
|
- **THEN** unified_id SHALL 反映最新的 provider_id 和 model_name 组合
|
||||||
|
|
||||||
### Requirement: 创建模型配置
|
### Requirement: 创建模型配置
|
||||||
|
|
||||||
网关 SHALL 允许为供应商创建新的模型配置。
|
网关 SHALL 允许为供应商创建新的模型配置。
|
||||||
@@ -16,6 +38,7 @@
|
|||||||
- **THEN** 网关 SHALL 返回创建的模型,状态码为 201
|
- **THEN** 网关 SHALL 返回创建的模型,状态码为 201
|
||||||
- **THEN** 模型 SHALL 默认启用
|
- **THEN** 模型 SHALL 默认启用
|
||||||
- **THEN** 返回的模型 SHALL 包含 `unified_id` 字段,值为 `{provider_id}/{model_name}`
|
- **THEN** 返回的模型 SHALL 包含 `unified_id` 字段,值为 `{provider_id}/{model_name}`
|
||||||
|
- **THEN** 前端 SHALL NOT 在请求体中发送 id 字段
|
||||||
|
|
||||||
#### Scenario: 使用不存在的供应商创建模型
|
#### Scenario: 使用不存在的供应商创建模型
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,29 @@
|
|||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: 前端适配协议字段
|
||||||
|
|
||||||
|
前端 SHALL 在供应商管理界面支持协议字段的显示和选择。
|
||||||
|
|
||||||
|
#### Scenario: 供应商列表返回协议字段
|
||||||
|
|
||||||
|
- **WHEN** 向 `/api/providers` 发送 GET 请求
|
||||||
|
- **THEN** 每个供应商 SHALL 包含 protocol 字段
|
||||||
|
- **THEN** protocol 值 SHALL 为 "openai" 或 "anthropic"
|
||||||
|
|
||||||
|
#### Scenario: 创建供应商携带协议字段
|
||||||
|
|
||||||
|
- **WHEN** 向 `/api/providers` 发送 POST 请求
|
||||||
|
- **THEN** 请求体 SHALL 包含 protocol 字段
|
||||||
|
- **THEN** protocol 值 SHALL 为 "openai" 或 "anthropic"
|
||||||
|
- **THEN** 前端 SHALL 提供协议选择下拉框
|
||||||
|
|
||||||
|
#### Scenario: 更新供应商携带协议字段
|
||||||
|
|
||||||
|
- **WHEN** 向 `/api/providers/:id` 发送 PUT 请求
|
||||||
|
- **THEN** 请求体 MAY 包含 protocol 字段
|
||||||
|
- **THEN** protocol 值 SHALL 为 "openai" 或 "anthropic"
|
||||||
|
|
||||||
### Requirement: 创建供应商配置
|
### Requirement: 创建供应商配置
|
||||||
|
|
||||||
网关 SHALL 允许通过管理 API 创建新的供应商配置。
|
网关 SHALL 允许通过管理 API 创建新的供应商配置。
|
||||||
@@ -16,7 +39,7 @@
|
|||||||
- **THEN** 网关 SHALL 在数据库中创建新的供应商记录
|
- **THEN** 网关 SHALL 在数据库中创建新的供应商记录
|
||||||
- **THEN** 网关 SHALL 返回创建的供应商,状态码为 201
|
- **THEN** 网关 SHALL 返回创建的供应商,状态码为 201
|
||||||
- **THEN** 供应商 SHALL 默认启用
|
- **THEN** 供应商 SHALL 默认启用
|
||||||
- **THEN** protocol 字段 SHALL 默认为 "openai"
|
- **THEN** protocol 字段 SHALL 为必填项,值为 "openai" 或 "anthropic"
|
||||||
|
|
||||||
#### Scenario: 使用重复 ID 创建供应商
|
#### Scenario: 使用重复 ID 创建供应商
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user