1
0

feat: 前端集成 Prettier 代码格式化

This commit is contained in:
2026-04-24 13:40:53 +08:00
parent 52007c9461
commit 365943e4c4
61 changed files with 1968 additions and 1698 deletions

12
frontend/.editorconfig Normal file
View File

@@ -0,0 +1,12 @@
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false

1
frontend/.gitignore vendored
View File

@@ -15,6 +15,7 @@ dist-ssr
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*
!.vscode/extensions.json !.vscode/extensions.json
!.vscode/settings.json
.idea .idea
.DS_Store .DS_Store
*.suo *.suo

16
frontend/.prettierignore Normal file
View File

@@ -0,0 +1,16 @@
node_modules
dist
dist-ssr
bun.lock
package-lock.json
yarn.lock
pnpm-lock.yaml
.env.*
*.local
coverage
**/*.snap
**/__snapshots__/**
*.svg
*.min.js
*.min.css
openspec/changes/archive/

16
frontend/.prettierrc Normal file
View File

@@ -0,0 +1,16 @@
{
"semi": false,
"singleQuote": true,
"jsxSingleQuote": true,
"tabWidth": 2,
"useTabs": false,
"trailingComma": "es5",
"printWidth": 120,
"bracketSpacing": true,
"arrowParens": "always",
"endOfLine": "lf",
"proseWrap": "preserve",
"htmlWhitespaceSensitivity": "css",
"embeddedLanguageFormatting": "auto",
"singleAttributePerLine": false
}

3
frontend/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["esbenp.prettier-vscode", "dbaeumer.vscode-eslint"]
}

7
frontend/.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,7 @@
{
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
}
}

View File

@@ -13,6 +13,7 @@ AI 网关管理前端,提供供应商配置和用量统计界面。
- **数据获取**: TanStack Query v5 - **数据获取**: TanStack Query v5
- **样式**: SCSS Modules禁止使用纯 CSS - **样式**: SCSS Modules禁止使用纯 CSS
- **测试**: Vitest + React Testing Library + Playwright - **测试**: Vitest + React Testing Library + Playwright
- **代码格式化**: Prettier
## API 层 ## API 层
@@ -22,10 +23,10 @@ AI 网关管理前端,提供供应商配置和用量统计界面。
```typescript ```typescript
// 发送请求时camelCase → snake_case // 发送请求时camelCase → snake_case
toApi({ providerId: "openai" }) // → { provider_id: "openai" } toApi({ providerId: 'openai' }) // → { provider_id: "openai" }
// 接收响应时snake_case → camelCase // 接收响应时snake_case → camelCase
fromApi({ provider_id: "openai" }) // → { providerId: "openai" } fromApi({ provider_id: 'openai' }) // → { providerId: "openai" }
``` ```
### 统一请求函数 ### 统一请求函数
@@ -42,9 +43,9 @@ export async function request<T>(method: string, path: string, body?: unknown):
```typescript ```typescript
class ApiError extends Error { class ApiError extends Error {
status: number; // HTTP 状态码 status: number // HTTP 状态码
code?: string; // 业务错误码 code?: string // 业务错误码
message: string; // 错误消息 message: string // 错误消息
} }
``` ```
@@ -56,13 +57,13 @@ class ApiError extends Error {
// src/hooks/useProviders.ts // src/hooks/useProviders.ts
export const providerKeys = { export const providerKeys = {
all: ['providers'] as const, all: ['providers'] as const,
}; }
// src/hooks/useModels.ts // src/hooks/useModels.ts
export const modelKeys = { export const modelKeys = {
all: ['models'] as const, all: ['models'] as const,
byProvider: (providerId: string) => [...modelKeys.all, { providerId }] as const, byProvider: (providerId: string) => [...modelKeys.all, { providerId }] as const,
}; }
``` ```
### Mutation 使用 ### Mutation 使用
@@ -71,9 +72,9 @@ export const modelKeys = {
const mutation = useMutation({ const mutation = useMutation({
mutationFn: createProvider, mutationFn: createProvider,
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: providerKeys.all }); queryClient.invalidateQueries({ queryKey: providerKeys.all })
}, },
}); })
``` ```
## 项目结构 ## 项目结构
@@ -142,9 +143,20 @@ bun run build
### 代码检查 ### 代码检查
```bash ```bash
bun run lint bun run lint # ESLint 检查
bun run format:check # Prettier 格式检查
bun run check # 同时检查 lint 和格式
``` ```
### 代码格式化
```bash
bun run format # 格式化所有文件
bun run fix # 修复 lint 问题并格式化
```
VS Code 保存时自动格式化(需安装 Prettier 扩展)。
## 测试 ## 测试
### 单元测试 + 组件测试 ### 单元测试 + 组件测试
@@ -220,25 +232,29 @@ __tests__/
## 环境变量 ## 环境变量
| 变量 | 开发环境 | 生产环境 | 说明 | | 变量 | 开发环境 | 生产环境 | 说明 |
|------|----------|----------|------| | --------------- | -------- | -------- | ------------------------------- |
| `VITE_API_BASE` | (空) | `/api` | API 基础路径,空则走 Vite proxy | | `VITE_API_BASE` | (空) | `/api` | API 基础路径,空则走 Vite proxy |
**E2E 测试特有** **E2E 测试特有**
- `NEX_BACKEND_PORT` - E2E 后端端口(默认 19026 - `NEX_BACKEND_PORT` - E2E 后端端口(默认 19026
- `NEX_E2E_TEMP_DIR` - E2E 临时目录 - `NEX_E2E_TEMP_DIR` - E2E 临时目录
## 开发规范 ## 开发规范
- 所有样式使用 SCSS禁止使用纯 CSS 文件 - 所有样式使用 SCSS禁止使用纯 CSS 文件
- 组件级样式使用 SCSS Modules*.module.scss - 组件级样式使用 SCSS Modules\*.module.scss
- 图标优先使用 TDesign 图标tdesign-icons-react - 图标优先使用 TDesign 图标tdesign-icons-react
- TypeScript strict 模式,禁止 any 类型 - TypeScript strict 模式,禁止 any 类型
- API 层自动处理 snake_case ↔ camelCase 字段转换 - API 层自动处理 snake_case ↔ camelCase 字段转换
- 使用路径别名 `@/` 引用 src 目录 - 使用路径别名 `@/` 引用 src 目录
- 代码格式化使用 Prettier配置见 `.prettierrc`
- 编辑器配置见 `.editorconfig`(统一缩进、换行符、编码)
### 环境要求 ### 环境要求
- Bun 1.0 或更高版本 - Bun 1.0 或更高版本
- VS Code 推荐安装 Prettier 和 ESLint 扩展(见 `.vscode/extensions.json`
### 添加新页面 ### 添加新页面

View File

@@ -29,6 +29,7 @@
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "^3.2.1", "@vitest/coverage-v8": "^3.2.1",
"eslint": "^9.39.4", "eslint": "^9.39.4",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-import": "^2.31.0", "eslint-plugin-import": "^2.31.0",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.5.2",
@@ -37,6 +38,7 @@
"javascript-obfuscator": "^5.4.1", "javascript-obfuscator": "^5.4.1",
"jsdom": "^26.1.0", "jsdom": "^26.1.0",
"msw": "^2.8.2", "msw": "^2.8.2",
"prettier": "^3.8.3",
"sass": "^1.99.0", "sass": "^1.99.0",
"sql.js": "^1.14.1", "sql.js": "^1.14.1",
"typescript": "~6.0.2", "typescript": "~6.0.2",
@@ -688,6 +690,8 @@
"eslint": ["eslint@9.39.4", "https://registry.npmmirror.com/eslint/-/eslint-9.39.4.tgz", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.5", "@eslint/js": "9.39.4", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ=="], "eslint": ["eslint@9.39.4", "https://registry.npmmirror.com/eslint/-/eslint-9.39.4.tgz", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.5", "@eslint/js": "9.39.4", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ=="],
"eslint-config-prettier": ["eslint-config-prettier@10.1.8", "https://registry.npmmirror.com/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", { "peerDependencies": { "eslint": ">=7.0.0" }, "bin": { "eslint-config-prettier": "bin/cli.js" } }, "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w=="],
"eslint-import-resolver-node": ["eslint-import-resolver-node@0.3.10", "https://registry.npmmirror.com/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz", { "dependencies": { "debug": "^3.2.7", "is-core-module": "^2.16.1", "resolve": "^2.0.0-next.6" } }, "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ=="], "eslint-import-resolver-node": ["eslint-import-resolver-node@0.3.10", "https://registry.npmmirror.com/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz", { "dependencies": { "debug": "^3.2.7", "is-core-module": "^2.16.1", "resolve": "^2.0.0-next.6" } }, "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ=="],
"eslint-module-utils": ["eslint-module-utils@2.12.1", "https://registry.npmmirror.com/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", { "dependencies": { "debug": "^3.2.7" } }, "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw=="], "eslint-module-utils": ["eslint-module-utils@2.12.1", "https://registry.npmmirror.com/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", { "dependencies": { "debug": "^3.2.7" } }, "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw=="],
@@ -1080,6 +1084,8 @@
"prelude-ls": ["prelude-ls@1.2.1", "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.2.1.tgz", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], "prelude-ls": ["prelude-ls@1.2.1", "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.2.1.tgz", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
"prettier": ["prettier@3.8.3", "https://registry.npmmirror.com/prettier/-/prettier-3.8.3.tgz", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="],
"pretty-format": ["pretty-format@27.5.1", "https://registry.npmmirror.com/pretty-format/-/pretty-format-27.5.1.tgz", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], "pretty-format": ["pretty-format@27.5.1", "https://registry.npmmirror.com/pretty-format/-/pretty-format-27.5.1.tgz", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="],
"process": ["process@0.11.10", "https://registry.npmmirror.com/process/-/process-0.11.10.tgz", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], "process": ["process@0.11.10", "https://registry.npmmirror.com/process/-/process-0.11.10.tgz", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="],

View File

@@ -27,9 +27,7 @@ export interface SeedStatsInput {
date: string date: string
} }
export async function clearDatabase( export async function clearDatabase(request: import('@playwright/test').APIRequestContext) {
request: import('@playwright/test').APIRequestContext,
) {
const providers = await request.get(`${API_BASE}/api/providers`) const providers = await request.get(`${API_BASE}/api/providers`)
if (providers.ok()) { if (providers.ok()) {
const data = await providers.json() const data = await providers.json()
@@ -39,10 +37,7 @@ export async function clearDatabase(
} }
} }
export async function seedProvider( export async function seedProvider(request: import('@playwright/test').APIRequestContext, data: SeedProviderInput) {
request: import('@playwright/test').APIRequestContext,
data: SeedProviderInput,
) {
const resp = await request.post(`${API_BASE}/api/providers`, { const resp = await request.post(`${API_BASE}/api/providers`, {
data: { data: {
id: data.id, id: data.id,
@@ -59,10 +54,7 @@ export async function seedProvider(
return resp.json() return resp.json()
} }
export async function seedModel( export async function seedModel(request: import('@playwright/test').APIRequestContext, data: SeedModelInput) {
request: import('@playwright/test').APIRequestContext,
data: SeedModelInput,
) {
const resp = await request.post(`${API_BASE}/api/models`, { const resp = await request.post(`${API_BASE}/api/models`, {
data: { data: {
provider_id: data.providerId, provider_id: data.providerId,
@@ -90,10 +82,12 @@ export async function seedUsageStats(statsData: SeedStatsInput[]) {
const db = new SQL.Database(buf) const db = new SQL.Database(buf)
for (const row of statsData) { for (const row of statsData) {
db.run( db.run('INSERT OR REPLACE INTO usage_stats (provider_id, model_name, request_count, date) VALUES (?, ?, ?, ?)', [
'INSERT OR REPLACE INTO usage_stats (provider_id, model_name, request_count, date) VALUES (?, ?, ?, ?)', row.providerId,
[row.providerId, row.modelName, row.requestCount, row.date], row.modelName,
) row.requestCount,
row.date,
])
} }
const data = db.export() const data = db.export()

View File

@@ -47,7 +47,10 @@ test.describe('模型管理', () => {
await page.locator('.t-table__expand-box').first().click() await page.locator('.t-table__expand-box').first().click()
await expect(page.locator('.t-table__expanded-row').first()).toBeVisible() await expect(page.locator('.t-table__expanded-row').first()).toBeVisible()
await page.locator('.t-dialog:visible').waitFor({ state: 'hidden', timeout: 3000 }).catch(() => {}) await page
.locator('.t-dialog:visible')
.waitFor({ state: 'hidden', timeout: 3000 })
.catch(() => {})
await page.locator('.t-table__expanded-row button:has-text("添加模型")').first().click() await page.locator('.t-table__expanded-row button:has-text("添加模型")').first().click()
await expect(page.locator('.t-dialog:visible')).toBeVisible() await expect(page.locator('.t-dialog:visible')).toBeVisible()
@@ -55,10 +58,14 @@ test.describe('模型管理', () => {
const inputs = modelFormInputs(page) const inputs = modelFormInputs(page)
await inputs.modelName.fill('gpt_4_turbo') await inputs.modelName.fill('gpt_4_turbo')
const responsePromise = page.waitForResponse(resp => resp.url().includes('/api/models') && resp.request().method() === 'POST') const responsePromise = page.waitForResponse(
(resp) => resp.url().includes('/api/models') && resp.request().method() === 'POST'
)
await inputs.saveBtn.click() await inputs.saveBtn.click()
await responsePromise await responsePromise
await expect(page.locator('.t-table__expanded-row').getByText('gpt_4_turbo', { exact: true })).toBeVisible({ timeout: 5000 }) await expect(page.locator('.t-table__expanded-row').getByText('gpt_4_turbo', { exact: true })).toBeVisible({
timeout: 5000,
})
}) })
test('应显示统一模型 ID', async ({ page, request }) => { test('应显示统一模型 ID', async ({ page, request }) => {
@@ -101,10 +108,14 @@ test.describe('模型管理', () => {
await inputs.modelName.clear() await inputs.modelName.clear()
await inputs.modelName.fill('gpt_4o') await inputs.modelName.fill('gpt_4o')
const responsePromise = page.waitForResponse(resp => resp.url().includes('/api/models') && resp.request().method() === 'PUT') const responsePromise = page.waitForResponse(
(resp) => resp.url().includes('/api/models') && resp.request().method() === 'PUT'
)
await inputs.saveBtn.click() await inputs.saveBtn.click()
await responsePromise await responsePromise
await expect(page.locator('.t-table__expanded-row').getByText('gpt_4o', { exact: true })).toBeVisible({ timeout: 5000 }) await expect(page.locator('.t-table__expanded-row').getByText('gpt_4o', { exact: true })).toBeVisible({
timeout: 5000,
})
}) })
test('应能删除模型', async ({ page, request }) => { test('应能删除模型', async ({ page, request }) => {
@@ -126,6 +137,8 @@ test.describe('模型管理', () => {
await page.locator('.t-table__expanded-row button:has-text("删除")').first().click() await page.locator('.t-table__expanded-row button:has-text("删除")').first().click()
await expect(page.getByText(/确定要删除/)).toBeVisible() await expect(page.getByText(/确定要删除/)).toBeVisible()
await page.locator('.t-popconfirm').getByRole('button', { name: '确定' }).click() await page.locator('.t-popconfirm').getByRole('button', { name: '确定' }).click()
await expect(page.locator('.t-table__expanded-row').getByText('to_delete_model', { exact: true })).not.toBeVisible({ timeout: 5000 }) await expect(page.locator('.t-table__expanded-row').getByText('to_delete_model', { exact: true })).not.toBeVisible({
timeout: 5000,
})
}) })
}) })

View File

@@ -61,7 +61,9 @@ test.describe('供应商管理', () => {
await page.locator('.t-select__dropdown .t-select-option').first().click() await page.locator('.t-select__dropdown .t-select-option').first().click()
await page.waitForSelector('.t-select__dropdown', { state: 'hidden', timeout: 3000 }) await page.waitForSelector('.t-select__dropdown', { state: 'hidden', timeout: 3000 })
const responsePromise = page.waitForResponse(resp => resp.url().includes('/api/providers') && resp.request().method() === 'POST') const responsePromise = page.waitForResponse(
(resp) => resp.url().includes('/api/providers') && resp.request().method() === 'POST'
)
await inputs.saveBtn.click() await inputs.saveBtn.click()
await responsePromise await responsePromise
await expect(page.locator('.t-table__body').getByText('Before Edit')).toBeVisible({ timeout: 5000 }) await expect(page.locator('.t-table__body').getByText('Before Edit')).toBeVisible({ timeout: 5000 })
@@ -73,7 +75,9 @@ test.describe('供应商管理', () => {
await editInputs.name.clear() await editInputs.name.clear()
await editInputs.name.fill('After Edit') await editInputs.name.fill('After Edit')
const updateResponsePromise = page.waitForResponse(resp => resp.url().includes('/api/providers') && resp.request().method() === 'PUT') const updateResponsePromise = page.waitForResponse(
(resp) => resp.url().includes('/api/providers') && resp.request().method() === 'PUT'
)
await editInputs.saveBtn.click() await editInputs.saveBtn.click()
await updateResponsePromise await updateResponsePromise
await expect(page.locator('.t-table__body').getByText('After Edit')).toBeVisible({ timeout: 5000 }) await expect(page.locator('.t-table__body').getByText('After Edit')).toBeVisible({ timeout: 5000 })

View File

@@ -42,10 +42,7 @@ function isHardcodedColor(value) {
function extractStyleProperties(expression) { function extractStyleProperties(expression) {
const properties = [] const properties = []
if ( if (expression.type === 'ObjectExpression' && expression.properties) {
expression.type === 'ObjectExpression' &&
expression.properties
) {
for (const styleProp of expression.properties) { for (const styleProp of expression.properties) {
if ( if (
styleProp.type === 'Property' && styleProp.type === 'Property' &&
@@ -92,9 +89,7 @@ export default ESLintUtils.RuleCreator((name) => {
node.value?.type === 'JSXExpressionContainer' && node.value?.type === 'JSXExpressionContainer' &&
node.value.expression node.value.expression
) { ) {
const styleProps = extractStyleProperties( const styleProps = extractStyleProperties(node.value.expression)
node.value.expression,
)
for (const prop of styleProps) { for (const prop of styleProps) {
if (isHardcodedColor(prop.value)) { if (isHardcodedColor(prop.value)) {

View File

@@ -6,6 +6,7 @@ import tseslint from 'typescript-eslint'
import importPlugin from 'eslint-plugin-import' import importPlugin from 'eslint-plugin-import'
import tanstackQuery from '@tanstack/eslint-plugin-query' import tanstackQuery from '@tanstack/eslint-plugin-query'
import localRules from './eslint-rules/index.js' import localRules from './eslint-rules/index.js'
import eslintConfigPrettier from 'eslint-config-prettier'
export default tseslint.config( export default tseslint.config(
{ ignores: ['dist'] }, { ignores: ['dist'] },
@@ -26,10 +27,7 @@ export default tseslint.config(
}, },
rules: { rules: {
...reactHooks.configs.recommended.rules, ...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [ 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
'warn',
{ allowConstantExport: true },
],
'no-console': ['error', { allow: ['warn', 'error'] }], 'no-console': ['error', { allow: ['warn', 'error'] }],
'@typescript-eslint/consistent-type-imports': [ '@typescript-eslint/consistent-type-imports': [
'error', 'error',
@@ -40,20 +38,10 @@ export default tseslint.config(
'import/order': [ 'import/order': [
'warn', 'warn',
{ {
groups: [ groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index', 'type'],
'builtin',
'external',
'internal',
'parent',
'sibling',
'index',
'type',
],
'newlines-between': 'never', 'newlines-between': 'never',
alphabetize: { order: 'asc', caseInsensitive: true }, alphabetize: { order: 'asc', caseInsensitive: true },
pathGroups: [ pathGroups: [{ pattern: '@/**', group: 'internal', position: 'before' }],
{ pattern: '@/**', group: 'internal', position: 'before' },
],
}, },
], ],
}, },
@@ -67,4 +55,5 @@ export default tseslint.config(
'no-console': 'off', 'no-console': 'off',
}, },
}, },
eslintConfigPrettier
) )

View File

@@ -1,4 +1,4 @@
<!DOCTYPE html> <!doctype html>
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />

View File

@@ -5,9 +5,13 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc -b && eslint . && vite build", "build": "tsc -b && bun run check && vite build",
"lint": "eslint .", "lint": "eslint .",
"lint:fix": "eslint . --fix", "lint:fix": "eslint . --fix",
"format": "prettier --write .",
"format:check": "prettier --check .",
"check": "bun run lint && bun run format:check",
"fix": "bun run lint:fix && bun run format",
"preview": "vite preview", "preview": "vite preview",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
@@ -39,6 +43,7 @@
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "^3.2.1", "@vitest/coverage-v8": "^3.2.1",
"eslint": "^9.39.4", "eslint": "^9.39.4",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-import": "^2.31.0", "eslint-plugin-import": "^2.31.0",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.5.2",
@@ -47,6 +52,7 @@
"javascript-obfuscator": "^5.4.1", "javascript-obfuscator": "^5.4.1",
"jsdom": "^26.1.0", "jsdom": "^26.1.0",
"msw": "^2.8.2", "msw": "^2.8.2",
"prettier": "^3.8.3",
"sass": "^1.99.0", "sass": "^1.99.0",
"sql.js": "^1.14.1", "sql.js": "^1.14.1",
"typescript": "~6.0.2", "typescript": "~6.0.2",

View File

@@ -1,7 +1,7 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BrowserRouter } from 'react-router'; import { BrowserRouter } from 'react-router'
import { ConfigProvider } from 'tdesign-react'; import { ConfigProvider } from 'tdesign-react'
import { AppRoutes } from '@/routes'; import { AppRoutes } from '@/routes'
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
@@ -11,21 +11,23 @@ const queryClient = new QueryClient({
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
}, },
}, },
}); })
function App() { function App() {
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<ConfigProvider globalConfig={{ <ConfigProvider
globalConfig={{
animation: { include: ['ripple', 'expand', 'fade'] }, animation: { include: ['ripple', 'expand', 'fade'] },
table: { size: 'medium' }, table: { size: 'medium' },
}}> }}
>
<BrowserRouter> <BrowserRouter>
<AppRoutes /> <AppRoutes />
</BrowserRouter> </BrowserRouter>
</ConfigProvider> </ConfigProvider>
</QueryClientProvider> </QueryClientProvider>
); )
} }
export default App; export default App

View File

@@ -1,88 +1,85 @@
import { http, HttpResponse } from 'msw'; import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'; import { setupServer } from 'msw/node'
import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest'; import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest'
import { request, fromApi, toApi } from '@/api/client'; import { request, fromApi, toApi } from '@/api/client'
import { ApiError } from '@/types'; import { ApiError } from '@/types'
describe('fromApi', () => { describe('fromApi', () => {
it('converts snake_case keys to camelCase', () => { it('converts snake_case keys to camelCase', () => {
const input = { first_name: 'John', last_name: 'Doe' }; const input = { first_name: 'John', last_name: 'Doe' }
const result = fromApi<{ firstName: string; lastName: string }>(input); const result = fromApi<{ firstName: string; lastName: string }>(input)
expect(result).toEqual({ firstName: 'John', lastName: 'Doe' }); expect(result).toEqual({ firstName: 'John', lastName: 'Doe' })
}); })
it('converts nested objects recursively', () => { it('converts nested objects recursively', () => {
const input = { const input = {
user_name: 'alice', user_name: 'alice',
contact_info: { email_address: 'alice@example.com' }, contact_info: { email_address: 'alice@example.com' },
}; }
const result = fromApi<{ const result = fromApi<{
userName: string; userName: string
contactInfo: { emailAddress: string }; contactInfo: { emailAddress: string }
}>(input); }>(input)
expect(result).toEqual({ expect(result).toEqual({
userName: 'alice', userName: 'alice',
contactInfo: { emailAddress: 'alice@example.com' }, contactInfo: { emailAddress: 'alice@example.com' },
}); })
}); })
it('converts arrays recursively', () => { it('converts arrays recursively', () => {
const input = [ const input = [{ item_name: 'a' }, { item_name: 'b' }]
{ item_name: 'a' }, const result = fromApi<Array<{ itemName: string }>>(input)
{ item_name: 'b' }, expect(result).toEqual([{ itemName: 'a' }, { itemName: 'b' }])
]; })
const result = fromApi<Array<{ itemName: string }>>(input);
expect(result).toEqual([{ itemName: 'a' }, { itemName: 'b' }]);
});
it('returns primitives unchanged', () => { it('returns primitives unchanged', () => {
expect(fromApi<string>('hello')).toBe('hello'); expect(fromApi<string>('hello')).toBe('hello')
expect(fromApi<number>(42)).toBe(42); expect(fromApi<number>(42)).toBe(42)
expect(fromApi<null>(null)).toBeNull(); expect(fromApi<null>(null)).toBeNull()
}); })
}); })
describe('toApi', () => { describe('toApi', () => {
it('converts camelCase keys to snake_case', () => { it('converts camelCase keys to snake_case', () => {
const input = { firstName: 'John', lastName: 'Doe' }; const input = { firstName: 'John', lastName: 'Doe' }
const result = toApi<{ first_name: string; last_name: string }>(input); const result = toApi<{ first_name: string; last_name: string }>(input)
expect(result).toEqual({ first_name: 'John', last_name: 'Doe' }); expect(result).toEqual({ first_name: 'John', last_name: 'Doe' })
}); })
it('converts nested objects recursively', () => { it('converts nested objects recursively', () => {
const input = { const input = {
userName: 'alice', userName: 'alice',
contactInfo: { emailAddress: 'alice@example.com' }, contactInfo: { emailAddress: 'alice@example.com' },
}; }
const result = toApi<{ const result = toApi<{
user_name: string; user_name: string
contact_info: { email_address: string }; contact_info: { email_address: string }
}>(input); }>(input)
expect(result).toEqual({ expect(result).toEqual({
user_name: 'alice', user_name: 'alice',
contact_info: { email_address: 'alice@example.com' }, contact_info: { email_address: 'alice@example.com' },
}); })
}); })
it('converts arrays recursively', () => { it('converts arrays recursively', () => {
const input = [{ itemName: 'a' }, { itemName: 'b' }]; const input = [{ itemName: 'a' }, { itemName: 'b' }]
const result = toApi<Array<{ item_name: string }>>(input); const result = toApi<Array<{ item_name: string }>>(input)
expect(result).toEqual([{ item_name: 'a' }, { item_name: 'b' }]); expect(result).toEqual([{ item_name: 'a' }, { item_name: 'b' }])
}); })
it('returns primitives unchanged', () => { it('returns primitives unchanged', () => {
expect(toApi<string>('hello')).toBe('hello'); expect(toApi<string>('hello')).toBe('hello')
expect(toApi<number>(42)).toBe(42); expect(toApi<number>(42)).toBe(42)
expect(toApi<null>(null)).toBeNull(); expect(toApi<null>(null)).toBeNull()
}); })
}); })
describe('request', () => { describe('request', () => {
const mswServer = setupServer(); const mswServer = setupServer()
beforeAll(() => mswServer.listen({ onUnhandledRequest: 'bypass' })); beforeAll(() => mswServer.listen({ onUnhandledRequest: 'bypass' }))
afterEach(() => mswServer.resetHandlers()); afterEach(() => mswServer.resetHandlers())
afterAll(() => mswServer.close()); afterAll(() => mswServer.close())
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(
@@ -91,139 +88,130 @@ describe('request', () => {
id: '1', id: '1',
created_at: '2025-01-01', created_at: '2025-01-01',
nested_obj: { inner_key: 'value' }, nested_obj: { inner_key: 'value' },
}); })
}), })
); )
const result = await request<{ const result = await request<{
id: string; id: string
createdAt: string; createdAt: string
nestedObj: { innerKey: string }; nestedObj: { innerKey: string }
}>('GET', '/api/test'); }>('GET', '/api/test')
expect(result).toEqual({ expect(result).toEqual({
id: '1', id: '1',
createdAt: '2025-01-01', createdAt: '2025-01-01',
nestedObj: { innerKey: 'value' }, nestedObj: { innerKey: 'value' },
}); })
}); })
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('http://localhost:3000/api/test', () => { http.get('http://localhost:3000/api/test', () => {
return HttpResponse.json( return HttpResponse.json({ message: 'Not found' }, { status: 404 })
{ message: 'Not found' }, })
{ status: 404 }, )
);
}),
);
await expect(request('GET', '/api/test')).rejects.toThrow(ApiError); await expect(request('GET', '/api/test')).rejects.toThrow(ApiError)
try { try {
await request('GET', '/api/test'); await request('GET', '/api/test')
} catch (error) { } catch (error) {
expect(error).toBeInstanceOf(ApiError); expect(error).toBeInstanceOf(ApiError)
const apiError = error as ApiError; const apiError = error as ApiError
expect(apiError.status).toBe(404); expect(apiError.status).toBe(404)
expect(apiError.message).toBe('Not found'); expect(apiError.message).toBe('Not found')
} }
}); })
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('http://localhost:3000/api/test', () => { http.get('http://localhost:3000/api/test', () => {
return HttpResponse.json( return HttpResponse.json({ details: 'something' }, { status: 500 })
{ details: 'something' }, })
{ status: 500 }, )
);
}),
);
try { try {
await request('GET', '/api/test'); await request('GET', '/api/test')
} catch (error) { } catch (error) {
expect(error).toBeInstanceOf(ApiError); expect(error).toBeInstanceOf(ApiError)
const apiError = error as ApiError; const apiError = error as ApiError
expect(apiError.status).toBe(500); expect(apiError.status).toBe(500)
expect(apiError.message).toContain('500'); expect(apiError.message).toContain('500')
} }
}); })
it('throws ApiError with code field when error response includes code', async () => { it('throws ApiError with code field when error response includes code', async () => {
mswServer.use( mswServer.use(
http.get('http://localhost:3000/api/test', () => { http.get('http://localhost:3000/api/test', () => {
return HttpResponse.json( return HttpResponse.json({ error: 'Model not found', code: 'MODEL_NOT_FOUND' }, { status: 404 })
{ error: 'Model not found', code: 'MODEL_NOT_FOUND' }, })
{ status: 404 }, )
);
}),
);
try { try {
await request('GET', '/api/test'); await request('GET', '/api/test')
} catch (error) { } catch (error) {
expect(error).toBeInstanceOf(ApiError); expect(error).toBeInstanceOf(ApiError)
const apiError = error as ApiError; const apiError = error as ApiError
expect(apiError.status).toBe(404); expect(apiError.status).toBe(404)
expect(apiError.message).toBe('Model not found'); expect(apiError.message).toBe('Model not found')
expect(apiError.code).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('http://localhost:3000/api/test', () => { http.get('http://localhost:3000/api/test', () => {
return HttpResponse.error(); return HttpResponse.error()
}), })
); )
await expect(request('GET', '/api/test')).rejects.toThrow(); await expect(request('GET', '/api/test')).rejects.toThrow()
}); })
it('returns undefined for 204 No Content', async () => { it('returns undefined for 204 No Content', async () => {
mswServer.use( mswServer.use(
http.delete('http://localhost:3000/api/test/1', () => { http.delete('http://localhost:3000/api/test/1', () => {
return new HttpResponse(null, { status: 204 }); return new HttpResponse(null, { status: 204 })
}), })
); )
const result = await request('DELETE', '/api/test/1'); const result = await request('DELETE', '/api/test/1')
expect(result).toBeUndefined(); expect(result).toBeUndefined()
}); })
it('sends body with camelCase keys converted to snake_case', async () => { it('sends body with camelCase keys converted to snake_case', async () => {
let receivedBody: Record<string, unknown> | null = null; let receivedBody: Record<string, unknown> | null = null
mswServer.use( mswServer.use(
http.post('http://localhost:3000/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' })
}), })
); )
await request('POST', '/api/test', { await request('POST', '/api/test', {
providerId: 'prov-1', providerId: 'prov-1',
modelName: 'gpt-4', modelName: 'gpt-4',
}); })
expect(receivedBody).toEqual({ expect(receivedBody).toEqual({
provider_id: 'prov-1', provider_id: 'prov-1',
model_name: 'gpt-4', model_name: 'gpt-4',
}); })
}); })
it('sends Content-Type header as application/json', async () => { it('sends Content-Type header as application/json', async () => {
let contentType: string | null = null; let contentType: string | null = null
mswServer.use( mswServer.use(
http.post('http://localhost:3000/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' })
}), })
); )
await request('POST', '/api/test', { name: 'test' }); await request('POST', '/api/test', { name: 'test' })
expect(contentType).toBe('application/json'); expect(contentType).toBe('application/json')
}); })
}); })

View File

@@ -1,7 +1,7 @@
import { http, HttpResponse } from 'msw'; import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'; import { setupServer } from 'msw/node'
import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest'; import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest'
import { listModels, createModel, updateModel, deleteModel } from '@/api/models'; import { listModels, createModel, updateModel, deleteModel } from '@/api/models'
const mockModels = [ const mockModels = [
{ {
@@ -20,24 +20,24 @@ const mockModels = [
enabled: false, enabled: false,
created_at: '2025-01-02T00:00:00Z', created_at: '2025-01-02T00:00:00Z',
}, },
]; ]
describe('models API', () => { describe('models API', () => {
const server = setupServer(); const server = setupServer()
beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' })); beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' }))
afterEach(() => server.resetHandlers()); afterEach(() => server.resetHandlers())
afterAll(() => server.close()); afterAll(() => server.close())
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('http://localhost:3000/api/models', () => { http.get('http://localhost:3000/api/models', () => {
return HttpResponse.json(mockModels); return HttpResponse.json(mockModels)
}), })
); )
const result = await listModels(); const result = await listModels()
expect(result).toEqual([ expect(result).toEqual([
{ {
@@ -56,114 +56,114 @@ describe('models API', () => {
enabled: false, enabled: false,
createdAt: '2025-01-02T00:00:00Z', createdAt: '2025-01-02T00:00:00Z',
}, },
]); ])
}); })
it('appends provider_id query parameter when providerId is given', async () => { it('appends provider_id query parameter when providerId is given', async () => {
let receivedUrl: string | null = null; let receivedUrl: string | null = null
server.use( server.use(
http.get('http://localhost:3000/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]])
}), })
); )
const result = await listModels('prov-1'); const result = await listModels('prov-1')
expect(receivedUrl).toContain('provider_id=prov-1'); expect(receivedUrl).toContain('provider_id=prov-1')
expect(result).toHaveLength(1); expect(result).toHaveLength(1)
expect(result[0].providerId).toBe('prov-1'); expect(result[0].providerId).toBe('prov-1')
}); })
}); })
describe('createModel', () => { describe('createModel', () => {
it('sends POST with correct body and returns model', async () => { it('sends POST with correct body and returns model', async () => {
let receivedMethod: string | null = null; let receivedMethod: string | null = null
let receivedBody: Record<string, unknown> | null = null; let receivedBody: Record<string, unknown> | null = null
server.use( server.use(
http.post('http://localhost:3000/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])
}), })
); )
const input = { const input = {
providerId: 'prov-1', providerId: 'prov-1',
modelName: 'gpt-4', modelName: 'gpt-4',
enabled: true, enabled: true,
}; }
const result = await createModel(input); const result = await createModel(input)
expect(receivedMethod).toBe('POST'); expect(receivedMethod).toBe('POST')
expect(receivedBody).toEqual({ expect(receivedBody).toEqual({
provider_id: 'prov-1', provider_id: 'prov-1',
model_name: 'gpt-4', model_name: 'gpt-4',
enabled: true, enabled: true,
}); })
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'); expect(result.unifiedId).toBe('prov-1/gpt-4')
}); })
}); })
describe('updateModel', () => { describe('updateModel', () => {
it('sends PUT with correct body and returns model', async () => { it('sends PUT with correct body and returns model', async () => {
let receivedMethod: string | null = null; let receivedMethod: string | null = null
let receivedUrl: string | null = null; let receivedUrl: string | null = null
let receivedBody: Record<string, unknown> | null = null; let receivedBody: Record<string, unknown> | null = null
server.use( server.use(
http.put('http://localhost:3000/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>
return HttpResponse.json({ return HttpResponse.json({
...mockModels[0], ...mockModels[0],
model_name: 'gpt-4-turbo', model_name: 'gpt-4-turbo',
enabled: false, enabled: false,
}); })
}), })
); )
const result = await updateModel('gpt-4', { const result = await updateModel('gpt-4', {
modelName: 'gpt-4-turbo', modelName: 'gpt-4-turbo',
enabled: false, enabled: false,
}); })
expect(receivedMethod).toBe('PUT'); expect(receivedMethod).toBe('PUT')
expect(receivedUrl).toBe('/api/models/gpt-4'); expect(receivedUrl).toBe('/api/models/gpt-4')
expect(receivedBody).toEqual({ expect(receivedBody).toEqual({
model_name: 'gpt-4-turbo', model_name: 'gpt-4-turbo',
enabled: false, enabled: false,
}); })
expect(result.modelName).toBe('gpt-4-turbo'); expect(result.modelName).toBe('gpt-4-turbo')
expect(result.enabled).toBe(false); expect(result.enabled).toBe(false)
}); })
}); })
describe('deleteModel', () => { describe('deleteModel', () => {
it('sends DELETE and returns void', async () => { it('sends DELETE and returns void', async () => {
let receivedMethod: string | null = null; let receivedMethod: string | null = null
let receivedUrl: string | null = null; let receivedUrl: string | null = null
server.use( server.use(
http.delete('http://localhost:3000/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 })
}), })
); )
const result = await deleteModel('gpt-4'); const result = await deleteModel('gpt-4')
expect(receivedMethod).toBe('DELETE'); expect(receivedMethod).toBe('DELETE')
expect(receivedUrl).toBe('/api/models/gpt-4'); expect(receivedUrl).toBe('/api/models/gpt-4')
expect(result).toBeUndefined(); expect(result).toBeUndefined()
}); })
}); })
}); })

View File

@@ -1,7 +1,7 @@
import { http, HttpResponse } from 'msw'; import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'; import { setupServer } from 'msw/node'
import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest'; import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest'
import { listProviders, createProvider, updateProvider, deleteProvider } from '@/api/providers'; import { listProviders, createProvider, updateProvider, deleteProvider } from '@/api/providers'
const mockProviders = [ const mockProviders = [
{ {
@@ -24,24 +24,24 @@ const mockProviders = [
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',
}, },
]; ]
describe('providers API', () => { describe('providers API', () => {
const server = setupServer(); const server = setupServer()
beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' })); beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' }))
afterEach(() => server.resetHandlers()); afterEach(() => server.resetHandlers())
afterAll(() => server.close()); afterAll(() => server.close())
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('http://localhost:3000/api/providers', () => { http.get('http://localhost:3000/api/providers', () => {
return HttpResponse.json(mockProviders); return HttpResponse.json(mockProviders)
}), })
); )
const result = await listProviders(); const result = await listProviders()
expect(result).toEqual([ expect(result).toEqual([
{ {
@@ -64,22 +64,22 @@ describe('providers API', () => {
createdAt: '2025-01-02T00:00:00Z', createdAt: '2025-01-02T00:00:00Z',
updatedAt: '2025-01-02T00:00:00Z', updatedAt: '2025-01-02T00:00:00Z',
}, },
]); ])
}); })
}); })
describe('createProvider', () => { describe('createProvider', () => {
it('sends POST with correct body and returns provider', async () => { it('sends POST with correct body and returns provider', async () => {
let receivedMethod: string | null = null; let receivedMethod: string | null = null
let receivedBody: Record<string, unknown> | null = null; let receivedBody: Record<string, unknown> | null = null
server.use( server.use(
http.post('http://localhost:3000/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])
}), })
); )
const input = { const input = {
id: 'prov-1', id: 'prov-1',
@@ -87,18 +87,18 @@ describe('providers API', () => {
apiKey: 'sk-xxx', apiKey: 'sk-xxx',
baseUrl: 'https://api.openai.com', baseUrl: 'https://api.openai.com',
enabled: true, enabled: true,
}; }
const result = await createProvider(input); const result = await createProvider(input)
expect(receivedMethod).toBe('POST'); expect(receivedMethod).toBe('POST')
expect(receivedBody).toEqual({ expect(receivedBody).toEqual({
id: 'prov-1', id: 'prov-1',
name: 'OpenAI', name: 'OpenAI',
api_key: 'sk-xxx', api_key: 'sk-xxx',
base_url: 'https://api.openai.com', base_url: 'https://api.openai.com',
enabled: true, enabled: true,
}); })
expect(result).toEqual({ expect(result).toEqual({
id: 'prov-1', id: 'prov-1',
name: 'OpenAI', name: 'OpenAI',
@@ -108,63 +108,63 @@ describe('providers API', () => {
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',
}); })
}); })
}); })
describe('updateProvider', () => { describe('updateProvider', () => {
it('sends PUT with correct body and returns provider', async () => { it('sends PUT with correct body and returns provider', async () => {
let receivedMethod: string | null = null; let receivedMethod: string | null = null
let receivedUrl: string | null = null; let receivedUrl: string | null = null
let receivedBody: Record<string, unknown> | null = null; let receivedBody: Record<string, unknown> | null = null
server.use( server.use(
http.put('http://localhost:3000/api/providers/:id', async ({ request }) => { http.put('http://localhost:3000/api/providers/: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>
return HttpResponse.json({ return HttpResponse.json({
...mockProviders[0], ...mockProviders[0],
name: 'Updated', name: 'Updated',
api_key: 'sk-updated', api_key: 'sk-updated',
}); })
}), })
); )
const result = await updateProvider('prov-1', { const result = await updateProvider('prov-1', {
name: 'Updated', name: 'Updated',
apiKey: 'sk-updated', apiKey: 'sk-updated',
}); })
expect(receivedMethod).toBe('PUT'); expect(receivedMethod).toBe('PUT')
expect(receivedUrl).toBe('/api/providers/prov-1'); expect(receivedUrl).toBe('/api/providers/prov-1')
expect(receivedBody).toEqual({ expect(receivedBody).toEqual({
name: 'Updated', name: 'Updated',
api_key: 'sk-updated', api_key: 'sk-updated',
}); })
expect(result.name).toBe('Updated'); expect(result.name).toBe('Updated')
expect(result.apiKey).toBe('sk-updated'); expect(result.apiKey).toBe('sk-updated')
}); })
}); })
describe('deleteProvider', () => { describe('deleteProvider', () => {
it('sends DELETE and returns void', async () => { it('sends DELETE and returns void', async () => {
let receivedMethod: string | null = null; let receivedMethod: string | null = null
let receivedUrl: string | null = null; let receivedUrl: string | null = null
server.use( server.use(
http.delete('http://localhost:3000/api/providers/:id', ({ request }) => { http.delete('http://localhost:3000/api/providers/: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 })
}), })
); )
const result = await deleteProvider('prov-1'); const result = await deleteProvider('prov-1')
expect(receivedMethod).toBe('DELETE'); expect(receivedMethod).toBe('DELETE')
expect(receivedUrl).toBe('/api/providers/prov-1'); expect(receivedUrl).toBe('/api/providers/prov-1')
expect(result).toBeUndefined(); expect(result).toBeUndefined()
}); })
}); })
}); })

View File

@@ -1,7 +1,7 @@
import { http, HttpResponse } from 'msw'; import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'; import { setupServer } from 'msw/node'
import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest'; import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest'
import { getStats } from '@/api/stats'; import { getStats } from '@/api/stats'
const mockStats = [ const mockStats = [
{ {
@@ -18,29 +18,29 @@ const mockStats = [
request_count: 50, request_count: 50,
date: '2025-01-16', date: '2025-01-16',
}, },
]; ]
describe('stats API', () => { describe('stats API', () => {
const server = setupServer(); const server = setupServer()
beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' })); beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' }))
afterEach(() => server.resetHandlers()); afterEach(() => server.resetHandlers())
afterAll(() => server.close()); afterAll(() => server.close())
describe('getStats', () => { describe('getStats', () => {
it('calls /api/stats without params', async () => { it('calls /api/stats without params', async () => {
let receivedUrl: string | null = null; let receivedUrl: string | null = null
server.use( server.use(
http.get('http://localhost:3000/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)
}), })
); )
const result = await getStats(); const result = await getStats()
expect(receivedUrl).toMatch(/\/api\/stats$/); expect(receivedUrl).toMatch(/\/api\/stats$/)
expect(result).toEqual([ expect(result).toEqual([
{ {
id: 1, id: 1,
@@ -56,76 +56,76 @@ describe('stats API', () => {
requestCount: 50, requestCount: 50,
date: '2025-01-16', date: '2025-01-16',
}, },
]); ])
}); })
it('builds correct query string with snake_case keys when params are provided', async () => { it('builds correct query string with snake_case keys when params are provided', async () => {
let receivedUrl: string | null = null; let receivedUrl: string | null = null
server.use( server.use(
http.get('http://localhost:3000/api/stats', ({ request }) => { http.get('http://localhost:3000/api/stats', ({ request }) => {
receivedUrl = request.url; receivedUrl = request.url
return HttpResponse.json([]); return HttpResponse.json([])
}), })
); )
await getStats({ await getStats({
providerId: 'prov-1', providerId: 'prov-1',
modelName: 'gpt-4', modelName: 'gpt-4',
startDate: '2025-01-01', startDate: '2025-01-01',
endDate: '2025-01-31', endDate: '2025-01-31',
}); })
expect(receivedUrl).toContain('provider_id=prov-1'); expect(receivedUrl).toContain('provider_id=prov-1')
expect(receivedUrl).toContain('model_name=gpt-4'); expect(receivedUrl).toContain('model_name=gpt-4')
expect(receivedUrl).toContain('start_date=2025-01-01'); expect(receivedUrl).toContain('start_date=2025-01-01')
expect(receivedUrl).toContain('end_date=2025-01-31'); expect(receivedUrl).toContain('end_date=2025-01-31')
}); })
it('omits undefined params from query string', async () => { it('omits undefined params from query string', async () => {
let receivedUrl: string | null = null; let receivedUrl: string | null = null
server.use( server.use(
http.get('http://localhost:3000/api/stats', ({ request }) => { http.get('http://localhost:3000/api/stats', ({ request }) => {
receivedUrl = request.url; receivedUrl = request.url
return HttpResponse.json([]); return HttpResponse.json([])
}), })
); )
await getStats({ await getStats({
providerId: 'prov-1', providerId: 'prov-1',
}); })
expect(receivedUrl).toContain('provider_id=prov-1'); expect(receivedUrl).toContain('provider_id=prov-1')
expect(receivedUrl).not.toContain('model_name'); expect(receivedUrl).not.toContain('model_name')
expect(receivedUrl).not.toContain('start_date'); expect(receivedUrl).not.toContain('start_date')
expect(receivedUrl).not.toContain('end_date'); expect(receivedUrl).not.toContain('end_date')
}); })
it('returns UsageStats array with camelCase keys', async () => { it('returns UsageStats array with camelCase keys', async () => {
server.use( server.use(
http.get('http://localhost:3000/api/stats', () => { http.get('http://localhost:3000/api/stats', () => {
return HttpResponse.json(mockStats); return HttpResponse.json(mockStats)
}), })
); )
const result = await getStats(); const result = await getStats()
expect(result).toHaveLength(2); expect(result).toHaveLength(2)
expect(result[0]).toEqual({ expect(result[0]).toEqual({
id: 1, id: 1,
providerId: 'prov-1', providerId: 'prov-1',
modelName: 'gpt-4', modelName: 'gpt-4',
requestCount: 100, requestCount: 100,
date: '2025-01-15', date: '2025-01-15',
}); })
expect(result[1]).toEqual({ expect(result[1]).toEqual({
id: 2, id: 2,
providerId: 'prov-2', providerId: 'prov-2',
modelName: 'claude-3', modelName: 'claude-3',
requestCount: 50, requestCount: 50,
date: '2025-01-16', date: '2025-01-16',
}); })
}); })
}); })
}); })

View File

@@ -1,52 +1,52 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react'
import { BrowserRouter } from 'react-router'; import { BrowserRouter } from 'react-router'
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest'
import { AppLayout } from '@/components/AppLayout'; import { AppLayout } from '@/components/AppLayout'
const renderWithRouter = (component: React.ReactNode) => { const renderWithRouter = (component: React.ReactNode) => {
return render(<BrowserRouter>{component}</BrowserRouter>); return render(<BrowserRouter>{component}</BrowserRouter>)
}; }
describe('AppLayout', () => { describe('AppLayout', () => {
it('renders sidebar with app name', () => { it('renders sidebar with app name', () => {
renderWithRouter(<AppLayout />); renderWithRouter(<AppLayout />)
const appNames = screen.getAllByText('AI Gateway'); const appNames = screen.getAllByText('AI Gateway')
expect(appNames.length).toBeGreaterThan(0); expect(appNames.length).toBeGreaterThan(0)
}); })
it('renders navigation menu items', () => { it('renders navigation menu items', () => {
renderWithRouter(<AppLayout />); renderWithRouter(<AppLayout />)
expect(screen.getByText('供应商管理')).toBeInTheDocument(); expect(screen.getByText('供应商管理')).toBeInTheDocument()
expect(screen.getByText('用量统计')).toBeInTheDocument(); expect(screen.getByText('用量统计')).toBeInTheDocument()
}); })
it('renders settings menu item', () => { it('renders settings menu item', () => {
renderWithRouter(<AppLayout />); renderWithRouter(<AppLayout />)
expect(screen.getByText('设置')).toBeInTheDocument(); expect(screen.getByText('设置')).toBeInTheDocument()
}); })
it('renders content outlet', () => { it('renders content outlet', () => {
const { container } = renderWithRouter(<AppLayout />); const { container } = renderWithRouter(<AppLayout />)
// TDesign Layout content // TDesign Layout content
expect(container.querySelector('.t-layout__content')).toBeInTheDocument(); expect(container.querySelector('.t-layout__content')).toBeInTheDocument()
}); })
it('renders sidebar', () => { it('renders sidebar', () => {
const { container } = renderWithRouter(<AppLayout />); const { container } = renderWithRouter(<AppLayout />)
// TDesign Layout.Aside might render with different class names // TDesign Layout.Aside might render with different class names
// Check for Menu component which is in the sidebar // Check for Menu component which is in the sidebar
expect(container.querySelector('.t-menu')).toBeInTheDocument(); expect(container.querySelector('.t-menu')).toBeInTheDocument()
}); })
it('renders header with page title', () => { it('renders header with page title', () => {
const { container } = renderWithRouter(<AppLayout />); const { container } = renderWithRouter(<AppLayout />)
// TDesign Layout header // TDesign Layout header
expect(container.querySelector('.t-layout__header')).toBeInTheDocument(); expect(container.querySelector('.t-layout__header')).toBeInTheDocument()
}); })
}); })

View File

@@ -1,8 +1,8 @@
import { render, screen, within } from '@testing-library/react'; import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi } from 'vitest'
import { ModelForm } from '@/pages/Providers/ModelForm'; import { ModelForm } from '@/pages/Providers/ModelForm'
import type { Provider, Model } from '@/types'; import type { Provider, Model } from '@/types'
const mockProviders: Provider[] = [ const mockProviders: Provider[] = [
{ {
@@ -25,7 +25,7 @@ const mockProviders: Provider[] = [
createdAt: '2024-01-02T00:00:00Z', createdAt: '2024-01-02T00:00:00Z',
updatedAt: '2024-01-02T00:00:00Z', updatedAt: '2024-01-02T00:00:00Z',
}, },
]; ]
const mockModel: Model = { const mockModel: Model = {
id: 'gpt-4o', id: 'gpt-4o',
@@ -34,7 +34,7 @@ const mockModel: Model = {
enabled: true, enabled: true,
createdAt: '2024-01-01T00:00:00Z', createdAt: '2024-01-01T00:00:00Z',
unifiedId: 'openai/gpt-4o', unifiedId: 'openai/gpt-4o',
}; }
const defaultProps = { const defaultProps = {
open: true, open: true,
@@ -43,69 +43,63 @@ const defaultProps = {
onSave: vi.fn(), onSave: vi.fn(),
onCancel: vi.fn(), onCancel: vi.fn(),
loading: false, loading: false,
}; }
function getDialog() { function getDialog() {
// TDesign Dialog doesn't have role="dialog", use class selector // TDesign Dialog doesn't have role="dialog", use class selector
const dialog = document.querySelector('.t-dialog'); const dialog = document.querySelector('.t-dialog')
if (!dialog) { if (!dialog) {
throw new Error('Dialog not found'); throw new Error('Dialog not found')
} }
return dialog; return dialog
} }
describe('ModelForm', () => { describe('ModelForm', () => {
it('renders form with provider select', () => { it('renders form with provider select', () => {
render(<ModelForm {...defaultProps} />); render(<ModelForm {...defaultProps} />)
const dialog = getDialog(); const dialog = getDialog()
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()
expect(within(dialog).getByText('启用')).toBeInTheDocument(); expect(within(dialog).getByText('启用')).toBeInTheDocument()
}); })
it('defaults providerId to the passed providerId in create mode', () => { it('defaults providerId to the passed providerId in create mode', () => {
render(<ModelForm {...defaultProps} />); render(<ModelForm {...defaultProps} />)
const dialog = getDialog(); const dialog = getDialog()
// Form renders with provider select // Form renders with provider select
expect(within(dialog).getByText('供应商')).toBeInTheDocument(); expect(within(dialog).getByText('供应商')).toBeInTheDocument()
}); })
it('shows validation error messages for required fields', async () => { it('shows validation error messages for required fields', async () => {
const user = userEvent.setup(); const user = userEvent.setup()
render( render(<ModelForm {...defaultProps} providerId={undefined as unknown as string} providers={[]} />)
<ModelForm
{...defaultProps}
providerId={undefined as unknown as string}
providers={[]}
/>,
);
const dialog = getDialog(); const dialog = getDialog()
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('请选择供应商')).toBeInTheDocument(); expect(await screen.findByText('请选择供应商')).toBeInTheDocument()
expect(screen.getByText('请输入模型名称')).toBeInTheDocument(); expect(screen.getByText('请输入模型名称')).toBeInTheDocument()
}); })
it('calls onSave with form values on successful submission', async () => { it('calls onSave with form values on successful submission', async () => {
const user = userEvent.setup(); const user = userEvent.setup()
const onSave = vi.fn(); const onSave = vi.fn()
render(<ModelForm {...defaultProps} onSave={onSave} />); render(<ModelForm {...defaultProps} onSave={onSave} />)
const dialog = getDialog(); const dialog = getDialog()
// Only one input with placeholder "例如: gpt-4o" for model name // Only one input with placeholder "例如: gpt-4o" for model name
const modelNameInput = within(dialog).getByPlaceholderText('例如: gpt-4o'); const modelNameInput = within(dialog).getByPlaceholderText('例如: gpt-4o')
// Type into the model name field // Type into the model name field
await user.clear(modelNameInput); await user.clear(modelNameInput)
await user.type(modelNameInput, '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)
// Wait for the onSave to be called // Wait for the onSave to be called
await vi.waitFor(() => { await vi.waitFor(() => {
@@ -114,30 +108,30 @@ describe('ModelForm', () => {
providerId: 'openai', providerId: 'openai',
modelName: 'gpt-4o-mini', modelName: 'gpt-4o-mini',
enabled: true, enabled: true,
}), })
); )
}); })
}, 10000); }, 10000)
it('renders pre-filled fields in edit mode', () => { it('renders pre-filled fields in edit mode', () => {
render(<ModelForm {...defaultProps} model={mockModel} />); render(<ModelForm {...defaultProps} model={mockModel} />)
const dialog = getDialog(); const dialog = getDialog()
expect(within(dialog).getByText('编辑模型')).toBeInTheDocument(); expect(within(dialog).getByText('编辑模型')).toBeInTheDocument()
// Check model name input // Check model name input
const modelNameInput = within(dialog).getByPlaceholderText('例如: gpt-4o') as HTMLInputElement; const modelNameInput = within(dialog).getByPlaceholderText('例如: gpt-4o') as HTMLInputElement
expect(modelNameInput.value).toBe('gpt-4o'); expect(modelNameInput.value).toBe('gpt-4o')
}); })
it('calls onCancel when clicking cancel button', async () => { it('calls onCancel when clicking cancel button', async () => {
const user = userEvent.setup(); const user = userEvent.setup()
const onCancel = vi.fn(); const onCancel = vi.fn()
render(<ModelForm {...defaultProps} onCancel={onCancel} />); render(<ModelForm {...defaultProps} onCancel={onCancel} />)
const dialog = getDialog(); const dialog = getDialog()
const cancelButton = within(dialog).getByRole('button', { name: /取/ }); const cancelButton = within(dialog).getByRole('button', { name: /取/ })
await user.click(cancelButton); await user.click(cancelButton)
expect(onCancel).toHaveBeenCalledTimes(1); expect(onCancel).toHaveBeenCalledTimes(1)
}); })
}); })

View File

@@ -1,8 +1,8 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ModelTable } from '@/pages/Providers/ModelTable'; import { ModelTable } from '@/pages/Providers/ModelTable'
import type { Model } from '@/types'; import type { Model } from '@/types'
const mockModels: Model[] = [ const mockModels: Model[] = [
{ {
@@ -21,103 +21,103 @@ const mockModels: Model[] = [
createdAt: '2024-01-02T00:00:00Z', createdAt: '2024-01-02T00:00:00Z',
unifiedId: 'openai/gpt-3.5-turbo', unifiedId: 'openai/gpt-3.5-turbo',
}, },
]; ]
const mockMutate = vi.fn(); const mockMutate = vi.fn()
vi.mock('@/hooks/useModels', () => ({ vi.mock('@/hooks/useModels', () => ({
useModels: vi.fn((providerId: string) => { useModels: vi.fn((providerId: string) => {
if (providerId === 'openai') { if (providerId === 'openai') {
return { data: mockModels, isLoading: false }; return { data: mockModels, isLoading: false }
} }
return { data: [], isLoading: false }; return { data: [], isLoading: false }
}), }),
useDeleteModel: vi.fn(() => ({ mutate: mockMutate })), useDeleteModel: vi.fn(() => ({ mutate: mockMutate })),
})); }))
const defaultProps = { const defaultProps = {
providerId: 'openai', providerId: 'openai',
onAdd: vi.fn(), onAdd: vi.fn(),
onEdit: vi.fn(), onEdit: vi.fn(),
}; }
describe('ModelTable', () => { describe('ModelTable', () => {
beforeEach(() => { beforeEach(() => {
mockMutate.mockClear(); mockMutate.mockClear()
}); })
it('renders model list with unified ID and model name', () => { it('renders model list with unified ID and model name', () => {
render(<ModelTable {...defaultProps} />); render(<ModelTable {...defaultProps} />)
expect(screen.getByText(/关联模型/)).toBeInTheDocument(); expect(screen.getByText(/关联模型/)).toBeInTheDocument()
expect(screen.getByText('openai/gpt-4o')).toBeInTheDocument(); expect(screen.getByText('openai/gpt-4o')).toBeInTheDocument()
expect(screen.getByText('openai/gpt-3.5-turbo')).toBeInTheDocument(); expect(screen.getByText('openai/gpt-3.5-turbo')).toBeInTheDocument()
expect(screen.getByText('gpt-4o')).toBeInTheDocument(); expect(screen.getByText('gpt-4o')).toBeInTheDocument()
expect(screen.getByText('gpt-3.5-turbo')).toBeInTheDocument(); expect(screen.getByText('gpt-3.5-turbo')).toBeInTheDocument()
}); })
it('renders status tags correctly', () => { it('renders status tags correctly', () => {
render(<ModelTable {...defaultProps} />); render(<ModelTable {...defaultProps} />)
const enabledTags = screen.getAllByText('启用'); const enabledTags = screen.getAllByText('启用')
const disabledTags = screen.getAllByText('禁用'); const disabledTags = screen.getAllByText('禁用')
expect(enabledTags.length).toBeGreaterThanOrEqual(1); expect(enabledTags.length).toBeGreaterThanOrEqual(1)
expect(disabledTags.length).toBeGreaterThanOrEqual(1); expect(disabledTags.length).toBeGreaterThanOrEqual(1)
}); })
it('calls onAdd when clicking "添加模型" button', async () => { it('calls onAdd when clicking "添加模型" button', async () => {
const user = userEvent.setup(); const user = userEvent.setup()
const onAdd = vi.fn(); const onAdd = vi.fn()
render(<ModelTable {...defaultProps} onAdd={onAdd} />); render(<ModelTable {...defaultProps} onAdd={onAdd} />)
await user.click(screen.getByRole('button', { name: '添加模型' })); await user.click(screen.getByRole('button', { name: '添加模型' }))
expect(onAdd).toHaveBeenCalledTimes(1); expect(onAdd).toHaveBeenCalledTimes(1)
}); })
it('calls onEdit with correct model when clicking "编辑"', async () => { it('calls onEdit with correct model when clicking "编辑"', async () => {
const user = userEvent.setup(); const user = userEvent.setup()
const onEdit = vi.fn(); const onEdit = vi.fn()
render(<ModelTable {...defaultProps} onEdit={onEdit} />); render(<ModelTable {...defaultProps} onEdit={onEdit} />)
const editButtons = screen.getAllByRole('button', { name: /编 ?辑/ }); const editButtons = screen.getAllByRole('button', { name: /编 ?辑/ })
await user.click(editButtons[0]); await user.click(editButtons[0])
expect(onEdit).toHaveBeenCalledTimes(1); expect(onEdit).toHaveBeenCalledTimes(1)
expect(onEdit).toHaveBeenCalledWith(mockModels[0]); expect(onEdit).toHaveBeenCalledWith(mockModels[0])
}); })
it('calls deleteModel.mutate with correct model ID when delete is confirmed', async () => { it('calls deleteModel.mutate with correct model ID when delete is confirmed', async () => {
const user = userEvent.setup(); const user = userEvent.setup()
render(<ModelTable {...defaultProps} />); render(<ModelTable {...defaultProps} />)
// Find and click the delete button for the first row // Find and click the delete button for the first row
const deleteButtons = screen.getAllByRole('button', { name: '删除' }); const deleteButtons = screen.getAllByRole('button', { name: '删除' })
await user.click(deleteButtons[0]); await user.click(deleteButtons[0])
// TDesign Popconfirm renders confirmation popup with "确定" button // TDesign Popconfirm renders confirmation popup with "确定" button
const confirmButton = await screen.findByRole('button', { name: '确定' }); const confirmButton = await screen.findByRole('button', { name: '确定' })
await user.click(confirmButton); await user.click(confirmButton)
// Assert that deleteModel.mutate was called with the correct model ID // Assert that deleteModel.mutate was called with the correct model ID
expect(mockMutate).toHaveBeenCalledTimes(1); expect(mockMutate).toHaveBeenCalledTimes(1)
expect(mockMutate).toHaveBeenCalledWith('model-1'); expect(mockMutate).toHaveBeenCalledWith('model-1')
}, 10000); }, 10000)
it('shows custom empty text when models list is empty', () => { it('shows custom empty text when models list is empty', () => {
render(<ModelTable providerId="anthropic" />); render(<ModelTable providerId='anthropic' />)
expect(screen.getByText('暂无模型,点击上方按钮添加')).toBeInTheDocument(); expect(screen.getByText('暂无模型,点击上方按钮添加')).toBeInTheDocument()
}); })
it('does not render add button when onAdd is not provided', () => { it('does not render add button when onAdd is not provided', () => {
render(<ModelTable providerId="openai" />); render(<ModelTable providerId='openai' />)
expect(screen.queryByRole('button', { name: '添加模型' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: '添加模型' })).not.toBeInTheDocument()
}); })
it('does not render edit button when onEdit is not provided', () => { it('does not render edit button when onEdit is not provided', () => {
render(<ModelTable providerId="openai" onAdd={vi.fn()} />); render(<ModelTable providerId='openai' onAdd={vi.fn()} />)
expect(screen.queryByRole('button', { name: /编 ?辑/ })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: /编 ?辑/ })).not.toBeInTheDocument()
}); })
}); })

View File

@@ -1,8 +1,8 @@
import { render, screen, within, fireEvent } from '@testing-library/react'; import { render, screen, within, fireEvent } from '@testing-library/react'
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi } from 'vitest'
import { ProviderForm } from '@/pages/Providers/ProviderForm'; import { ProviderForm } from '@/pages/Providers/ProviderForm'
import type { Provider } from '@/types'; import type { Provider } from '@/types'
const mockProvider: Provider = { const mockProvider: Provider = {
id: 'openai', id: 'openai',
@@ -13,187 +13,193 @@ const mockProvider: Provider = {
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',
}; }
const defaultProps = { const defaultProps = {
open: true, open: true,
onSave: vi.fn(), onSave: vi.fn(),
onCancel: vi.fn(), onCancel: vi.fn(),
loading: false, loading: false,
}; }
function getDialog() { function getDialog() {
// TDesign Dialog doesn't have role="dialog", use class selector // TDesign Dialog doesn't have role="dialog", use class selector
const dialog = document.querySelector('.t-dialog'); const dialog = document.querySelector('.t-dialog')
if (!dialog) { if (!dialog) {
throw new Error('Dialog not found'); throw new Error('Dialog not found')
} }
return dialog; return dialog
} }
describe('ProviderForm', () => { describe('ProviderForm', () => {
it('renders form fields in create mode', () => { it('renders form fields in create mode', () => {
render(<ProviderForm {...defaultProps} />); render(<ProviderForm {...defaultProps} />)
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('ID')).toBeInTheDocument()
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).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()
expect(within(dialog).getByPlaceholderText('例如: https://api.openai.com/v1')).toBeInTheDocument(); expect(within(dialog).getByPlaceholderText('例如: https://api.openai.com/v1')).toBeInTheDocument()
}); })
it('renders pre-filled fields in edit mode', () => { it('renders pre-filled fields in edit mode', () => {
render(<ProviderForm {...defaultProps} provider={mockProvider} />); render(<ProviderForm {...defaultProps} provider={mockProvider} />)
const dialog = getDialog(); const dialog = getDialog()
expect(within(dialog).getByText('编辑供应商')).toBeInTheDocument(); expect(within(dialog).getByText('编辑供应商')).toBeInTheDocument()
const idInput = within(dialog).getByPlaceholderText('例如: openai') as HTMLInputElement; const idInput = within(dialog).getByPlaceholderText('例如: openai') as HTMLInputElement
expect(idInput.value).toBe('openai'); expect(idInput.value).toBe('openai')
expect(idInput).toBeDisabled(); expect(idInput).toBeDisabled()
const nameInput = within(dialog).getByPlaceholderText('例如: OpenAI') as HTMLInputElement; const nameInput = within(dialog).getByPlaceholderText('例如: OpenAI') as HTMLInputElement
expect(nameInput.value).toBe('OpenAI'); expect(nameInput.value).toBe('OpenAI')
const baseUrlInput = within(dialog).getByPlaceholderText('例如: https://api.openai.com/v1') as HTMLInputElement; const baseUrlInput = within(dialog).getByPlaceholderText('例如: https://api.openai.com/v1') as HTMLInputElement
expect(baseUrlInput.value).toBe('https://api.openai.com/v1'); expect(baseUrlInput.value).toBe('https://api.openai.com/v1')
const apiKeyInput = within(dialog).getByPlaceholderText('sk-...') as HTMLInputElement; const apiKeyInput = within(dialog).getByPlaceholderText('sk-...') as HTMLInputElement
expect(apiKeyInput.value).toBe('sk-old-key'); expect(apiKeyInput.value).toBe('sk-old-key')
}); })
it('shows API Key label in edit mode', () => { it('shows API Key label in edit mode', () => {
render(<ProviderForm {...defaultProps} provider={mockProvider} />); render(<ProviderForm {...defaultProps} provider={mockProvider} />)
const dialog = getDialog(); const dialog = getDialog()
expect(within(dialog).getByText('API Key')).toBeInTheDocument(); expect(within(dialog).getByText('API Key')).toBeInTheDocument()
}); })
it('shows validation error messages for required fields', async () => { it('shows validation error messages for required fields', async () => {
const user = userEvent.setup(); const user = userEvent.setup()
render(<ProviderForm {...defaultProps} />); render(<ProviderForm {...defaultProps} />)
const dialog = getDialog(); const dialog = getDialog()
const okButton = within(dialog).getByRole('button', { name: /保/ }); const okButton = within(dialog).getByRole('button', { name: /保/ })
await user.click(okButton); await user.click(okButton)
// Wait for validation messages to appear // Wait for validation messages to appear
expect(await screen.findByText('请输入供应商 ID')).toBeInTheDocument(); expect(await screen.findByText('请输入供应商 ID')).toBeInTheDocument()
expect(screen.getByText('请输入名称')).toBeInTheDocument(); expect(screen.getByText('请输入名称')).toBeInTheDocument()
expect(screen.getByText('请输入 API Key')).toBeInTheDocument(); expect(screen.getByText('请输入 API Key')).toBeInTheDocument()
expect(screen.getByText('请输入 Base URL')).toBeInTheDocument(); expect(screen.getByText('请输入 Base URL')).toBeInTheDocument()
}); })
it('calls onSave with form values on successful submission', async () => { it('calls onSave with form values on successful submission', async () => {
const onSave = vi.fn(); const onSave = vi.fn()
render(<ProviderForm {...defaultProps} onSave={onSave} />); render(<ProviderForm {...defaultProps} onSave={onSave} />)
const dialog = getDialog(); const dialog = getDialog()
// Get form instance and set values directly // Get form instance and set values directly
const idInput = within(dialog).getByPlaceholderText('例如: openai') as HTMLInputElement; const idInput = within(dialog).getByPlaceholderText('例如: openai') as HTMLInputElement
const nameInput = within(dialog).getByPlaceholderText('例如: OpenAI') as HTMLInputElement; const nameInput = within(dialog).getByPlaceholderText('例如: OpenAI') as HTMLInputElement
const apiKeyInput = within(dialog).getByPlaceholderText('sk-...') as HTMLInputElement; const apiKeyInput = within(dialog).getByPlaceholderText('sk-...') as HTMLInputElement
const baseUrlInput = within(dialog).getByPlaceholderText('例如: https://api.openai.com/v1') as HTMLInputElement; const baseUrlInput = within(dialog).getByPlaceholderText('例如: https://api.openai.com/v1') as HTMLInputElement
// Simulate user input by directly setting values // Simulate user input by directly setting values
fireEvent.change(idInput, { target: { value: 'test-provider' } }); fireEvent.change(idInput, { target: { value: 'test-provider' } })
fireEvent.change(nameInput, { target: { value: 'Test Provider' } }); fireEvent.change(nameInput, { target: { value: 'Test Provider' } })
fireEvent.change(apiKeyInput, { target: { value: 'sk-test-key' } }); fireEvent.change(apiKeyInput, { target: { value: 'sk-test-key' } })
fireEvent.change(baseUrlInput, { target: { value: 'https://api.test.com/v1' } }); fireEvent.change(baseUrlInput, { target: { value: 'https://api.test.com/v1' } })
const okButton = within(dialog).getByRole('button', { name: /保/ }); const okButton = within(dialog).getByRole('button', { name: /保/ })
fireEvent.click(okButton); fireEvent.click(okButton)
// Wait for the onSave to be called // Wait for the onSave to be called
await vi.waitFor(() => { await vi.waitFor(
expect(onSave).toHaveBeenCalled(); () => {
}, { timeout: 5000 }); expect(onSave).toHaveBeenCalled()
}, 10000); },
{ timeout: 5000 }
)
}, 10000)
it('calls onCancel when clicking cancel button', async () => { it('calls onCancel when clicking cancel button', async () => {
const user = userEvent.setup(); const user = userEvent.setup()
const onCancel = vi.fn(); const onCancel = vi.fn()
render(<ProviderForm {...defaultProps} onCancel={onCancel} />); render(<ProviderForm {...defaultProps} onCancel={onCancel} />)
const dialog = getDialog(); const dialog = getDialog()
const cancelButton = within(dialog).getByRole('button', { name: /取/ }); const cancelButton = within(dialog).getByRole('button', { name: /取/ })
await user.click(cancelButton); await user.click(cancelButton)
expect(onCancel).toHaveBeenCalledTimes(1); expect(onCancel).toHaveBeenCalledTimes(1)
}); })
it('shows confirm loading state', () => { it('shows confirm loading state', () => {
render(<ProviderForm {...defaultProps} loading={true} />); render(<ProviderForm {...defaultProps} loading={true} />)
const dialog = getDialog(); const dialog = getDialog()
const okButton = within(dialog).getByRole('button', { name: /保/ }); const okButton = within(dialog).getByRole('button', { name: /保/ })
// TDesign uses t-is-loading class for loading state // TDesign uses t-is-loading class for loading state
expect(okButton).toHaveClass('t-is-loading'); expect(okButton).toHaveClass('t-is-loading')
}); })
it('shows validation error for invalid URL format', async () => { it('shows validation error for invalid URL format', async () => {
const user = userEvent.setup(); const user = userEvent.setup()
render(<ProviderForm {...defaultProps} />); render(<ProviderForm {...defaultProps} />)
const dialog = getDialog(); const dialog = getDialog()
// Fill in required fields // Fill in required fields
await user.type(within(dialog).getByPlaceholderText('例如: openai'), 'test-provider'); await user.type(within(dialog).getByPlaceholderText('例如: openai'), 'test-provider')
await user.type(within(dialog).getByPlaceholderText('例如: OpenAI'), 'Test Provider'); await user.type(within(dialog).getByPlaceholderText('例如: OpenAI'), 'Test Provider')
await user.type(within(dialog).getByPlaceholderText('sk-...'), 'sk-test-key'); await user.type(within(dialog).getByPlaceholderText('sk-...'), 'sk-test-key')
// Enter an invalid URL in the Base URL field // Enter an invalid URL in the Base URL field
await user.type(within(dialog).getByPlaceholderText('例如: https://api.openai.com/v1'), 'not-a-url'); await user.type(within(dialog).getByPlaceholderText('例如: https://api.openai.com/v1'), 'not-a-url')
// Submit the form // Submit the form
const okButton = within(dialog).getByRole('button', { name: /保/ }); const okButton = within(dialog).getByRole('button', { name: /保/ })
await user.click(okButton); await user.click(okButton)
// Verify that a URL validation error message appears // Verify that a URL validation error message appears
await vi.waitFor(() => { await vi.waitFor(() => {
expect(screen.getByText('请输入有效的 URL')).toBeInTheDocument(); expect(screen.getByText('请输入有效的 URL')).toBeInTheDocument()
}); })
}, 15000); }, 15000)
it('renders protocol select field with default value', () => { it('renders protocol select field with default value', () => {
render(<ProviderForm {...defaultProps} />); render(<ProviderForm {...defaultProps} />)
const dialog = getDialog(); const dialog = getDialog()
expect(within(dialog).getByText('协议')).toBeInTheDocument(); expect(within(dialog).getByText('协议')).toBeInTheDocument()
}); })
it('includes protocol field in form submission', async () => { it('includes protocol field in form submission', async () => {
const onSave = vi.fn(); const onSave = vi.fn()
render(<ProviderForm {...defaultProps} onSave={onSave} />); render(<ProviderForm {...defaultProps} onSave={onSave} />)
const dialog = getDialog(); const dialog = getDialog()
// Get form instance and set values directly // Get form instance and set values directly
const idInput = within(dialog).getByPlaceholderText('例如: openai') as HTMLInputElement; const idInput = within(dialog).getByPlaceholderText('例如: openai') as HTMLInputElement
const nameInput = within(dialog).getByPlaceholderText('例如: OpenAI') as HTMLInputElement; const nameInput = within(dialog).getByPlaceholderText('例如: OpenAI') as HTMLInputElement
const apiKeyInput = within(dialog).getByPlaceholderText('sk-...') as HTMLInputElement; const apiKeyInput = within(dialog).getByPlaceholderText('sk-...') as HTMLInputElement
const baseUrlInput = within(dialog).getByPlaceholderText('例如: https://api.openai.com/v1') as HTMLInputElement; const baseUrlInput = within(dialog).getByPlaceholderText('例如: https://api.openai.com/v1') as HTMLInputElement
// Simulate user input by directly setting values // Simulate user input by directly setting values
fireEvent.change(idInput, { target: { value: 'test-provider' } }); fireEvent.change(idInput, { target: { value: 'test-provider' } })
fireEvent.change(nameInput, { target: { value: 'Test Provider' } }); fireEvent.change(nameInput, { target: { value: 'Test Provider' } })
fireEvent.change(apiKeyInput, { target: { value: 'sk-test-key' } }); fireEvent.change(apiKeyInput, { target: { value: 'sk-test-key' } })
fireEvent.change(baseUrlInput, { target: { value: 'https://api.test.com/v1' } }); fireEvent.change(baseUrlInput, { target: { value: 'https://api.test.com/v1' } })
const okButton = within(dialog).getByRole('button', { name: /保/ }); const okButton = within(dialog).getByRole('button', { name: /保/ })
fireEvent.click(okButton); fireEvent.click(okButton)
// Wait for the onSave to be called // Wait for the onSave to be called
await vi.waitFor(() => { await vi.waitFor(
expect(onSave).toHaveBeenCalled(); () => {
expect(onSave).toHaveBeenCalled()
// Verify that the saved data includes a protocol field // Verify that the saved data includes a protocol field
const savedData = onSave.mock.calls[0][0]; const savedData = onSave.mock.calls[0][0]
expect(savedData).toHaveProperty('protocol'); expect(savedData).toHaveProperty('protocol')
}, { timeout: 5000 }); },
}, 10000); { timeout: 5000 }
}); )
}, 10000)
})

View File

@@ -1,18 +1,24 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi } from 'vitest'
import { ProviderTable } from '@/pages/Providers/ProviderTable'; 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, unifiedId: 'openai/gpt-4o' }, { 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, unifiedId: 'openai/gpt-3.5-turbo' }, {
]; 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', () => ({
useModels: vi.fn(() => ({ data: mockModelsData, isLoading: false })), useModels: vi.fn(() => ({ data: mockModelsData, isLoading: false })),
useDeleteModel: vi.fn(() => ({ mutate: vi.fn() })), useDeleteModel: vi.fn(() => ({ mutate: vi.fn() })),
})); }))
const mockProviders: Provider[] = [ const mockProviders: Provider[] = [
{ {
@@ -35,7 +41,7 @@ const mockProviders: Provider[] = [
createdAt: '2024-01-02T00:00:00Z', createdAt: '2024-01-02T00:00:00Z',
updatedAt: '2024-01-02T00:00:00Z', updatedAt: '2024-01-02T00:00:00Z',
}, },
]; ]
const defaultProps = { const defaultProps = {
providers: mockProviders, providers: mockProviders,
@@ -45,36 +51,36 @@ const defaultProps = {
onDelete: vi.fn(), onDelete: vi.fn(),
onAddModel: vi.fn(), onAddModel: vi.fn(),
onEditModel: vi.fn(), onEditModel: vi.fn(),
}; }
describe('ProviderTable', () => { describe('ProviderTable', () => {
it('renders provider list with name, baseUrl, apiKey, and status tags', () => { it('renders provider list with name, baseUrl, apiKey, and status tags', () => {
render(<ProviderTable {...defaultProps} />); render(<ProviderTable {...defaultProps} />)
expect(screen.getByText('供应商列表')).toBeInTheDocument(); expect(screen.getByText('供应商列表')).toBeInTheDocument()
expect(screen.getAllByText('OpenAI').length).toBeGreaterThan(0); 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('sk-abcdefgh12345678')).toBeInTheDocument(); expect(screen.getByText('sk-abcdefgh12345678')).toBeInTheDocument()
expect(screen.getAllByText('Anthropic').length).toBeGreaterThan(0); 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('sk-ant-test')).toBeInTheDocument(); expect(screen.getByText('sk-ant-test')).toBeInTheDocument()
const enabledTags = screen.getAllByText('启用'); const enabledTags = screen.getAllByText('启用')
const disabledTags = screen.getAllByText('禁用'); const disabledTags = screen.getAllByText('禁用')
expect(enabledTags.length).toBeGreaterThanOrEqual(1); expect(enabledTags.length).toBeGreaterThanOrEqual(1)
expect(disabledTags.length).toBeGreaterThanOrEqual(1); expect(disabledTags.length).toBeGreaterThanOrEqual(1)
}); })
it('renders within a Card component', () => { it('renders within a Card component', () => {
const { container } = render(<ProviderTable {...defaultProps} />); const { container } = render(<ProviderTable {...defaultProps} />)
// TDesign Card component // TDesign Card component
expect(container.querySelector('.t-card')).toBeInTheDocument(); expect(container.querySelector('.t-card')).toBeInTheDocument()
expect(container.querySelector('.t-card__header')).toBeInTheDocument(); expect(container.querySelector('.t-card__header')).toBeInTheDocument()
expect(container.querySelector('.t-card__body')).toBeInTheDocument(); expect(container.querySelector('.t-card__body')).toBeInTheDocument()
}); })
it('renders short api keys directly', () => { it('renders short api keys directly', () => {
const shortKeyProvider: Provider[] = [ const shortKeyProvider: Provider[] = [
@@ -84,99 +90,99 @@ describe('ProviderTable', () => {
name: 'ShortKey', name: 'ShortKey',
apiKey: 'ab', apiKey: 'ab',
}, },
]; ]
render(<ProviderTable {...defaultProps} providers={shortKeyProvider} />); render(<ProviderTable {...defaultProps} providers={shortKeyProvider} />)
expect(screen.getByText('ab')).toBeInTheDocument(); expect(screen.getByText('ab')).toBeInTheDocument()
}); })
it('calls onAdd when clicking "添加供应商" button', async () => { it('calls onAdd when clicking "添加供应商" button', async () => {
const user = userEvent.setup(); const user = userEvent.setup()
const onAdd = vi.fn(); const onAdd = vi.fn()
render(<ProviderTable {...defaultProps} onAdd={onAdd} />); render(<ProviderTable {...defaultProps} onAdd={onAdd} />)
await user.click(screen.getByRole('button', { name: '添加供应商' })); await user.click(screen.getByRole('button', { name: '添加供应商' }))
expect(onAdd).toHaveBeenCalledTimes(1); expect(onAdd).toHaveBeenCalledTimes(1)
}); })
it('calls onEdit with correct provider when clicking "编辑"', async () => { it('calls onEdit with correct provider when clicking "编辑"', async () => {
const user = userEvent.setup(); const user = userEvent.setup()
const onEdit = vi.fn(); const onEdit = vi.fn()
render(<ProviderTable {...defaultProps} onEdit={onEdit} />); render(<ProviderTable {...defaultProps} onEdit={onEdit} />)
const editButtons = screen.getAllByRole('button', { name: /编 ?辑/ }); const editButtons = screen.getAllByRole('button', { name: /编 ?辑/ })
await user.click(editButtons[0]); await user.click(editButtons[0])
expect(onEdit).toHaveBeenCalledTimes(1); expect(onEdit).toHaveBeenCalledTimes(1)
expect(onEdit).toHaveBeenCalledWith(mockProviders[0]); expect(onEdit).toHaveBeenCalledWith(mockProviders[0])
}); })
it('calls onDelete with correct provider ID when delete is confirmed', async () => { it('calls onDelete with correct provider ID when delete is confirmed', async () => {
const user = userEvent.setup(); const user = userEvent.setup()
const onDelete = vi.fn(); const onDelete = vi.fn()
render(<ProviderTable {...defaultProps} onDelete={onDelete} />); render(<ProviderTable {...defaultProps} onDelete={onDelete} />)
// Find and click the delete button for the first row // Find and click the delete button for the first row
const deleteButtons = screen.getAllByRole('button', { name: '删除' }); const deleteButtons = screen.getAllByRole('button', { name: '删除' })
await user.click(deleteButtons[0]); await user.click(deleteButtons[0])
// TDesign Popconfirm renders confirmation popup with "确定" button // TDesign Popconfirm renders confirmation popup with "确定" button
const confirmButton = await screen.findByRole('button', { name: '确定' }); const confirmButton = await screen.findByRole('button', { name: '确定' })
await user.click(confirmButton); await user.click(confirmButton)
// Assert that onDelete was called with the correct provider ID // Assert that onDelete was called with the correct provider ID
expect(onDelete).toHaveBeenCalledTimes(1); expect(onDelete).toHaveBeenCalledTimes(1)
expect(onDelete).toHaveBeenCalledWith('openai'); expect(onDelete).toHaveBeenCalledWith('openai')
}, 10000); }, 10000)
it('shows loading state', () => { it('shows loading state', () => {
const { container } = render(<ProviderTable {...defaultProps} loading={true} />); const { container } = render(<ProviderTable {...defaultProps} loading={true} />)
// TDesign Table loading indicator // TDesign Table loading indicator
const loadingElement = container.querySelector('.t-table__loading') || container.querySelector('.t-loading'); const loadingElement = container.querySelector('.t-table__loading') || container.querySelector('.t-loading')
expect(loadingElement).toBeInTheDocument(); expect(loadingElement).toBeInTheDocument()
}); })
it('renders expandable ModelTable when row is expanded', async () => { it('renders expandable ModelTable when row is expanded', async () => {
const user = userEvent.setup(); const user = userEvent.setup()
const { container } = render(<ProviderTable {...defaultProps} />); const { container } = render(<ProviderTable {...defaultProps} />)
// TDesign Table expand icon is rendered as a button with specific class // TDesign Table expand icon is rendered as a button with specific class
const expandIcon = container.querySelector('.t-table__expandable-icon'); const expandIcon = container.querySelector('.t-table__expandable-icon')
if (expandIcon) { if (expandIcon) {
await user.click(expandIcon); await user.click(expandIcon)
// Verify that ModelTable content is rendered with data from mocked useModels // Verify that ModelTable content is rendered with data from mocked useModels
expect(await screen.findByText('gpt-4o')).toBeInTheDocument(); expect(await screen.findByText('gpt-4o')).toBeInTheDocument()
expect(screen.getByText('gpt-3.5-turbo')).toBeInTheDocument(); expect(screen.getByText('gpt-3.5-turbo')).toBeInTheDocument()
} else { } else {
// If no expand icon found, the test should still pass as expandable rows are optional // If no expand icon found, the test should still pass as expandable rows are optional
expect(true).toBe(true); expect(true).toBe(true)
} }
}); })
it('sets fixed width and ellipsis on name column', () => { it('sets fixed width and ellipsis on name column', () => {
const { container } = render(<ProviderTable {...defaultProps} />); const { container } = render(<ProviderTable {...defaultProps} />)
// TDesign Table // TDesign Table
const table = container.querySelector('.t-table'); const table = container.querySelector('.t-table')
expect(table).toBeInTheDocument(); expect(table).toBeInTheDocument()
}); })
it('shows custom empty text when providers list is empty', () => { it('shows custom empty text when providers list is empty', () => {
render(<ProviderTable {...defaultProps} providers={[]} />); render(<ProviderTable {...defaultProps} providers={[]} />)
expect(screen.getByText('暂无供应商,点击上方按钮添加')).toBeInTheDocument(); expect(screen.getByText('暂无供应商,点击上方按钮添加')).toBeInTheDocument()
}); })
it('renders protocol column with correct tags', () => { it('renders protocol column with correct tags', () => {
const { container } = render(<ProviderTable {...defaultProps} />); const { container } = render(<ProviderTable {...defaultProps} />)
// Check that protocol tags are displayed in the table // Check that protocol tags are displayed in the table
const protocolCells = container.querySelectorAll('[data-colkey="protocol"]'); const protocolCells = container.querySelectorAll('[data-colkey="protocol"]')
expect(protocolCells.length).toBeGreaterThan(0); expect(protocolCells.length).toBeGreaterThan(0)
// Verify protocol tags exist // Verify protocol tags exist
const tags = container.querySelectorAll('.t-tag'); const tags = container.querySelectorAll('.t-tag')
expect(tags.length).toBeGreaterThan(0); expect(tags.length).toBeGreaterThan(0)
}); })
it('displays protocol tag for each provider', () => { it('displays protocol tag for each provider', () => {
const singleProvider: Provider[] = [ const singleProvider: Provider[] = [
@@ -190,11 +196,11 @@ describe('ProviderTable', () => {
createdAt: '2024-01-01T00:00:00Z', createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z',
}, },
]; ]
const { container } = render(<ProviderTable {...defaultProps} providers={singleProvider} />); const { container } = render(<ProviderTable {...defaultProps} providers={singleProvider} />)
// Should display protocol column // Should display protocol column
const protocolCell = container.querySelector('[data-colkey="protocol"]'); const protocolCell = container.querySelector('[data-colkey="protocol"]')
expect(protocolCell).toBeInTheDocument(); expect(protocolCell).toBeInTheDocument()
}); })
}); })

View File

@@ -1,7 +1,7 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react'
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest'
import { StatCards } from '@/pages/Stats/StatCards'; import { StatCards } from '@/pages/Stats/StatCards'
import type { UsageStats } from '@/types'; import type { UsageStats } from '@/types'
const mockStats: UsageStats[] = [ const mockStats: UsageStats[] = [
{ {
@@ -25,31 +25,31 @@ const mockStats: UsageStats[] = [
requestCount: 150, requestCount: 150,
date: '2024-01-02', date: '2024-01-02',
}, },
]; ]
describe('StatCards', () => { describe('StatCards', () => {
it('renders all statistic cards', () => { it('renders all statistic cards', () => {
render(<StatCards stats={mockStats} />); render(<StatCards stats={mockStats} />)
expect(screen.getByText('总请求量')).toBeInTheDocument(); expect(screen.getByText('总请求量')).toBeInTheDocument()
expect(screen.getByText('活跃模型数')).toBeInTheDocument(); expect(screen.getByText('活跃模型数')).toBeInTheDocument()
expect(screen.getByText('活跃供应商数')).toBeInTheDocument(); expect(screen.getByText('活跃供应商数')).toBeInTheDocument()
expect(screen.getByText('今日请求量')).toBeInTheDocument(); expect(screen.getByText('今日请求量')).toBeInTheDocument()
}); })
it('renders with empty stats', () => { it('renders with empty stats', () => {
render(<StatCards stats={[]} />); render(<StatCards stats={[]} />)
expect(screen.getByText('总请求量')).toBeInTheDocument(); expect(screen.getByText('总请求量')).toBeInTheDocument()
expect(screen.getByText('活跃模型数')).toBeInTheDocument(); expect(screen.getByText('活跃模型数')).toBeInTheDocument()
expect(screen.getByText('活跃供应商数')).toBeInTheDocument(); expect(screen.getByText('活跃供应商数')).toBeInTheDocument()
expect(screen.getByText('今日请求量')).toBeInTheDocument(); expect(screen.getByText('今日请求量')).toBeInTheDocument()
}); })
it('renders suffix units', () => { it('renders suffix units', () => {
render(<StatCards stats={mockStats} />); render(<StatCards stats={mockStats} />)
expect(screen.getAllByText('次').length).toBeGreaterThan(0); expect(screen.getAllByText('次').length).toBeGreaterThan(0)
expect(screen.getAllByText('个').length).toBeGreaterThan(0); expect(screen.getAllByText('个').length).toBeGreaterThan(0)
}); })
}); })

View File

@@ -1,7 +1,7 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi } from 'vitest'
import { StatsTable } from '@/pages/Stats/StatsTable'; import { StatsTable } from '@/pages/Stats/StatsTable'
import type { Provider, UsageStats } from '@/types'; import type { Provider, UsageStats } from '@/types'
const mockProviders: Provider[] = [ const mockProviders: Provider[] = [
{ {
@@ -24,7 +24,7 @@ const mockProviders: Provider[] = [
createdAt: '2024-01-02T00:00:00Z', createdAt: '2024-01-02T00:00:00Z',
updatedAt: '2024-01-02T00:00:00Z', updatedAt: '2024-01-02T00:00:00Z',
}, },
]; ]
const mockStats: UsageStats[] = [ const mockStats: UsageStats[] = [
{ {
@@ -41,7 +41,7 @@ const mockStats: UsageStats[] = [
requestCount: 50, requestCount: 50,
date: '2024-01-15', date: '2024-01-15',
}, },
]; ]
const defaultProps = { const defaultProps = {
providers: mockProviders, providers: mockProviders,
@@ -53,80 +53,80 @@ const defaultProps = {
onProviderIdChange: vi.fn(), onProviderIdChange: vi.fn(),
onModelNameChange: vi.fn(), onModelNameChange: vi.fn(),
onDateRangeChange: vi.fn(), onDateRangeChange: vi.fn(),
}; }
describe('StatsTable', () => { describe('StatsTable', () => {
it('renders stats table with data', () => { it('renders stats table with data', () => {
render(<StatsTable {...defaultProps} />); render(<StatsTable {...defaultProps} />)
expect(screen.getByText('gpt-4o')).toBeInTheDocument(); expect(screen.getByText('gpt-4o')).toBeInTheDocument()
expect(screen.getByText('claude-3-opus')).toBeInTheDocument(); expect(screen.getByText('claude-3-opus')).toBeInTheDocument()
const dateCells = screen.getAllByText('2024-01-15'); const dateCells = screen.getAllByText('2024-01-15')
expect(dateCells.length).toBe(2); expect(dateCells.length).toBe(2)
expect(screen.getByText('100')).toBeInTheDocument(); expect(screen.getByText('100')).toBeInTheDocument()
expect(screen.getByText('50')).toBeInTheDocument(); expect(screen.getByText('50')).toBeInTheDocument()
}); })
it('shows provider name from providers prop instead of providerId', () => { it('shows provider name from providers prop instead of providerId', () => {
render(<StatsTable {...defaultProps} />); render(<StatsTable {...defaultProps} />)
expect(screen.getByText('OpenAI')).toBeInTheDocument(); expect(screen.getByText('OpenAI')).toBeInTheDocument()
const allAnthropic = screen.getAllByText('Anthropic'); const allAnthropic = screen.getAllByText('Anthropic')
expect(allAnthropic.length).toBeGreaterThanOrEqual(1); expect(allAnthropic.length).toBeGreaterThanOrEqual(1)
}); })
it('renders filter controls with Select, Input, and DatePicker', () => { it('renders filter controls with Select, Input, and DatePicker', () => {
const { container } = render(<StatsTable {...defaultProps} />); const { container } = render(<StatsTable {...defaultProps} />)
// TDesign Select component // TDesign Select component
const selects = document.querySelectorAll('.t-select'); const selects = document.querySelectorAll('.t-select')
expect(selects.length).toBeGreaterThanOrEqual(1); expect(selects.length).toBeGreaterThanOrEqual(1)
const modelInput = screen.getByPlaceholderText('模型名称'); const modelInput = screen.getByPlaceholderText('模型名称')
expect(modelInput).toBeInTheDocument(); expect(modelInput).toBeInTheDocument()
// TDesign Select placeholder is shown in the input // TDesign Select placeholder is shown in the input
const selectInput = document.querySelector('.t-select .t-input__inner'); const selectInput = document.querySelector('.t-select .t-input__inner')
expect(selectInput).toBeInTheDocument(); expect(selectInput).toBeInTheDocument()
// TDesign DateRangePicker - could be .t-date-picker or .t-range-input // TDesign DateRangePicker - could be .t-date-picker or .t-range-input
const rangePicker = container.querySelector('.t-date-picker') || container.querySelector('.t-range-input'); const rangePicker = container.querySelector('.t-date-picker') || container.querySelector('.t-range-input')
expect(rangePicker).toBeInTheDocument(); expect(rangePicker).toBeInTheDocument()
}); })
it('renders table headers correctly', () => { it('renders table headers correctly', () => {
render(<StatsTable {...defaultProps} />); render(<StatsTable {...defaultProps} />)
expect(screen.getAllByText('供应商').length).toBeGreaterThanOrEqual(1); expect(screen.getAllByText('供应商').length).toBeGreaterThanOrEqual(1)
expect(screen.getAllByText('模型').length).toBeGreaterThanOrEqual(1); expect(screen.getAllByText('模型').length).toBeGreaterThanOrEqual(1)
expect(screen.getAllByText('日期').length).toBeGreaterThanOrEqual(1); expect(screen.getAllByText('日期').length).toBeGreaterThanOrEqual(1)
expect(screen.getAllByText('请求数').length).toBeGreaterThanOrEqual(1); expect(screen.getAllByText('请求数').length).toBeGreaterThanOrEqual(1)
}); })
it('falls back to providerId when provider not found in providers prop', () => { it('falls back to providerId when provider not found in providers prop', () => {
const limitedProviders = [mockProviders[0]]; const limitedProviders = [mockProviders[0]]
render(<StatsTable {...defaultProps} providers={limitedProviders} />); render(<StatsTable {...defaultProps} providers={limitedProviders} />)
expect(screen.getByText('OpenAI')).toBeInTheDocument(); expect(screen.getByText('OpenAI')).toBeInTheDocument()
expect(screen.getByText('anthropic')).toBeInTheDocument(); expect(screen.getByText('anthropic')).toBeInTheDocument()
}); })
it('renders with empty stats data', () => { it('renders with empty stats data', () => {
render(<StatsTable {...defaultProps} stats={[]} />); render(<StatsTable {...defaultProps} stats={[]} />)
expect(screen.getAllByText('供应商').length).toBeGreaterThanOrEqual(1); expect(screen.getAllByText('供应商').length).toBeGreaterThanOrEqual(1)
expect(screen.getAllByText('模型').length).toBeGreaterThanOrEqual(1); expect(screen.getAllByText('模型').length).toBeGreaterThanOrEqual(1)
}); })
it('shows loading state', () => { it('shows loading state', () => {
const { container } = render(<StatsTable {...defaultProps} loading={true} />); const { container } = render(<StatsTable {...defaultProps} loading={true} />)
// TDesign Table loading indicator - could be .t-table__loading or .t-loading // TDesign Table loading indicator - could be .t-table__loading or .t-loading
const loadingElement = container.querySelector('.t-table__loading') || container.querySelector('.t-loading'); const loadingElement = container.querySelector('.t-table__loading') || container.querySelector('.t-loading')
expect(loadingElement).toBeInTheDocument(); expect(loadingElement).toBeInTheDocument()
}); })
it('shows custom empty text when stats data is empty', () => { it('shows custom empty text when stats data is empty', () => {
render(<StatsTable {...defaultProps} stats={[]} />); render(<StatsTable {...defaultProps} stats={[]} />)
expect(screen.getByText('暂无统计数据')).toBeInTheDocument(); expect(screen.getByText('暂无统计数据')).toBeInTheDocument()
}); })
}); })

View File

@@ -1,18 +1,18 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi } from 'vitest'
import { UsageChart } from '@/pages/Stats/UsageChart'; import { UsageChart } from '@/pages/Stats/UsageChart'
import type { UsageStats } from '@/types'; import type { UsageStats } from '@/types'
// Mock Recharts components // Mock Recharts components
vi.mock('recharts', () => ({ vi.mock('recharts', () => ({
ResponsiveContainer: vi.fn(({ children }) => <div data-testid="mock-chart-container">{children}</div>), ResponsiveContainer: vi.fn(({ children }) => <div data-testid='mock-chart-container'>{children}</div>),
AreaChart: vi.fn(() => <div data-testid="mock-area-chart" />), AreaChart: vi.fn(() => <div data-testid='mock-area-chart' />),
Area: vi.fn(() => null), Area: vi.fn(() => null),
XAxis: vi.fn(() => null), XAxis: vi.fn(() => null),
YAxis: vi.fn(() => null), YAxis: vi.fn(() => null),
CartesianGrid: vi.fn(() => null), CartesianGrid: vi.fn(() => null),
Tooltip: vi.fn(() => null), Tooltip: vi.fn(() => null),
})); }))
const mockStats: UsageStats[] = [ const mockStats: UsageStats[] = [
{ {
@@ -36,36 +36,36 @@ const mockStats: UsageStats[] = [
requestCount: 150, requestCount: 150,
date: '2024-01-02', date: '2024-01-02',
}, },
]; ]
describe('UsageChart', () => { describe('UsageChart', () => {
it('renders chart title', () => { it('renders chart title', () => {
render(<UsageChart stats={mockStats} />); render(<UsageChart stats={mockStats} />)
expect(screen.getByText('请求趋势')).toBeInTheDocument(); expect(screen.getByText('请求趋势')).toBeInTheDocument()
}); })
it('renders with data', () => { it('renders with data', () => {
const { container } = render(<UsageChart stats={mockStats} />); const { container } = render(<UsageChart stats={mockStats} />)
// TDesign Card component // TDesign Card component
expect(container.querySelector('.t-card')).toBeInTheDocument(); expect(container.querySelector('.t-card')).toBeInTheDocument()
// Mocked chart container // Mocked chart container
expect(screen.getByTestId('mock-chart-container')).toBeInTheDocument(); expect(screen.getByTestId('mock-chart-container')).toBeInTheDocument()
}); })
it('renders empty state when no data', () => { it('renders empty state when no data', () => {
render(<UsageChart stats={[]} />); render(<UsageChart stats={[]} />)
expect(screen.getByText('暂无数据')).toBeInTheDocument(); expect(screen.getByText('暂无数据')).toBeInTheDocument()
}); })
it('aggregates data by date correctly', () => { it('aggregates data by date correctly', () => {
const { container } = render(<UsageChart stats={mockStats} />); const { container } = render(<UsageChart stats={mockStats} />)
// TDesign Card component // TDesign Card component
expect(container.querySelector('.t-card')).toBeInTheDocument(); expect(container.querySelector('.t-card')).toBeInTheDocument()
// Mocked chart should render // Mocked chart should render
expect(screen.getByTestId('mock-chart-container')).toBeInTheDocument(); expect(screen.getByTestId('mock-chart-container')).toBeInTheDocument()
}); })
}); })

View File

@@ -1,8 +1,6 @@
import { RuleTester } from '@typescript-eslint/rule-tester' import { RuleTester } from '@typescript-eslint/rule-tester'
import { describe, it, afterAll } from 'vitest' import { describe, it, afterAll } from 'vitest'
import rule, { import rule, { RULE_NAME } from '../../../eslint-rules/rules/no-hardcoded-color-in-style.js'
RULE_NAME,
} from '../../../eslint-rules/rules/no-hardcoded-color-in-style.js'
RuleTester.it = it RuleTester.it = it
RuleTester.describe = describe RuleTester.describe = describe

View File

@@ -1,11 +1,11 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { renderHook, waitFor } from '@testing-library/react'; import { renderHook, waitFor } from '@testing-library/react'
import { http, HttpResponse } from 'msw'; import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'; import { setupServer } from 'msw/node'
import React from 'react'; import React from 'react'
import { MessagePlugin } from 'tdesign-react'; import { MessagePlugin } from 'tdesign-react'
import { useModels, useCreateModel, useUpdateModel, useDeleteModel } from '@/hooks/useModels'; import { useModels, useCreateModel, useUpdateModel, useDeleteModel } from '@/hooks/useModels'
import type { Model, CreateModelInput, UpdateModelInput } from '@/types'; import type { Model, CreateModelInput, UpdateModelInput } from '@/types'
// Mock MessagePlugin // Mock MessagePlugin
vi.mock('tdesign-react', () => ({ vi.mock('tdesign-react', () => ({
@@ -13,7 +13,7 @@ vi.mock('tdesign-react', () => ({
success: vi.fn(), success: vi.fn(),
error: vi.fn(), error: vi.fn(),
}, },
})); }))
// Test data // Test data
const mockModels: Model[] = [ const mockModels: Model[] = [
@@ -33,7 +33,7 @@ const mockModels: Model[] = [
createdAt: '2026-01-02T00:00:00Z', createdAt: '2026-01-02T00:00:00Z',
unifiedId: 'gpt-4o-mini', unifiedId: 'gpt-4o-mini',
}, },
]; ]
const mockFilteredModels: Model[] = [ const mockFilteredModels: Model[] = [
{ {
@@ -44,7 +44,7 @@ const mockFilteredModels: Model[] = [
createdAt: '2026-02-01T00:00:00Z', createdAt: '2026-02-01T00:00:00Z',
unifiedId: 'claude-sonnet-4-5', unifiedId: 'claude-sonnet-4-5',
}, },
]; ]
const mockCreatedModel: Model = { const mockCreatedModel: Model = {
id: 'model-4', id: 'model-4',
@@ -53,36 +53,36 @@ const mockCreatedModel: Model = {
enabled: true, enabled: true,
createdAt: '2026-03-01T00:00:00Z', createdAt: '2026-03-01T00:00:00Z',
unifiedId: 'gpt-4.1', unifiedId: 'gpt-4.1',
}; }
// MSW handlers // MSW handlers
const handlers = [ const handlers = [
http.get('/api/models', ({ request }) => { http.get('/api/models', ({ request }) => {
const url = new URL(request.url); const url = new URL(request.url)
const providerId = url.searchParams.get('provider_id'); const providerId = url.searchParams.get('provider_id')
if (providerId === 'provider-2') { if (providerId === 'provider-2') {
return HttpResponse.json(mockFilteredModels); return HttpResponse.json(mockFilteredModels)
} }
return HttpResponse.json(mockModels); return HttpResponse.json(mockModels)
}), }),
http.post('/api/models', async ({ request }) => { http.post('/api/models', async ({ request }) => {
const body = await request.json() as Record<string, unknown>; const body = (await request.json()) as Record<string, unknown>
return HttpResponse.json({ return HttpResponse.json({
...mockCreatedModel, ...mockCreatedModel,
...body, ...body,
}); })
}), }),
http.put('/api/models/:id', async ({ request, params }) => { http.put('/api/models/:id', async ({ request, params }) => {
const body = await request.json() as Record<string, unknown>; const body = (await request.json()) as Record<string, unknown>
const existing = mockModels.find((m) => m.id === params['id']); const existing = mockModels.find((m) => m.id === params['id'])
return HttpResponse.json({ ...existing, ...body }); return HttpResponse.json({ ...existing, ...body })
}), }),
http.delete('/api/models/:id', () => { http.delete('/api/models/:id', () => {
return new HttpResponse(null, { status: 204 }); return new HttpResponse(null, { status: 204 })
}), }),
]; ]
const server = setupServer(...handlers); const server = setupServer(...handlers)
function createTestQueryClient() { function createTestQueryClient() {
return new QueryClient({ return new QueryClient({
@@ -90,201 +90,185 @@ function createTestQueryClient() {
queries: { retry: false }, queries: { retry: false },
mutations: { retry: false }, mutations: { retry: false },
}, },
}); })
} }
function createWrapper() { function createWrapper() {
const testQueryClient = createTestQueryClient(); const testQueryClient = createTestQueryClient()
return function Wrapper({ children }: { children: React.ReactNode }) { return function Wrapper({ children }: { children: React.ReactNode }) {
return ( return <QueryClientProvider client={testQueryClient}>{children}</QueryClientProvider>
<QueryClientProvider client={testQueryClient}> }
{children}
</QueryClientProvider>
);
};
} }
beforeAll(() => server.listen()); beforeAll(() => server.listen())
afterEach(() => { afterEach(() => {
server.resetHandlers(); server.resetHandlers()
vi.clearAllMocks(); vi.clearAllMocks()
}); })
afterAll(() => server.close()); afterAll(() => server.close())
describe('useModels', () => { describe('useModels', () => {
it('fetches model list', async () => { it('fetches model list', async () => {
const { result } = renderHook(() => useModels(), { const { result } = renderHook(() => useModels(), {
wrapper: createWrapper(), wrapper: createWrapper(),
}); })
await waitFor(() => expect(result.current.isSuccess).toBe(true)); await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(result.current.data).toEqual(mockModels); expect(result.current.data).toEqual(mockModels)
expect(result.current.data).toHaveLength(2); expect(result.current.data).toHaveLength(2)
expect(result.current.data![0]!.modelName).toBe('gpt-4o'); expect(result.current.data![0]!.modelName).toBe('gpt-4o')
}); })
it('with providerId passes it to API and returns filtered models', async () => { it('with providerId passes it to API and returns filtered models', async () => {
const { result } = renderHook(() => useModels('provider-2'), { const { result } = renderHook(() => useModels('provider-2'), {
wrapper: createWrapper(), wrapper: createWrapper(),
}); })
await waitFor(() => expect(result.current.isSuccess).toBe(true)); await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(result.current.data).toEqual(mockFilteredModels); expect(result.current.data).toEqual(mockFilteredModels)
expect(result.current.data).toHaveLength(1); expect(result.current.data).toHaveLength(1)
expect(result.current.data![0]!.modelName).toBe('claude-sonnet-4-5'); expect(result.current.data![0]!.modelName).toBe('claude-sonnet-4-5')
}); })
}); })
describe('useCreateModel', () => { describe('useCreateModel', () => {
it('calls API and invalidates model queries', async () => { it('calls API and invalidates model queries', async () => {
const queryClient = createTestQueryClient(); const queryClient = createTestQueryClient()
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries')
function Wrapper({ children }: { children: React.ReactNode }) { function Wrapper({ children }: { children: React.ReactNode }) {
return ( return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
} }
const { result } = renderHook(() => useCreateModel(), { const { result } = renderHook(() => useCreateModel(), {
wrapper: Wrapper, wrapper: Wrapper,
}); })
const input: CreateModelInput = { const input: CreateModelInput = {
id: 'model-4', id: 'model-4',
providerId: 'provider-1', providerId: 'provider-1',
modelName: 'gpt-4.1', modelName: 'gpt-4.1',
enabled: true, enabled: true,
}; }
result.current.mutate(input); result.current.mutate(input)
await waitFor(() => expect(result.current.isSuccess).toBe(true)); await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(result.current.data).toMatchObject({ expect(result.current.data).toMatchObject({
id: 'model-4', id: 'model-4',
modelName: 'gpt-4.1', modelName: 'gpt-4.1',
}); })
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['models'] }); expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['models'] })
expect(MessagePlugin.success).toHaveBeenCalledWith('模型创建成功'); expect(MessagePlugin.success).toHaveBeenCalledWith('模型创建成功')
}); })
it('calls message.error on failure', async () => { it('calls message.error on failure', async () => {
server.use( server.use(
http.post('/api/models', () => { http.post('/api/models', () => {
return HttpResponse.json({ message: '创建失败' }, { status: 500 }); return HttpResponse.json({ message: '创建失败' }, { status: 500 })
}), })
); )
const { result } = renderHook(() => useCreateModel(), { const { result } = renderHook(() => useCreateModel(), {
wrapper: createWrapper(), wrapper: createWrapper(),
}); })
const input: CreateModelInput = { const input: CreateModelInput = {
id: 'model-4', id: 'model-4',
providerId: 'provider-1', providerId: 'provider-1',
modelName: 'gpt-4.1', modelName: 'gpt-4.1',
enabled: true, enabled: true,
}; }
result.current.mutate(input); result.current.mutate(input)
await waitFor(() => expect(result.current.isError).toBe(true)); await waitFor(() => expect(result.current.isError).toBe(true))
expect(MessagePlugin.error).toHaveBeenCalled(); expect(MessagePlugin.error).toHaveBeenCalled()
}); })
}); })
describe('useUpdateModel', () => { describe('useUpdateModel', () => {
it('calls API and invalidates model queries', async () => { it('calls API and invalidates model queries', async () => {
const queryClient = createTestQueryClient(); const queryClient = createTestQueryClient()
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries')
function Wrapper({ children }: { children: React.ReactNode }) { function Wrapper({ children }: { children: React.ReactNode }) {
return ( return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
} }
const { result } = renderHook(() => useUpdateModel(), { const { result } = renderHook(() => useUpdateModel(), {
wrapper: Wrapper, wrapper: Wrapper,
}); })
const input: UpdateModelInput = { modelName: 'gpt-4o-updated' }; const input: UpdateModelInput = { modelName: 'gpt-4o-updated' }
result.current.mutate({ id: 'model-1', input }); result.current.mutate({ id: 'model-1', input })
await waitFor(() => expect(result.current.isSuccess).toBe(true)); await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(result.current.data).toMatchObject({ expect(result.current.data).toMatchObject({
modelName: 'gpt-4o-updated', modelName: 'gpt-4o-updated',
}); })
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['models'] }); expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['models'] })
expect(MessagePlugin.success).toHaveBeenCalledWith('模型更新成功'); expect(MessagePlugin.success).toHaveBeenCalledWith('模型更新成功')
}); })
it('calls message.error on failure', async () => { it('calls message.error on failure', async () => {
server.use( server.use(
http.put('/api/models/:id', () => { http.put('/api/models/:id', () => {
return HttpResponse.json({ message: '更新失败' }, { status: 500 }); return HttpResponse.json({ message: '更新失败' }, { status: 500 })
}), })
); )
const { result } = renderHook(() => useUpdateModel(), { const { result } = renderHook(() => useUpdateModel(), {
wrapper: createWrapper(), wrapper: createWrapper(),
}); })
result.current.mutate({ id: 'model-1', input: { modelName: 'Updated' } }); result.current.mutate({ id: 'model-1', input: { modelName: 'Updated' } })
await waitFor(() => expect(result.current.isError).toBe(true)); await waitFor(() => expect(result.current.isError).toBe(true))
expect(MessagePlugin.error).toHaveBeenCalled(); expect(MessagePlugin.error).toHaveBeenCalled()
}); })
}); })
describe('useDeleteModel', () => { describe('useDeleteModel', () => {
it('calls API and invalidates model queries', async () => { it('calls API and invalidates model queries', async () => {
const queryClient = createTestQueryClient(); const queryClient = createTestQueryClient()
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries')
function Wrapper({ children }: { children: React.ReactNode }) { function Wrapper({ children }: { children: React.ReactNode }) {
return ( return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
} }
const { result } = renderHook(() => useDeleteModel(), { const { result } = renderHook(() => useDeleteModel(), {
wrapper: Wrapper, wrapper: Wrapper,
}); })
result.current.mutate('model-1'); result.current.mutate('model-1')
await waitFor(() => expect(result.current.isSuccess).toBe(true)); await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['models'] }); expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['models'] })
expect(MessagePlugin.success).toHaveBeenCalledWith('模型删除成功'); expect(MessagePlugin.success).toHaveBeenCalledWith('模型删除成功')
}); })
it('calls message.error on failure', async () => { it('calls message.error on failure', async () => {
server.use( server.use(
http.delete('/api/models/:id', () => { http.delete('/api/models/:id', () => {
return HttpResponse.json({ message: '删除失败' }, { status: 500 }); return HttpResponse.json({ message: '删除失败' }, { status: 500 })
}), })
); )
const { result } = renderHook(() => useDeleteModel(), { const { result } = renderHook(() => useDeleteModel(), {
wrapper: createWrapper(), wrapper: createWrapper(),
}); })
result.current.mutate('model-1'); result.current.mutate('model-1')
await waitFor(() => expect(result.current.isError).toBe(true)); await waitFor(() => expect(result.current.isError).toBe(true))
expect(MessagePlugin.error).toHaveBeenCalled(); expect(MessagePlugin.error).toHaveBeenCalled()
}); })
}); })

View File

@@ -1,11 +1,11 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { renderHook, waitFor } from '@testing-library/react'; import { renderHook, waitFor } from '@testing-library/react'
import { http, HttpResponse } from 'msw'; import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'; import { setupServer } from 'msw/node'
import React from 'react'; import React from 'react'
import { MessagePlugin } from 'tdesign-react'; import { MessagePlugin } from 'tdesign-react'
import { useProviders, useCreateProvider, useUpdateProvider, useDeleteProvider } from '@/hooks/useProviders'; import { useProviders, useCreateProvider, useUpdateProvider, useDeleteProvider } from '@/hooks/useProviders'
import type { Provider, CreateProviderInput, UpdateProviderInput } from '@/types'; import type { Provider, CreateProviderInput, UpdateProviderInput } from '@/types'
// Mock MessagePlugin // Mock MessagePlugin
vi.mock('tdesign-react', () => ({ vi.mock('tdesign-react', () => ({
@@ -13,7 +13,7 @@ vi.mock('tdesign-react', () => ({
success: vi.fn(), success: vi.fn(),
error: vi.fn(), error: vi.fn(),
}, },
})); }))
// Test data // Test data
const mockProviders: Provider[] = [ const mockProviders: Provider[] = [
@@ -37,7 +37,7 @@ const mockProviders: Provider[] = [
createdAt: '2026-02-01T00:00:00Z', createdAt: '2026-02-01T00:00:00Z',
updatedAt: '2026-02-01T00:00:00Z', updatedAt: '2026-02-01T00:00:00Z',
}, },
]; ]
const mockCreatedProvider: Provider = { const mockCreatedProvider: Provider = {
id: 'provider-3', id: 'provider-3',
@@ -48,31 +48,31 @@ const mockCreatedProvider: Provider = {
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',
}; }
// MSW handlers // MSW handlers
const handlers = [ const handlers = [
http.get('/api/providers', () => { http.get('/api/providers', () => {
return HttpResponse.json(mockProviders); return HttpResponse.json(mockProviders)
}), }),
http.post('/api/providers', async ({ request }) => { http.post('/api/providers', async ({ request }) => {
const body = await request.json() as Record<string, unknown>; const body = (await request.json()) as Record<string, unknown>
return HttpResponse.json({ return HttpResponse.json({
...mockCreatedProvider, ...mockCreatedProvider,
...body, ...body,
}); })
}), }),
http.put('/api/providers/:id', async ({ request, params }) => { http.put('/api/providers/:id', async ({ request, params }) => {
const body = await request.json() as Record<string, unknown>; const body = (await request.json()) as Record<string, unknown>
const existing = mockProviders.find((p) => p.id === params['id']); const existing = mockProviders.find((p) => p.id === params['id'])
return HttpResponse.json({ ...existing, ...body }); return HttpResponse.json({ ...existing, ...body })
}), }),
http.delete('/api/providers/:id', () => { http.delete('/api/providers/:id', () => {
return new HttpResponse(null, { status: 204 }); return new HttpResponse(null, { status: 204 })
}), }),
]; ]
const server = setupServer(...handlers); const server = setupServer(...handlers)
function createTestQueryClient() { function createTestQueryClient() {
return new QueryClient({ return new QueryClient({
@@ -80,58 +80,50 @@ function createTestQueryClient() {
queries: { retry: false }, queries: { retry: false },
mutations: { retry: false }, mutations: { retry: false },
}, },
}); })
} }
function createWrapper() { function createWrapper() {
const testQueryClient = createTestQueryClient(); const testQueryClient = createTestQueryClient()
return function Wrapper({ children }: { children: React.ReactNode }) { return function Wrapper({ children }: { children: React.ReactNode }) {
return ( return <QueryClientProvider client={testQueryClient}>{children}</QueryClientProvider>
<QueryClientProvider client={testQueryClient}> }
{children}
</QueryClientProvider>
);
};
} }
beforeAll(() => server.listen()); beforeAll(() => server.listen())
afterEach(() => { afterEach(() => {
server.resetHandlers(); server.resetHandlers()
vi.clearAllMocks(); vi.clearAllMocks()
}); })
afterAll(() => server.close()); afterAll(() => server.close())
describe('useProviders', () => { describe('useProviders', () => {
it('fetches and returns provider list', async () => { it('fetches and returns provider list', async () => {
const { result } = renderHook(() => useProviders(), { const { result } = renderHook(() => useProviders(), {
wrapper: createWrapper(), wrapper: createWrapper(),
}); })
await waitFor(() => expect(result.current.isSuccess).toBe(true)); await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(result.current.data).toEqual(mockProviders); expect(result.current.data).toEqual(mockProviders)
expect(result.current.data).toHaveLength(2); expect(result.current.data).toHaveLength(2)
expect(result.current.data![0]!.name).toBe('OpenAI'); expect(result.current.data![0]!.name).toBe('OpenAI')
expect(result.current.data![1]!.name).toBe('Anthropic'); expect(result.current.data![1]!.name).toBe('Anthropic')
}); })
}); })
describe('useCreateProvider', () => { describe('useCreateProvider', () => {
it('calls API and invalidates provider queries', async () => { it('calls API and invalidates provider queries', async () => {
const queryClient = createTestQueryClient(); const queryClient = createTestQueryClient()
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries')
function Wrapper({ children }: { children: React.ReactNode }) { function Wrapper({ children }: { children: React.ReactNode }) {
return ( return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
} }
const { result } = renderHook(() => useCreateProvider(), { const { result } = renderHook(() => useCreateProvider(), {
wrapper: Wrapper, wrapper: Wrapper,
}); })
const input: CreateProviderInput = { const input: CreateProviderInput = {
id: 'provider-3', id: 'provider-3',
@@ -140,30 +132,30 @@ describe('useCreateProvider', () => {
baseUrl: 'https://api.newprovider.com', baseUrl: 'https://api.newprovider.com',
protocol: 'openai', protocol: 'openai',
enabled: true, enabled: true,
}; }
result.current.mutate(input); result.current.mutate(input)
await waitFor(() => expect(result.current.isSuccess).toBe(true)); await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(result.current.data).toMatchObject({ expect(result.current.data).toMatchObject({
id: 'provider-3', id: 'provider-3',
name: 'NewProvider', name: 'NewProvider',
}); })
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['providers'] }); expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['providers'] })
expect(MessagePlugin.success).toHaveBeenCalledWith('供应商创建成功'); expect(MessagePlugin.success).toHaveBeenCalledWith('供应商创建成功')
}); })
it('calls message.error on failure', async () => { it('calls message.error on failure', async () => {
server.use( server.use(
http.post('/api/providers', () => { http.post('/api/providers', () => {
return HttpResponse.json({ message: '创建失败' }, { status: 500 }); return HttpResponse.json({ message: '创建失败' }, { status: 500 })
}), })
); )
const { result } = renderHook(() => useCreateProvider(), { const { result } = renderHook(() => useCreateProvider(), {
wrapper: createWrapper(), wrapper: createWrapper(),
}); })
const input: CreateProviderInput = { const input: CreateProviderInput = {
id: 'provider-3', id: 'provider-3',
@@ -172,102 +164,94 @@ describe('useCreateProvider', () => {
baseUrl: 'https://api.newprovider.com', baseUrl: 'https://api.newprovider.com',
protocol: 'openai', protocol: 'openai',
enabled: true, enabled: true,
}; }
result.current.mutate(input); result.current.mutate(input)
await waitFor(() => expect(result.current.isError).toBe(true)); await waitFor(() => expect(result.current.isError).toBe(true))
expect(MessagePlugin.error).toHaveBeenCalled(); expect(MessagePlugin.error).toHaveBeenCalled()
}); })
}); })
describe('useUpdateProvider', () => { describe('useUpdateProvider', () => {
it('calls API and invalidates provider queries', async () => { it('calls API and invalidates provider queries', async () => {
const queryClient = createTestQueryClient(); const queryClient = createTestQueryClient()
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries')
function Wrapper({ children }: { children: React.ReactNode }) { function Wrapper({ children }: { children: React.ReactNode }) {
return ( return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
} }
const { result } = renderHook(() => useUpdateProvider(), { const { result } = renderHook(() => useUpdateProvider(), {
wrapper: Wrapper, wrapper: Wrapper,
}); })
const input: UpdateProviderInput = { name: 'UpdatedProvider' }; const input: UpdateProviderInput = { name: 'UpdatedProvider' }
result.current.mutate({ id: 'provider-1', input }); result.current.mutate({ id: 'provider-1', input })
await waitFor(() => expect(result.current.isSuccess).toBe(true)); await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(result.current.data).toMatchObject({ expect(result.current.data).toMatchObject({
name: 'UpdatedProvider', name: 'UpdatedProvider',
}); })
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['providers'] }); expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['providers'] })
expect(MessagePlugin.success).toHaveBeenCalledWith('供应商更新成功'); expect(MessagePlugin.success).toHaveBeenCalledWith('供应商更新成功')
}); })
it('calls message.error on failure', async () => { it('calls message.error on failure', async () => {
server.use( server.use(
http.put('/api/providers/:id', () => { http.put('/api/providers/:id', () => {
return HttpResponse.json({ message: '更新失败' }, { status: 500 }); return HttpResponse.json({ message: '更新失败' }, { status: 500 })
}), })
); )
const { result } = renderHook(() => useUpdateProvider(), { const { result } = renderHook(() => useUpdateProvider(), {
wrapper: createWrapper(), wrapper: createWrapper(),
}); })
result.current.mutate({ id: 'provider-1', input: { name: 'Updated' } }); result.current.mutate({ id: 'provider-1', input: { name: 'Updated' } })
await waitFor(() => expect(result.current.isError).toBe(true)); await waitFor(() => expect(result.current.isError).toBe(true))
expect(MessagePlugin.error).toHaveBeenCalled(); expect(MessagePlugin.error).toHaveBeenCalled()
}); })
}); })
describe('useDeleteProvider', () => { describe('useDeleteProvider', () => {
it('calls API and invalidates provider queries', async () => { it('calls API and invalidates provider queries', async () => {
const queryClient = createTestQueryClient(); const queryClient = createTestQueryClient()
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries')
function Wrapper({ children }: { children: React.ReactNode }) { function Wrapper({ children }: { children: React.ReactNode }) {
return ( return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
} }
const { result } = renderHook(() => useDeleteProvider(), { const { result } = renderHook(() => useDeleteProvider(), {
wrapper: Wrapper, wrapper: Wrapper,
}); })
result.current.mutate('provider-1'); result.current.mutate('provider-1')
await waitFor(() => expect(result.current.isSuccess).toBe(true)); await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['providers'] }); expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['providers'] })
expect(MessagePlugin.success).toHaveBeenCalledWith('供应商删除成功'); expect(MessagePlugin.success).toHaveBeenCalledWith('供应商删除成功')
}); })
it('calls message.error on failure', async () => { it('calls message.error on failure', async () => {
server.use( server.use(
http.delete('/api/providers/:id', () => { http.delete('/api/providers/:id', () => {
return HttpResponse.json({ message: '删除失败' }, { status: 500 }); return HttpResponse.json({ message: '删除失败' }, { status: 500 })
}), })
); )
const { result } = renderHook(() => useDeleteProvider(), { const { result } = renderHook(() => useDeleteProvider(), {
wrapper: createWrapper(), wrapper: createWrapper(),
}); })
result.current.mutate('provider-1'); result.current.mutate('provider-1')
await waitFor(() => expect(result.current.isError).toBe(true)); await waitFor(() => expect(result.current.isError).toBe(true))
expect(MessagePlugin.error).toHaveBeenCalled(); expect(MessagePlugin.error).toHaveBeenCalled()
}); })
}); })

View File

@@ -1,10 +1,10 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { renderHook, waitFor } from '@testing-library/react'; import { renderHook, waitFor } from '@testing-library/react'
import { http, HttpResponse } from 'msw'; import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'; import { setupServer } from 'msw/node'
import React from 'react'; import React from 'react'
import { useStats } from '@/hooks/useStats'; import { useStats } from '@/hooks/useStats'
import type { UsageStats, StatsQueryParams } from '@/types'; import type { UsageStats, StatsQueryParams } from '@/types'
// Test data // Test data
const mockStats: UsageStats[] = [ const mockStats: UsageStats[] = [
@@ -22,7 +22,7 @@ const mockStats: UsageStats[] = [
requestCount: 50, requestCount: 50,
date: '2026-04-01', date: '2026-04-01',
}, },
]; ]
const mockFilteredStats: UsageStats[] = [ const mockFilteredStats: UsageStats[] = [
{ {
@@ -32,24 +32,24 @@ const mockFilteredStats: UsageStats[] = [
requestCount: 200, requestCount: 200,
date: '2026-04-01', date: '2026-04-01',
}, },
]; ]
// Track the request URL for assertions // Track the request URL for assertions
let capturedUrl: URL | null = null; let capturedUrl: URL | null = null
// MSW handlers // MSW handlers
const handlers = [ const handlers = [
http.get('/api/stats', ({ request }) => { http.get('/api/stats', ({ request }) => {
capturedUrl = new URL(request.url); capturedUrl = new URL(request.url)
const providerId = capturedUrl.searchParams.get('provider_id'); const providerId = capturedUrl.searchParams.get('provider_id')
if (providerId === 'provider-2') { if (providerId === 'provider-2') {
return HttpResponse.json(mockFilteredStats); return HttpResponse.json(mockFilteredStats)
} }
return HttpResponse.json(mockStats); return HttpResponse.json(mockStats)
}), }),
]; ]
const server = setupServer(...handlers); const server = setupServer(...handlers)
function createTestQueryClient() { function createTestQueryClient() {
return new QueryClient({ return new QueryClient({
@@ -57,43 +57,39 @@ function createTestQueryClient() {
queries: { retry: false }, queries: { retry: false },
mutations: { retry: false }, mutations: { retry: false },
}, },
}); })
} }
function createWrapper() { function createWrapper() {
const testQueryClient = createTestQueryClient(); const testQueryClient = createTestQueryClient()
return function Wrapper({ children }: { children: React.ReactNode }) { return function Wrapper({ children }: { children: React.ReactNode }) {
return ( return <QueryClientProvider client={testQueryClient}>{children}</QueryClientProvider>
<QueryClientProvider client={testQueryClient}> }
{children}
</QueryClientProvider>
);
};
} }
beforeAll(() => server.listen()); beforeAll(() => server.listen())
afterEach(() => { afterEach(() => {
server.resetHandlers(); server.resetHandlers()
capturedUrl = null; capturedUrl = null
}); })
afterAll(() => server.close()); afterAll(() => server.close())
describe('useStats', () => { describe('useStats', () => {
it('fetches stats without params', async () => { it('fetches stats without params', async () => {
const { result } = renderHook(() => useStats(), { const { result } = renderHook(() => useStats(), {
wrapper: createWrapper(), wrapper: createWrapper(),
}); })
await waitFor(() => expect(result.current.isSuccess).toBe(true)); await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(result.current.data).toEqual(mockStats); expect(result.current.data).toEqual(mockStats)
expect(result.current.data).toHaveLength(2); expect(result.current.data).toHaveLength(2)
expect(result.current.data![0]!.modelName).toBe('gpt-4o'); expect(result.current.data![0]!.modelName).toBe('gpt-4o')
expect(result.current.data![1]!.requestCount).toBe(50); expect(result.current.data![1]!.requestCount).toBe(50)
// Verify no query params were sent // Verify no query params were sent
expect(capturedUrl!.search).toBe(''); expect(capturedUrl!.search).toBe('')
}); })
it('with filter params passes them correctly', async () => { it('with filter params passes them correctly', async () => {
const params: StatsQueryParams = { const params: StatsQueryParams = {
@@ -101,40 +97,40 @@ describe('useStats', () => {
modelName: 'claude-sonnet-4-5', modelName: 'claude-sonnet-4-5',
startDate: '2026-04-01', startDate: '2026-04-01',
endDate: '2026-04-15', endDate: '2026-04-15',
}; }
const { result } = renderHook(() => useStats(params), { const { result } = renderHook(() => useStats(params), {
wrapper: createWrapper(), wrapper: createWrapper(),
}); })
await waitFor(() => expect(result.current.isSuccess).toBe(true)); await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(result.current.data).toEqual(mockFilteredStats); expect(result.current.data).toEqual(mockFilteredStats)
expect(result.current.data).toHaveLength(1); expect(result.current.data).toHaveLength(1)
expect(result.current.data![0]!.modelName).toBe('claude-sonnet-4-5'); expect(result.current.data![0]!.modelName).toBe('claude-sonnet-4-5')
// Verify query params were passed correctly (snake_case) // Verify query params were passed correctly (snake_case)
expect(capturedUrl!.searchParams.get('provider_id')).toBe('provider-2'); expect(capturedUrl!.searchParams.get('provider_id')).toBe('provider-2')
expect(capturedUrl!.searchParams.get('model_name')).toBe('claude-sonnet-4-5'); expect(capturedUrl!.searchParams.get('model_name')).toBe('claude-sonnet-4-5')
expect(capturedUrl!.searchParams.get('start_date')).toBe('2026-04-01'); expect(capturedUrl!.searchParams.get('start_date')).toBe('2026-04-01')
expect(capturedUrl!.searchParams.get('end_date')).toBe('2026-04-15'); expect(capturedUrl!.searchParams.get('end_date')).toBe('2026-04-15')
}); })
it('with partial filter params only sends provided params', async () => { it('with partial filter params only sends provided params', async () => {
const params: StatsQueryParams = { const params: StatsQueryParams = {
providerId: 'provider-1', providerId: 'provider-1',
}; }
const { result } = renderHook(() => useStats(params), { const { result } = renderHook(() => useStats(params), {
wrapper: createWrapper(), wrapper: createWrapper(),
}); })
await waitFor(() => expect(result.current.isSuccess).toBe(true)); await waitFor(() => expect(result.current.isSuccess).toBe(true))
// Verify only provider_id was sent // Verify only provider_id was sent
expect(capturedUrl!.searchParams.get('provider_id')).toBe('provider-1'); expect(capturedUrl!.searchParams.get('provider_id')).toBe('provider-1')
expect(capturedUrl!.searchParams.get('model_name')).toBeNull(); expect(capturedUrl!.searchParams.get('model_name')).toBeNull()
expect(capturedUrl!.searchParams.get('start_date')).toBeNull(); expect(capturedUrl!.searchParams.get('start_date')).toBeNull()
expect(capturedUrl!.searchParams.get('end_date')).toBeNull(); expect(capturedUrl!.searchParams.get('end_date')).toBeNull()
}); })
}); })

View File

@@ -1,8 +1,8 @@
import '@testing-library/jest-dom/vitest'; import '@testing-library/jest-dom/vitest'
// Ensure happy-dom 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('happy-dom 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)
@@ -18,38 +18,37 @@ Object.defineProperty(window, 'matchMedia', {
removeEventListener: () => {}, removeEventListener: () => {},
dispatchEvent: () => false, dispatchEvent: () => false,
}), }),
}); })
// Polyfill window.getComputedStyle to suppress jsdom warnings // Polyfill window.getComputedStyle to suppress jsdom warnings
const originalGetComputedStyle = window.getComputedStyle; const originalGetComputedStyle = window.getComputedStyle
window.getComputedStyle = (elt: Element, pseudoElt?: string | null) => { window.getComputedStyle = (elt: Element, pseudoElt?: string | null) => {
try { try {
return originalGetComputedStyle(elt, pseudoElt); return originalGetComputedStyle(elt, pseudoElt)
} catch { } catch {
return {} as CSSStyleDeclaration; return {} as CSSStyleDeclaration
}
} }
};
// Polyfill ResizeObserver for TDesign // Polyfill ResizeObserver for TDesign
global.ResizeObserver = class ResizeObserver { global.ResizeObserver = class ResizeObserver {
observe() {} observe() {}
unobserve() {} unobserve() {}
disconnect() {} disconnect() {}
}; }
// Suppress TDesign Form internal act() warnings // Suppress TDesign Form internal act() warnings
// These warnings come from TDesign's FormItem component internal async state updates // These warnings come from TDesign's FormItem component internal async state updates
// They don't affect test reliability - all tests pass successfully // They don't affect test reliability - all tests pass successfully
const originalError = console.error; const originalError = console.error
console.error = (...args: unknown[]) => { console.error = (...args: unknown[]) => {
const message = args[0]; const message = args[0]
// Filter out TDesign FormItem act() warnings // Filter out TDesign FormItem act() warnings
if ( if (
typeof message === 'string' && typeof message === 'string' &&
message.includes('An update to FormItem inside a test was not wrapped in act(...)') message.includes('An update to FormItem inside a test was not wrapped in act(...)')
) { ) {
return; return
}
originalError(...args)
} }
originalError(...args);
};

View File

@@ -1,81 +1,77 @@
import { ApiError } from '@/types'; import { ApiError } from '@/types'
const API_BASE = import.meta.env.VITE_API_BASE || ''; const API_BASE = import.meta.env.VITE_API_BASE || ''
function toCamelCase(str: string): string { function toCamelCase(str: string): string {
return str.replace(/_([a-z])/g, (_, letter: string) => letter.toUpperCase()); return str.replace(/_([a-z])/g, (_, letter: string) => letter.toUpperCase())
} }
function toSnakeCase(str: string): string { function toSnakeCase(str: string): string {
return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`); return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`)
} }
function transformKeys<T>(obj: unknown, transformer: (key: string) => string): T { function transformKeys<T>(obj: unknown, transformer: (key: string) => string): T {
if (Array.isArray(obj)) { if (Array.isArray(obj)) {
return obj.map((item) => transformKeys(item, transformer)) as T; return obj.map((item) => transformKeys(item, transformer)) as T
} }
if (obj !== null && typeof obj === 'object') { if (obj !== null && typeof obj === 'object') {
const result: Record<string, unknown> = {}; const result: Record<string, unknown> = {}
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) { for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
result[transformer(key)] = transformKeys(value, transformer); result[transformer(key)] = transformKeys(value, transformer)
} }
return result as T; return result as T
} }
return obj as T; return obj as T
} }
export function fromApi<T>(data: unknown): T { export function fromApi<T>(data: unknown): T {
return transformKeys<T>(data, toCamelCase); return transformKeys<T>(data, toCamelCase)
} }
export function toApi<T>(data: unknown): T { export function toApi<T>(data: unknown): T {
return transformKeys<T>(data, toSnakeCase); return transformKeys<T>(data, toSnakeCase)
} }
export async function request<T>( export async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
method: string, const url = `${API_BASE}${path}`
path: string,
body?: unknown,
): Promise<T> {
const url = `${API_BASE}${path}`;
const options: RequestInit = { const options: RequestInit = {
method, method,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
};
if (body !== undefined) {
options.body = JSON.stringify(toApi(body));
} }
const response = await fetch(url, options); if (body !== undefined) {
options.body = JSON.stringify(toApi(body))
}
const response = await fetch(url, options)
if (!response.ok) { if (!response.ok) {
let message = `请求失败 (${response.status})`; let message = `请求失败 (${response.status})`
let code: string | undefined; let code: string | undefined
try { try {
const errorData = await response.json(); const errorData = await response.json()
if (typeof errorData === 'object' && errorData !== null) { if (typeof errorData === 'object' && errorData !== null) {
// 提取结构化错误响应 // 提取结构化错误响应
if ('error' in errorData && typeof errorData.error === 'string') { if ('error' in errorData && typeof errorData.error === 'string') {
message = errorData.error; message = errorData.error
} else if ('message' in errorData && typeof errorData.message === 'string') { } else if ('message' in errorData && typeof errorData.message === 'string') {
message = errorData.message; message = errorData.message
} }
// 提取错误码 // 提取错误码
if ('code' in errorData && typeof errorData.code === 'string') { if ('code' in errorData && typeof errorData.code === 'string') {
code = errorData.code; code = errorData.code
} }
} }
} catch { } catch {
// ignore JSON parse error // ignore JSON parse error
} }
throw new ApiError(response.status, message, code); throw new ApiError(response.status, message, code)
} }
if (response.status === 204) { if (response.status === 204) {
return undefined as T; return undefined as T
} }
const data = await response.json(); const data = await response.json()
return fromApi<T>(data); return fromApi<T>(data)
} }

View File

@@ -1,24 +1,19 @@
import type { Model, CreateModelInput, UpdateModelInput } from '@/types'; import type { Model, CreateModelInput, UpdateModelInput } from '@/types'
import { request } from './client'; import { request } from './client'
export async function listModels(providerId?: string): Promise<Model[]> { export async function listModels(providerId?: string): Promise<Model[]> {
const path = providerId const path = providerId ? `/api/models?provider_id=${encodeURIComponent(providerId)}` : '/api/models'
? `/api/models?provider_id=${encodeURIComponent(providerId)}` return request<Model[]>('GET', path)
: '/api/models';
return request<Model[]>('GET', path);
} }
export async function createModel(input: CreateModelInput): Promise<Model> { export async function createModel(input: CreateModelInput): Promise<Model> {
return request<Model>('POST', '/api/models', input); return request<Model>('POST', '/api/models', input)
} }
export async function updateModel( export async function updateModel(id: string, input: UpdateModelInput): Promise<Model> {
id: string, return request<Model>('PUT', `/api/models/${id}`, input)
input: UpdateModelInput,
): Promise<Model> {
return request<Model>('PUT', `/api/models/${id}`, input);
} }
export async function deleteModel(id: string): Promise<void> { export async function deleteModel(id: string): Promise<void> {
return request<void>('DELETE', `/api/models/${id}`); return request<void>('DELETE', `/api/models/${id}`)
} }

View File

@@ -1,21 +1,18 @@
import type { Provider, CreateProviderInput, UpdateProviderInput } from '@/types'; import type { Provider, CreateProviderInput, UpdateProviderInput } from '@/types'
import { request } from './client'; import { request } from './client'
export async function listProviders(): Promise<Provider[]> { export async function listProviders(): Promise<Provider[]> {
return request<Provider[]>('GET', '/api/providers'); return request<Provider[]>('GET', '/api/providers')
} }
export async function createProvider(input: CreateProviderInput): Promise<Provider> { export async function createProvider(input: CreateProviderInput): Promise<Provider> {
return request<Provider>('POST', '/api/providers', input); return request<Provider>('POST', '/api/providers', input)
} }
export async function updateProvider( export async function updateProvider(id: string, input: UpdateProviderInput): Promise<Provider> {
id: string, return request<Provider>('PUT', `/api/providers/${id}`, input)
input: UpdateProviderInput,
): Promise<Provider> {
return request<Provider>('PUT', `/api/providers/${id}`, input);
} }
export async function deleteProvider(id: string): Promise<void> { export async function deleteProvider(id: string): Promise<void> {
return request<void>('DELETE', `/api/providers/${id}`); return request<void>('DELETE', `/api/providers/${id}`)
} }

View File

@@ -1,26 +1,26 @@
import type { UsageStats, StatsQueryParams } from '@/types'; import type { UsageStats, StatsQueryParams } from '@/types'
import { request } from './client'; import { request } from './client'
export async function getStats(params?: StatsQueryParams): Promise<UsageStats[]> { export async function getStats(params?: StatsQueryParams): Promise<UsageStats[]> {
if (!params) { if (!params) {
return request<UsageStats[]>('GET', '/api/stats'); return request<UsageStats[]>('GET', '/api/stats')
} }
const query = new URLSearchParams(); const query = new URLSearchParams()
const snakeParams: Record<string, string | undefined> = { const snakeParams: Record<string, string | undefined> = {
provider_id: params.providerId, provider_id: params.providerId,
model_name: params.modelName, model_name: params.modelName,
start_date: params.startDate, start_date: params.startDate,
end_date: params.endDate, end_date: params.endDate,
}; }
for (const [key, value] of Object.entries(snakeParams)) { for (const [key, value] of Object.entries(snakeParams)) {
if (value) { if (value) {
query.set(key, value); query.set(key, value)
} }
} }
const queryString = query.toString(); const queryString = query.toString()
const path = queryString ? `/api/stats?${queryString}` : '/api/stats'; const path = queryString ? `/api/stats?${queryString}` : '/api/stats'
return request<UsageStats[]>('GET', path); return request<UsageStats[]>('GET', path)
} }

View File

@@ -1,23 +1,23 @@
import { useState } from 'react'; import { useState } from 'react'
import { Outlet, useLocation, useNavigate } from 'react-router'; import { Outlet, useLocation, useNavigate } from 'react-router'
import { ServerIcon, ChartLineIcon, SettingIcon, ChevronLeftIcon, ChevronRightIcon } from 'tdesign-icons-react'; import { ServerIcon, ChartLineIcon, SettingIcon, ChevronLeftIcon, ChevronRightIcon } from 'tdesign-icons-react'
import { Layout, Menu, Button } from 'tdesign-react'; import { Layout, Menu, Button } from 'tdesign-react'
const { MenuItem } = Menu; const { MenuItem } = Menu
export function AppLayout() { export function AppLayout() {
const location = useLocation(); const location = useLocation()
const navigate = useNavigate(); const navigate = useNavigate()
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false)
const getPageTitle = () => { const getPageTitle = () => {
if (location.pathname === '/providers') return '供应商管理'; if (location.pathname === '/providers') return '供应商管理'
if (location.pathname === '/stats') return '用量统计'; if (location.pathname === '/stats') return '用量统计'
if (location.pathname === '/settings') return '设置'; if (location.pathname === '/settings') return '设置'
return 'AI Gateway'; return 'AI Gateway'
}; }
const asideWidth = collapsed ? '64px' : '232px'; const asideWidth = collapsed ? '64px' : '232px'
return ( return (
<Layout style={{ minHeight: '100vh' }}> <Layout style={{ minHeight: '100vh' }}>
@@ -38,34 +38,36 @@ export function AppLayout() {
collapsed={collapsed} collapsed={collapsed}
width={['232px', '64px']} width={['232px', '64px']}
logo={ logo={
<div style={{ <div
style={{
height: 64, height: 64,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
fontSize: '1.25rem', fontSize: '1.25rem',
fontWeight: 600, fontWeight: 600,
}}> }}
>
{!collapsed && 'AI Gateway'} {!collapsed && 'AI Gateway'}
</div> </div>
} }
operations={ operations={
<Button <Button
variant="text" variant='text'
shape="square" shape='square'
icon={collapsed ? <ChevronRightIcon /> : <ChevronLeftIcon />} icon={collapsed ? <ChevronRightIcon /> : <ChevronLeftIcon />}
onClick={() => setCollapsed(!collapsed)} onClick={() => setCollapsed(!collapsed)}
/> />
} }
style={{ height: '100%' }} style={{ height: '100%' }}
> >
<MenuItem value="/providers" icon={<ServerIcon />}> <MenuItem value='/providers' icon={<ServerIcon />}>
</MenuItem> </MenuItem>
<MenuItem value="/stats" icon={<ChartLineIcon />}> <MenuItem value='/stats' icon={<ChartLineIcon />}>
</MenuItem> </MenuItem>
<MenuItem value="/settings" icon={<SettingIcon />}> <MenuItem value='/settings' icon={<SettingIcon />}>
</MenuItem> </MenuItem>
</Menu> </Menu>
@@ -95,5 +97,5 @@ export function AppLayout() {
</Layout.Content> </Layout.Content>
</Layout> </Layout>
</Layout> </Layout>
); )
} }

View File

@@ -1,73 +1,72 @@
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 * as api from '@/api/models'; import * as api from '@/api/models'
import type { CreateModelInput, UpdateModelInput, ApiError } from '@/types'; import type { CreateModelInput, UpdateModelInput, ApiError } from '@/types'
const ERROR_MESSAGES: Record<string, string> = { const ERROR_MESSAGES: Record<string, string> = {
duplicate_model: '同一供应商下模型名称已存在', duplicate_model: '同一供应商下模型名称已存在',
invalid_provider_id: '供应商 ID 仅允许字母、数字、下划线,长度 1-64', invalid_provider_id: '供应商 ID 仅允许字母、数字、下划线,长度 1-64',
immutable_field: '供应商 ID 不允许修改', immutable_field: '供应商 ID 不允许修改',
provider_not_found: '供应商不存在', provider_not_found: '供应商不存在',
}; }
function getErrorMessage(error: ApiError): string { function getErrorMessage(error: ApiError): string {
return error.code ? ERROR_MESSAGES[error.code] || error.message : error.message; 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,
}; }
export function useModels(providerId?: string) { export function useModels(providerId?: string) {
return useQuery({ return useQuery({
queryKey: modelKeys.filtered(providerId), queryKey: modelKeys.filtered(providerId),
queryFn: () => api.listModels(providerId), queryFn: () => api.listModels(providerId),
}); })
} }
export function useCreateModel() { export function useCreateModel() {
const queryClient = useQueryClient(); const queryClient = useQueryClient()
return useMutation({ return useMutation({
mutationFn: (input: CreateModelInput) => api.createModel(input), mutationFn: (input: CreateModelInput) => api.createModel(input),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: modelKeys.all }); queryClient.invalidateQueries({ queryKey: modelKeys.all })
MessagePlugin.success('模型创建成功'); MessagePlugin.success('模型创建成功')
}, },
onError: (error: ApiError) => { onError: (error: ApiError) => {
MessagePlugin.error(getErrorMessage(error)); MessagePlugin.error(getErrorMessage(error))
}, },
}); })
} }
export function useUpdateModel() { export function useUpdateModel() {
const queryClient = useQueryClient(); const queryClient = useQueryClient()
return useMutation({ return useMutation({
mutationFn: ({ id, input }: { id: string; input: UpdateModelInput }) => mutationFn: ({ id, input }: { id: string; input: UpdateModelInput }) => api.updateModel(id, input),
api.updateModel(id, input),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: modelKeys.all }); queryClient.invalidateQueries({ queryKey: modelKeys.all })
MessagePlugin.success('模型更新成功'); MessagePlugin.success('模型更新成功')
}, },
onError: (error: ApiError) => { onError: (error: ApiError) => {
MessagePlugin.error(getErrorMessage(error)); MessagePlugin.error(getErrorMessage(error))
}, },
}); })
} }
export function useDeleteModel() { export function useDeleteModel() {
const queryClient = useQueryClient(); const queryClient = useQueryClient()
return useMutation({ return useMutation({
mutationFn: (id: string) => api.deleteModel(id), mutationFn: (id: string) => api.deleteModel(id),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: modelKeys.all }); queryClient.invalidateQueries({ queryKey: modelKeys.all })
MessagePlugin.success('模型删除成功'); MessagePlugin.success('模型删除成功')
}, },
onError: (error: Error) => { onError: (error: Error) => {
MessagePlugin.error(error.message); MessagePlugin.error(error.message)
}, },
}); })
} }

View File

@@ -1,72 +1,71 @@
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 * as api from '@/api/providers'; import * as api from '@/api/providers'
import type { CreateProviderInput, UpdateProviderInput, ApiError } from '@/types'; import type { CreateProviderInput, UpdateProviderInput, ApiError } from '@/types'
const ERROR_MESSAGES: Record<string, string> = { const ERROR_MESSAGES: Record<string, string> = {
duplicate_model: '同一供应商下模型名称已存在', duplicate_model: '同一供应商下模型名称已存在',
invalid_provider_id: '供应商 ID 仅允许字母、数字、下划线,长度 1-64', invalid_provider_id: '供应商 ID 仅允许字母、数字、下划线,长度 1-64',
immutable_field: '供应商 ID 不允许修改', immutable_field: '供应商 ID 不允许修改',
provider_not_found: '供应商不存在', provider_not_found: '供应商不存在',
}; }
function getErrorMessage(error: ApiError): string { function getErrorMessage(error: ApiError): string {
return error.code ? ERROR_MESSAGES[error.code] || error.message : error.message; 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,
}; }
export function useProviders() { export function useProviders() {
return useQuery({ return useQuery({
queryKey: providerKeys.all, queryKey: providerKeys.all,
queryFn: api.listProviders, queryFn: api.listProviders,
}); })
} }
export function useCreateProvider() { export function useCreateProvider() {
const queryClient = useQueryClient(); const queryClient = useQueryClient()
return useMutation({ return useMutation({
mutationFn: (input: CreateProviderInput) => api.createProvider(input), mutationFn: (input: CreateProviderInput) => api.createProvider(input),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: providerKeys.all }); queryClient.invalidateQueries({ queryKey: providerKeys.all })
MessagePlugin.success('供应商创建成功'); MessagePlugin.success('供应商创建成功')
}, },
onError: (error: ApiError) => { onError: (error: ApiError) => {
MessagePlugin.error(getErrorMessage(error)); MessagePlugin.error(getErrorMessage(error))
}, },
}); })
} }
export function useUpdateProvider() { export function useUpdateProvider() {
const queryClient = useQueryClient(); const queryClient = useQueryClient()
return useMutation({ return useMutation({
mutationFn: ({ id, input }: { id: string; input: UpdateProviderInput }) => mutationFn: ({ id, input }: { id: string; input: UpdateProviderInput }) => api.updateProvider(id, input),
api.updateProvider(id, input),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: providerKeys.all }); queryClient.invalidateQueries({ queryKey: providerKeys.all })
MessagePlugin.success('供应商更新成功'); MessagePlugin.success('供应商更新成功')
}, },
onError: (error: ApiError) => { onError: (error: ApiError) => {
MessagePlugin.error(getErrorMessage(error)); MessagePlugin.error(getErrorMessage(error))
}, },
}); })
} }
export function useDeleteProvider() { export function useDeleteProvider() {
const queryClient = useQueryClient(); const queryClient = useQueryClient()
return useMutation({ return useMutation({
mutationFn: (id: string) => api.deleteProvider(id), mutationFn: (id: string) => api.deleteProvider(id),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: providerKeys.all }); queryClient.invalidateQueries({ queryKey: providerKeys.all })
MessagePlugin.success('供应商删除成功'); MessagePlugin.success('供应商删除成功')
}, },
onError: (error: Error) => { onError: (error: Error) => {
MessagePlugin.error(error.message); MessagePlugin.error(error.message)
}, },
}); })
} }

View File

@@ -1,14 +1,14 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query'
import * as api from '@/api/stats'; import * as api from '@/api/stats'
import type { StatsQueryParams } from '@/types'; import type { StatsQueryParams } from '@/types'
export const statsKeys = { export const statsKeys = {
filtered: (params?: StatsQueryParams) => ['stats', params] as const, filtered: (params?: StatsQueryParams) => ['stats', params] as const,
}; }
export function useStats(params?: StatsQueryParams) { export function useStats(params?: StatsQueryParams) {
return useQuery({ return useQuery({
queryKey: statsKeys.filtered(params), queryKey: statsKeys.filtered(params),
queryFn: () => api.getStats(params), queryFn: () => api.getStats(params),
}); })
} }

View File

@@ -20,7 +20,7 @@ body,
--td-radius-extraLarge: 16px; --td-radius-extraLarge: 16px;
/* 系统字体栈 */ /* 系统字体栈 */
--td-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, --td-font-family:
"Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif,
"Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
} }

View File

@@ -12,5 +12,5 @@ if (!root) {
createRoot(root).render( createRoot(root).render(
<StrictMode> <StrictMode>
<App /> <App />
</StrictMode>, </StrictMode>
) )

View File

@@ -1,25 +1,27 @@
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router'
import { Button } from 'tdesign-react'; import { Button } from 'tdesign-react'
export default function NotFound() { export default function NotFound() {
const navigate = useNavigate(); const navigate = useNavigate()
return ( return (
<div style={{ <div
style={{
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
minHeight: '100vh', minHeight: '100vh',
padding: '2rem', padding: '2rem',
}}> }}
>
<h1 style={{ fontSize: '6rem', margin: 0, color: 'var(--td-text-color-placeholder)' }}>404</h1> <h1 style={{ fontSize: '6rem', margin: 0, color: 'var(--td-text-color-placeholder)' }}>404</h1>
<p style={{ fontSize: '1.25rem', color: 'var(--td-text-color-secondary)', marginBottom: '2rem' }}> <p style={{ fontSize: '1.25rem', color: 'var(--td-text-color-secondary)', marginBottom: '2rem' }}>
访 访
</p> </p>
<Button theme="primary" onClick={() => navigate('/providers')}> <Button theme='primary' onClick={() => navigate('/providers')}>
</Button> </Button>
</div> </div>
); )
} }

View File

@@ -1,35 +1,27 @@
import { useEffect } from 'react'; import { useEffect } from 'react'
import { Dialog, Form, Input, Select, Switch } from 'tdesign-react'; import { Dialog, Form, Input, Select, Switch } from 'tdesign-react'
import type { Provider, Model } from '@/types'; 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 {
providerId: string; providerId: string
modelName: string; modelName: string
enabled: boolean; enabled: boolean
} }
interface ModelFormProps { interface ModelFormProps {
open: boolean; open: boolean
model?: Model; model?: Model
providerId: string; providerId: string
providers: Provider[]; providers: Provider[]
onSave: (values: ModelFormValues) => Promise<void> | void; onSave: (values: ModelFormValues) => Promise<void> | void
onCancel: () => void; onCancel: () => void
loading: boolean; loading: boolean
} }
export function ModelForm({ export function ModelForm({ open, model, providerId, providers, onSave, onCancel, loading }: ModelFormProps) {
open, const [form] = Form.useForm()
model, const isEdit = !!model
providerId,
providers,
onSave,
onCancel,
loading,
}: ModelFormProps) {
const [form] = Form.useForm();
const isEdit = !!model;
// 当弹窗打开或model变化时设置表单值 // 当弹窗打开或model变化时设置表单值
useEffect(() => { useEffect(() => {
@@ -40,63 +32,56 @@ export function ModelForm({
providerId: model.providerId, providerId: model.providerId,
modelName: model.modelName, modelName: model.modelName,
enabled: model.enabled, enabled: model.enabled,
}); })
} else { } else {
// 新增模式重置表单并设置默认providerId // 新增模式重置表单并设置默认providerId
form.reset(); form.reset()
form.setFieldsValue({ form.setFieldsValue({
providerId, providerId,
enabled: true enabled: true,
}); })
} }
} }
}, [open, model, providerId]); // 移除form依赖避免循环 }, [open, model, providerId]) // 移除form依赖避免循环
const handleSubmit = (context: SubmitContext) => { const handleSubmit = (context: SubmitContext) => {
if (context.validateResult === true && form) { if (context.validateResult === true && form) {
const values = form.getFieldsValue(true) as ModelFormValues; const values = form.getFieldsValue(true) as ModelFormValues
onSave(values); onSave(values)
}
} }
};
return ( return (
<Dialog <Dialog
header={isEdit ? '编辑模型' : '添加模型'} header={isEdit ? '编辑模型' : '添加模型'}
visible={open} visible={open}
placement="center" placement='center'
width="520px" width='520px'
closeOnOverlayClick={false} closeOnOverlayClick={false}
closeOnEscKeydown={false} closeOnEscKeydown={false}
lazy={false} lazy={false}
onConfirm={() => { form?.submit(); return false; }} onConfirm={() => {
form?.submit()
return false
}}
onClose={onCancel} onClose={onCancel}
confirmLoading={loading} confirmLoading={loading}
confirmBtn="保存" confirmBtn='保存'
cancelBtn="取消" cancelBtn='取消'
> >
<Form form={form} layout="vertical" onSubmit={handleSubmit}> <Form form={form} layout='vertical' onSubmit={handleSubmit}>
<Form.FormItem <Form.FormItem label='供应商' name='providerId' rules={[{ required: true, message: '请选择供应商' }]}>
label="供应商" <Select options={providers.map((p) => ({ label: p.name, value: p.id }))} />
name="providerId"
rules={[{ required: true, message: '请选择供应商' }]}
>
<Select
options={providers.map((p) => ({ label: p.name, value: p.id }))}
/>
</Form.FormItem> </Form.FormItem>
<Form.FormItem <Form.FormItem label='模型名称' name='modelName' rules={[{ required: true, message: '请输入模型名称' }]}>
label="模型名称" <Input placeholder='例如: gpt-4o' />
name="modelName"
rules={[{ required: true, message: '请输入模型名称' }]}
>
<Input placeholder="例如: gpt-4o" />
</Form.FormItem> </Form.FormItem>
<Form.FormItem label="启用" name="enabled"> <Form.FormItem label='启用' name='enabled'>
<Switch /> <Switch />
</Form.FormItem> </Form.FormItem>
</Form> </Form>
</Dialog> </Dialog>
); )
} }

View File

@@ -1,17 +1,17 @@
import { Button, Table, Tag, Popconfirm, Space } from 'tdesign-react'; import { Button, Table, Tag, Popconfirm, Space } from 'tdesign-react'
import { useModels, useDeleteModel } from '@/hooks/useModels'; import { useModels, useDeleteModel } from '@/hooks/useModels'
import type { Model } from '@/types'; import type { Model } from '@/types'
import type { PrimaryTableCol } from 'tdesign-react/es/table/type'; import type { PrimaryTableCol } from 'tdesign-react/es/table/type'
interface ModelTableProps { interface ModelTableProps {
providerId: string; providerId: string
onAdd?: () => void; onAdd?: () => void
onEdit?: (model: Model) => void; onEdit?: (model: Model) => void
} }
export function ModelTable({ providerId, onAdd, onEdit }: ModelTableProps) { export function ModelTable({ providerId, onAdd, onEdit }: ModelTableProps) {
const { data: models = [], isLoading } = useModels(providerId); const { data: models = [], isLoading } = useModels(providerId)
const deleteModel = useDeleteModel(); const deleteModel = useDeleteModel()
const columns: PrimaryTableCol<Model>[] = [ const columns: PrimaryTableCol<Model>[] = [
{ {
@@ -32,9 +32,13 @@ export function ModelTable({ providerId, onAdd, onEdit }: ModelTableProps) {
width: 80, width: 80,
cell: ({ row }) => cell: ({ row }) =>
row.enabled ? ( row.enabled ? (
<Tag theme="success" variant="light" shape="round"></Tag> <Tag theme='success' variant='light' shape='round'>
</Tag>
) : ( ) : (
<Tag theme="danger" variant="light" shape="round"></Tag> <Tag theme='danger' variant='light' shape='round'>
</Tag>
), ),
}, },
{ {
@@ -44,29 +48,26 @@ export function ModelTable({ providerId, onAdd, onEdit }: ModelTableProps) {
cell: ({ row }) => ( cell: ({ row }) => (
<Space> <Space>
{onEdit && ( {onEdit && (
<Button variant="text" size="small" onClick={() => onEdit(row)}> <Button variant='text' size='small' onClick={() => onEdit(row)}>
</Button> </Button>
)} )}
<Popconfirm <Popconfirm content='确定要删除这个模型吗?' onConfirm={() => deleteModel.mutate(row.id)}>
content="确定要删除这个模型吗?" <Button variant='text' theme='danger' size='small'>
onConfirm={() => deleteModel.mutate(row.id)}
>
<Button variant="text" theme="danger" size="small">
</Button> </Button>
</Popconfirm> </Popconfirm>
</Space> </Space>
), ),
}, },
]; ]
return ( return (
<div style={{ padding: '8px 16px' }}> <div style={{ padding: '8px 16px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}> <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
<span style={{ fontWeight: 500 }}> ({models.length})</span> <span style={{ fontWeight: 500 }}> ({models.length})</span>
{onAdd && ( {onAdd && (
<Button variant="text" size="small" onClick={onAdd}> <Button variant='text' size='small' onClick={onAdd}>
</Button> </Button>
)} )}
@@ -74,13 +75,13 @@ export function ModelTable({ providerId, onAdd, onEdit }: ModelTableProps) {
<Table<Model> <Table<Model>
columns={columns} columns={columns}
data={models} data={models}
rowKey="id" rowKey='id'
loading={isLoading} loading={isLoading}
stripe stripe
pagination={undefined} pagination={undefined}
size="small" size='small'
empty="暂无模型,点击上方按钮添加" empty='暂无模型,点击上方按钮添加'
/> />
</div> </div>
); )
} }

View File

@@ -1,34 +1,28 @@
import { useEffect } from 'react'; import { useEffect } from 'react'
import { Dialog, Form, Input, Switch, Select } 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'
interface ProviderFormValues { interface ProviderFormValues {
id: string; id: string
name: string; name: string
apiKey: string; apiKey: string
baseUrl: string; baseUrl: string
protocol: 'openai' | 'anthropic'; protocol: 'openai' | 'anthropic'
enabled: boolean; enabled: boolean
} }
interface ProviderFormProps { interface ProviderFormProps {
open: boolean; open: boolean
provider?: Provider; provider?: Provider
onSave: (values: ProviderFormValues) => Promise<void> | void; onSave: (values: ProviderFormValues) => Promise<void> | void
onCancel: () => void; onCancel: () => void
loading: boolean; loading: boolean
} }
export function ProviderForm({ export function ProviderForm({ open, provider, onSave, onCancel, loading }: ProviderFormProps) {
open, const [form] = Form.useForm()
provider, const isEdit = !!provider
onSave,
onCancel,
loading,
}: ProviderFormProps) {
const [form] = Form.useForm();
const isEdit = !!provider;
useEffect(() => { useEffect(() => {
if (open && form) { if (open && form) {
@@ -40,75 +34,74 @@ export function ProviderForm({
baseUrl: provider.baseUrl, baseUrl: provider.baseUrl,
protocol: provider.protocol, protocol: provider.protocol,
enabled: provider.enabled, enabled: provider.enabled,
}); })
} else { } else {
form.reset(); form.reset()
form.setFieldsValue({ enabled: true, protocol: 'openai' }); form.setFieldsValue({ enabled: true, protocol: 'openai' })
} }
} }
}, [open, provider]); }, [open, provider])
const handleSubmit = (context: SubmitContext) => { const handleSubmit = (context: SubmitContext) => {
if (context.validateResult === true && form) { if (context.validateResult === true && form) {
const values = form.getFieldsValue(true) as ProviderFormValues; const values = form.getFieldsValue(true) as ProviderFormValues
onSave(values); onSave(values)
}
} }
};
return ( return (
<Dialog <Dialog
header={isEdit ? '编辑供应商' : '添加供应商'} header={isEdit ? '编辑供应商' : '添加供应商'}
visible={open} visible={open}
placement="center" placement='center'
width="520px" width='520px'
closeOnOverlayClick={false} closeOnOverlayClick={false}
closeOnEscKeydown={false} closeOnEscKeydown={false}
lazy={false} lazy={false}
onConfirm={() => { form?.submit(); return false; }} onConfirm={() => {
form?.submit()
return false
}}
onClose={onCancel} onClose={onCancel}
confirmLoading={loading} confirmLoading={loading}
confirmBtn="保存" confirmBtn='保存'
cancelBtn="取消" cancelBtn='取消'
> >
<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' }]}> <Form.FormItem label='ID' name='id' rules={[{ required: true, message: '请输入供应商 ID' }]}>
<Input disabled={isEdit} placeholder="例如: openai" /> <Input disabled={isEdit} placeholder='例如: openai' />
</Form.FormItem> </Form.FormItem>
<Form.FormItem label="名称" name="name" rules={[{ required: true, message: '请输入名称' }]}> <Form.FormItem label='名称' name='name' rules={[{ required: true, message: '请输入名称' }]}>
<Input placeholder="例如: OpenAI" /> <Input placeholder='例如: OpenAI' />
</Form.FormItem>
<Form.FormItem label='API Key' name='apiKey' rules={[{ required: true, message: '请输入 API Key' }]}>
<Input placeholder='sk-...' />
</Form.FormItem> </Form.FormItem>
<Form.FormItem <Form.FormItem
label="API Key" label='Base URL'
name="apiKey" name='baseUrl'
rules={[{ required: true, message: '请输入 API Key' }]}
>
<Input placeholder="sk-..." />
</Form.FormItem>
<Form.FormItem
label="Base URL"
name="baseUrl"
rules={[ rules={[
{ required: true, message: '请输入 Base URL' }, { required: true, message: '请输入 Base URL' },
{ url: true, message: '请输入有效的 URL' }, { url: true, message: '请输入有效的 URL' },
]} ]}
> >
<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: '请选择协议' }]}> <Form.FormItem label='协议' name='protocol' rules={[{ required: true, message: '请选择协议' }]}>
<Select> <Select>
<Select.Option value="openai">OpenAI</Select.Option> <Select.Option value='openai'>OpenAI</Select.Option>
<Select.Option value="anthropic">Anthropic</Select.Option> <Select.Option value='anthropic'>Anthropic</Select.Option>
</Select> </Select>
</Form.FormItem> </Form.FormItem>
<Form.FormItem label="启用" name="enabled"> <Form.FormItem label='启用' name='enabled'>
<Switch /> <Switch />
</Form.FormItem> </Form.FormItem>
</Form> </Form>
</Dialog> </Dialog>
); )
} }

View File

@@ -1,16 +1,16 @@
import { Button, Table, Tag, Popconfirm, Space, Card } from 'tdesign-react'; import { Button, Table, Tag, Popconfirm, Space, Card } from 'tdesign-react'
import type { Provider, Model } from '@/types'; import type { Provider, Model } from '@/types'
import { ModelTable } from './ModelTable'; import { ModelTable } from './ModelTable'
import type { PrimaryTableCol } from 'tdesign-react/es/table/type'; import type { PrimaryTableCol } from 'tdesign-react/es/table/type'
interface ProviderTableProps { interface ProviderTableProps {
providers: Provider[]; providers: Provider[]
loading: boolean; loading: boolean
onAdd: () => void; onAdd: () => void
onEdit: (provider: Provider) => void; onEdit: (provider: Provider) => void
onDelete: (id: string) => void; onDelete: (id: string) => void
onAddModel: (providerId: string) => void; onAddModel: (providerId: string) => void
onEditModel: (model: Model) => void; onEditModel: (model: Model) => void
} }
export function ProviderTable({ export function ProviderTable({
@@ -39,7 +39,7 @@ export function ProviderTable({
colKey: 'protocol', colKey: 'protocol',
width: 100, width: 100,
cell: ({ row }) => ( cell: ({ row }) => (
<Tag theme={row.protocol === 'openai' ? 'primary' : 'success'} variant="light" shape="round"> <Tag theme={row.protocol === 'openai' ? 'primary' : 'success'} variant='light' shape='round'>
{row.protocol === 'openai' ? 'OpenAI' : 'Anthropic'} {row.protocol === 'openai' ? 'OpenAI' : 'Anthropic'}
</Tag> </Tag>
), ),
@@ -55,9 +55,13 @@ export function ProviderTable({
width: 80, width: 80,
cell: ({ row }) => cell: ({ row }) =>
row.enabled ? ( row.enabled ? (
<Tag theme="success" variant="light" shape="round"></Tag> <Tag theme='success' variant='light' shape='round'>
</Tag>
) : ( ) : (
<Tag theme="danger" variant="light" shape="round"></Tag> <Tag theme='danger' variant='light' shape='round'>
</Tag>
), ),
}, },
{ {
@@ -66,29 +70,26 @@ export function ProviderTable({
width: 160, width: 160,
cell: ({ row }) => ( cell: ({ row }) => (
<Space> <Space>
<Button variant="text" size="small" onClick={() => onEdit(row)}> <Button variant='text' size='small' onClick={() => onEdit(row)}>
</Button> </Button>
<Popconfirm <Popconfirm content='确定要删除这个供应商吗?关联的模型也会被删除。' onConfirm={() => onDelete(row.id)}>
content="确定要删除这个供应商吗?关联的模型也会被删除。" <Button variant='text' theme='danger' size='small'>
onConfirm={() => onDelete(row.id)}
>
<Button variant="text" theme="danger" size="small">
</Button> </Button>
</Popconfirm> </Popconfirm>
</Space> </Space>
), ),
}, },
]; ]
return ( return (
<Card <Card
title="供应商列表" title='供应商列表'
headerBordered headerBordered
hoverShadow hoverShadow
actions={ actions={
<Button theme="primary" onClick={onAdd}> <Button theme='primary' onClick={onAdd}>
</Button> </Button>
} }
@@ -96,19 +97,15 @@ export function ProviderTable({
<Table<Provider> <Table<Provider>
columns={columns} columns={columns}
data={providers} data={providers}
rowKey="id" rowKey='id'
loading={loading} loading={loading}
stripe stripe
expandedRow={({ row }) => ( expandedRow={({ row }) => (
<ModelTable <ModelTable providerId={row.id} onAdd={() => onAddModel(row.id)} onEdit={onEditModel} />
providerId={row.id}
onAdd={() => onAddModel(row.id)}
onEdit={onEditModel}
/>
)} )}
pagination={undefined} pagination={undefined}
empty="暂无供应商,点击上方按钮添加" empty='暂无供应商,点击上方按钮添加'
/> />
</Card> </Card>
); )
} }

View File

@@ -1,24 +1,24 @@
import { useState } from 'react'; import { useState } from 'react'
import { useCreateModel, useUpdateModel } from '@/hooks/useModels'; import { useCreateModel, useUpdateModel } from '@/hooks/useModels'
import { useProviders, useCreateProvider, useUpdateProvider, useDeleteProvider } from '@/hooks/useProviders'; import { useProviders, useCreateProvider, useUpdateProvider, useDeleteProvider } from '@/hooks/useProviders'
import type { Provider, Model, UpdateProviderInput, UpdateModelInput } from '@/types'; import type { Provider, Model, UpdateProviderInput, UpdateModelInput } from '@/types'
import { ModelForm } from './ModelForm'; import { ModelForm } from './ModelForm'
import { ProviderForm } from './ProviderForm'; import { ProviderForm } from './ProviderForm'
import { ProviderTable } from './ProviderTable'; import { ProviderTable } from './ProviderTable'
export default function ProvidersPage() { export default function ProvidersPage() {
const { data: providers = [], isLoading } = useProviders(); const { data: providers = [], isLoading } = useProviders()
const createProvider = useCreateProvider(); const createProvider = useCreateProvider()
const updateProvider = useUpdateProvider(); const updateProvider = useUpdateProvider()
const deleteProvider = useDeleteProvider(); const deleteProvider = useDeleteProvider()
const createModel = useCreateModel(); const createModel = useCreateModel()
const updateModel = useUpdateModel(); const updateModel = useUpdateModel()
const [providerFormOpen, setProviderFormOpen] = useState(false); const [providerFormOpen, setProviderFormOpen] = useState(false)
const [editingProvider, setEditingProvider] = useState<Provider | undefined>(); const [editingProvider, setEditingProvider] = useState<Provider | undefined>()
const [modelFormOpen, setModelFormOpen] = useState(false); const [modelFormOpen, setModelFormOpen] = useState(false)
const [editingModel, setEditingModel] = useState<Model | undefined>(); const [editingModel, setEditingModel] = useState<Model | undefined>()
const [modelFormProviderId, setModelFormProviderId] = useState(''); const [modelFormProviderId, setModelFormProviderId] = useState('')
return ( return (
<div> <div>
@@ -26,23 +26,23 @@ export default function ProvidersPage() {
providers={providers} providers={providers}
loading={isLoading} loading={isLoading}
onAdd={() => { onAdd={() => {
setEditingProvider(undefined); setEditingProvider(undefined)
setProviderFormOpen(true); setProviderFormOpen(true)
}} }}
onEdit={(provider) => { onEdit={(provider) => {
setEditingProvider(provider); setEditingProvider(provider)
setProviderFormOpen(true); setProviderFormOpen(true)
}} }}
onDelete={(id) => deleteProvider.mutate(id)} onDelete={(id) => deleteProvider.mutate(id)}
onAddModel={(providerId) => { onAddModel={(providerId) => {
setEditingModel(undefined); setEditingModel(undefined)
setModelFormProviderId(providerId); setModelFormProviderId(providerId)
setModelFormOpen(true); setModelFormOpen(true)
}} }}
onEditModel={(model) => { onEditModel={(model) => {
setEditingModel(model); setEditingModel(model)
setModelFormProviderId(model.providerId); setModelFormProviderId(model.providerId)
setModelFormOpen(true); setModelFormOpen(true)
}} }}
/> />
@@ -53,16 +53,16 @@ export default function ProvidersPage() {
onSave={async (values) => { onSave={async (values) => {
try { try {
if (editingProvider) { if (editingProvider) {
const input: Partial<UpdateProviderInput> = {}; const input: Partial<UpdateProviderInput> = {}
if (values.name !== editingProvider.name) input.name = values.name; if (values.name !== editingProvider.name) input.name = values.name
if (values.apiKey !== editingProvider.apiKey) input.apiKey = values.apiKey; if (values.apiKey !== editingProvider.apiKey) input.apiKey = values.apiKey
if (values.baseUrl !== editingProvider.baseUrl) input.baseUrl = values.baseUrl; if (values.baseUrl !== editingProvider.baseUrl) input.baseUrl = values.baseUrl
if (values.enabled !== editingProvider.enabled) input.enabled = values.enabled; if (values.enabled !== editingProvider.enabled) input.enabled = values.enabled
await updateProvider.mutateAsync({ id: editingProvider.id, input }); await updateProvider.mutateAsync({ id: editingProvider.id, input })
} else { } else {
await createProvider.mutateAsync(values); await createProvider.mutateAsync(values)
} }
setProviderFormOpen(false); setProviderFormOpen(false)
} catch { } catch {
// 错误已由 hooks 的 onError 处理 // 错误已由 hooks 的 onError 处理
} }
@@ -79,15 +79,15 @@ export default function ProvidersPage() {
onSave={async (values) => { onSave={async (values) => {
try { try {
if (editingModel) { if (editingModel) {
const input: Partial<UpdateModelInput> = {}; const input: Partial<UpdateModelInput> = {}
if (values.providerId !== editingModel.providerId) input.providerId = values.providerId; if (values.providerId !== editingModel.providerId) input.providerId = values.providerId
if (values.modelName !== editingModel.modelName) input.modelName = values.modelName; if (values.modelName !== editingModel.modelName) input.modelName = values.modelName
if (values.enabled !== editingModel.enabled) input.enabled = values.enabled; if (values.enabled !== editingModel.enabled) input.enabled = values.enabled
await updateModel.mutateAsync({ id: editingModel.id, input }); await updateModel.mutateAsync({ id: editingModel.id, input })
} else { } else {
await createModel.mutateAsync(values); await createModel.mutateAsync(values)
} }
setModelFormOpen(false); setModelFormOpen(false)
} catch { } catch {
// 错误已由 hooks 的 onError 处理 // 错误已由 hooks 的 onError 处理
} }
@@ -95,5 +95,5 @@ export default function ProvidersPage() {
onCancel={() => setModelFormOpen(false)} onCancel={() => setModelFormOpen(false)}
/> />
</div> </div>
); )
} }

View File

@@ -1,11 +1,11 @@
import { Card } from 'tdesign-react'; import { Card } from 'tdesign-react'
export default function SettingsPage() { export default function SettingsPage() {
return ( return (
<Card title="设置"> <Card title='设置'>
<div style={{ textAlign: 'center', padding: '40px 0', color: 'var(--td-text-color-placeholder)' }}> <div style={{ textAlign: 'center', padding: '40px 0', color: 'var(--td-text-color-placeholder)' }}>
... ...
</div> </div>
</Card> </Card>
); )
} }

View File

@@ -1,31 +1,29 @@
import { ChartBarIcon, ChartLineIcon, ServerIcon, Calendar1Icon } from 'tdesign-icons-react'; import { ChartBarIcon, ChartLineIcon, ServerIcon, Calendar1Icon } from 'tdesign-icons-react'
import { Row, Col, Card, Statistic } from 'tdesign-react'; import { Row, Col, Card, Statistic } from 'tdesign-react'
import type { UsageStats } from '@/types'; import type { UsageStats } from '@/types'
interface StatCardsProps { interface StatCardsProps {
stats: UsageStats[]; stats: UsageStats[]
} }
export function StatCards({ stats }: StatCardsProps) { export function StatCards({ stats }: StatCardsProps) {
const totalRequests = stats.reduce((sum, s) => sum + s.requestCount, 0); const totalRequests = stats.reduce((sum, s) => sum + s.requestCount, 0)
const activeModels = new Set(stats.map((s) => s.modelName)).size; const activeModels = new Set(stats.map((s) => s.modelName)).size
const activeProviders = new Set(stats.map((s) => s.providerId)).size; const activeProviders = new Set(stats.map((s) => s.providerId)).size
const today = new Date().toISOString().split('T')[0]; const today = new Date().toISOString().split('T')[0]
const todayRequests = stats const todayRequests = stats.filter((s) => s.date === today).reduce((sum, s) => sum + s.requestCount, 0)
.filter((s) => s.date === today)
.reduce((sum, s) => sum + s.requestCount, 0);
return ( return (
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}> <Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col xs={12} md={6}> <Col xs={12} md={6}>
<Card bordered={false} hoverShadow> <Card bordered={false} hoverShadow>
<Statistic <Statistic
title="总请求量" title='总请求量'
value={totalRequests} value={totalRequests}
color="blue" color='blue'
prefix={<ChartBarIcon />} prefix={<ChartBarIcon />}
suffix="次" suffix='次'
animation={{ duration: 800, valueFrom: 0 }} animation={{ duration: 800, valueFrom: 0 }}
animationStart animationStart
/> />
@@ -34,11 +32,11 @@ export function StatCards({ stats }: StatCardsProps) {
<Col xs={12} md={6}> <Col xs={12} md={6}>
<Card bordered={false} hoverShadow> <Card bordered={false} hoverShadow>
<Statistic <Statistic
title="活跃模型数" title='活跃模型数'
value={activeModels} value={activeModels}
color="green" color='green'
prefix={<ChartLineIcon />} prefix={<ChartLineIcon />}
suffix="个" suffix='个'
animation={{ duration: 800, valueFrom: 0 }} animation={{ duration: 800, valueFrom: 0 }}
animationStart animationStart
/> />
@@ -47,11 +45,11 @@ export function StatCards({ stats }: StatCardsProps) {
<Col xs={12} md={6}> <Col xs={12} md={6}>
<Card bordered={false} hoverShadow> <Card bordered={false} hoverShadow>
<Statistic <Statistic
title="活跃供应商数" title='活跃供应商数'
value={activeProviders} value={activeProviders}
color="orange" color='orange'
prefix={<ServerIcon />} prefix={<ServerIcon />}
suffix="个" suffix='个'
animation={{ duration: 800, valueFrom: 0 }} animation={{ duration: 800, valueFrom: 0 }}
animationStart animationStart
/> />
@@ -60,16 +58,16 @@ export function StatCards({ stats }: StatCardsProps) {
<Col xs={12} md={6}> <Col xs={12} md={6}>
<Card bordered={false} hoverShadow> <Card bordered={false} hoverShadow>
<Statistic <Statistic
title="今日请求量" title='今日请求量'
value={todayRequests} value={todayRequests}
color="red" color='red'
prefix={<Calendar1Icon />} prefix={<Calendar1Icon />}
suffix="次" suffix='次'
animation={{ duration: 800, valueFrom: 0 }} animation={{ duration: 800, valueFrom: 0 }}
animationStart animationStart
/> />
</Card> </Card>
</Col> </Col>
</Row> </Row>
); )
} }

View File

@@ -1,18 +1,18 @@
import { useMemo } from 'react'; import { useMemo } from 'react'
import { Table, Select, Input, DateRangePicker, Space, Card } from 'tdesign-react'; import { Table, Select, Input, DateRangePicker, Space, Card } from 'tdesign-react'
import type { UsageStats, Provider } from '@/types'; import type { UsageStats, Provider } from '@/types'
import type { PrimaryTableCol } from 'tdesign-react/es/table/type'; import type { PrimaryTableCol } from 'tdesign-react/es/table/type'
interface StatsTableProps { interface StatsTableProps {
providers: Provider[]; providers: Provider[]
stats: UsageStats[]; stats: UsageStats[]
loading: boolean; loading: boolean
providerId?: string; providerId?: string
modelName?: string; modelName?: string
dateRange: [Date | null, Date | null] | null; dateRange: [Date | null, Date | null] | null
onProviderIdChange: (value: string | undefined) => void; onProviderIdChange: (value: string | undefined) => void
onModelNameChange: (value: string | undefined) => void; onModelNameChange: (value: string | undefined) => void
onDateRangeChange: (dates: [Date | null, Date | null] | null) => void; onDateRangeChange: (dates: [Date | null, Date | null] | null) => void
} }
export function StatsTable({ export function StatsTable({
@@ -27,12 +27,12 @@ export function StatsTable({
onDateRangeChange, onDateRangeChange,
}: StatsTableProps) { }: StatsTableProps) {
const providerMap = useMemo(() => { const providerMap = useMemo(() => {
const map = new Map<string, string>(); const map = new Map<string, string>()
for (const p of providers) { for (const p of providers) {
map.set(p.id, p.name); map.set(p.id, p.name)
} }
return map; return map
}, [providers]); }, [providers])
const columns: PrimaryTableCol<UsageStats>[] = [ const columns: PrimaryTableCol<UsageStats>[] = [
{ {
@@ -50,7 +50,7 @@ export function StatsTable({
cell: ({ row }) => { cell: ({ row }) => {
// 如果后端返回统一 ID 格式(包含 /),直接显示 // 如果后端返回统一 ID 格式(包含 /),直接显示
// 否则显示原始 model_name // 否则显示原始 model_name
return row.modelName; return row.modelName
}, },
}, },
{ {
@@ -64,25 +64,25 @@ export function StatsTable({
width: 100, width: 100,
align: 'right', align: 'right',
}, },
]; ]
const handleDateChange = (value: unknown) => { const handleDateChange = (value: unknown) => {
if (Array.isArray(value) && value.length === 2) { if (Array.isArray(value) && value.length === 2) {
// 将值转换为Date对象 // 将值转换为Date对象
const startDate = value[0] ? new Date(value[0] as string | number | Date) : null; const startDate = value[0] ? new Date(value[0] as string | number | Date) : null
const endDate = value[1] ? new Date(value[1] as string | number | Date) : null; const endDate = value[1] ? new Date(value[1] as string | number | Date) : null
onDateRangeChange([startDate, endDate]); onDateRangeChange([startDate, endDate])
} else { } else {
onDateRangeChange(null); onDateRangeChange(null)
}
} }
};
return ( return (
<Card title="统计数据" headerBordered hoverShadow> <Card title='统计数据' headerBordered hoverShadow>
<Space style={{ marginBottom: 16 }} size="medium" breakLine> <Space style={{ marginBottom: 16 }} size='medium' breakLine>
<Select <Select
clearable clearable
placeholder="所有供应商" placeholder='所有供应商'
style={{ width: 200 }} style={{ width: 200 }}
value={providerId} value={providerId}
onChange={(value) => onProviderIdChange(value as string | undefined)} onChange={(value) => onProviderIdChange(value as string | undefined)}
@@ -90,13 +90,13 @@ export function StatsTable({
/> />
<Input <Input
clearable clearable
placeholder="模型名称" placeholder='模型名称'
style={{ width: 200 }} style={{ width: 200 }}
value={modelName ?? ''} value={modelName ?? ''}
onChange={(value) => onModelNameChange((value as string) || undefined)} onChange={(value) => onModelNameChange((value as string) || undefined)}
/> />
<DateRangePicker <DateRangePicker
mode="date" mode='date'
value={dateRange && dateRange[0] && dateRange[1] ? [dateRange[0], dateRange[1]] : []} value={dateRange && dateRange[0] && dateRange[1] ? [dateRange[0], dateRange[1]] : []}
onChange={handleDateChange} onChange={handleDateChange}
/> />
@@ -105,12 +105,12 @@ export function StatsTable({
<Table<UsageStats> <Table<UsageStats>
columns={columns} columns={columns}
data={stats} data={stats}
rowKey="id" rowKey='id'
loading={loading} loading={loading}
stripe stripe
pagination={{ pageSize: 20 }} pagination={{ pageSize: 20 }}
empty="暂无统计数据" empty='暂无统计数据'
/> />
</Card> </Card>
); )
} }

View File

@@ -1,43 +1,43 @@
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, ResponsiveContainer, Tooltip } from 'recharts'; import { AreaChart, Area, XAxis, YAxis, CartesianGrid, ResponsiveContainer, Tooltip } from 'recharts'
import { Card } from 'tdesign-react'; import { Card } from 'tdesign-react'
import type { UsageStats } from '@/types'; import type { UsageStats } from '@/types'
interface UsageChartProps { interface UsageChartProps {
stats: UsageStats[]; stats: UsageStats[]
isLoading?: boolean; isLoading?: boolean
} }
export function UsageChart({ stats, isLoading }: UsageChartProps) { export function UsageChart({ stats, isLoading }: UsageChartProps) {
const chartData = Object.entries( const chartData = Object.entries(
stats.reduce<Record<string, number>>((acc, s) => { stats.reduce<Record<string, number>>((acc, s) => {
acc[s.date] = (acc[s.date] || 0) + s.requestCount; acc[s.date] = (acc[s.date] || 0) + s.requestCount
return acc; return acc
}, {}) }, {})
) )
.map(([date, requestCount]) => ({ date, requestCount })) .map(([date, requestCount]) => ({ date, requestCount }))
.sort((a, b) => a.date.localeCompare(b.date)); .sort((a, b) => a.date.localeCompare(b.date))
return ( return (
<Card title="请求趋势" headerBordered hoverShadow loading={isLoading} style={{ marginBottom: 16 }}> <Card title='请求趋势' headerBordered hoverShadow loading={isLoading} style={{ marginBottom: 16 }}>
{chartData.length > 0 ? ( {chartData.length > 0 ? (
<ResponsiveContainer width="100%" height={300}> <ResponsiveContainer width='100%' height={300}>
<AreaChart data={chartData}> <AreaChart data={chartData}>
<defs> <defs>
<linearGradient id="requestGradient" x1="0" y1="0" x2="0" y2="1"> <linearGradient id='requestGradient' x1='0' y1='0' x2='0' y2='1'>
<stop offset="0%" stopColor="#0052D9" stopOpacity={0.4} /> <stop offset='0%' stopColor='#0052D9' stopOpacity={0.4} />
<stop offset="100%" stopColor="#0052D9" stopOpacity={0} /> <stop offset='100%' stopColor='#0052D9' stopOpacity={0} />
</linearGradient> </linearGradient>
</defs> </defs>
<CartesianGrid strokeDasharray="3 3" stroke="#e8e8e8" /> <CartesianGrid strokeDasharray='3 3' stroke='#e8e8e8' />
<XAxis dataKey="date" /> <XAxis dataKey='date' />
<YAxis /> <YAxis />
<Tooltip /> <Tooltip />
<Area <Area
type="monotone" type='monotone'
dataKey="requestCount" dataKey='requestCount'
stroke="#0052D9" stroke='#0052D9'
strokeWidth={2} strokeWidth={2}
fill="url(#requestGradient)" fill='url(#requestGradient)'
/> />
</AreaChart> </AreaChart>
</ResponsiveContainer> </ResponsiveContainer>
@@ -47,5 +47,5 @@ export function UsageChart({ stats, isLoading }: UsageChartProps) {
</div> </div>
)} )}
</Card> </Card>
); )
} }

View File

@@ -1,16 +1,16 @@
import { useState, useMemo } from 'react'; import { useState, useMemo } from 'react'
import { useProviders } from '@/hooks/useProviders'; import { useProviders } from '@/hooks/useProviders'
import { useStats } from '@/hooks/useStats'; import { useStats } from '@/hooks/useStats'
import { StatCards } from './StatCards'; import { StatCards } from './StatCards'
import { StatsTable } from './StatsTable'; import { StatsTable } from './StatsTable'
import { UsageChart } from './UsageChart'; import { UsageChart } from './UsageChart'
export default function StatsPage() { export default function StatsPage() {
const { data: providers = [] } = useProviders(); const { data: providers = [] } = useProviders()
const [providerId, setProviderId] = useState<string | undefined>(); const [providerId, setProviderId] = useState<string | undefined>()
const [modelName, setModelName] = useState<string | undefined>(); const [modelName, setModelName] = useState<string | undefined>()
const [dateRange, setDateRange] = useState<[Date | null, Date | null] | null>(null); const [dateRange, setDateRange] = useState<[Date | null, Date | null] | null>(null)
const params = useMemo( const params = useMemo(
() => ({ () => ({
@@ -19,10 +19,10 @@ export default function StatsPage() {
startDate: dateRange?.[0]?.toISOString().split('T')[0], startDate: dateRange?.[0]?.toISOString().split('T')[0],
endDate: dateRange?.[1]?.toISOString().split('T')[0], endDate: dateRange?.[1]?.toISOString().split('T')[0],
}), }),
[providerId, modelName, dateRange], [providerId, modelName, dateRange]
); )
const { data: stats = [], isLoading } = useStats(params); const { data: stats = [], isLoading } = useStats(params)
return ( return (
<div> <div>
@@ -40,5 +40,5 @@ export default function StatsPage() {
onDateRangeChange={setDateRange} onDateRangeChange={setDateRange}
/> />
</div> </div>
); )
} }

View File

@@ -1,25 +1,25 @@
import { lazy, Suspense } from 'react'; import { lazy, Suspense } from 'react'
import { Routes, Route, Navigate } from 'react-router'; import { Routes, Route, Navigate } from 'react-router'
import { Loading } from 'tdesign-react'; import { Loading } from 'tdesign-react'
import { AppLayout } from '@/components/AppLayout'; import { AppLayout } from '@/components/AppLayout'
const ProvidersPage = lazy(() => import('@/pages/Providers')); const ProvidersPage = lazy(() => import('@/pages/Providers'))
const StatsPage = lazy(() => import('@/pages/Stats')); const StatsPage = lazy(() => import('@/pages/Stats'))
const SettingsPage = lazy(() => import('@/pages/Settings')); const SettingsPage = lazy(() => import('@/pages/Settings'))
const NotFound = lazy(() => import('@/pages/NotFound')); const NotFound = lazy(() => import('@/pages/NotFound'))
export function AppRoutes() { export function AppRoutes() {
return ( return (
<Suspense fallback={<Loading />}> <Suspense fallback={<Loading />}>
<Routes> <Routes>
<Route element={<AppLayout />}> <Route element={<AppLayout />}>
<Route index element={<Navigate to="/providers" replace />} /> <Route index element={<Navigate to='/providers' replace />} />
<Route path="providers" element={<ProvidersPage />} /> <Route path='providers' element={<ProvidersPage />} />
<Route path="stats" element={<StatsPage />} /> <Route path='stats' element={<StatsPage />} />
<Route path="settings" element={<SettingsPage />} /> <Route path='settings' element={<SettingsPage />} />
<Route path="*" element={<NotFound />} /> <Route path='*' element={<NotFound />} />
</Route> </Route>
</Routes> </Routes>
</Suspense> </Suspense>
); )
} }

View File

@@ -1,84 +1,80 @@
export interface Provider { export interface Provider {
id: string; id: string
name: string; name: string
apiKey: string; apiKey: string
baseUrl: string; baseUrl: string
protocol: 'openai' | 'anthropic'; protocol: 'openai' | 'anthropic'
enabled: boolean; enabled: boolean
createdAt: string; createdAt: string
updatedAt: string; updatedAt: string
} }
export interface Model { export interface Model {
id: string; id: string
providerId: string; providerId: string
modelName: string; modelName: string
enabled: boolean; enabled: boolean
createdAt: string; createdAt: string
unifiedId?: string; unifiedId?: string
} }
export interface UsageStats { export interface UsageStats {
id: number; id: number
providerId: string; providerId: string
modelName: string; modelName: string
requestCount: number; requestCount: number
date: string; date: string
} }
export interface CreateProviderInput { export interface CreateProviderInput {
id: string; id: string
name: string; name: string
apiKey: string; apiKey: string
baseUrl: string; baseUrl: string
protocol: 'openai' | 'anthropic'; protocol: 'openai' | 'anthropic'
enabled: boolean; enabled: boolean
} }
export interface UpdateProviderInput { export interface UpdateProviderInput {
name?: string; name?: string
apiKey?: string; apiKey?: string
baseUrl?: string; baseUrl?: string
protocol?: 'openai' | 'anthropic'; protocol?: 'openai' | 'anthropic'
enabled?: boolean; enabled?: boolean
} }
export interface CreateModelInput { export interface CreateModelInput {
providerId: string; providerId: string
modelName: string; modelName: string
enabled: boolean; enabled: boolean
} }
export interface UpdateModelInput { export interface UpdateModelInput {
providerId?: string; providerId?: string
modelName?: string; modelName?: string
enabled?: boolean; enabled?: boolean
} }
export interface StatsQueryParams { export interface StatsQueryParams {
providerId?: string; providerId?: string
modelName?: string; modelName?: string
startDate?: string; startDate?: string
endDate?: string; endDate?: string
} }
export class ApiError extends Error { export class ApiError extends Error {
status: number; status: number
code?: string; code?: string
constructor( constructor(status: number, message: string, code?: string) {
status: number, super(message)
message: string, this.name = 'ApiError'
code?: string, this.status = status
) { this.code = code
super(message);
this.name = 'ApiError';
this.status = status;
this.code = code;
} }
} }
export interface ApiErrorResponse { export interface ApiErrorResponse {
error: string; error: string
code?: string; code?: string
} }

View File

@@ -17,12 +17,7 @@ export default defineConfig({
coverage: { coverage: {
provider: 'v8', provider: 'v8',
include: ['src/**/*.{ts,tsx}'], include: ['src/**/*.{ts,tsx}'],
exclude: [ exclude: ['src/__tests__/**', 'src/main.tsx', 'src/**/*.module.scss', 'src/types/**'],
'src/__tests__/**',
'src/main.tsx',
'src/**/*.module.scss',
'src/types/**',
],
}, },
}, },
}) })

View File

@@ -49,18 +49,20 @@ TBD - 定义前端 ESLint 规则配置、构建集成 lint 检查、以及自定
### Requirement: 构建集成 lint 检查 ### Requirement: 构建集成 lint 检查
前端 SHALL 在 `build` 命令中集成 ESLint 检查。 前端 SHALL 在 `build` 命令中集成 ESLint 检查和 Prettier 格式检查
#### Scenario: 构建时执行 lint #### Scenario: 构建时执行 lint 和格式检查
- **WHEN** 执行 `bun run build` - **WHEN** 执行 `bun run build`
- **THEN** 构建 SHALL 依次执行 `tsc -b``eslint .``vite build` - **THEN** 构建 SHALL 依次执行 `tsc -b``bun run check``vite build`
- **THEN** `bun run check` SHALL 执行 `bun run lint && bun run format:check`
- **THEN** 若 `eslint .` 报告任何错误,构建 SHALL 中断 - **THEN** 若 `eslint .` 报告任何错误,构建 SHALL 中断
- **THEN** 若 `prettier --check .` 报告任何格式问题,构建 SHALL 中断
#### Scenario: lint 警告不中断构建 #### Scenario: lint 警告不中断构建
- **WHEN** `eslint .` 仅报告警告(无错误) - **WHEN** `eslint .` 仅报告警告(无错误)
- **THEN** 构建 SHALL 继续执行 `vite build` - **THEN** 构建 SHALL 继续执行格式检查和 `vite build`
#### Scenario: 单独执行 lint #### Scenario: 单独执行 lint
@@ -72,6 +74,19 @@ TBD - 定义前端 ESLint 规则配置、构建集成 lint 检查、以及自定
- **WHEN** 执行 `bun run lint:fix` - **WHEN** 执行 `bun run lint:fix`
- **THEN** SHALL 运行 `eslint . --fix` - **THEN** SHALL 运行 `eslint . --fix`
#### Scenario: 统一检查命令
- **WHEN** 执行 `bun run check`
- **THEN** SHALL 运行 `bun run lint && bun run format:check`
- **THEN** lint 错误和格式问题 SHALL 都被检查
#### Scenario: 统一修复命令
- **WHEN** 执行 `bun run fix`
- **THEN** SHALL 运行 `bun run lint:fix && bun run format`
- **THEN** lint 问题 SHALL 被修复
- **THEN** 文件 SHALL 被格式化
### Requirement: 自定义规则禁止硬编码颜色 ### Requirement: 自定义规则禁止硬编码颜色
前端 SHALL 提供自定义 ESLint 规则 `no-hardcoded-color-in-style`,检测 JSX style 属性中的硬编码颜色值。 前端 SHALL 提供自定义 ESLint 规则 `no-hardcoded-color-in-style`,检测 JSX style 属性中的硬编码颜色值。
@@ -112,3 +127,14 @@ TBD - 定义前端 ESLint 规则配置、构建集成 lint 检查、以及自定
- **THEN** 规则文件 SHALL 放置在 `frontend/eslint-rules/` 目录下 - **THEN** 规则文件 SHALL 放置在 `frontend/eslint-rules/` 目录下
- **THEN** `eslint.config.js` SHALL 通过相对路径引用本地插件 - **THEN** `eslint.config.js` SHALL 通过相对路径引用本地插件
- **THEN** 自定义规则 SHALL NOT 作为 npm 包发布 - **THEN** 自定义规则 SHALL NOT 作为 npm 包发布
### Requirement: ESLint 与 Prettier 集成配置
前端 SHALL 在 `eslint.config.js` 中集成 `eslint-config-prettier`,确保 ESLint 和 Prettier 职责分离且不冲突。
#### Scenario: 职责分离
- **WHEN** 检查代码
- **THEN** ESLint SHALL 负责代码质量检查(如未使用变量、语法错误)
- **THEN** Prettier SHALL 负责代码格式化(如缩进、引号、分号)
- **THEN** 两者 SHALL NOT 重复检查同一规则

View File

@@ -508,8 +508,30 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
- **THEN** Vite SHALL 对业务代码执行混淆处理 - **THEN** Vite SHALL 对业务代码执行混淆处理
- **THEN** 混淆 SHALL 仅应用于 src 目录下的业务代码 - **THEN** 混淆 SHALL 仅应用于 src 目录下的业务代码
- **THEN** 混淆 SHALL NOT 应用于 node_modules 中的第三方库 - **THEN** 混淆 SHALL NOT 应用于 node_modules 中的第三方库
- **THEN** 构建流程 SHALL 在 vite build 之前执行 ESLint 检查 - **THEN** 构建流程 SHALL 在 vite build 之前执行 ESLint 检查和 Prettier 格式检查
- **THEN** ESLint 检查失败 SHALL 中断构建 - **THEN** ESLint 检查失败 SHALL 中断构建
- **THEN** Prettier 格式检查失败 SHALL 中断构建
### Requirement: 开发环境格式化工具
前端 SHALL 配置开发环境格式化工具,确保开发者保存时自动格式化代码。
#### Scenario: VS Code 保存时自动格式化
- **WHEN** 开发者在 VS Code 中保存文件
- **THEN** 文件 SHALL 自动使用 Prettier 格式化
- **THEN** ESLint 可修复的问题 SHALL 自动修复
#### Scenario: 编辑器统一配置
- **WHEN** 开发者在编辑器中打开项目
- **THEN** 编辑器 SHALL 自动应用 `.editorconfig` 配置
- **THEN** 编辑器 SHALL 使用 2 空格缩进、UTF-8 编码、Unix 换行符
#### Scenario: VS Code 推荐安装扩展
- **WHEN** 开发者使用 VS Code 打开项目
- **THEN** VS Code SHALL 提示安装 Prettier 和 ESLint 扩展
### Requirement: 与后端 API 通信 ### Requirement: 与后端 API 通信

View File

@@ -0,0 +1,232 @@
# Prettier 代码格式化
## Purpose
定义前端代码格式化规则、工具集成、编辑器配置,确保多人协作时代码风格一致。
## Requirements
### Requirement: Prettier 核心配置
前端 SHALL 在 `.prettierrc` 文件中配置以下格式化规则:
- `semi`: `false` — 语句末尾 SHALL NOT 使用分号
- `singleQuote`: `true` — 字符串 SHALL 使用单引号
- `jsxSingleQuote`: `true` — JSX 属性 SHALL 使用单引号
- `tabWidth`: `2` — 缩进 SHALL 使用 2 个空格
- `useTabs`: `false` — 缩进 SHALL NOT 使用制表符
- `trailingComma`: `"es5"` — 多行结构末尾 SHALL 使用 ES5 兼容的尾随逗号
- `printWidth`: `120` — 每行最大字符数 SHALL 为 120
- `bracketSpacing`: `true` — 对象字面量花括号内 SHALL 有空格
- `arrowParens`: `"always"` — 箭头函数参数 SHALL 始终使用括号
- `endOfLine`: `"lf"` — 换行符 SHALL 使用 Unix 风格 (LF)
- `proseWrap`: `"preserve"` — Markdown 文本换行 SHALL 保持原样
- `htmlWhitespaceSensitivity`: `"css"` — HTML 空白处理 SHALL 根据 CSS display 属性
- `embeddedLanguageFormatting`: `"auto"` — 嵌入语言(如 Markdown 中的代码块SHALL 自动格式化
- `singleAttributePerLine`: `false` — JSX 多属性 SHALL NOT 强制每行一个
#### Scenario: 格式化 JavaScript 代码
- **WHEN** 运行 `prettier --write` 格式化 JavaScript 文件
- **THEN** 代码 SHALL 使用单引号、无分号、2 空格缩进
- **THEN** 行宽超过 120 字符时 SHALL 自动换行
#### Scenario: 格式化 TypeScript 代码
- **WHEN** 运行 `prettier --write` 格式化 TypeScript 文件
- **THEN** 代码 SHALL 使用单引号、无分号、2 空格缩进
- **THEN** type import SHALL 保持内联风格 `import { type Foo }`
#### Scenario: 格式化 JSX 代码
- **WHEN** 运行 `prettier --write` 格式化 JSX 文件
- **THEN** JSX 属性 SHALL 使用单引号
- **THEN** 多属性 SHALL 根据行宽自动换行
#### Scenario: 格式化 SCSS 代码
- **WHEN** 运行 `prettier --write` 格式化 SCSS 文件
- **THEN** 代码 SHALL 使用 2 空格缩进
- **THEN** CSS 规则 SHALL 保持一致的格式
#### Scenario: 格式化 JSON 文件
- **WHEN** 运行 `prettier --write` 格式化 JSON 文件
- **THEN** JSON SHALL 使用 2 空格缩进
- **THEN** JSON SHALL 保持尾随换行
#### Scenario: 格式化 Markdown 文件
- **WHEN** 运行 `prettier --write` 格式化 Markdown 文件
- **THEN** 文本换行 SHALL 保持原样
- **THEN** 代码块 SHALL 自动格式化
### Requirement: Prettier 忽略文件配置
前端 SHALL 在 `.prettierignore` 文件中配置以下忽略规则:
- `node_modules` — 依赖目录 SHALL NOT 格式化
- `dist` — 构建输出 SHALL NOT 格式化
- `dist-ssr` — SSR 构建输出 SHALL NOT 格式化
- `bun.lock` — Bun 锁文件 SHALL NOT 格式化
- `package-lock.json` — npm 锁文件 SHALL NOT 格式化
- `yarn.lock` — Yarn 锁文件 SHALL NOT 格式化
- `pnpm-lock.yaml` — pnpm 锁文件 SHALL NOT 格式化
- `.env.*` — 环境变量文件 SHALL NOT 格式化
- `*.local` — 本地配置文件 SHALL NOT 格式化
- `coverage` — 测试覆盖率报告 SHALL NOT 格式化
- `**/*.snap` — Jest snapshot 文件 SHALL NOT 格式化
- `**/__snapshots__/**` — Jest snapshot 目录 SHALL NOT 格式化
- `*.svg` — SVG 文件 SHALL NOT 格式化
- `*.min.js` — 压缩的 JS 文件 SHALL NOT 格式化
- `*.min.css` — 压缩的 CSS 文件 SHALL NOT 格式化
- `openspec/changes/archive/` — 已归档的变更 SHALL NOT 格式化
#### Scenario: 不格式化依赖目录
- **WHEN** 运行 `prettier --write .`
- **THEN** `node_modules` 目录 SHALL NOT 被格式化
#### Scenario: 不格式化锁文件
- **WHEN** 运行 `prettier --write .`
- **THEN** `bun.lock` 文件 SHALL NOT 被格式化
#### Scenario: 不格式化测试快照
- **WHEN** 运行 `prettier --write .`
- **THEN** `**/*.snap` 文件 SHALL NOT 被格式化
- **THEN** `**/__snapshots__/**` 目录 SHALL NOT 被格式化
#### Scenario: 不格式化 SVG 文件
- **WHEN** 运行 `prettier --write .`
- **THEN** `*.svg` 文件 SHALL NOT 被格式化
### Requirement: EditorConfig 配置
前端 SHALL 在 `.editorconfig` 文件中配置以下编辑器设置:
- `root = true` — 声明为根配置文件
- `[*]` `charset = utf-8` — 所有文件 SHALL 使用 UTF-8 编码
- `[*]` `indent_style = space` — 所有文件 SHALL 使用空格缩进
- `[*]` `indent_size = 2` — 所有文件 SHALL 使用 2 空格缩进
- `[*]` `end_of_line = lf` — 所有文件 SHALL 使用 Unix 换行符
- `[*]` `insert_final_newline = true` — 所有文件 SHALL 在末尾插入空行
- `[*]` `trim_trailing_whitespace = true` — 所有文件 SHALL 删除行尾空白
- `[*.md]` `trim_trailing_whitespace = false` — Markdown 文件 SHALL NOT 删除行尾空白Markdown 语法需要)
#### Scenario: 编辑器使用统一缩进
- **WHEN** 开发者在编辑器中打开项目
- **THEN** 编辑器 SHALL 自动使用 2 空格缩进
- **THEN** 编辑器 SHALL NOT 使用制表符缩进
#### Scenario: 编辑器使用统一换行符
- **WHEN** 开发者在编辑器中创建新文件
- **THEN** 编辑器 SHALL 使用 Unix 换行符 (LF)
- **THEN** 编辑器 SHALL NOT 使用 Windows 换行符 (CRLF)
#### Scenario: 编辑器使用统一编码
- **WHEN** 开发者在编辑器中保存文件
- **THEN** 文件 SHALL 使用 UTF-8 编码保存
### Requirement: VS Code 扩展推荐
前端 SHALL 在 `.vscode/extensions.json` 文件中推荐以下扩展:
- `esbenp.prettier-vscode` — Prettier 格式化扩展
- `dbaeumer.vscode-eslint` — ESLint 检查扩展
#### Scenario: VS Code 提示安装扩展
- **WHEN** 开发者使用 VS Code 打开项目
- **THEN** VS Code SHALL 提示安装推荐的扩展
- **THEN** 推荐列表 SHALL 包含 Prettier 和 ESLint 扩展
### Requirement: VS Code 格式化设置
前端 SHALL 在 `.vscode/settings.json` 文件中配置以下设置:
- `editor.formatOnSave = true` — 保存时 SHALL 自动格式化
- `editor.defaultFormatter = "esbenp.prettier-vscode"` — 默认格式化器 SHALL 为 Prettier
- `editor.codeActionsOnSave.source.fixAll.eslint = "explicit"` — 保存时 SHALL 自动修复 ESLint 问题
#### Scenario: 保存时自动格式化
- **WHEN** 开发者在 VS Code 中保存文件
- **THEN** 文件 SHALL 自动使用 Prettier 格式化
- **THEN** ESLint 可修复的问题 SHALL 自动修复
#### Scenario: 使用 Prettier 作为默认格式化器
- **WHEN** 开发者在 VS Code 中使用格式化命令
- **THEN** SHALL 使用 Prettier 进行格式化
- **THEN** SHALL NOT 使用其他格式化器
### Requirement: Prettier 与 ESLint 集成
前端 SHALL 在 `eslint.config.js` 中导入 `eslint-config-prettier` 配置,关闭与 Prettier 冲突的 ESLint 规则。
#### Scenario: ESLint 配置集成 Prettier
- **WHEN** 配置 `eslint.config.js`
- **THEN** SHALL 导入 `eslint-config-prettier`
- **THEN** `eslint-config-prettier` SHALL 放在配置数组的最后
- **THEN** 与 Prettier 冲突的 ESLint 规则 SHALL 被关闭
#### Scenario: ESLint 与 Prettier 不冲突
- **WHEN** 运行 `eslint .``prettier --check .`
- **THEN** ESLint 检查和 Prettier 格式化 SHALL NOT 产生冲突
- **THEN** 同一文件 SHALL NOT 同时报告 ESLint 错误和 Prettier 格式问题
### Requirement: 格式化脚本配置
前端 SHALL 在 `package.json` 中配置以下脚本:
- `format = "prettier --write ."` — 格式化所有文件
- `format:check = "prettier --check ."` — 检查文件格式
- `check = "bun run lint && bun run format:check"` — 检查 lint 和格式
- `fix = "bun run lint:fix && bun run format"` — 修复 lint 问题并格式化
#### Scenario: 运行格式化命令
- **WHEN** 执行 `bun run format`
- **THEN** SHALL 运行 `prettier --write .`
- **THEN** 所有文件 SHALL 被格式化
#### Scenario: 运行格式检查命令
- **WHEN** 执行 `bun run format:check`
- **THEN** SHALL 运行 `prettier --check .`
- **THEN** 未格式化的文件 SHALL 报告错误
#### Scenario: 运行统一检查命令
- **WHEN** 执行 `bun run check`
- **THEN** SHALL 运行 `bun run lint && bun run format:check`
- **THEN** lint 错误和格式问题 SHALL 都被检查
#### Scenario: 运行统一修复命令
- **WHEN** 执行 `bun run fix`
- **THEN** SHALL 运行 `bun run lint:fix && bun run format`
- **THEN** lint 问题 SHALL 被修复
- **THEN** 文件 SHALL 被格式化
### Requirement: Prettier 依赖安装
前端 SHALL 安装以下依赖:
- `prettier` — Prettier 核心库
- `eslint-config-prettier` — 关闭与 Prettier 冲突的 ESLint 规则
#### Scenario: 安装 Prettier 依赖
- **WHEN** 执行 `bun install`
- **THEN** `prettier` SHALL 被安装
- **THEN** `eslint-config-prettier` SHALL 被安装
- **THEN** 依赖版本 SHALL 在 `package.json` 中声明