From 365943e4c45ce5d71ebd68928c007747367eb672 Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Fri, 24 Apr 2026 13:40:53 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=89=8D=E7=AB=AF=E9=9B=86=E6=88=90=20?= =?UTF-8?q?Prettier=20=E4=BB=A3=E7=A0=81=E6=A0=BC=E5=BC=8F=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/.editorconfig | 12 + frontend/.gitignore | 1 + frontend/.prettierignore | 16 ++ frontend/.prettierrc | 16 ++ frontend/.vscode/extensions.json | 3 + frontend/.vscode/settings.json | 7 + frontend/README.md | 44 +++- frontend/bun.lock | 6 + frontend/e2e/fixtures.ts | 28 +- frontend/e2e/models.spec.ts | 31 ++- frontend/e2e/providers.spec.ts | 14 +- frontend/eslint-rules/index.js | 2 +- .../rules/no-hardcoded-color-in-style.js | 11 +- frontend/eslint.config.js | 21 +- frontend/index.html | 2 +- frontend/package.json | 8 +- frontend/src/App.tsx | 24 +- frontend/src/__tests__/api/client.test.ts | 238 +++++++++-------- frontend/src/__tests__/api/models.test.ts | 148 +++++------ frontend/src/__tests__/api/providers.test.ts | 122 ++++----- frontend/src/__tests__/api/stats.test.ts | 100 ++++---- .../__tests__/components/AppLayout.test.tsx | 54 ++-- .../__tests__/components/ModelForm.test.tsx | 122 +++++---- .../__tests__/components/ModelTable.test.tsx | 118 ++++----- .../components/ProviderForm.test.tsx | 240 +++++++++--------- .../components/ProviderTable.test.tsx | 180 ++++++------- .../__tests__/components/StatCards.test.tsx | 44 ++-- .../__tests__/components/StatsTable.test.tsx | 106 ++++---- .../__tests__/components/UsageChart.test.tsx | 46 ++-- .../eslint-rules/no-hardcoded-color.test.ts | 6 +- .../src/__tests__/hooks/useModels.test.tsx | 226 ++++++++--------- .../src/__tests__/hooks/useProviders.test.tsx | 208 +++++++-------- .../src/__tests__/hooks/useStats.test.tsx | 110 ++++---- frontend/src/__tests__/setup.ts | 27 +- frontend/src/api/client.ts | 60 ++--- frontend/src/api/models.ts | 21 +- frontend/src/api/providers.ts | 17 +- frontend/src/api/stats.ts | 18 +- frontend/src/components/AppLayout/index.tsx | 58 +++-- frontend/src/hooks/useModels.ts | 49 ++-- frontend/src/hooks/useProviders.ts | 49 ++-- frontend/src/hooks/useStats.ts | 10 +- frontend/src/index.scss | 6 +- frontend/src/main.tsx | 2 +- frontend/src/pages/NotFound.tsx | 28 +- frontend/src/pages/Providers/ModelForm.tsx | 95 +++---- frontend/src/pages/Providers/ModelTable.tsx | 47 ++-- frontend/src/pages/Providers/ProviderForm.tsx | 107 ++++---- .../src/pages/Providers/ProviderTable.tsx | 59 ++--- frontend/src/pages/Providers/index.tsx | 88 +++---- frontend/src/pages/Settings/index.tsx | 6 +- frontend/src/pages/Stats/StatCards.tsx | 46 ++-- frontend/src/pages/Stats/StatsTable.tsx | 64 ++--- frontend/src/pages/Stats/UsageChart.tsx | 40 +-- frontend/src/pages/Stats/index.tsx | 28 +- frontend/src/routes/index.tsx | 28 +- frontend/src/types/index.ts | 102 ++++---- frontend/vitest.config.ts | 7 +- openspec/specs/frontend-lint-rules/spec.md | 34 ++- openspec/specs/frontend/spec.md | 24 +- openspec/specs/prettier-formatting/spec.md | 232 +++++++++++++++++ 61 files changed, 1968 insertions(+), 1698 deletions(-) create mode 100644 frontend/.editorconfig create mode 100644 frontend/.prettierignore create mode 100644 frontend/.prettierrc create mode 100644 frontend/.vscode/extensions.json create mode 100644 frontend/.vscode/settings.json create mode 100644 openspec/specs/prettier-formatting/spec.md diff --git a/frontend/.editorconfig b/frontend/.editorconfig new file mode 100644 index 0000000..4bd3bd8 --- /dev/null +++ b/frontend/.editorconfig @@ -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 diff --git a/frontend/.gitignore b/frontend/.gitignore index a547bf3..49ef0bd 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -15,6 +15,7 @@ dist-ssr # Editor directories and files .vscode/* !.vscode/extensions.json +!.vscode/settings.json .idea .DS_Store *.suo diff --git a/frontend/.prettierignore b/frontend/.prettierignore new file mode 100644 index 0000000..26ac5c2 --- /dev/null +++ b/frontend/.prettierignore @@ -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/ diff --git a/frontend/.prettierrc b/frontend/.prettierrc new file mode 100644 index 0000000..b6a2caa --- /dev/null +++ b/frontend/.prettierrc @@ -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 +} diff --git a/frontend/.vscode/extensions.json b/frontend/.vscode/extensions.json new file mode 100644 index 0000000..d7df89c --- /dev/null +++ b/frontend/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["esbenp.prettier-vscode", "dbaeumer.vscode-eslint"] +} diff --git a/frontend/.vscode/settings.json b/frontend/.vscode/settings.json new file mode 100644 index 0000000..02b5dc1 --- /dev/null +++ b/frontend/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit" + } +} diff --git a/frontend/README.md b/frontend/README.md index 4f2b6ee..a89fb8d 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -13,6 +13,7 @@ AI 网关管理前端,提供供应商配置和用量统计界面。 - **数据获取**: TanStack Query v5 - **样式**: SCSS Modules(禁止使用纯 CSS) - **测试**: Vitest + React Testing Library + Playwright +- **代码格式化**: Prettier ## API 层 @@ -22,10 +23,10 @@ AI 网关管理前端,提供供应商配置和用量统计界面。 ```typescript // 发送请求时:camelCase → snake_case -toApi({ providerId: "openai" }) // → { provider_id: "openai" } +toApi({ providerId: 'openai' }) // → { provider_id: "openai" } // 接收响应时:snake_case → camelCase -fromApi({ provider_id: "openai" }) // → { providerId: "openai" } +fromApi({ provider_id: 'openai' }) // → { providerId: "openai" } ``` ### 统一请求函数 @@ -42,9 +43,9 @@ export async function request(method: string, path: string, body?: unknown): ```typescript class ApiError extends Error { - status: number; // HTTP 状态码 - code?: string; // 业务错误码 - message: string; // 错误消息 + status: number // HTTP 状态码 + code?: string // 业务错误码 + message: string // 错误消息 } ``` @@ -56,13 +57,13 @@ class ApiError extends Error { // src/hooks/useProviders.ts export const providerKeys = { all: ['providers'] as const, -}; +} // src/hooks/useModels.ts export const modelKeys = { all: ['models'] as const, byProvider: (providerId: string) => [...modelKeys.all, { providerId }] as const, -}; +} ``` ### Mutation 使用 @@ -71,9 +72,9 @@ export const modelKeys = { const mutation = useMutation({ mutationFn: createProvider, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: providerKeys.all }); + queryClient.invalidateQueries({ queryKey: providerKeys.all }) }, -}); +}) ``` ## 项目结构 @@ -142,9 +143,20 @@ bun run build ### 代码检查 ```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 扩展)。 + ## 测试 ### 单元测试 + 组件测试 @@ -219,26 +231,30 @@ __tests__/ ## 环境变量 -| 变量 | 开发环境 | 生产环境 | 说明 | -|------|----------|----------|------| -| `VITE_API_BASE` | (空) | `/api` | API 基础路径,空则走 Vite proxy | +| 变量 | 开发环境 | 生产环境 | 说明 | +| --------------- | -------- | -------- | ------------------------------- | +| `VITE_API_BASE` | (空) | `/api` | API 基础路径,空则走 Vite proxy | **E2E 测试特有**: + - `NEX_BACKEND_PORT` - E2E 后端端口(默认 19026) - `NEX_E2E_TEMP_DIR` - E2E 临时目录 ## 开发规范 - 所有样式使用 SCSS,禁止使用纯 CSS 文件 -- 组件级样式使用 SCSS Modules(*.module.scss) +- 组件级样式使用 SCSS Modules(\*.module.scss) - 图标优先使用 TDesign 图标(tdesign-icons-react) - TypeScript strict 模式,禁止 any 类型 - API 层自动处理 snake_case ↔ camelCase 字段转换 - 使用路径别名 `@/` 引用 src 目录 +- 代码格式化使用 Prettier,配置见 `.prettierrc` +- 编辑器配置见 `.editorconfig`(统一缩进、换行符、编码) ### 环境要求 - Bun 1.0 或更高版本 +- VS Code 推荐安装 Prettier 和 ESLint 扩展(见 `.vscode/extensions.json`) ### 添加新页面 diff --git a/frontend/bun.lock b/frontend/bun.lock index 183cbf3..a07089e 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -29,6 +29,7 @@ "@vitejs/plugin-react": "^6.0.1", "@vitest/coverage-v8": "^3.2.1", "eslint": "^9.39.4", + "eslint-config-prettier": "^10.1.8", "eslint-plugin-import": "^2.31.0", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", @@ -37,6 +38,7 @@ "javascript-obfuscator": "^5.4.1", "jsdom": "^26.1.0", "msw": "^2.8.2", + "prettier": "^3.8.3", "sass": "^1.99.0", "sql.js": "^1.14.1", "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-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-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=="], + "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=="], "process": ["process@0.11.10", "https://registry.npmmirror.com/process/-/process-0.11.10.tgz", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], diff --git a/frontend/e2e/fixtures.ts b/frontend/e2e/fixtures.ts index 104f8c7..968aaea 100644 --- a/frontend/e2e/fixtures.ts +++ b/frontend/e2e/fixtures.ts @@ -27,9 +27,7 @@ export interface SeedStatsInput { date: string } -export async function clearDatabase( - request: import('@playwright/test').APIRequestContext, -) { +export async function clearDatabase(request: import('@playwright/test').APIRequestContext) { const providers = await request.get(`${API_BASE}/api/providers`) if (providers.ok()) { const data = await providers.json() @@ -39,10 +37,7 @@ export async function clearDatabase( } } -export async function seedProvider( - request: import('@playwright/test').APIRequestContext, - data: SeedProviderInput, -) { +export async function seedProvider(request: import('@playwright/test').APIRequestContext, data: SeedProviderInput) { const resp = await request.post(`${API_BASE}/api/providers`, { data: { id: data.id, @@ -59,10 +54,7 @@ export async function seedProvider( return resp.json() } -export async function seedModel( - request: import('@playwright/test').APIRequestContext, - data: SeedModelInput, -) { +export async function seedModel(request: import('@playwright/test').APIRequestContext, data: SeedModelInput) { const resp = await request.post(`${API_BASE}/api/models`, { data: { provider_id: data.providerId, @@ -80,20 +72,22 @@ export async function seedUsageStats(statsData: SeedStatsInput[]) { const tempDir = path.join(os.tmpdir(), 'nex-e2e') const dbPath = path.join(tempDir, 'test.db') - + if (!fs.existsSync(dbPath)) { throw new Error(`Database file not found at ${dbPath}. Backend may not have created it yet.`) } - + const SQL = await initSqlite() const buf = fs.readFileSync(dbPath) const db = new SQL.Database(buf) for (const row of statsData) { - db.run( - 'INSERT OR REPLACE INTO usage_stats (provider_id, model_name, request_count, date) VALUES (?, ?, ?, ?)', - [row.providerId, row.modelName, row.requestCount, row.date], - ) + db.run('INSERT OR REPLACE INTO usage_stats (provider_id, model_name, request_count, date) VALUES (?, ?, ?, ?)', [ + row.providerId, + row.modelName, + row.requestCount, + row.date, + ]) } const data = db.export() diff --git a/frontend/e2e/models.spec.ts b/frontend/e2e/models.spec.ts index 5fa07e6..1d17e57 100644 --- a/frontend/e2e/models.spec.ts +++ b/frontend/e2e/models.spec.ts @@ -47,18 +47,25 @@ test.describe('模型管理', () => { await page.locator('.t-table__expand-box').first().click() 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 expect(page.locator('.t-dialog:visible')).toBeVisible() const inputs = modelFormInputs(page) 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 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 }) => { @@ -100,11 +107,15 @@ test.describe('模型管理', () => { const inputs = modelFormInputs(page) await inputs.modelName.clear() 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 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 }) => { @@ -126,6 +137,8 @@ test.describe('模型管理', () => { await page.locator('.t-table__expanded-row button:has-text("删除")').first().click() await expect(page.getByText(/确定要删除/)).toBeVisible() 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, + }) }) }) diff --git a/frontend/e2e/providers.spec.ts b/frontend/e2e/providers.spec.ts index 4b03d8b..b71d704 100644 --- a/frontend/e2e/providers.spec.ts +++ b/frontend/e2e/providers.spec.ts @@ -43,7 +43,7 @@ test.describe('供应商管理', () => { await page.waitForSelector('.t-select__dropdown', { state: 'hidden', timeout: 3000 }) await inputs.saveBtn.click() - + await expect(page.locator('.t-table__body').getByText('Test Provider')).toBeVisible({ timeout: 10000 }) }) @@ -60,8 +60,10 @@ test.describe('供应商管理', () => { await page.waitForSelector('.t-select__dropdown', { timeout: 3000 }) await page.locator('.t-select__dropdown .t-select-option').first().click() 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 responsePromise await expect(page.locator('.t-table__body').getByText('Before Edit')).toBeVisible({ timeout: 5000 }) @@ -72,8 +74,10 @@ test.describe('供应商管理', () => { const editInputs = formInputs(page) await editInputs.name.clear() 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 updateResponsePromise await expect(page.locator('.t-table__body').getByText('After Edit')).toBeVisible({ timeout: 5000 }) diff --git a/frontend/eslint-rules/index.js b/frontend/eslint-rules/index.js index 317b657..42583b6 100644 --- a/frontend/eslint-rules/index.js +++ b/frontend/eslint-rules/index.js @@ -10,4 +10,4 @@ const plugin = { }, } -export default plugin \ No newline at end of file +export default plugin diff --git a/frontend/eslint-rules/rules/no-hardcoded-color-in-style.js b/frontend/eslint-rules/rules/no-hardcoded-color-in-style.js index f52e96c..f815d9b 100644 --- a/frontend/eslint-rules/rules/no-hardcoded-color-in-style.js +++ b/frontend/eslint-rules/rules/no-hardcoded-color-in-style.js @@ -42,10 +42,7 @@ function isHardcodedColor(value) { function extractStyleProperties(expression) { const properties = [] - if ( - expression.type === 'ObjectExpression' && - expression.properties - ) { + if (expression.type === 'ObjectExpression' && expression.properties) { for (const styleProp of expression.properties) { if ( styleProp.type === 'Property' && @@ -92,9 +89,7 @@ export default ESLintUtils.RuleCreator((name) => { node.value?.type === 'JSXExpressionContainer' && node.value.expression ) { - const styleProps = extractStyleProperties( - node.value.expression, - ) + const styleProps = extractStyleProperties(node.value.expression) for (const prop of styleProps) { if (isHardcodedColor(prop.value)) { @@ -109,4 +104,4 @@ export default ESLintUtils.RuleCreator((name) => { }, } }, -}) \ No newline at end of file +}) diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index f0bf189..d37de8b 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -6,6 +6,7 @@ import tseslint from 'typescript-eslint' import importPlugin from 'eslint-plugin-import' import tanstackQuery from '@tanstack/eslint-plugin-query' import localRules from './eslint-rules/index.js' +import eslintConfigPrettier from 'eslint-config-prettier' export default tseslint.config( { ignores: ['dist'] }, @@ -26,10 +27,7 @@ export default tseslint.config( }, rules: { ...reactHooks.configs.recommended.rules, - 'react-refresh/only-export-components': [ - 'warn', - { allowConstantExport: true }, - ], + 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], 'no-console': ['error', { allow: ['warn', 'error'] }], '@typescript-eslint/consistent-type-imports': [ 'error', @@ -40,20 +38,10 @@ export default tseslint.config( 'import/order': [ 'warn', { - groups: [ - 'builtin', - 'external', - 'internal', - 'parent', - 'sibling', - 'index', - 'type', - ], + groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index', 'type'], 'newlines-between': 'never', alphabetize: { order: 'asc', caseInsensitive: true }, - pathGroups: [ - { pattern: '@/**', group: 'internal', position: 'before' }, - ], + pathGroups: [{ pattern: '@/**', group: 'internal', position: 'before' }], }, ], }, @@ -67,4 +55,5 @@ export default tseslint.config( 'no-console': 'off', }, }, + eslintConfigPrettier ) diff --git a/frontend/index.html b/frontend/index.html index 6e70a00..d2f2908 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,4 +1,4 @@ - + diff --git a/frontend/package.json b/frontend/package.json index 672d2fd..5c88e20 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,9 +5,13 @@ "type": "module", "scripts": { "dev": "vite", - "build": "tsc -b && eslint . && vite build", + "build": "tsc -b && bun run check && vite build", "lint": "eslint .", "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", "test": "vitest run", "test:watch": "vitest", @@ -39,6 +43,7 @@ "@vitejs/plugin-react": "^6.0.1", "@vitest/coverage-v8": "^3.2.1", "eslint": "^9.39.4", + "eslint-config-prettier": "^10.1.8", "eslint-plugin-import": "^2.31.0", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", @@ -47,6 +52,7 @@ "javascript-obfuscator": "^5.4.1", "jsdom": "^26.1.0", "msw": "^2.8.2", + "prettier": "^3.8.3", "sass": "^1.99.0", "sql.js": "^1.14.1", "typescript": "~6.0.2", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9f1abb7..f964a89 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,7 +1,7 @@ -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { BrowserRouter } from 'react-router'; -import { ConfigProvider } from 'tdesign-react'; -import { AppRoutes } from '@/routes'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { BrowserRouter } from 'react-router' +import { ConfigProvider } from 'tdesign-react' +import { AppRoutes } from '@/routes' const queryClient = new QueryClient({ defaultOptions: { @@ -11,21 +11,23 @@ const queryClient = new QueryClient({ refetchOnWindowFocus: false, }, }, -}); +}) function App() { return ( - + - ); + ) } -export default App; +export default App diff --git a/frontend/src/__tests__/api/client.test.ts b/frontend/src/__tests__/api/client.test.ts index 47c29bd..52bf864 100644 --- a/frontend/src/__tests__/api/client.test.ts +++ b/frontend/src/__tests__/api/client.test.ts @@ -1,88 +1,85 @@ -import { http, HttpResponse } from 'msw'; -import { setupServer } from 'msw/node'; -import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest'; -import { request, fromApi, toApi } from '@/api/client'; -import { ApiError } from '@/types'; +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest' +import { request, fromApi, toApi } from '@/api/client' +import { ApiError } from '@/types' describe('fromApi', () => { it('converts snake_case keys to camelCase', () => { - const input = { first_name: 'John', last_name: 'Doe' }; - const result = fromApi<{ firstName: string; lastName: string }>(input); - expect(result).toEqual({ firstName: 'John', lastName: 'Doe' }); - }); + const input = { first_name: 'John', last_name: 'Doe' } + const result = fromApi<{ firstName: string; lastName: string }>(input) + expect(result).toEqual({ firstName: 'John', lastName: 'Doe' }) + }) it('converts nested objects recursively', () => { const input = { user_name: 'alice', contact_info: { email_address: 'alice@example.com' }, - }; + } const result = fromApi<{ - userName: string; - contactInfo: { emailAddress: string }; - }>(input); + userName: string + contactInfo: { emailAddress: string } + }>(input) expect(result).toEqual({ userName: 'alice', contactInfo: { emailAddress: 'alice@example.com' }, - }); - }); + }) + }) it('converts arrays recursively', () => { - const input = [ - { item_name: 'a' }, - { item_name: 'b' }, - ]; - const result = fromApi>(input); - expect(result).toEqual([{ itemName: 'a' }, { itemName: 'b' }]); - }); + const input = [{ item_name: 'a' }, { item_name: 'b' }] + const result = fromApi>(input) + expect(result).toEqual([{ itemName: 'a' }, { itemName: 'b' }]) + }) it('returns primitives unchanged', () => { - expect(fromApi('hello')).toBe('hello'); - expect(fromApi(42)).toBe(42); - expect(fromApi(null)).toBeNull(); - }); -}); + expect(fromApi('hello')).toBe('hello') + expect(fromApi(42)).toBe(42) + expect(fromApi(null)).toBeNull() + }) +}) describe('toApi', () => { it('converts camelCase keys to snake_case', () => { - const input = { firstName: 'John', lastName: 'Doe' }; - const result = toApi<{ first_name: string; last_name: string }>(input); - expect(result).toEqual({ first_name: 'John', last_name: 'Doe' }); - }); + const input = { firstName: 'John', lastName: 'Doe' } + const result = toApi<{ first_name: string; last_name: string }>(input) + expect(result).toEqual({ first_name: 'John', last_name: 'Doe' }) + }) it('converts nested objects recursively', () => { const input = { userName: 'alice', contactInfo: { emailAddress: 'alice@example.com' }, - }; + } const result = toApi<{ - user_name: string; - contact_info: { email_address: string }; - }>(input); + user_name: string + contact_info: { email_address: string } + }>(input) expect(result).toEqual({ user_name: 'alice', contact_info: { email_address: 'alice@example.com' }, - }); - }); + }) + }) it('converts arrays recursively', () => { - const input = [{ itemName: 'a' }, { itemName: 'b' }]; - const result = toApi>(input); - expect(result).toEqual([{ item_name: 'a' }, { item_name: 'b' }]); - }); + const input = [{ itemName: 'a' }, { itemName: 'b' }] + const result = toApi>(input) + expect(result).toEqual([{ item_name: 'a' }, { item_name: 'b' }]) + }) it('returns primitives unchanged', () => { - expect(toApi('hello')).toBe('hello'); - expect(toApi(42)).toBe(42); - expect(toApi(null)).toBeNull(); - }); -}); + expect(toApi('hello')).toBe('hello') + expect(toApi(42)).toBe(42) + expect(toApi(null)).toBeNull() + }) +}) describe('request', () => { - const mswServer = setupServer(); + const mswServer = setupServer() - beforeAll(() => mswServer.listen({ onUnhandledRequest: 'bypass' })); - afterEach(() => mswServer.resetHandlers()); - afterAll(() => mswServer.close()); + beforeAll(() => mswServer.listen({ onUnhandledRequest: 'bypass' })) + afterEach(() => mswServer.resetHandlers()) + afterAll(() => mswServer.close()) it('parses JSON and converts snake_case keys to camelCase on success', async () => { mswServer.use( @@ -91,139 +88,130 @@ describe('request', () => { id: '1', created_at: '2025-01-01', nested_obj: { inner_key: 'value' }, - }); - }), - ); + }) + }) + ) const result = await request<{ - id: string; - createdAt: string; - nestedObj: { innerKey: string }; - }>('GET', '/api/test'); + id: string + createdAt: string + nestedObj: { innerKey: string } + }>('GET', '/api/test') expect(result).toEqual({ id: '1', createdAt: '2025-01-01', nestedObj: { innerKey: 'value' }, - }); - }); + }) + }) it('throws ApiError with status and message on HTTP error', async () => { mswServer.use( http.get('http://localhost:3000/api/test', () => { - return HttpResponse.json( - { message: 'Not found' }, - { status: 404 }, - ); - }), - ); + return HttpResponse.json({ message: 'Not found' }, { status: 404 }) + }) + ) - await expect(request('GET', '/api/test')).rejects.toThrow(ApiError); + await expect(request('GET', '/api/test')).rejects.toThrow(ApiError) try { - await request('GET', '/api/test'); + await request('GET', '/api/test') } catch (error) { - expect(error).toBeInstanceOf(ApiError); - const apiError = error as ApiError; - expect(apiError.status).toBe(404); - expect(apiError.message).toBe('Not found'); + expect(error).toBeInstanceOf(ApiError) + const apiError = error as ApiError + expect(apiError.status).toBe(404) + expect(apiError.message).toBe('Not found') } - }); + }) it('throws ApiError with default message when error body has no message', async () => { mswServer.use( http.get('http://localhost:3000/api/test', () => { - return HttpResponse.json( - { details: 'something' }, - { status: 500 }, - ); - }), - ); + return HttpResponse.json({ details: 'something' }, { status: 500 }) + }) + ) try { - await request('GET', '/api/test'); + await request('GET', '/api/test') } catch (error) { - expect(error).toBeInstanceOf(ApiError); - const apiError = error as ApiError; - expect(apiError.status).toBe(500); - expect(apiError.message).toContain('500'); + expect(error).toBeInstanceOf(ApiError) + const apiError = error as ApiError + expect(apiError.status).toBe(500) + expect(apiError.message).toContain('500') } - }); + }) it('throws ApiError with code field when error response includes code', async () => { mswServer.use( http.get('http://localhost:3000/api/test', () => { - return HttpResponse.json( - { error: 'Model not found', code: 'MODEL_NOT_FOUND' }, - { status: 404 }, - ); - }), - ); + return HttpResponse.json({ error: 'Model not found', code: 'MODEL_NOT_FOUND' }, { status: 404 }) + }) + ) try { - await request('GET', '/api/test'); + await request('GET', '/api/test') } catch (error) { - expect(error).toBeInstanceOf(ApiError); - const apiError = error as ApiError; - expect(apiError.status).toBe(404); - expect(apiError.message).toBe('Model not found'); - expect(apiError.code).toBe('MODEL_NOT_FOUND'); + expect(error).toBeInstanceOf(ApiError) + const apiError = error as ApiError + expect(apiError.status).toBe(404) + expect(apiError.message).toBe('Model not found') + expect(apiError.code).toBe('MODEL_NOT_FOUND') } - }); + }) it('throws Error on network failure', async () => { mswServer.use( 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 () => { mswServer.use( 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'); - expect(result).toBeUndefined(); - }); + const result = await request('DELETE', '/api/test/1') + expect(result).toBeUndefined() + }) it('sends body with camelCase keys converted to snake_case', async () => { - let receivedBody: Record | null = null; + let receivedBody: Record | null = null mswServer.use( http.post('http://localhost:3000/api/test', async ({ request }) => { - receivedBody = (await request.json()) as Record; - return HttpResponse.json({ id: '1' }); - }), - ); + receivedBody = (await request.json()) as Record + return HttpResponse.json({ id: '1' }) + }) + ) await request('POST', '/api/test', { providerId: 'prov-1', modelName: 'gpt-4', - }); + }) expect(receivedBody).toEqual({ provider_id: 'prov-1', model_name: 'gpt-4', - }); - }); + }) + }) it('sends Content-Type header as application/json', async () => { - let contentType: string | null = null; + let contentType: string | null = null mswServer.use( http.post('http://localhost:3000/api/test', async ({ request }) => { - contentType = request.headers.get('Content-Type'); - return HttpResponse.json({ id: '1' }); - }), - ); + contentType = request.headers.get('Content-Type') + 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') + }) +}) diff --git a/frontend/src/__tests__/api/models.test.ts b/frontend/src/__tests__/api/models.test.ts index 264a47c..2a44239 100644 --- a/frontend/src/__tests__/api/models.test.ts +++ b/frontend/src/__tests__/api/models.test.ts @@ -1,7 +1,7 @@ -import { http, HttpResponse } from 'msw'; -import { setupServer } from 'msw/node'; -import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest'; -import { listModels, createModel, updateModel, deleteModel } from '@/api/models'; +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest' +import { listModels, createModel, updateModel, deleteModel } from '@/api/models' const mockModels = [ { @@ -20,24 +20,24 @@ const mockModels = [ enabled: false, created_at: '2025-01-02T00:00:00Z', }, -]; +] describe('models API', () => { - const server = setupServer(); + const server = setupServer() - beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' })); - afterEach(() => server.resetHandlers()); - afterAll(() => server.close()); + beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' })) + afterEach(() => server.resetHandlers()) + afterAll(() => server.close()) describe('listModels', () => { it('returns array of Model objects with camelCase keys', async () => { server.use( 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([ { @@ -56,114 +56,114 @@ describe('models API', () => { enabled: false, createdAt: '2025-01-02T00:00:00Z', }, - ]); - }); + ]) + }) it('appends provider_id query parameter when providerId is given', async () => { - let receivedUrl: string | null = null; + let receivedUrl: string | null = null server.use( http.get('http://localhost:3000/api/models', ({ request }) => { - receivedUrl = request.url; - return HttpResponse.json([mockModels[0]]); - }), - ); + receivedUrl = request.url + 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(result).toHaveLength(1); - expect(result[0].providerId).toBe('prov-1'); - }); - }); + expect(receivedUrl).toContain('provider_id=prov-1') + expect(result).toHaveLength(1) + expect(result[0].providerId).toBe('prov-1') + }) + }) describe('createModel', () => { it('sends POST with correct body and returns model', async () => { - let receivedMethod: string | null = null; - let receivedBody: Record | null = null; + let receivedMethod: string | null = null + let receivedBody: Record | null = null server.use( http.post('http://localhost:3000/api/models', async ({ request }) => { - receivedMethod = request.method; - receivedBody = (await request.json()) as Record; - return HttpResponse.json(mockModels[0]); - }), - ); + receivedMethod = request.method + receivedBody = (await request.json()) as Record + return HttpResponse.json(mockModels[0]) + }) + ) const input = { providerId: 'prov-1', modelName: 'gpt-4', enabled: true, - }; + } - const result = await createModel(input); + const result = await createModel(input) - expect(receivedMethod).toBe('POST'); + expect(receivedMethod).toBe('POST') expect(receivedBody).toEqual({ provider_id: 'prov-1', model_name: 'gpt-4', enabled: true, - }); - expect(result.id).toBe('gpt-4'); - expect(result.providerId).toBe('prov-1'); - expect(result.modelName).toBe('gpt-4'); - expect(result.unifiedId).toBe('prov-1/gpt-4'); - }); - }); + }) + expect(result.id).toBe('gpt-4') + expect(result.providerId).toBe('prov-1') + expect(result.modelName).toBe('gpt-4') + expect(result.unifiedId).toBe('prov-1/gpt-4') + }) + }) describe('updateModel', () => { it('sends PUT with correct body and returns model', async () => { - let receivedMethod: string | null = null; - let receivedUrl: string | null = null; - let receivedBody: Record | null = null; + let receivedMethod: string | null = null + let receivedUrl: string | null = null + let receivedBody: Record | null = null server.use( http.put('http://localhost:3000/api/models/:id', async ({ request }) => { - receivedMethod = request.method; - receivedUrl = new URL(request.url).pathname; - receivedBody = (await request.json()) as Record; + receivedMethod = request.method + receivedUrl = new URL(request.url).pathname + receivedBody = (await request.json()) as Record return HttpResponse.json({ ...mockModels[0], model_name: 'gpt-4-turbo', enabled: false, - }); - }), - ); + }) + }) + ) const result = await updateModel('gpt-4', { modelName: 'gpt-4-turbo', enabled: false, - }); + }) - expect(receivedMethod).toBe('PUT'); - expect(receivedUrl).toBe('/api/models/gpt-4'); + expect(receivedMethod).toBe('PUT') + expect(receivedUrl).toBe('/api/models/gpt-4') expect(receivedBody).toEqual({ model_name: 'gpt-4-turbo', enabled: false, - }); - expect(result.modelName).toBe('gpt-4-turbo'); - expect(result.enabled).toBe(false); - }); - }); + }) + expect(result.modelName).toBe('gpt-4-turbo') + expect(result.enabled).toBe(false) + }) + }) describe('deleteModel', () => { it('sends DELETE and returns void', async () => { - let receivedMethod: string | null = null; - let receivedUrl: string | null = null; + let receivedMethod: string | null = null + let receivedUrl: string | null = null server.use( http.delete('http://localhost:3000/api/models/:id', ({ request }) => { - receivedMethod = request.method; - receivedUrl = new URL(request.url).pathname; - return new HttpResponse(null, { status: 204 }); - }), - ); + receivedMethod = request.method + receivedUrl = new URL(request.url).pathname + return new HttpResponse(null, { status: 204 }) + }) + ) - const result = await deleteModel('gpt-4'); + const result = await deleteModel('gpt-4') - expect(receivedMethod).toBe('DELETE'); - expect(receivedUrl).toBe('/api/models/gpt-4'); - expect(result).toBeUndefined(); - }); - }); -}); + expect(receivedMethod).toBe('DELETE') + expect(receivedUrl).toBe('/api/models/gpt-4') + expect(result).toBeUndefined() + }) + }) +}) diff --git a/frontend/src/__tests__/api/providers.test.ts b/frontend/src/__tests__/api/providers.test.ts index b75bde5..665567c 100644 --- a/frontend/src/__tests__/api/providers.test.ts +++ b/frontend/src/__tests__/api/providers.test.ts @@ -1,7 +1,7 @@ -import { http, HttpResponse } from 'msw'; -import { setupServer } from 'msw/node'; -import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest'; -import { listProviders, createProvider, updateProvider, deleteProvider } from '@/api/providers'; +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest' +import { listProviders, createProvider, updateProvider, deleteProvider } from '@/api/providers' const mockProviders = [ { @@ -24,24 +24,24 @@ const mockProviders = [ created_at: '2025-01-02T00:00:00Z', updated_at: '2025-01-02T00:00:00Z', }, -]; +] describe('providers API', () => { - const server = setupServer(); + const server = setupServer() - beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' })); - afterEach(() => server.resetHandlers()); - afterAll(() => server.close()); + beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' })) + afterEach(() => server.resetHandlers()) + afterAll(() => server.close()) describe('listProviders', () => { it('returns array of Provider objects with camelCase keys', async () => { server.use( 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([ { @@ -64,22 +64,22 @@ describe('providers API', () => { createdAt: '2025-01-02T00:00:00Z', updatedAt: '2025-01-02T00:00:00Z', }, - ]); - }); - }); + ]) + }) + }) describe('createProvider', () => { it('sends POST with correct body and returns provider', async () => { - let receivedMethod: string | null = null; - let receivedBody: Record | null = null; + let receivedMethod: string | null = null + let receivedBody: Record | null = null server.use( http.post('http://localhost:3000/api/providers', async ({ request }) => { - receivedMethod = request.method; - receivedBody = (await request.json()) as Record; - return HttpResponse.json(mockProviders[0]); - }), - ); + receivedMethod = request.method + receivedBody = (await request.json()) as Record + return HttpResponse.json(mockProviders[0]) + }) + ) const input = { id: 'prov-1', @@ -87,18 +87,18 @@ describe('providers API', () => { apiKey: 'sk-xxx', baseUrl: 'https://api.openai.com', enabled: true, - }; + } - const result = await createProvider(input); + const result = await createProvider(input) - expect(receivedMethod).toBe('POST'); + expect(receivedMethod).toBe('POST') expect(receivedBody).toEqual({ id: 'prov-1', name: 'OpenAI', api_key: 'sk-xxx', base_url: 'https://api.openai.com', enabled: true, - }); + }) expect(result).toEqual({ id: 'prov-1', name: 'OpenAI', @@ -108,63 +108,63 @@ describe('providers API', () => { enabled: true, createdAt: '2025-01-01T00:00:00Z', updatedAt: '2025-01-01T00:00:00Z', - }); - }); - }); + }) + }) + }) describe('updateProvider', () => { it('sends PUT with correct body and returns provider', async () => { - let receivedMethod: string | null = null; - let receivedUrl: string | null = null; - let receivedBody: Record | null = null; + let receivedMethod: string | null = null + let receivedUrl: string | null = null + let receivedBody: Record | null = null server.use( http.put('http://localhost:3000/api/providers/:id', async ({ request }) => { - receivedMethod = request.method; - receivedUrl = new URL(request.url).pathname; - receivedBody = (await request.json()) as Record; + receivedMethod = request.method + receivedUrl = new URL(request.url).pathname + receivedBody = (await request.json()) as Record return HttpResponse.json({ ...mockProviders[0], name: 'Updated', api_key: 'sk-updated', - }); - }), - ); + }) + }) + ) const result = await updateProvider('prov-1', { name: 'Updated', apiKey: 'sk-updated', - }); + }) - expect(receivedMethod).toBe('PUT'); - expect(receivedUrl).toBe('/api/providers/prov-1'); + expect(receivedMethod).toBe('PUT') + expect(receivedUrl).toBe('/api/providers/prov-1') expect(receivedBody).toEqual({ name: 'Updated', api_key: 'sk-updated', - }); - expect(result.name).toBe('Updated'); - expect(result.apiKey).toBe('sk-updated'); - }); - }); + }) + expect(result.name).toBe('Updated') + expect(result.apiKey).toBe('sk-updated') + }) + }) describe('deleteProvider', () => { it('sends DELETE and returns void', async () => { - let receivedMethod: string | null = null; - let receivedUrl: string | null = null; + let receivedMethod: string | null = null + let receivedUrl: string | null = null server.use( http.delete('http://localhost:3000/api/providers/:id', ({ request }) => { - receivedMethod = request.method; - receivedUrl = new URL(request.url).pathname; - return new HttpResponse(null, { status: 204 }); - }), - ); + receivedMethod = request.method + receivedUrl = new URL(request.url).pathname + return new HttpResponse(null, { status: 204 }) + }) + ) - const result = await deleteProvider('prov-1'); + const result = await deleteProvider('prov-1') - expect(receivedMethod).toBe('DELETE'); - expect(receivedUrl).toBe('/api/providers/prov-1'); - expect(result).toBeUndefined(); - }); - }); -}); + expect(receivedMethod).toBe('DELETE') + expect(receivedUrl).toBe('/api/providers/prov-1') + expect(result).toBeUndefined() + }) + }) +}) diff --git a/frontend/src/__tests__/api/stats.test.ts b/frontend/src/__tests__/api/stats.test.ts index 32dd943..9531015 100644 --- a/frontend/src/__tests__/api/stats.test.ts +++ b/frontend/src/__tests__/api/stats.test.ts @@ -1,7 +1,7 @@ -import { http, HttpResponse } from 'msw'; -import { setupServer } from 'msw/node'; -import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest'; -import { getStats } from '@/api/stats'; +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest' +import { getStats } from '@/api/stats' const mockStats = [ { @@ -18,29 +18,29 @@ const mockStats = [ request_count: 50, date: '2025-01-16', }, -]; +] describe('stats API', () => { - const server = setupServer(); + const server = setupServer() - beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' })); - afterEach(() => server.resetHandlers()); - afterAll(() => server.close()); + beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' })) + afterEach(() => server.resetHandlers()) + afterAll(() => server.close()) describe('getStats', () => { it('calls /api/stats without params', async () => { - let receivedUrl: string | null = null; + let receivedUrl: string | null = null server.use( http.get('http://localhost:3000/api/stats', ({ request }) => { - receivedUrl = request.url; - return HttpResponse.json(mockStats); - }), - ); + receivedUrl = request.url + 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([ { id: 1, @@ -56,76 +56,76 @@ describe('stats API', () => { requestCount: 50, date: '2025-01-16', }, - ]); - }); + ]) + }) 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( http.get('http://localhost:3000/api/stats', ({ request }) => { - receivedUrl = request.url; - return HttpResponse.json([]); - }), - ); + receivedUrl = request.url + return HttpResponse.json([]) + }) + ) await getStats({ providerId: 'prov-1', modelName: 'gpt-4', startDate: '2025-01-01', endDate: '2025-01-31', - }); + }) - expect(receivedUrl).toContain('provider_id=prov-1'); - expect(receivedUrl).toContain('model_name=gpt-4'); - expect(receivedUrl).toContain('start_date=2025-01-01'); - expect(receivedUrl).toContain('end_date=2025-01-31'); - }); + expect(receivedUrl).toContain('provider_id=prov-1') + expect(receivedUrl).toContain('model_name=gpt-4') + expect(receivedUrl).toContain('start_date=2025-01-01') + expect(receivedUrl).toContain('end_date=2025-01-31') + }) it('omits undefined params from query string', async () => { - let receivedUrl: string | null = null; + let receivedUrl: string | null = null server.use( http.get('http://localhost:3000/api/stats', ({ request }) => { - receivedUrl = request.url; - return HttpResponse.json([]); - }), - ); + receivedUrl = request.url + return HttpResponse.json([]) + }) + ) await getStats({ providerId: 'prov-1', - }); + }) - expect(receivedUrl).toContain('provider_id=prov-1'); - expect(receivedUrl).not.toContain('model_name'); - expect(receivedUrl).not.toContain('start_date'); - expect(receivedUrl).not.toContain('end_date'); - }); + expect(receivedUrl).toContain('provider_id=prov-1') + expect(receivedUrl).not.toContain('model_name') + expect(receivedUrl).not.toContain('start_date') + expect(receivedUrl).not.toContain('end_date') + }) it('returns UsageStats array with camelCase keys', async () => { server.use( 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({ id: 1, providerId: 'prov-1', modelName: 'gpt-4', requestCount: 100, date: '2025-01-15', - }); + }) expect(result[1]).toEqual({ id: 2, providerId: 'prov-2', modelName: 'claude-3', requestCount: 50, date: '2025-01-16', - }); - }); - }); -}); + }) + }) + }) +}) diff --git a/frontend/src/__tests__/components/AppLayout.test.tsx b/frontend/src/__tests__/components/AppLayout.test.tsx index c5ac659..b454842 100644 --- a/frontend/src/__tests__/components/AppLayout.test.tsx +++ b/frontend/src/__tests__/components/AppLayout.test.tsx @@ -1,52 +1,52 @@ -import { render, screen } from '@testing-library/react'; -import { BrowserRouter } from 'react-router'; -import { describe, it, expect } from 'vitest'; -import { AppLayout } from '@/components/AppLayout'; +import { render, screen } from '@testing-library/react' +import { BrowserRouter } from 'react-router' +import { describe, it, expect } from 'vitest' +import { AppLayout } from '@/components/AppLayout' const renderWithRouter = (component: React.ReactNode) => { - return render({component}); -}; + return render({component}) +} describe('AppLayout', () => { it('renders sidebar with app name', () => { - renderWithRouter(); + renderWithRouter() - const appNames = screen.getAllByText('AI Gateway'); - expect(appNames.length).toBeGreaterThan(0); - }); + const appNames = screen.getAllByText('AI Gateway') + expect(appNames.length).toBeGreaterThan(0) + }) it('renders navigation menu items', () => { - renderWithRouter(); + renderWithRouter() - expect(screen.getByText('供应商管理')).toBeInTheDocument(); - expect(screen.getByText('用量统计')).toBeInTheDocument(); - }); + expect(screen.getByText('供应商管理')).toBeInTheDocument() + expect(screen.getByText('用量统计')).toBeInTheDocument() + }) it('renders settings menu item', () => { - renderWithRouter(); + renderWithRouter() - expect(screen.getByText('设置')).toBeInTheDocument(); - }); + expect(screen.getByText('设置')).toBeInTheDocument() + }) it('renders content outlet', () => { - const { container } = renderWithRouter(); + const { container } = renderWithRouter() // TDesign Layout content - expect(container.querySelector('.t-layout__content')).toBeInTheDocument(); - }); + expect(container.querySelector('.t-layout__content')).toBeInTheDocument() + }) it('renders sidebar', () => { - const { container } = renderWithRouter(); + const { container } = renderWithRouter() // TDesign Layout.Aside might render with different class names // 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', () => { - const { container } = renderWithRouter(); + const { container } = renderWithRouter() // TDesign Layout header - expect(container.querySelector('.t-layout__header')).toBeInTheDocument(); - }); -}); + expect(container.querySelector('.t-layout__header')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/__tests__/components/ModelForm.test.tsx b/frontend/src/__tests__/components/ModelForm.test.tsx index 353874e..53a0b81 100644 --- a/frontend/src/__tests__/components/ModelForm.test.tsx +++ b/frontend/src/__tests__/components/ModelForm.test.tsx @@ -1,8 +1,8 @@ -import { render, screen, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { describe, it, expect, vi } from 'vitest'; -import { ModelForm } from '@/pages/Providers/ModelForm'; -import type { Provider, Model } from '@/types'; +import { render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, it, expect, vi } from 'vitest' +import { ModelForm } from '@/pages/Providers/ModelForm' +import type { Provider, Model } from '@/types' const mockProviders: Provider[] = [ { @@ -25,7 +25,7 @@ const mockProviders: Provider[] = [ createdAt: '2024-01-02T00:00:00Z', updatedAt: '2024-01-02T00:00:00Z', }, -]; +] const mockModel: Model = { id: 'gpt-4o', @@ -34,7 +34,7 @@ const mockModel: Model = { enabled: true, createdAt: '2024-01-01T00:00:00Z', unifiedId: 'openai/gpt-4o', -}; +} const defaultProps = { open: true, @@ -43,69 +43,63 @@ const defaultProps = { onSave: vi.fn(), onCancel: vi.fn(), loading: false, -}; +} function getDialog() { // TDesign Dialog doesn't have role="dialog", use class selector - const dialog = document.querySelector('.t-dialog'); + const dialog = document.querySelector('.t-dialog') if (!dialog) { - throw new Error('Dialog not found'); + throw new Error('Dialog not found') } - return dialog; + return dialog } describe('ModelForm', () => { it('renders form with provider select', () => { - render(); + render() - const dialog = getDialog(); - expect(within(dialog).getByText('添加模型')).toBeInTheDocument(); - expect(within(dialog).getByText('供应商')).toBeInTheDocument(); - expect(within(dialog).getByText('模型名称')).toBeInTheDocument(); - expect(within(dialog).getByText('启用')).toBeInTheDocument(); - }); + const dialog = getDialog() + 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', () => { - render(); + render() - const dialog = getDialog(); + const dialog = getDialog() // Form renders with provider select - expect(within(dialog).getByText('供应商')).toBeInTheDocument(); - }); + expect(within(dialog).getByText('供应商')).toBeInTheDocument() + }) it('shows validation error messages for required fields', async () => { - const user = userEvent.setup(); - render( - , - ); + const user = userEvent.setup() + render() - const dialog = getDialog(); - const okButton = within(dialog).getByRole('button', { name: /保/ }); - await user.click(okButton); + const dialog = getDialog() + const okButton = within(dialog).getByRole('button', { name: /保/ }) + await user.click(okButton) - expect(await screen.findByText('请选择供应商')).toBeInTheDocument(); - expect(screen.getByText('请输入模型名称')).toBeInTheDocument(); - }); + expect(await screen.findByText('请选择供应商')).toBeInTheDocument() + expect(screen.getByText('请输入模型名称')).toBeInTheDocument() + }) it('calls onSave with form values on successful submission', async () => { - const user = userEvent.setup(); - const onSave = vi.fn(); - render(); + const user = userEvent.setup() + const onSave = vi.fn() + render() - const dialog = getDialog(); + const dialog = getDialog() // 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 - await user.clear(modelNameInput); - await user.type(modelNameInput, 'gpt-4o-mini'); + await user.clear(modelNameInput) + await user.type(modelNameInput, 'gpt-4o-mini') - const okButton = within(dialog).getByRole('button', { name: /保/ }); - await user.click(okButton); + const okButton = within(dialog).getByRole('button', { name: /保/ }) + await user.click(okButton) // Wait for the onSave to be called await vi.waitFor(() => { @@ -114,30 +108,30 @@ describe('ModelForm', () => { providerId: 'openai', modelName: 'gpt-4o-mini', enabled: true, - }), - ); - }); - }, 10000); + }) + ) + }) + }, 10000) it('renders pre-filled fields in edit mode', () => { - render(); + render() - const dialog = getDialog(); - expect(within(dialog).getByText('编辑模型')).toBeInTheDocument(); + const dialog = getDialog() + expect(within(dialog).getByText('编辑模型')).toBeInTheDocument() // Check model name input - const modelNameInput = within(dialog).getByPlaceholderText('例如: gpt-4o') as HTMLInputElement; - expect(modelNameInput.value).toBe('gpt-4o'); - }); + const modelNameInput = within(dialog).getByPlaceholderText('例如: gpt-4o') as HTMLInputElement + expect(modelNameInput.value).toBe('gpt-4o') + }) it('calls onCancel when clicking cancel button', async () => { - const user = userEvent.setup(); - const onCancel = vi.fn(); - render(); + const user = userEvent.setup() + const onCancel = vi.fn() + render() - const dialog = getDialog(); - const cancelButton = within(dialog).getByRole('button', { name: /取/ }); - await user.click(cancelButton); - expect(onCancel).toHaveBeenCalledTimes(1); - }); -}); + const dialog = getDialog() + const cancelButton = within(dialog).getByRole('button', { name: /取/ }) + await user.click(cancelButton) + expect(onCancel).toHaveBeenCalledTimes(1) + }) +}) diff --git a/frontend/src/__tests__/components/ModelTable.test.tsx b/frontend/src/__tests__/components/ModelTable.test.tsx index d25403d..2adf59c 100644 --- a/frontend/src/__tests__/components/ModelTable.test.tsx +++ b/frontend/src/__tests__/components/ModelTable.test.tsx @@ -1,8 +1,8 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { ModelTable } from '@/pages/Providers/ModelTable'; -import type { Model } from '@/types'; +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { ModelTable } from '@/pages/Providers/ModelTable' +import type { Model } from '@/types' const mockModels: Model[] = [ { @@ -21,103 +21,103 @@ const mockModels: Model[] = [ createdAt: '2024-01-02T00:00:00Z', unifiedId: 'openai/gpt-3.5-turbo', }, -]; +] -const mockMutate = vi.fn(); +const mockMutate = vi.fn() vi.mock('@/hooks/useModels', () => ({ useModels: vi.fn((providerId: string) => { 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 })), -})); +})) const defaultProps = { providerId: 'openai', onAdd: vi.fn(), onEdit: vi.fn(), -}; +} describe('ModelTable', () => { beforeEach(() => { - mockMutate.mockClear(); - }); + mockMutate.mockClear() + }) it('renders model list with unified ID and model name', () => { - render(); + render() - expect(screen.getByText(/关联模型/)).toBeInTheDocument(); - expect(screen.getByText('openai/gpt-4o')).toBeInTheDocument(); - expect(screen.getByText('openai/gpt-3.5-turbo')).toBeInTheDocument(); - expect(screen.getByText('gpt-4o')).toBeInTheDocument(); - expect(screen.getByText('gpt-3.5-turbo')).toBeInTheDocument(); - }); + expect(screen.getByText(/关联模型/)).toBeInTheDocument() + expect(screen.getByText('openai/gpt-4o')).toBeInTheDocument() + expect(screen.getByText('openai/gpt-3.5-turbo')).toBeInTheDocument() + expect(screen.getByText('gpt-4o')).toBeInTheDocument() + expect(screen.getByText('gpt-3.5-turbo')).toBeInTheDocument() + }) it('renders status tags correctly', () => { - render(); + render() - const enabledTags = screen.getAllByText('启用'); - const disabledTags = screen.getAllByText('禁用'); - expect(enabledTags.length).toBeGreaterThanOrEqual(1); - expect(disabledTags.length).toBeGreaterThanOrEqual(1); - }); + const enabledTags = screen.getAllByText('启用') + const disabledTags = screen.getAllByText('禁用') + expect(enabledTags.length).toBeGreaterThanOrEqual(1) + expect(disabledTags.length).toBeGreaterThanOrEqual(1) + }) it('calls onAdd when clicking "添加模型" button', async () => { - const user = userEvent.setup(); - const onAdd = vi.fn(); - render(); + const user = userEvent.setup() + const onAdd = vi.fn() + render() - await user.click(screen.getByRole('button', { name: '添加模型' })); - expect(onAdd).toHaveBeenCalledTimes(1); - }); + await user.click(screen.getByRole('button', { name: '添加模型' })) + expect(onAdd).toHaveBeenCalledTimes(1) + }) it('calls onEdit with correct model when clicking "编辑"', async () => { - const user = userEvent.setup(); - const onEdit = vi.fn(); - render(); + const user = userEvent.setup() + const onEdit = vi.fn() + render() - const editButtons = screen.getAllByRole('button', { name: /编 ?辑/ }); - await user.click(editButtons[0]); + const editButtons = screen.getAllByRole('button', { name: /编 ?辑/ }) + await user.click(editButtons[0]) - expect(onEdit).toHaveBeenCalledTimes(1); - expect(onEdit).toHaveBeenCalledWith(mockModels[0]); - }); + expect(onEdit).toHaveBeenCalledTimes(1) + expect(onEdit).toHaveBeenCalledWith(mockModels[0]) + }) it('calls deleteModel.mutate with correct model ID when delete is confirmed', async () => { - const user = userEvent.setup(); + const user = userEvent.setup() - render(); + render() // Find and click the delete button for the first row - const deleteButtons = screen.getAllByRole('button', { name: '删除' }); - await user.click(deleteButtons[0]); + const deleteButtons = screen.getAllByRole('button', { name: '删除' }) + await user.click(deleteButtons[0]) // TDesign Popconfirm renders confirmation popup with "确定" button - const confirmButton = await screen.findByRole('button', { name: '确定' }); - await user.click(confirmButton); + const confirmButton = await screen.findByRole('button', { name: '确定' }) + await user.click(confirmButton) // Assert that deleteModel.mutate was called with the correct model ID - expect(mockMutate).toHaveBeenCalledTimes(1); - expect(mockMutate).toHaveBeenCalledWith('model-1'); - }, 10000); + expect(mockMutate).toHaveBeenCalledTimes(1) + expect(mockMutate).toHaveBeenCalledWith('model-1') + }, 10000) it('shows custom empty text when models list is empty', () => { - render(); - expect(screen.getByText('暂无模型,点击上方按钮添加')).toBeInTheDocument(); - }); + render() + expect(screen.getByText('暂无模型,点击上方按钮添加')).toBeInTheDocument() + }) it('does not render add button when onAdd is not provided', () => { - render(); + render() - 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', () => { - render(); + render() - expect(screen.queryByRole('button', { name: /编 ?辑/ })).not.toBeInTheDocument(); - }); -}); + expect(screen.queryByRole('button', { name: /编 ?辑/ })).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/__tests__/components/ProviderForm.test.tsx b/frontend/src/__tests__/components/ProviderForm.test.tsx index 6af758b..5c67857 100644 --- a/frontend/src/__tests__/components/ProviderForm.test.tsx +++ b/frontend/src/__tests__/components/ProviderForm.test.tsx @@ -1,8 +1,8 @@ -import { render, screen, within, fireEvent } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { describe, it, expect, vi } from 'vitest'; -import { ProviderForm } from '@/pages/Providers/ProviderForm'; -import type { Provider } from '@/types'; +import { render, screen, within, fireEvent } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, it, expect, vi } from 'vitest' +import { ProviderForm } from '@/pages/Providers/ProviderForm' +import type { Provider } from '@/types' const mockProvider: Provider = { id: 'openai', @@ -13,187 +13,193 @@ const mockProvider: Provider = { enabled: true, createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z', -}; +} const defaultProps = { open: true, onSave: vi.fn(), onCancel: vi.fn(), loading: false, -}; +} function getDialog() { // TDesign Dialog doesn't have role="dialog", use class selector - const dialog = document.querySelector('.t-dialog'); + const dialog = document.querySelector('.t-dialog') if (!dialog) { - throw new Error('Dialog not found'); + throw new Error('Dialog not found') } - return dialog; + return dialog } describe('ProviderForm', () => { it('renders form fields in create mode', () => { - render(); + render() - const dialog = getDialog(); - expect(within(dialog).getByText('添加供应商')).toBeInTheDocument(); - expect(within(dialog).getByText('ID')).toBeInTheDocument(); - expect(within(dialog).getByText('名称')).toBeInTheDocument(); - expect(within(dialog).getByText('API Key')).toBeInTheDocument(); - expect(within(dialog).getByText('Base URL')).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('例如: https://api.openai.com/v1')).toBeInTheDocument(); - }); + const dialog = getDialog() + expect(within(dialog).getByText('添加供应商')).toBeInTheDocument() + expect(within(dialog).getByText('ID')).toBeInTheDocument() + expect(within(dialog).getByText('名称')).toBeInTheDocument() + expect(within(dialog).getByText('API Key')).toBeInTheDocument() + expect(within(dialog).getByText('Base URL')).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('例如: https://api.openai.com/v1')).toBeInTheDocument() + }) it('renders pre-filled fields in edit mode', () => { - render(); + render() - const dialog = getDialog(); - expect(within(dialog).getByText('编辑供应商')).toBeInTheDocument(); + const dialog = getDialog() + expect(within(dialog).getByText('编辑供应商')).toBeInTheDocument() - const idInput = within(dialog).getByPlaceholderText('例如: openai') as HTMLInputElement; - expect(idInput.value).toBe('openai'); - expect(idInput).toBeDisabled(); + const idInput = within(dialog).getByPlaceholderText('例如: openai') as HTMLInputElement + expect(idInput.value).toBe('openai') + expect(idInput).toBeDisabled() - const nameInput = within(dialog).getByPlaceholderText('例如: OpenAI') as HTMLInputElement; - expect(nameInput.value).toBe('OpenAI'); + const nameInput = within(dialog).getByPlaceholderText('例如: OpenAI') as HTMLInputElement + expect(nameInput.value).toBe('OpenAI') - const baseUrlInput = within(dialog).getByPlaceholderText('例如: https://api.openai.com/v1') as HTMLInputElement; - expect(baseUrlInput.value).toBe('https://api.openai.com/v1'); + const baseUrlInput = within(dialog).getByPlaceholderText('例如: https://api.openai.com/v1') as HTMLInputElement + expect(baseUrlInput.value).toBe('https://api.openai.com/v1') - const apiKeyInput = within(dialog).getByPlaceholderText('sk-...') as HTMLInputElement; - expect(apiKeyInput.value).toBe('sk-old-key'); - }); + const apiKeyInput = within(dialog).getByPlaceholderText('sk-...') as HTMLInputElement + expect(apiKeyInput.value).toBe('sk-old-key') + }) it('shows API Key label in edit mode', () => { - render(); + render() - const dialog = getDialog(); - expect(within(dialog).getByText('API Key')).toBeInTheDocument(); - }); + const dialog = getDialog() + expect(within(dialog).getByText('API Key')).toBeInTheDocument() + }) it('shows validation error messages for required fields', async () => { - const user = userEvent.setup(); - render(); + const user = userEvent.setup() + render() - const dialog = getDialog(); - const okButton = within(dialog).getByRole('button', { name: /保/ }); - await user.click(okButton); + const dialog = getDialog() + const okButton = within(dialog).getByRole('button', { name: /保/ }) + await user.click(okButton) // Wait for validation messages to appear - expect(await screen.findByText('请输入供应商 ID')).toBeInTheDocument(); - expect(screen.getByText('请输入名称')).toBeInTheDocument(); - expect(screen.getByText('请输入 API Key')).toBeInTheDocument(); - expect(screen.getByText('请输入 Base URL')).toBeInTheDocument(); - }); + expect(await screen.findByText('请输入供应商 ID')).toBeInTheDocument() + expect(screen.getByText('请输入名称')).toBeInTheDocument() + expect(screen.getByText('请输入 API Key')).toBeInTheDocument() + expect(screen.getByText('请输入 Base URL')).toBeInTheDocument() + }) it('calls onSave with form values on successful submission', async () => { - const onSave = vi.fn(); - render(); + const onSave = vi.fn() + render() - const dialog = getDialog(); + const dialog = getDialog() // Get form instance and set values directly - const idInput = within(dialog).getByPlaceholderText('例如: openai') as HTMLInputElement; - const nameInput = within(dialog).getByPlaceholderText('例如: OpenAI') as HTMLInputElement; - const apiKeyInput = within(dialog).getByPlaceholderText('sk-...') as HTMLInputElement; - const baseUrlInput = within(dialog).getByPlaceholderText('例如: https://api.openai.com/v1') as HTMLInputElement; + const idInput = within(dialog).getByPlaceholderText('例如: openai') as HTMLInputElement + const nameInput = within(dialog).getByPlaceholderText('例如: OpenAI') as HTMLInputElement + const apiKeyInput = within(dialog).getByPlaceholderText('sk-...') as HTMLInputElement + const baseUrlInput = within(dialog).getByPlaceholderText('例如: https://api.openai.com/v1') as HTMLInputElement // Simulate user input by directly setting values - fireEvent.change(idInput, { target: { value: 'test-provider' } }); - fireEvent.change(nameInput, { target: { value: 'Test Provider' } }); - fireEvent.change(apiKeyInput, { target: { value: 'sk-test-key' } }); - fireEvent.change(baseUrlInput, { target: { value: 'https://api.test.com/v1' } }); + fireEvent.change(idInput, { target: { value: 'test-provider' } }) + fireEvent.change(nameInput, { target: { value: 'Test Provider' } }) + fireEvent.change(apiKeyInput, { target: { value: 'sk-test-key' } }) + fireEvent.change(baseUrlInput, { target: { value: 'https://api.test.com/v1' } }) - const okButton = within(dialog).getByRole('button', { name: /保/ }); - fireEvent.click(okButton); + const okButton = within(dialog).getByRole('button', { name: /保/ }) + fireEvent.click(okButton) // Wait for the onSave to be called - await vi.waitFor(() => { - expect(onSave).toHaveBeenCalled(); - }, { timeout: 5000 }); - }, 10000); + await vi.waitFor( + () => { + expect(onSave).toHaveBeenCalled() + }, + { timeout: 5000 } + ) + }, 10000) it('calls onCancel when clicking cancel button', async () => { - const user = userEvent.setup(); - const onCancel = vi.fn(); - render(); + const user = userEvent.setup() + const onCancel = vi.fn() + render() - const dialog = getDialog(); - const cancelButton = within(dialog).getByRole('button', { name: /取/ }); - await user.click(cancelButton); - expect(onCancel).toHaveBeenCalledTimes(1); - }); + const dialog = getDialog() + const cancelButton = within(dialog).getByRole('button', { name: /取/ }) + await user.click(cancelButton) + expect(onCancel).toHaveBeenCalledTimes(1) + }) it('shows confirm loading state', () => { - render(); - const dialog = getDialog(); - const okButton = within(dialog).getByRole('button', { name: /保/ }); + render() + const dialog = getDialog() + const okButton = within(dialog).getByRole('button', { name: /保/ }) // 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 () => { - const user = userEvent.setup(); - render(); + const user = userEvent.setup() + render() - const dialog = getDialog(); + const dialog = getDialog() // 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('sk-...'), 'sk-test-key'); + 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') // 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 - const okButton = within(dialog).getByRole('button', { name: /保/ }); - await user.click(okButton); + const okButton = within(dialog).getByRole('button', { name: /保/ }) + await user.click(okButton) // Verify that a URL validation error message appears await vi.waitFor(() => { - expect(screen.getByText('请输入有效的 URL')).toBeInTheDocument(); - }); - }, 15000); + expect(screen.getByText('请输入有效的 URL')).toBeInTheDocument() + }) + }, 15000) it('renders protocol select field with default value', () => { - render(); + render() - const dialog = getDialog(); - expect(within(dialog).getByText('协议')).toBeInTheDocument(); - }); + const dialog = getDialog() + expect(within(dialog).getByText('协议')).toBeInTheDocument() + }) it('includes protocol field in form submission', async () => { - const onSave = vi.fn(); - render(); + const onSave = vi.fn() + render() - const dialog = getDialog(); + const dialog = getDialog() // Get form instance and set values directly - const idInput = within(dialog).getByPlaceholderText('例如: openai') as HTMLInputElement; - const nameInput = within(dialog).getByPlaceholderText('例如: OpenAI') as HTMLInputElement; - const apiKeyInput = within(dialog).getByPlaceholderText('sk-...') as HTMLInputElement; - const baseUrlInput = within(dialog).getByPlaceholderText('例如: https://api.openai.com/v1') as HTMLInputElement; + const idInput = within(dialog).getByPlaceholderText('例如: openai') as HTMLInputElement + const nameInput = within(dialog).getByPlaceholderText('例如: OpenAI') as HTMLInputElement + const apiKeyInput = within(dialog).getByPlaceholderText('sk-...') as HTMLInputElement + const baseUrlInput = within(dialog).getByPlaceholderText('例如: https://api.openai.com/v1') as HTMLInputElement // Simulate user input by directly setting values - fireEvent.change(idInput, { target: { value: 'test-provider' } }); - fireEvent.change(nameInput, { target: { value: 'Test Provider' } }); - fireEvent.change(apiKeyInput, { target: { value: 'sk-test-key' } }); - fireEvent.change(baseUrlInput, { target: { value: 'https://api.test.com/v1' } }); + fireEvent.change(idInput, { target: { value: 'test-provider' } }) + fireEvent.change(nameInput, { target: { value: 'Test Provider' } }) + fireEvent.change(apiKeyInput, { target: { value: 'sk-test-key' } }) + fireEvent.change(baseUrlInput, { target: { value: 'https://api.test.com/v1' } }) - const okButton = within(dialog).getByRole('button', { name: /保/ }); - fireEvent.click(okButton); + const okButton = within(dialog).getByRole('button', { name: /保/ }) + fireEvent.click(okButton) // Wait for the onSave to be called - await vi.waitFor(() => { - expect(onSave).toHaveBeenCalled(); - // Verify that the saved data includes a protocol field - const savedData = onSave.mock.calls[0][0]; - expect(savedData).toHaveProperty('protocol'); - }, { timeout: 5000 }); - }, 10000); -}); + await vi.waitFor( + () => { + expect(onSave).toHaveBeenCalled() + // Verify that the saved data includes a protocol field + const savedData = onSave.mock.calls[0][0] + expect(savedData).toHaveProperty('protocol') + }, + { timeout: 5000 } + ) + }, 10000) +}) diff --git a/frontend/src/__tests__/components/ProviderTable.test.tsx b/frontend/src/__tests__/components/ProviderTable.test.tsx index f7bf532..eba84d1 100644 --- a/frontend/src/__tests__/components/ProviderTable.test.tsx +++ b/frontend/src/__tests__/components/ProviderTable.test.tsx @@ -1,18 +1,24 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { describe, it, expect, vi } from 'vitest'; -import { ProviderTable } from '@/pages/Providers/ProviderTable'; -import type { Provider } from '@/types'; +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, it, expect, vi } from 'vitest' +import { ProviderTable } from '@/pages/Providers/ProviderTable' +import type { Provider } from '@/types' const mockModelsData = [ { 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', () => ({ useModels: vi.fn(() => ({ data: mockModelsData, isLoading: false })), useDeleteModel: vi.fn(() => ({ mutate: vi.fn() })), -})); +})) const mockProviders: Provider[] = [ { @@ -35,7 +41,7 @@ const mockProviders: Provider[] = [ createdAt: '2024-01-02T00:00:00Z', updatedAt: '2024-01-02T00:00:00Z', }, -]; +] const defaultProps = { providers: mockProviders, @@ -45,36 +51,36 @@ const defaultProps = { onDelete: vi.fn(), onAddModel: vi.fn(), onEditModel: vi.fn(), -}; +} describe('ProviderTable', () => { it('renders provider list with name, baseUrl, apiKey, and status tags', () => { - render(); + render() - expect(screen.getByText('供应商列表')).toBeInTheDocument(); + expect(screen.getByText('供应商列表')).toBeInTheDocument() - expect(screen.getAllByText('OpenAI').length).toBeGreaterThan(0); - expect(screen.getByText('https://api.openai.com/v1')).toBeInTheDocument(); - expect(screen.getByText('sk-abcdefgh12345678')).toBeInTheDocument(); + expect(screen.getAllByText('OpenAI').length).toBeGreaterThan(0) + expect(screen.getByText('https://api.openai.com/v1')).toBeInTheDocument() + expect(screen.getByText('sk-abcdefgh12345678')).toBeInTheDocument() - expect(screen.getAllByText('Anthropic').length).toBeGreaterThan(0); - expect(screen.getByText('https://api.anthropic.com')).toBeInTheDocument(); - expect(screen.getByText('sk-ant-test')).toBeInTheDocument(); + expect(screen.getAllByText('Anthropic').length).toBeGreaterThan(0) + expect(screen.getByText('https://api.anthropic.com')).toBeInTheDocument() + expect(screen.getByText('sk-ant-test')).toBeInTheDocument() - const enabledTags = screen.getAllByText('启用'); - const disabledTags = screen.getAllByText('禁用'); - expect(enabledTags.length).toBeGreaterThanOrEqual(1); - expect(disabledTags.length).toBeGreaterThanOrEqual(1); - }); + const enabledTags = screen.getAllByText('启用') + const disabledTags = screen.getAllByText('禁用') + expect(enabledTags.length).toBeGreaterThanOrEqual(1) + expect(disabledTags.length).toBeGreaterThanOrEqual(1) + }) it('renders within a Card component', () => { - const { container } = render(); + const { container } = render() // TDesign Card component - expect(container.querySelector('.t-card')).toBeInTheDocument(); - expect(container.querySelector('.t-card__header')).toBeInTheDocument(); - expect(container.querySelector('.t-card__body')).toBeInTheDocument(); - }); + expect(container.querySelector('.t-card')).toBeInTheDocument() + expect(container.querySelector('.t-card__header')).toBeInTheDocument() + expect(container.querySelector('.t-card__body')).toBeInTheDocument() + }) it('renders short api keys directly', () => { const shortKeyProvider: Provider[] = [ @@ -84,99 +90,99 @@ describe('ProviderTable', () => { name: 'ShortKey', apiKey: 'ab', }, - ]; - render(); + ] + render() - expect(screen.getByText('ab')).toBeInTheDocument(); - }); + expect(screen.getByText('ab')).toBeInTheDocument() + }) it('calls onAdd when clicking "添加供应商" button', async () => { - const user = userEvent.setup(); - const onAdd = vi.fn(); - render(); + const user = userEvent.setup() + const onAdd = vi.fn() + render() - await user.click(screen.getByRole('button', { name: '添加供应商' })); - expect(onAdd).toHaveBeenCalledTimes(1); - }); + await user.click(screen.getByRole('button', { name: '添加供应商' })) + expect(onAdd).toHaveBeenCalledTimes(1) + }) it('calls onEdit with correct provider when clicking "编辑"', async () => { - const user = userEvent.setup(); - const onEdit = vi.fn(); - render(); + const user = userEvent.setup() + const onEdit = vi.fn() + render() - const editButtons = screen.getAllByRole('button', { name: /编 ?辑/ }); - await user.click(editButtons[0]); + const editButtons = screen.getAllByRole('button', { name: /编 ?辑/ }) + await user.click(editButtons[0]) - expect(onEdit).toHaveBeenCalledTimes(1); - expect(onEdit).toHaveBeenCalledWith(mockProviders[0]); - }); + expect(onEdit).toHaveBeenCalledTimes(1) + expect(onEdit).toHaveBeenCalledWith(mockProviders[0]) + }) it('calls onDelete with correct provider ID when delete is confirmed', async () => { - const user = userEvent.setup(); - const onDelete = vi.fn(); - render(); + const user = userEvent.setup() + const onDelete = vi.fn() + render() // Find and click the delete button for the first row - const deleteButtons = screen.getAllByRole('button', { name: '删除' }); - await user.click(deleteButtons[0]); + const deleteButtons = screen.getAllByRole('button', { name: '删除' }) + await user.click(deleteButtons[0]) // TDesign Popconfirm renders confirmation popup with "确定" button - const confirmButton = await screen.findByRole('button', { name: '确定' }); - await user.click(confirmButton); + const confirmButton = await screen.findByRole('button', { name: '确定' }) + await user.click(confirmButton) // Assert that onDelete was called with the correct provider ID - expect(onDelete).toHaveBeenCalledTimes(1); - expect(onDelete).toHaveBeenCalledWith('openai'); - }, 10000); + expect(onDelete).toHaveBeenCalledTimes(1) + expect(onDelete).toHaveBeenCalledWith('openai') + }, 10000) it('shows loading state', () => { - const { container } = render(); + const { container } = render() // TDesign Table loading indicator - const loadingElement = container.querySelector('.t-table__loading') || container.querySelector('.t-loading'); - expect(loadingElement).toBeInTheDocument(); - }); + const loadingElement = container.querySelector('.t-table__loading') || container.querySelector('.t-loading') + expect(loadingElement).toBeInTheDocument() + }) it('renders expandable ModelTable when row is expanded', async () => { - const user = userEvent.setup(); - const { container } = render(); + const user = userEvent.setup() + const { container } = render() // 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) { - await user.click(expandIcon); + await user.click(expandIcon) // Verify that ModelTable content is rendered with data from mocked useModels - expect(await screen.findByText('gpt-4o')).toBeInTheDocument(); - expect(screen.getByText('gpt-3.5-turbo')).toBeInTheDocument(); + expect(await screen.findByText('gpt-4o')).toBeInTheDocument() + expect(screen.getByText('gpt-3.5-turbo')).toBeInTheDocument() } else { // 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', () => { - const { container } = render(); + const { container } = render() // TDesign Table - const table = container.querySelector('.t-table'); - expect(table).toBeInTheDocument(); - }); + const table = container.querySelector('.t-table') + expect(table).toBeInTheDocument() + }) it('shows custom empty text when providers list is empty', () => { - render(); - expect(screen.getByText('暂无供应商,点击上方按钮添加')).toBeInTheDocument(); - }); + render() + expect(screen.getByText('暂无供应商,点击上方按钮添加')).toBeInTheDocument() + }) it('renders protocol column with correct tags', () => { - const { container } = render(); + const { container } = render() // Check that protocol tags are displayed in the table - const protocolCells = container.querySelectorAll('[data-colkey="protocol"]'); - expect(protocolCells.length).toBeGreaterThan(0); + const protocolCells = container.querySelectorAll('[data-colkey="protocol"]') + expect(protocolCells.length).toBeGreaterThan(0) // Verify protocol tags exist - const tags = container.querySelectorAll('.t-tag'); - expect(tags.length).toBeGreaterThan(0); - }); + const tags = container.querySelectorAll('.t-tag') + expect(tags.length).toBeGreaterThan(0) + }) it('displays protocol tag for each provider', () => { const singleProvider: Provider[] = [ @@ -190,11 +196,11 @@ describe('ProviderTable', () => { createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z', }, - ]; - const { container } = render(); + ] + const { container } = render() // Should display protocol column - const protocolCell = container.querySelector('[data-colkey="protocol"]'); - expect(protocolCell).toBeInTheDocument(); - }); -}); + const protocolCell = container.querySelector('[data-colkey="protocol"]') + expect(protocolCell).toBeInTheDocument() + }) +}) diff --git a/frontend/src/__tests__/components/StatCards.test.tsx b/frontend/src/__tests__/components/StatCards.test.tsx index 6a9abfd..0ebae69 100644 --- a/frontend/src/__tests__/components/StatCards.test.tsx +++ b/frontend/src/__tests__/components/StatCards.test.tsx @@ -1,7 +1,7 @@ -import { render, screen } from '@testing-library/react'; -import { describe, it, expect } from 'vitest'; -import { StatCards } from '@/pages/Stats/StatCards'; -import type { UsageStats } from '@/types'; +import { render, screen } from '@testing-library/react' +import { describe, it, expect } from 'vitest' +import { StatCards } from '@/pages/Stats/StatCards' +import type { UsageStats } from '@/types' const mockStats: UsageStats[] = [ { @@ -25,31 +25,31 @@ const mockStats: UsageStats[] = [ requestCount: 150, date: '2024-01-02', }, -]; +] describe('StatCards', () => { it('renders all statistic cards', () => { - render(); + render() - 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', () => { - render(); + render() - 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', () => { - render(); + render() - expect(screen.getAllByText('次').length).toBeGreaterThan(0); - expect(screen.getAllByText('个').length).toBeGreaterThan(0); - }); -}); + expect(screen.getAllByText('次').length).toBeGreaterThan(0) + expect(screen.getAllByText('个').length).toBeGreaterThan(0) + }) +}) diff --git a/frontend/src/__tests__/components/StatsTable.test.tsx b/frontend/src/__tests__/components/StatsTable.test.tsx index c7b5879..8a5c310 100644 --- a/frontend/src/__tests__/components/StatsTable.test.tsx +++ b/frontend/src/__tests__/components/StatsTable.test.tsx @@ -1,7 +1,7 @@ -import { render, screen } from '@testing-library/react'; -import { describe, it, expect, vi } from 'vitest'; -import { StatsTable } from '@/pages/Stats/StatsTable'; -import type { Provider, UsageStats } from '@/types'; +import { render, screen } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' +import { StatsTable } from '@/pages/Stats/StatsTable' +import type { Provider, UsageStats } from '@/types' const mockProviders: Provider[] = [ { @@ -24,7 +24,7 @@ const mockProviders: Provider[] = [ createdAt: '2024-01-02T00:00:00Z', updatedAt: '2024-01-02T00:00:00Z', }, -]; +] const mockStats: UsageStats[] = [ { @@ -41,7 +41,7 @@ const mockStats: UsageStats[] = [ requestCount: 50, date: '2024-01-15', }, -]; +] const defaultProps = { providers: mockProviders, @@ -53,80 +53,80 @@ const defaultProps = { onProviderIdChange: vi.fn(), onModelNameChange: vi.fn(), onDateRangeChange: vi.fn(), -}; +} describe('StatsTable', () => { it('renders stats table with data', () => { - render(); + render() - expect(screen.getByText('gpt-4o')).toBeInTheDocument(); - expect(screen.getByText('claude-3-opus')).toBeInTheDocument(); - const dateCells = screen.getAllByText('2024-01-15'); - expect(dateCells.length).toBe(2); - expect(screen.getByText('100')).toBeInTheDocument(); - expect(screen.getByText('50')).toBeInTheDocument(); - }); + expect(screen.getByText('gpt-4o')).toBeInTheDocument() + expect(screen.getByText('claude-3-opus')).toBeInTheDocument() + const dateCells = screen.getAllByText('2024-01-15') + expect(dateCells.length).toBe(2) + expect(screen.getByText('100')).toBeInTheDocument() + expect(screen.getByText('50')).toBeInTheDocument() + }) it('shows provider name from providers prop instead of providerId', () => { - render(); + render() - expect(screen.getByText('OpenAI')).toBeInTheDocument(); - const allAnthropic = screen.getAllByText('Anthropic'); - expect(allAnthropic.length).toBeGreaterThanOrEqual(1); - }); + expect(screen.getByText('OpenAI')).toBeInTheDocument() + const allAnthropic = screen.getAllByText('Anthropic') + expect(allAnthropic.length).toBeGreaterThanOrEqual(1) + }) it('renders filter controls with Select, Input, and DatePicker', () => { - const { container } = render(); + const { container } = render() // TDesign Select component - const selects = document.querySelectorAll('.t-select'); - expect(selects.length).toBeGreaterThanOrEqual(1); + const selects = document.querySelectorAll('.t-select') + expect(selects.length).toBeGreaterThanOrEqual(1) - const modelInput = screen.getByPlaceholderText('模型名称'); - expect(modelInput).toBeInTheDocument(); + const modelInput = screen.getByPlaceholderText('模型名称') + expect(modelInput).toBeInTheDocument() // TDesign Select placeholder is shown in the input - const selectInput = document.querySelector('.t-select .t-input__inner'); - expect(selectInput).toBeInTheDocument(); + const selectInput = document.querySelector('.t-select .t-input__inner') + expect(selectInput).toBeInTheDocument() // TDesign DateRangePicker - could be .t-date-picker or .t-range-input - const rangePicker = container.querySelector('.t-date-picker') || container.querySelector('.t-range-input'); - expect(rangePicker).toBeInTheDocument(); - }); + const rangePicker = container.querySelector('.t-date-picker') || container.querySelector('.t-range-input') + expect(rangePicker).toBeInTheDocument() + }) it('renders table headers correctly', () => { - render(); + render() - 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', () => { - const limitedProviders = [mockProviders[0]]; - render(); + const limitedProviders = [mockProviders[0]] + render() - expect(screen.getByText('OpenAI')).toBeInTheDocument(); - expect(screen.getByText('anthropic')).toBeInTheDocument(); - }); + expect(screen.getByText('OpenAI')).toBeInTheDocument() + expect(screen.getByText('anthropic')).toBeInTheDocument() + }) it('renders with empty stats data', () => { - render(); + render() - 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', () => { - const { container } = render(); + const { container } = render() // TDesign Table loading indicator - could be .t-table__loading or .t-loading - const loadingElement = container.querySelector('.t-table__loading') || container.querySelector('.t-loading'); - expect(loadingElement).toBeInTheDocument(); - }); + const loadingElement = container.querySelector('.t-table__loading') || container.querySelector('.t-loading') + expect(loadingElement).toBeInTheDocument() + }) it('shows custom empty text when stats data is empty', () => { - render(); - expect(screen.getByText('暂无统计数据')).toBeInTheDocument(); - }); -}); + render() + expect(screen.getByText('暂无统计数据')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/__tests__/components/UsageChart.test.tsx b/frontend/src/__tests__/components/UsageChart.test.tsx index 44b25cf..d539e73 100644 --- a/frontend/src/__tests__/components/UsageChart.test.tsx +++ b/frontend/src/__tests__/components/UsageChart.test.tsx @@ -1,18 +1,18 @@ -import { render, screen } from '@testing-library/react'; -import { describe, it, expect, vi } from 'vitest'; -import { UsageChart } from '@/pages/Stats/UsageChart'; -import type { UsageStats } from '@/types'; +import { render, screen } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' +import { UsageChart } from '@/pages/Stats/UsageChart' +import type { UsageStats } from '@/types' // Mock Recharts components vi.mock('recharts', () => ({ - ResponsiveContainer: vi.fn(({ children }) =>
{children}
), - AreaChart: vi.fn(() =>
), + ResponsiveContainer: vi.fn(({ children }) =>
{children}
), + AreaChart: vi.fn(() =>
), Area: vi.fn(() => null), XAxis: vi.fn(() => null), YAxis: vi.fn(() => null), CartesianGrid: vi.fn(() => null), Tooltip: vi.fn(() => null), -})); +})) const mockStats: UsageStats[] = [ { @@ -36,36 +36,36 @@ const mockStats: UsageStats[] = [ requestCount: 150, date: '2024-01-02', }, -]; +] describe('UsageChart', () => { it('renders chart title', () => { - render(); + render() - expect(screen.getByText('请求趋势')).toBeInTheDocument(); - }); + expect(screen.getByText('请求趋势')).toBeInTheDocument() + }) it('renders with data', () => { - const { container } = render(); + const { container } = render() // TDesign Card component - expect(container.querySelector('.t-card')).toBeInTheDocument(); + expect(container.querySelector('.t-card')).toBeInTheDocument() // Mocked chart container - expect(screen.getByTestId('mock-chart-container')).toBeInTheDocument(); - }); + expect(screen.getByTestId('mock-chart-container')).toBeInTheDocument() + }) it('renders empty state when no data', () => { - render(); + render() - expect(screen.getByText('暂无数据')).toBeInTheDocument(); - }); + expect(screen.getByText('暂无数据')).toBeInTheDocument() + }) it('aggregates data by date correctly', () => { - const { container } = render(); + const { container } = render() // TDesign Card component - expect(container.querySelector('.t-card')).toBeInTheDocument(); + expect(container.querySelector('.t-card')).toBeInTheDocument() // Mocked chart should render - expect(screen.getByTestId('mock-chart-container')).toBeInTheDocument(); - }); -}); + expect(screen.getByTestId('mock-chart-container')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/__tests__/eslint-rules/no-hardcoded-color.test.ts b/frontend/src/__tests__/eslint-rules/no-hardcoded-color.test.ts index 7a467aa..855db9b 100644 --- a/frontend/src/__tests__/eslint-rules/no-hardcoded-color.test.ts +++ b/frontend/src/__tests__/eslint-rules/no-hardcoded-color.test.ts @@ -1,8 +1,6 @@ import { RuleTester } from '@typescript-eslint/rule-tester' import { describe, it, afterAll } from 'vitest' -import rule, { - RULE_NAME, -} from '../../../eslint-rules/rules/no-hardcoded-color-in-style.js' +import rule, { RULE_NAME } from '../../../eslint-rules/rules/no-hardcoded-color-in-style.js' RuleTester.it = it RuleTester.describe = describe @@ -121,4 +119,4 @@ describe('no-hardcoded-color-in-style (ESLint rule)', () => { }, ], }) -}) \ No newline at end of file +}) diff --git a/frontend/src/__tests__/hooks/useModels.test.tsx b/frontend/src/__tests__/hooks/useModels.test.tsx index 2b15088..4a476f4 100644 --- a/frontend/src/__tests__/hooks/useModels.test.tsx +++ b/frontend/src/__tests__/hooks/useModels.test.tsx @@ -1,11 +1,11 @@ -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { renderHook, waitFor } from '@testing-library/react'; -import { http, HttpResponse } from 'msw'; -import { setupServer } from 'msw/node'; -import React from 'react'; -import { MessagePlugin } from 'tdesign-react'; -import { useModels, useCreateModel, useUpdateModel, useDeleteModel } from '@/hooks/useModels'; -import type { Model, CreateModelInput, UpdateModelInput } from '@/types'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { renderHook, waitFor } from '@testing-library/react' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import React from 'react' +import { MessagePlugin } from 'tdesign-react' +import { useModels, useCreateModel, useUpdateModel, useDeleteModel } from '@/hooks/useModels' +import type { Model, CreateModelInput, UpdateModelInput } from '@/types' // Mock MessagePlugin vi.mock('tdesign-react', () => ({ @@ -13,7 +13,7 @@ vi.mock('tdesign-react', () => ({ success: vi.fn(), error: vi.fn(), }, -})); +})) // Test data const mockModels: Model[] = [ @@ -33,7 +33,7 @@ const mockModels: Model[] = [ createdAt: '2026-01-02T00:00:00Z', unifiedId: 'gpt-4o-mini', }, -]; +] const mockFilteredModels: Model[] = [ { @@ -44,7 +44,7 @@ const mockFilteredModels: Model[] = [ createdAt: '2026-02-01T00:00:00Z', unifiedId: 'claude-sonnet-4-5', }, -]; +] const mockCreatedModel: Model = { id: 'model-4', @@ -53,36 +53,36 @@ const mockCreatedModel: Model = { enabled: true, createdAt: '2026-03-01T00:00:00Z', unifiedId: 'gpt-4.1', -}; +} // MSW handlers const handlers = [ http.get('/api/models', ({ request }) => { - const url = new URL(request.url); - const providerId = url.searchParams.get('provider_id'); + const url = new URL(request.url) + const providerId = url.searchParams.get('provider_id') 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 }) => { - const body = await request.json() as Record; + const body = (await request.json()) as Record return HttpResponse.json({ ...mockCreatedModel, ...body, - }); + }) }), http.put('/api/models/:id', async ({ request, params }) => { - const body = await request.json() as Record; - const existing = mockModels.find((m) => m.id === params['id']); - return HttpResponse.json({ ...existing, ...body }); + const body = (await request.json()) as Record + const existing = mockModels.find((m) => m.id === params['id']) + return HttpResponse.json({ ...existing, ...body }) }), 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() { return new QueryClient({ @@ -90,201 +90,185 @@ function createTestQueryClient() { queries: { retry: false }, mutations: { retry: false }, }, - }); + }) } function createWrapper() { - const testQueryClient = createTestQueryClient(); + const testQueryClient = createTestQueryClient() return function Wrapper({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); - }; + return {children} + } } -beforeAll(() => server.listen()); +beforeAll(() => server.listen()) afterEach(() => { - server.resetHandlers(); - vi.clearAllMocks(); -}); -afterAll(() => server.close()); + server.resetHandlers() + vi.clearAllMocks() +}) +afterAll(() => server.close()) describe('useModels', () => { it('fetches model list', async () => { const { result } = renderHook(() => useModels(), { 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).toHaveLength(2); - expect(result.current.data![0]!.modelName).toBe('gpt-4o'); - }); + expect(result.current.data).toEqual(mockModels) + expect(result.current.data).toHaveLength(2) + expect(result.current.data![0]!.modelName).toBe('gpt-4o') + }) it('with providerId passes it to API and returns filtered models', async () => { const { result } = renderHook(() => useModels('provider-2'), { 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).toHaveLength(1); - expect(result.current.data![0]!.modelName).toBe('claude-sonnet-4-5'); - }); -}); + expect(result.current.data).toEqual(mockFilteredModels) + expect(result.current.data).toHaveLength(1) + expect(result.current.data![0]!.modelName).toBe('claude-sonnet-4-5') + }) +}) describe('useCreateModel', () => { it('calls API and invalidates model queries', async () => { - const queryClient = createTestQueryClient(); - const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + const queryClient = createTestQueryClient() + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') function Wrapper({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); + return {children} } const { result } = renderHook(() => useCreateModel(), { wrapper: Wrapper, - }); + }) const input: CreateModelInput = { id: 'model-4', providerId: 'provider-1', modelName: 'gpt-4.1', 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({ id: 'model-4', modelName: 'gpt-4.1', - }); - expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['models'] }); - expect(MessagePlugin.success).toHaveBeenCalledWith('模型创建成功'); - }); + }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['models'] }) + expect(MessagePlugin.success).toHaveBeenCalledWith('模型创建成功') + }) it('calls message.error on failure', async () => { server.use( http.post('/api/models', () => { - return HttpResponse.json({ message: '创建失败' }, { status: 500 }); - }), - ); + return HttpResponse.json({ message: '创建失败' }, { status: 500 }) + }) + ) const { result } = renderHook(() => useCreateModel(), { wrapper: createWrapper(), - }); + }) const input: CreateModelInput = { id: 'model-4', providerId: 'provider-1', modelName: 'gpt-4.1', enabled: true, - }; + } - result.current.mutate(input); + result.current.mutate(input) - await waitFor(() => expect(result.current.isError).toBe(true)); - expect(MessagePlugin.error).toHaveBeenCalled(); - }); -}); + await waitFor(() => expect(result.current.isError).toBe(true)) + expect(MessagePlugin.error).toHaveBeenCalled() + }) +}) describe('useUpdateModel', () => { it('calls API and invalidates model queries', async () => { - const queryClient = createTestQueryClient(); - const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + const queryClient = createTestQueryClient() + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') function Wrapper({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); + return {children} } const { result } = renderHook(() => useUpdateModel(), { 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({ modelName: 'gpt-4o-updated', - }); - expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['models'] }); - expect(MessagePlugin.success).toHaveBeenCalledWith('模型更新成功'); - }); + }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['models'] }) + expect(MessagePlugin.success).toHaveBeenCalledWith('模型更新成功') + }) it('calls message.error on failure', async () => { server.use( http.put('/api/models/:id', () => { - return HttpResponse.json({ message: '更新失败' }, { status: 500 }); - }), - ); + return HttpResponse.json({ message: '更新失败' }, { status: 500 }) + }) + ) const { result } = renderHook(() => useUpdateModel(), { 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)); - expect(MessagePlugin.error).toHaveBeenCalled(); - }); -}); + await waitFor(() => expect(result.current.isError).toBe(true)) + expect(MessagePlugin.error).toHaveBeenCalled() + }) +}) describe('useDeleteModel', () => { it('calls API and invalidates model queries', async () => { - const queryClient = createTestQueryClient(); - const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + const queryClient = createTestQueryClient() + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') function Wrapper({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); + return {children} } const { result } = renderHook(() => useDeleteModel(), { 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(MessagePlugin.success).toHaveBeenCalledWith('模型删除成功'); - }); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['models'] }) + expect(MessagePlugin.success).toHaveBeenCalledWith('模型删除成功') + }) it('calls message.error on failure', async () => { server.use( http.delete('/api/models/:id', () => { - return HttpResponse.json({ message: '删除失败' }, { status: 500 }); - }), - ); + return HttpResponse.json({ message: '删除失败' }, { status: 500 }) + }) + ) const { result } = renderHook(() => useDeleteModel(), { wrapper: createWrapper(), - }); + }) - result.current.mutate('model-1'); + result.current.mutate('model-1') - await waitFor(() => expect(result.current.isError).toBe(true)); - expect(MessagePlugin.error).toHaveBeenCalled(); - }); -}); + await waitFor(() => expect(result.current.isError).toBe(true)) + expect(MessagePlugin.error).toHaveBeenCalled() + }) +}) diff --git a/frontend/src/__tests__/hooks/useProviders.test.tsx b/frontend/src/__tests__/hooks/useProviders.test.tsx index e2d289c..f213a94 100644 --- a/frontend/src/__tests__/hooks/useProviders.test.tsx +++ b/frontend/src/__tests__/hooks/useProviders.test.tsx @@ -1,11 +1,11 @@ -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { renderHook, waitFor } from '@testing-library/react'; -import { http, HttpResponse } from 'msw'; -import { setupServer } from 'msw/node'; -import React from 'react'; -import { MessagePlugin } from 'tdesign-react'; -import { useProviders, useCreateProvider, useUpdateProvider, useDeleteProvider } from '@/hooks/useProviders'; -import type { Provider, CreateProviderInput, UpdateProviderInput } from '@/types'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { renderHook, waitFor } from '@testing-library/react' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import React from 'react' +import { MessagePlugin } from 'tdesign-react' +import { useProviders, useCreateProvider, useUpdateProvider, useDeleteProvider } from '@/hooks/useProviders' +import type { Provider, CreateProviderInput, UpdateProviderInput } from '@/types' // Mock MessagePlugin vi.mock('tdesign-react', () => ({ @@ -13,7 +13,7 @@ vi.mock('tdesign-react', () => ({ success: vi.fn(), error: vi.fn(), }, -})); +})) // Test data const mockProviders: Provider[] = [ @@ -37,7 +37,7 @@ const mockProviders: Provider[] = [ createdAt: '2026-02-01T00:00:00Z', updatedAt: '2026-02-01T00:00:00Z', }, -]; +] const mockCreatedProvider: Provider = { id: 'provider-3', @@ -48,31 +48,31 @@ const mockCreatedProvider: Provider = { enabled: true, createdAt: '2026-03-01T00:00:00Z', updatedAt: '2026-03-01T00:00:00Z', -}; +} // MSW handlers const handlers = [ http.get('/api/providers', () => { - return HttpResponse.json(mockProviders); + return HttpResponse.json(mockProviders) }), http.post('/api/providers', async ({ request }) => { - const body = await request.json() as Record; + const body = (await request.json()) as Record return HttpResponse.json({ ...mockCreatedProvider, ...body, - }); + }) }), http.put('/api/providers/:id', async ({ request, params }) => { - const body = await request.json() as Record; - const existing = mockProviders.find((p) => p.id === params['id']); - return HttpResponse.json({ ...existing, ...body }); + const body = (await request.json()) as Record + const existing = mockProviders.find((p) => p.id === params['id']) + return HttpResponse.json({ ...existing, ...body }) }), 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() { return new QueryClient({ @@ -80,58 +80,50 @@ function createTestQueryClient() { queries: { retry: false }, mutations: { retry: false }, }, - }); + }) } function createWrapper() { - const testQueryClient = createTestQueryClient(); + const testQueryClient = createTestQueryClient() return function Wrapper({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); - }; + return {children} + } } -beforeAll(() => server.listen()); +beforeAll(() => server.listen()) afterEach(() => { - server.resetHandlers(); - vi.clearAllMocks(); -}); -afterAll(() => server.close()); + server.resetHandlers() + vi.clearAllMocks() +}) +afterAll(() => server.close()) describe('useProviders', () => { it('fetches and returns provider list', async () => { const { result } = renderHook(() => useProviders(), { 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).toHaveLength(2); - expect(result.current.data![0]!.name).toBe('OpenAI'); - expect(result.current.data![1]!.name).toBe('Anthropic'); - }); -}); + expect(result.current.data).toEqual(mockProviders) + expect(result.current.data).toHaveLength(2) + expect(result.current.data![0]!.name).toBe('OpenAI') + expect(result.current.data![1]!.name).toBe('Anthropic') + }) +}) describe('useCreateProvider', () => { it('calls API and invalidates provider queries', async () => { - const queryClient = createTestQueryClient(); - const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + const queryClient = createTestQueryClient() + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') function Wrapper({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); + return {children} } const { result } = renderHook(() => useCreateProvider(), { wrapper: Wrapper, - }); + }) const input: CreateProviderInput = { id: 'provider-3', @@ -140,30 +132,30 @@ describe('useCreateProvider', () => { baseUrl: 'https://api.newprovider.com', protocol: 'openai', 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({ id: 'provider-3', name: 'NewProvider', - }); - expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['providers'] }); - expect(MessagePlugin.success).toHaveBeenCalledWith('供应商创建成功'); - }); + }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['providers'] }) + expect(MessagePlugin.success).toHaveBeenCalledWith('供应商创建成功') + }) it('calls message.error on failure', async () => { server.use( http.post('/api/providers', () => { - return HttpResponse.json({ message: '创建失败' }, { status: 500 }); - }), - ); + return HttpResponse.json({ message: '创建失败' }, { status: 500 }) + }) + ) const { result } = renderHook(() => useCreateProvider(), { wrapper: createWrapper(), - }); + }) const input: CreateProviderInput = { id: 'provider-3', @@ -172,102 +164,94 @@ describe('useCreateProvider', () => { baseUrl: 'https://api.newprovider.com', protocol: 'openai', enabled: true, - }; + } - result.current.mutate(input); + result.current.mutate(input) - await waitFor(() => expect(result.current.isError).toBe(true)); - expect(MessagePlugin.error).toHaveBeenCalled(); - }); -}); + await waitFor(() => expect(result.current.isError).toBe(true)) + expect(MessagePlugin.error).toHaveBeenCalled() + }) +}) describe('useUpdateProvider', () => { it('calls API and invalidates provider queries', async () => { - const queryClient = createTestQueryClient(); - const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + const queryClient = createTestQueryClient() + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') function Wrapper({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); + return {children} } const { result } = renderHook(() => useUpdateProvider(), { 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({ name: 'UpdatedProvider', - }); - expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['providers'] }); - expect(MessagePlugin.success).toHaveBeenCalledWith('供应商更新成功'); - }); + }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['providers'] }) + expect(MessagePlugin.success).toHaveBeenCalledWith('供应商更新成功') + }) it('calls message.error on failure', async () => { server.use( http.put('/api/providers/:id', () => { - return HttpResponse.json({ message: '更新失败' }, { status: 500 }); - }), - ); + return HttpResponse.json({ message: '更新失败' }, { status: 500 }) + }) + ) const { result } = renderHook(() => useUpdateProvider(), { 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)); - expect(MessagePlugin.error).toHaveBeenCalled(); - }); -}); + await waitFor(() => expect(result.current.isError).toBe(true)) + expect(MessagePlugin.error).toHaveBeenCalled() + }) +}) describe('useDeleteProvider', () => { it('calls API and invalidates provider queries', async () => { - const queryClient = createTestQueryClient(); - const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + const queryClient = createTestQueryClient() + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') function Wrapper({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); + return {children} } const { result } = renderHook(() => useDeleteProvider(), { 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(MessagePlugin.success).toHaveBeenCalledWith('供应商删除成功'); - }); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['providers'] }) + expect(MessagePlugin.success).toHaveBeenCalledWith('供应商删除成功') + }) it('calls message.error on failure', async () => { server.use( http.delete('/api/providers/:id', () => { - return HttpResponse.json({ message: '删除失败' }, { status: 500 }); - }), - ); + return HttpResponse.json({ message: '删除失败' }, { status: 500 }) + }) + ) const { result } = renderHook(() => useDeleteProvider(), { wrapper: createWrapper(), - }); + }) - result.current.mutate('provider-1'); + result.current.mutate('provider-1') - await waitFor(() => expect(result.current.isError).toBe(true)); - expect(MessagePlugin.error).toHaveBeenCalled(); - }); -}); + await waitFor(() => expect(result.current.isError).toBe(true)) + expect(MessagePlugin.error).toHaveBeenCalled() + }) +}) diff --git a/frontend/src/__tests__/hooks/useStats.test.tsx b/frontend/src/__tests__/hooks/useStats.test.tsx index e4bf99e..13457d9 100644 --- a/frontend/src/__tests__/hooks/useStats.test.tsx +++ b/frontend/src/__tests__/hooks/useStats.test.tsx @@ -1,10 +1,10 @@ -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { renderHook, waitFor } from '@testing-library/react'; -import { http, HttpResponse } from 'msw'; -import { setupServer } from 'msw/node'; -import React from 'react'; -import { useStats } from '@/hooks/useStats'; -import type { UsageStats, StatsQueryParams } from '@/types'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { renderHook, waitFor } from '@testing-library/react' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import React from 'react' +import { useStats } from '@/hooks/useStats' +import type { UsageStats, StatsQueryParams } from '@/types' // Test data const mockStats: UsageStats[] = [ @@ -22,7 +22,7 @@ const mockStats: UsageStats[] = [ requestCount: 50, date: '2026-04-01', }, -]; +] const mockFilteredStats: UsageStats[] = [ { @@ -32,24 +32,24 @@ const mockFilteredStats: UsageStats[] = [ requestCount: 200, date: '2026-04-01', }, -]; +] // Track the request URL for assertions -let capturedUrl: URL | null = null; +let capturedUrl: URL | null = null // MSW handlers const handlers = [ http.get('/api/stats', ({ request }) => { - capturedUrl = new URL(request.url); - const providerId = capturedUrl.searchParams.get('provider_id'); + capturedUrl = new URL(request.url) + const providerId = capturedUrl.searchParams.get('provider_id') 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() { return new QueryClient({ @@ -57,43 +57,39 @@ function createTestQueryClient() { queries: { retry: false }, mutations: { retry: false }, }, - }); + }) } function createWrapper() { - const testQueryClient = createTestQueryClient(); + const testQueryClient = createTestQueryClient() return function Wrapper({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); - }; + return {children} + } } -beforeAll(() => server.listen()); +beforeAll(() => server.listen()) afterEach(() => { - server.resetHandlers(); - capturedUrl = null; -}); -afterAll(() => server.close()); + server.resetHandlers() + capturedUrl = null +}) +afterAll(() => server.close()) describe('useStats', () => { it('fetches stats without params', async () => { const { result } = renderHook(() => useStats(), { 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).toHaveLength(2); - expect(result.current.data![0]!.modelName).toBe('gpt-4o'); - expect(result.current.data![1]!.requestCount).toBe(50); + expect(result.current.data).toEqual(mockStats) + expect(result.current.data).toHaveLength(2) + expect(result.current.data![0]!.modelName).toBe('gpt-4o') + expect(result.current.data![1]!.requestCount).toBe(50) // Verify no query params were sent - expect(capturedUrl!.search).toBe(''); - }); + expect(capturedUrl!.search).toBe('') + }) it('with filter params passes them correctly', async () => { const params: StatsQueryParams = { @@ -101,40 +97,40 @@ describe('useStats', () => { modelName: 'claude-sonnet-4-5', startDate: '2026-04-01', endDate: '2026-04-15', - }; + } const { result } = renderHook(() => useStats(params), { 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).toHaveLength(1); - expect(result.current.data![0]!.modelName).toBe('claude-sonnet-4-5'); + expect(result.current.data).toEqual(mockFilteredStats) + expect(result.current.data).toHaveLength(1) + expect(result.current.data![0]!.modelName).toBe('claude-sonnet-4-5') // Verify query params were passed correctly (snake_case) - expect(capturedUrl!.searchParams.get('provider_id')).toBe('provider-2'); - 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('end_date')).toBe('2026-04-15'); - }); + expect(capturedUrl!.searchParams.get('provider_id')).toBe('provider-2') + 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('end_date')).toBe('2026-04-15') + }) it('with partial filter params only sends provided params', async () => { const params: StatsQueryParams = { providerId: 'provider-1', - }; + } const { result } = renderHook(() => useStats(params), { wrapper: createWrapper(), - }); + }) - await waitFor(() => expect(result.current.isSuccess).toBe(true)); + await waitFor(() => expect(result.current.isSuccess).toBe(true)) // Verify only provider_id was sent - expect(capturedUrl!.searchParams.get('provider_id')).toBe('provider-1'); - expect(capturedUrl!.searchParams.get('model_name')).toBeNull(); - expect(capturedUrl!.searchParams.get('start_date')).toBeNull(); - expect(capturedUrl!.searchParams.get('end_date')).toBeNull(); - }); -}); + expect(capturedUrl!.searchParams.get('provider_id')).toBe('provider-1') + expect(capturedUrl!.searchParams.get('model_name')).toBeNull() + expect(capturedUrl!.searchParams.get('start_date')).toBeNull() + expect(capturedUrl!.searchParams.get('end_date')).toBeNull() + }) +}) diff --git a/frontend/src/__tests__/setup.ts b/frontend/src/__tests__/setup.ts index 064eb42..8ec8529 100644 --- a/frontend/src/__tests__/setup.ts +++ b/frontend/src/__tests__/setup.ts @@ -1,8 +1,8 @@ -import '@testing-library/jest-dom/vitest'; +import '@testing-library/jest-dom/vitest' // Ensure happy-dom environment is properly initialized 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) @@ -18,38 +18,37 @@ Object.defineProperty(window, 'matchMedia', { removeEventListener: () => {}, dispatchEvent: () => false, }), -}); +}) // Polyfill window.getComputedStyle to suppress jsdom warnings -const originalGetComputedStyle = window.getComputedStyle; +const originalGetComputedStyle = window.getComputedStyle window.getComputedStyle = (elt: Element, pseudoElt?: string | null) => { try { - return originalGetComputedStyle(elt, pseudoElt); + return originalGetComputedStyle(elt, pseudoElt) } catch { - return {} as CSSStyleDeclaration; + return {} as CSSStyleDeclaration } -}; +} // Polyfill ResizeObserver for TDesign global.ResizeObserver = class ResizeObserver { observe() {} unobserve() {} disconnect() {} -}; +} // Suppress TDesign Form internal act() warnings // These warnings come from TDesign's FormItem component internal async state updates // They don't affect test reliability - all tests pass successfully -const originalError = console.error; +const originalError = console.error console.error = (...args: unknown[]) => { - const message = args[0]; + const message = args[0] // Filter out TDesign FormItem act() warnings if ( typeof message === 'string' && message.includes('An update to FormItem inside a test was not wrapped in act(...)') ) { - return; + return } - originalError(...args); -}; - + originalError(...args) +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 30fb7e2..dff4846 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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 { - 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 { - return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`); + return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`) } function transformKeys(obj: unknown, transformer: (key: string) => string): T { 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') { - const result: Record = {}; + const result: Record = {} for (const [key, value] of Object.entries(obj as Record)) { - 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(data: unknown): T { - return transformKeys(data, toCamelCase); + return transformKeys(data, toCamelCase) } export function toApi(data: unknown): T { - return transformKeys(data, toSnakeCase); + return transformKeys(data, toSnakeCase) } -export async function request( - method: string, - path: string, - body?: unknown, -): Promise { - const url = `${API_BASE}${path}`; +export async function request(method: string, path: string, body?: unknown): Promise { + const url = `${API_BASE}${path}` const options: RequestInit = { method, 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) { - let message = `请求失败 (${response.status})`; - let code: string | undefined; + let message = `请求失败 (${response.status})` + let code: string | undefined try { - const errorData = await response.json(); + const errorData = await response.json() if (typeof errorData === 'object' && errorData !== null) { // 提取结构化错误响应 if ('error' in errorData && typeof errorData.error === 'string') { - message = errorData.error; + message = errorData.error } else if ('message' in errorData && typeof errorData.message === 'string') { - message = errorData.message; + message = errorData.message } // 提取错误码 if ('code' in errorData && typeof errorData.code === 'string') { - code = errorData.code; + code = errorData.code } } } catch { // ignore JSON parse error } - throw new ApiError(response.status, message, code); + throw new ApiError(response.status, message, code) } if (response.status === 204) { - return undefined as T; + return undefined as T } - const data = await response.json(); - return fromApi(data); + const data = await response.json() + return fromApi(data) } diff --git a/frontend/src/api/models.ts b/frontend/src/api/models.ts index 8326023..c7088b9 100644 --- a/frontend/src/api/models.ts +++ b/frontend/src/api/models.ts @@ -1,24 +1,19 @@ -import type { Model, CreateModelInput, UpdateModelInput } from '@/types'; -import { request } from './client'; +import type { Model, CreateModelInput, UpdateModelInput } from '@/types' +import { request } from './client' export async function listModels(providerId?: string): Promise { - const path = providerId - ? `/api/models?provider_id=${encodeURIComponent(providerId)}` - : '/api/models'; - return request('GET', path); + const path = providerId ? `/api/models?provider_id=${encodeURIComponent(providerId)}` : '/api/models' + return request('GET', path) } export async function createModel(input: CreateModelInput): Promise { - return request('POST', '/api/models', input); + return request('POST', '/api/models', input) } -export async function updateModel( - id: string, - input: UpdateModelInput, -): Promise { - return request('PUT', `/api/models/${id}`, input); +export async function updateModel(id: string, input: UpdateModelInput): Promise { + return request('PUT', `/api/models/${id}`, input) } export async function deleteModel(id: string): Promise { - return request('DELETE', `/api/models/${id}`); + return request('DELETE', `/api/models/${id}`) } diff --git a/frontend/src/api/providers.ts b/frontend/src/api/providers.ts index 9370f88..85a86f7 100644 --- a/frontend/src/api/providers.ts +++ b/frontend/src/api/providers.ts @@ -1,21 +1,18 @@ -import type { Provider, CreateProviderInput, UpdateProviderInput } from '@/types'; -import { request } from './client'; +import type { Provider, CreateProviderInput, UpdateProviderInput } from '@/types' +import { request } from './client' export async function listProviders(): Promise { - return request('GET', '/api/providers'); + return request('GET', '/api/providers') } export async function createProvider(input: CreateProviderInput): Promise { - return request('POST', '/api/providers', input); + return request('POST', '/api/providers', input) } -export async function updateProvider( - id: string, - input: UpdateProviderInput, -): Promise { - return request('PUT', `/api/providers/${id}`, input); +export async function updateProvider(id: string, input: UpdateProviderInput): Promise { + return request('PUT', `/api/providers/${id}`, input) } export async function deleteProvider(id: string): Promise { - return request('DELETE', `/api/providers/${id}`); + return request('DELETE', `/api/providers/${id}`) } diff --git a/frontend/src/api/stats.ts b/frontend/src/api/stats.ts index b1a5d2a..7e775f2 100644 --- a/frontend/src/api/stats.ts +++ b/frontend/src/api/stats.ts @@ -1,26 +1,26 @@ -import type { UsageStats, StatsQueryParams } from '@/types'; -import { request } from './client'; +import type { UsageStats, StatsQueryParams } from '@/types' +import { request } from './client' export async function getStats(params?: StatsQueryParams): Promise { if (!params) { - return request('GET', '/api/stats'); + return request('GET', '/api/stats') } - const query = new URLSearchParams(); + const query = new URLSearchParams() const snakeParams: Record = { provider_id: params.providerId, model_name: params.modelName, start_date: params.startDate, end_date: params.endDate, - }; + } for (const [key, value] of Object.entries(snakeParams)) { if (value) { - query.set(key, value); + query.set(key, value) } } - const queryString = query.toString(); - const path = queryString ? `/api/stats?${queryString}` : '/api/stats'; - return request('GET', path); + const queryString = query.toString() + const path = queryString ? `/api/stats?${queryString}` : '/api/stats' + return request('GET', path) } diff --git a/frontend/src/components/AppLayout/index.tsx b/frontend/src/components/AppLayout/index.tsx index e6429c0..463ce75 100644 --- a/frontend/src/components/AppLayout/index.tsx +++ b/frontend/src/components/AppLayout/index.tsx @@ -1,23 +1,23 @@ -import { useState } from 'react'; -import { Outlet, useLocation, useNavigate } from 'react-router'; -import { ServerIcon, ChartLineIcon, SettingIcon, ChevronLeftIcon, ChevronRightIcon } from 'tdesign-icons-react'; -import { Layout, Menu, Button } from 'tdesign-react'; +import { useState } from 'react' +import { Outlet, useLocation, useNavigate } from 'react-router' +import { ServerIcon, ChartLineIcon, SettingIcon, ChevronLeftIcon, ChevronRightIcon } from 'tdesign-icons-react' +import { Layout, Menu, Button } from 'tdesign-react' -const { MenuItem } = Menu; +const { MenuItem } = Menu export function AppLayout() { - const location = useLocation(); - const navigate = useNavigate(); - const [collapsed, setCollapsed] = useState(false); + const location = useLocation() + const navigate = useNavigate() + const [collapsed, setCollapsed] = useState(false) const getPageTitle = () => { - if (location.pathname === '/providers') return '供应商管理'; - if (location.pathname === '/stats') return '用量统计'; - if (location.pathname === '/settings') return '设置'; - return 'AI Gateway'; - }; + if (location.pathname === '/providers') return '供应商管理' + if (location.pathname === '/stats') return '用量统计' + if (location.pathname === '/settings') return '设置' + return 'AI Gateway' + } - const asideWidth = collapsed ? '64px' : '232px'; + const asideWidth = collapsed ? '64px' : '232px' return ( @@ -38,34 +38,36 @@ export function AppLayout() { collapsed={collapsed} width={['232px', '64px']} logo={ -
+
{!collapsed && 'AI Gateway'}
} operations={
- ); + ) } diff --git a/frontend/src/pages/Providers/ModelForm.tsx b/frontend/src/pages/Providers/ModelForm.tsx index 24f1ac6..38640d4 100644 --- a/frontend/src/pages/Providers/ModelForm.tsx +++ b/frontend/src/pages/Providers/ModelForm.tsx @@ -1,35 +1,27 @@ -import { useEffect } from 'react'; -import { Dialog, Form, Input, Select, Switch } from 'tdesign-react'; -import type { Provider, Model } from '@/types'; -import type { SubmitContext } from 'tdesign-react/es/form/type'; +import { useEffect } from 'react' +import { Dialog, Form, Input, Select, Switch } from 'tdesign-react' +import type { Provider, Model } from '@/types' +import type { SubmitContext } from 'tdesign-react/es/form/type' interface ModelFormValues { - providerId: string; - modelName: string; - enabled: boolean; + providerId: string + modelName: string + enabled: boolean } interface ModelFormProps { - open: boolean; - model?: Model; - providerId: string; - providers: Provider[]; - onSave: (values: ModelFormValues) => Promise | void; - onCancel: () => void; - loading: boolean; + open: boolean + model?: Model + providerId: string + providers: Provider[] + onSave: (values: ModelFormValues) => Promise | void + onCancel: () => void + loading: boolean } -export function ModelForm({ - open, - model, - providerId, - providers, - onSave, - onCancel, - loading, -}: ModelFormProps) { - const [form] = Form.useForm(); - const isEdit = !!model; +export function ModelForm({ open, model, providerId, providers, onSave, onCancel, loading }: ModelFormProps) { + const [form] = Form.useForm() + const isEdit = !!model // 当弹窗打开或model变化时,设置表单值 useEffect(() => { @@ -40,63 +32,56 @@ export function ModelForm({ providerId: model.providerId, modelName: model.modelName, enabled: model.enabled, - }); + }) } else { // 新增模式:重置表单并设置默认providerId - form.reset(); + form.reset() form.setFieldsValue({ providerId, - enabled: true - }); + enabled: true, + }) } } - }, [open, model, providerId]); // 移除form依赖,避免循环 + }, [open, model, providerId]) // 移除form依赖,避免循环 const handleSubmit = (context: SubmitContext) => { if (context.validateResult === true && form) { - const values = form.getFieldsValue(true) as ModelFormValues; - onSave(values); + const values = form.getFieldsValue(true) as ModelFormValues + onSave(values) } - }; + } return ( { form?.submit(); return false; }} + onConfirm={() => { + form?.submit() + return false + }} onClose={onCancel} confirmLoading={loading} - confirmBtn="保存" - cancelBtn="取消" + confirmBtn='保存' + cancelBtn='取消' > -
- - ({ label: p.name, value: p.id }))} /> - - + + - +
- ); + ) } diff --git a/frontend/src/pages/Providers/ModelTable.tsx b/frontend/src/pages/Providers/ModelTable.tsx index 6b41eeb..c989f8a 100644 --- a/frontend/src/pages/Providers/ModelTable.tsx +++ b/frontend/src/pages/Providers/ModelTable.tsx @@ -1,17 +1,17 @@ -import { Button, Table, Tag, Popconfirm, Space } from 'tdesign-react'; -import { useModels, useDeleteModel } from '@/hooks/useModels'; -import type { Model } from '@/types'; -import type { PrimaryTableCol } from 'tdesign-react/es/table/type'; +import { Button, Table, Tag, Popconfirm, Space } from 'tdesign-react' +import { useModels, useDeleteModel } from '@/hooks/useModels' +import type { Model } from '@/types' +import type { PrimaryTableCol } from 'tdesign-react/es/table/type' interface ModelTableProps { - providerId: string; - onAdd?: () => void; - onEdit?: (model: Model) => void; + providerId: string + onAdd?: () => void + onEdit?: (model: Model) => void } export function ModelTable({ providerId, onAdd, onEdit }: ModelTableProps) { - const { data: models = [], isLoading } = useModels(providerId); - const deleteModel = useDeleteModel(); + const { data: models = [], isLoading } = useModels(providerId) + const deleteModel = useDeleteModel() const columns: PrimaryTableCol[] = [ { @@ -32,9 +32,13 @@ export function ModelTable({ providerId, onAdd, onEdit }: ModelTableProps) { width: 80, cell: ({ row }) => row.enabled ? ( - 启用 + + 启用 + ) : ( - 禁用 + + 禁用 + ), }, { @@ -44,29 +48,26 @@ export function ModelTable({ providerId, onAdd, onEdit }: ModelTableProps) { cell: ({ row }) => ( {onEdit && ( - )} - deleteModel.mutate(row.id)} - > - ), }, - ]; + ] return (
关联模型 ({models.length}) {onAdd && ( - )} @@ -74,13 +75,13 @@ export function ModelTable({ providerId, onAdd, onEdit }: ModelTableProps) { columns={columns} data={models} - rowKey="id" + rowKey='id' loading={isLoading} stripe pagination={undefined} - size="small" - empty="暂无模型,点击上方按钮添加" + size='small' + empty='暂无模型,点击上方按钮添加' />
- ); + ) } diff --git a/frontend/src/pages/Providers/ProviderForm.tsx b/frontend/src/pages/Providers/ProviderForm.tsx index a62322f..ebf8198 100644 --- a/frontend/src/pages/Providers/ProviderForm.tsx +++ b/frontend/src/pages/Providers/ProviderForm.tsx @@ -1,34 +1,28 @@ -import { useEffect } from 'react'; -import { Dialog, Form, Input, Switch, Select } from 'tdesign-react'; -import type { Provider } from '@/types'; -import type { SubmitContext } from 'tdesign-react/es/form/type'; +import { useEffect } from 'react' +import { Dialog, Form, Input, Switch, Select } from 'tdesign-react' +import type { Provider } from '@/types' +import type { SubmitContext } from 'tdesign-react/es/form/type' interface ProviderFormValues { - id: string; - name: string; - apiKey: string; - baseUrl: string; - protocol: 'openai' | 'anthropic'; - enabled: boolean; + id: string + name: string + apiKey: string + baseUrl: string + protocol: 'openai' | 'anthropic' + enabled: boolean } interface ProviderFormProps { - open: boolean; - provider?: Provider; - onSave: (values: ProviderFormValues) => Promise | void; - onCancel: () => void; - loading: boolean; + open: boolean + provider?: Provider + onSave: (values: ProviderFormValues) => Promise | void + onCancel: () => void + loading: boolean } -export function ProviderForm({ - open, - provider, - onSave, - onCancel, - loading, -}: ProviderFormProps) { - const [form] = Form.useForm(); - const isEdit = !!provider; +export function ProviderForm({ open, provider, onSave, onCancel, loading }: ProviderFormProps) { + const [form] = Form.useForm() + const isEdit = !!provider useEffect(() => { if (open && form) { @@ -40,75 +34,74 @@ export function ProviderForm({ baseUrl: provider.baseUrl, protocol: provider.protocol, enabled: provider.enabled, - }); + }) } else { - form.reset(); - form.setFieldsValue({ enabled: true, protocol: 'openai' }); + form.reset() + form.setFieldsValue({ enabled: true, protocol: 'openai' }) } } - }, [open, provider]); + }, [open, provider]) const handleSubmit = (context: SubmitContext) => { if (context.validateResult === true && form) { - const values = form.getFieldsValue(true) as ProviderFormValues; - onSave(values); + const values = form.getFieldsValue(true) as ProviderFormValues + onSave(values) } - }; + } return ( { form?.submit(); return false; }} + onConfirm={() => { + form?.submit() + return false + }} onClose={onCancel} confirmLoading={loading} - confirmBtn="保存" - cancelBtn="取消" + confirmBtn='保存' + cancelBtn='取消' > -
- - + + + - - + + + + + + - - - - - + - + - +
- ); + ) } diff --git a/frontend/src/pages/Providers/ProviderTable.tsx b/frontend/src/pages/Providers/ProviderTable.tsx index 1df29f4..374fd32 100644 --- a/frontend/src/pages/Providers/ProviderTable.tsx +++ b/frontend/src/pages/Providers/ProviderTable.tsx @@ -1,16 +1,16 @@ -import { Button, Table, Tag, Popconfirm, Space, Card } from 'tdesign-react'; -import type { Provider, Model } from '@/types'; -import { ModelTable } from './ModelTable'; -import type { PrimaryTableCol } from 'tdesign-react/es/table/type'; +import { Button, Table, Tag, Popconfirm, Space, Card } from 'tdesign-react' +import type { Provider, Model } from '@/types' +import { ModelTable } from './ModelTable' +import type { PrimaryTableCol } from 'tdesign-react/es/table/type' interface ProviderTableProps { - providers: Provider[]; - loading: boolean; - onAdd: () => void; - onEdit: (provider: Provider) => void; - onDelete: (id: string) => void; - onAddModel: (providerId: string) => void; - onEditModel: (model: Model) => void; + providers: Provider[] + loading: boolean + onAdd: () => void + onEdit: (provider: Provider) => void + onDelete: (id: string) => void + onAddModel: (providerId: string) => void + onEditModel: (model: Model) => void } export function ProviderTable({ @@ -39,7 +39,7 @@ export function ProviderTable({ colKey: 'protocol', width: 100, cell: ({ row }) => ( - + {row.protocol === 'openai' ? 'OpenAI' : 'Anthropic'} ), @@ -55,9 +55,13 @@ export function ProviderTable({ width: 80, cell: ({ row }) => row.enabled ? ( - 启用 + + 启用 + ) : ( - 禁用 + + 禁用 + ), }, { @@ -66,29 +70,26 @@ export function ProviderTable({ width: 160, cell: ({ row }) => ( - - onDelete(row.id)} - > - ), }, - ]; + ] return ( + } @@ -96,19 +97,15 @@ export function ProviderTable({ columns={columns} data={providers} - rowKey="id" + rowKey='id' loading={loading} stripe expandedRow={({ row }) => ( - onAddModel(row.id)} - onEdit={onEditModel} - /> + onAddModel(row.id)} onEdit={onEditModel} /> )} pagination={undefined} - empty="暂无供应商,点击上方按钮添加" + empty='暂无供应商,点击上方按钮添加' /> - ); + ) } diff --git a/frontend/src/pages/Providers/index.tsx b/frontend/src/pages/Providers/index.tsx index b877e4e..b4ef74a 100644 --- a/frontend/src/pages/Providers/index.tsx +++ b/frontend/src/pages/Providers/index.tsx @@ -1,24 +1,24 @@ -import { useState } from 'react'; -import { useCreateModel, useUpdateModel } from '@/hooks/useModels'; -import { useProviders, useCreateProvider, useUpdateProvider, useDeleteProvider } from '@/hooks/useProviders'; -import type { Provider, Model, UpdateProviderInput, UpdateModelInput } from '@/types'; -import { ModelForm } from './ModelForm'; -import { ProviderForm } from './ProviderForm'; -import { ProviderTable } from './ProviderTable'; +import { useState } from 'react' +import { useCreateModel, useUpdateModel } from '@/hooks/useModels' +import { useProviders, useCreateProvider, useUpdateProvider, useDeleteProvider } from '@/hooks/useProviders' +import type { Provider, Model, UpdateProviderInput, UpdateModelInput } from '@/types' +import { ModelForm } from './ModelForm' +import { ProviderForm } from './ProviderForm' +import { ProviderTable } from './ProviderTable' export default function ProvidersPage() { - const { data: providers = [], isLoading } = useProviders(); - const createProvider = useCreateProvider(); - const updateProvider = useUpdateProvider(); - const deleteProvider = useDeleteProvider(); - const createModel = useCreateModel(); - const updateModel = useUpdateModel(); + const { data: providers = [], isLoading } = useProviders() + const createProvider = useCreateProvider() + const updateProvider = useUpdateProvider() + const deleteProvider = useDeleteProvider() + const createModel = useCreateModel() + const updateModel = useUpdateModel() - const [providerFormOpen, setProviderFormOpen] = useState(false); - const [editingProvider, setEditingProvider] = useState(); - const [modelFormOpen, setModelFormOpen] = useState(false); - const [editingModel, setEditingModel] = useState(); - const [modelFormProviderId, setModelFormProviderId] = useState(''); + const [providerFormOpen, setProviderFormOpen] = useState(false) + const [editingProvider, setEditingProvider] = useState() + const [modelFormOpen, setModelFormOpen] = useState(false) + const [editingModel, setEditingModel] = useState() + const [modelFormProviderId, setModelFormProviderId] = useState('') return (
@@ -26,23 +26,23 @@ export default function ProvidersPage() { providers={providers} loading={isLoading} onAdd={() => { - setEditingProvider(undefined); - setProviderFormOpen(true); + setEditingProvider(undefined) + setProviderFormOpen(true) }} onEdit={(provider) => { - setEditingProvider(provider); - setProviderFormOpen(true); + setEditingProvider(provider) + setProviderFormOpen(true) }} onDelete={(id) => deleteProvider.mutate(id)} onAddModel={(providerId) => { - setEditingModel(undefined); - setModelFormProviderId(providerId); - setModelFormOpen(true); + setEditingModel(undefined) + setModelFormProviderId(providerId) + setModelFormOpen(true) }} onEditModel={(model) => { - setEditingModel(model); - setModelFormProviderId(model.providerId); - setModelFormOpen(true); + setEditingModel(model) + setModelFormProviderId(model.providerId) + setModelFormOpen(true) }} /> @@ -53,16 +53,16 @@ export default function ProvidersPage() { onSave={async (values) => { try { if (editingProvider) { - const input: Partial = {}; - if (values.name !== editingProvider.name) input.name = values.name; - if (values.apiKey !== editingProvider.apiKey) input.apiKey = values.apiKey; - if (values.baseUrl !== editingProvider.baseUrl) input.baseUrl = values.baseUrl; - if (values.enabled !== editingProvider.enabled) input.enabled = values.enabled; - await updateProvider.mutateAsync({ id: editingProvider.id, input }); + const input: Partial = {} + if (values.name !== editingProvider.name) input.name = values.name + if (values.apiKey !== editingProvider.apiKey) input.apiKey = values.apiKey + if (values.baseUrl !== editingProvider.baseUrl) input.baseUrl = values.baseUrl + if (values.enabled !== editingProvider.enabled) input.enabled = values.enabled + await updateProvider.mutateAsync({ id: editingProvider.id, input }) } else { - await createProvider.mutateAsync(values); + await createProvider.mutateAsync(values) } - setProviderFormOpen(false); + setProviderFormOpen(false) } catch { // 错误已由 hooks 的 onError 处理 } @@ -79,15 +79,15 @@ export default function ProvidersPage() { onSave={async (values) => { try { if (editingModel) { - const input: Partial = {}; - if (values.providerId !== editingModel.providerId) input.providerId = values.providerId; - if (values.modelName !== editingModel.modelName) input.modelName = values.modelName; - if (values.enabled !== editingModel.enabled) input.enabled = values.enabled; - await updateModel.mutateAsync({ id: editingModel.id, input }); + const input: Partial = {} + if (values.providerId !== editingModel.providerId) input.providerId = values.providerId + if (values.modelName !== editingModel.modelName) input.modelName = values.modelName + if (values.enabled !== editingModel.enabled) input.enabled = values.enabled + await updateModel.mutateAsync({ id: editingModel.id, input }) } else { - await createModel.mutateAsync(values); + await createModel.mutateAsync(values) } - setModelFormOpen(false); + setModelFormOpen(false) } catch { // 错误已由 hooks 的 onError 处理 } @@ -95,5 +95,5 @@ export default function ProvidersPage() { onCancel={() => setModelFormOpen(false)} />
- ); + ) } diff --git a/frontend/src/pages/Settings/index.tsx b/frontend/src/pages/Settings/index.tsx index 1aeff76..529d8c9 100644 --- a/frontend/src/pages/Settings/index.tsx +++ b/frontend/src/pages/Settings/index.tsx @@ -1,11 +1,11 @@ -import { Card } from 'tdesign-react'; +import { Card } from 'tdesign-react' export default function SettingsPage() { return ( - +
设置功能开发中...
- ); + ) } diff --git a/frontend/src/pages/Stats/StatCards.tsx b/frontend/src/pages/Stats/StatCards.tsx index 7fc8340..f50600b 100644 --- a/frontend/src/pages/Stats/StatCards.tsx +++ b/frontend/src/pages/Stats/StatCards.tsx @@ -1,31 +1,29 @@ -import { ChartBarIcon, ChartLineIcon, ServerIcon, Calendar1Icon } from 'tdesign-icons-react'; -import { Row, Col, Card, Statistic } from 'tdesign-react'; -import type { UsageStats } from '@/types'; +import { ChartBarIcon, ChartLineIcon, ServerIcon, Calendar1Icon } from 'tdesign-icons-react' +import { Row, Col, Card, Statistic } from 'tdesign-react' +import type { UsageStats } from '@/types' interface StatCardsProps { - stats: UsageStats[]; + stats: UsageStats[] } export function StatCards({ stats }: StatCardsProps) { - const totalRequests = stats.reduce((sum, s) => sum + s.requestCount, 0); - const activeModels = new Set(stats.map((s) => s.modelName)).size; - const activeProviders = new Set(stats.map((s) => s.providerId)).size; + const totalRequests = stats.reduce((sum, s) => sum + s.requestCount, 0) + const activeModels = new Set(stats.map((s) => s.modelName)).size + const activeProviders = new Set(stats.map((s) => s.providerId)).size - const today = new Date().toISOString().split('T')[0]; - const todayRequests = stats - .filter((s) => s.date === today) - .reduce((sum, s) => sum + s.requestCount, 0); + const today = new Date().toISOString().split('T')[0] + const todayRequests = stats.filter((s) => s.date === today).reduce((sum, s) => sum + s.requestCount, 0) return ( } - suffix="次" + suffix='次' animation={{ duration: 800, valueFrom: 0 }} animationStart /> @@ -34,11 +32,11 @@ export function StatCards({ stats }: StatCardsProps) { } - suffix="个" + suffix='个' animation={{ duration: 800, valueFrom: 0 }} animationStart /> @@ -47,11 +45,11 @@ export function StatCards({ stats }: StatCardsProps) { } - suffix="个" + suffix='个' animation={{ duration: 800, valueFrom: 0 }} animationStart /> @@ -60,16 +58,16 @@ export function StatCards({ stats }: StatCardsProps) { } - suffix="次" + suffix='次' animation={{ duration: 800, valueFrom: 0 }} animationStart /> - ); + ) } diff --git a/frontend/src/pages/Stats/StatsTable.tsx b/frontend/src/pages/Stats/StatsTable.tsx index b6cd145..52625d5 100644 --- a/frontend/src/pages/Stats/StatsTable.tsx +++ b/frontend/src/pages/Stats/StatsTable.tsx @@ -1,18 +1,18 @@ -import { useMemo } from 'react'; -import { Table, Select, Input, DateRangePicker, Space, Card } from 'tdesign-react'; -import type { UsageStats, Provider } from '@/types'; -import type { PrimaryTableCol } from 'tdesign-react/es/table/type'; +import { useMemo } from 'react' +import { Table, Select, Input, DateRangePicker, Space, Card } from 'tdesign-react' +import type { UsageStats, Provider } from '@/types' +import type { PrimaryTableCol } from 'tdesign-react/es/table/type' interface StatsTableProps { - providers: Provider[]; - stats: UsageStats[]; - loading: boolean; - providerId?: string; - modelName?: string; - dateRange: [Date | null, Date | null] | null; - onProviderIdChange: (value: string | undefined) => void; - onModelNameChange: (value: string | undefined) => void; - onDateRangeChange: (dates: [Date | null, Date | null] | null) => void; + providers: Provider[] + stats: UsageStats[] + loading: boolean + providerId?: string + modelName?: string + dateRange: [Date | null, Date | null] | null + onProviderIdChange: (value: string | undefined) => void + onModelNameChange: (value: string | undefined) => void + onDateRangeChange: (dates: [Date | null, Date | null] | null) => void } export function StatsTable({ @@ -27,12 +27,12 @@ export function StatsTable({ onDateRangeChange, }: StatsTableProps) { const providerMap = useMemo(() => { - const map = new Map(); + const map = new Map() for (const p of providers) { - map.set(p.id, p.name); + map.set(p.id, p.name) } - return map; - }, [providers]); + return map + }, [providers]) const columns: PrimaryTableCol[] = [ { @@ -50,7 +50,7 @@ export function StatsTable({ cell: ({ row }) => { // 如果后端返回统一 ID 格式(包含 /),直接显示 // 否则显示原始 model_name - return row.modelName; + return row.modelName }, }, { @@ -64,25 +64,25 @@ export function StatsTable({ width: 100, align: 'right', }, - ]; + ] const handleDateChange = (value: unknown) => { if (Array.isArray(value) && value.length === 2) { // 将值转换为Date对象 - 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; - onDateRangeChange([startDate, endDate]); + 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 + onDateRangeChange([startDate, endDate]) } else { - onDateRangeChange(null); + onDateRangeChange(null) } - }; + } return ( - - + + onModelNameChange((value as string) || undefined)} /> @@ -105,12 +105,12 @@ export function StatsTable({ columns={columns} data={stats} - rowKey="id" + rowKey='id' loading={loading} stripe pagination={{ pageSize: 20 }} - empty="暂无统计数据" + empty='暂无统计数据' /> - ); + ) } diff --git a/frontend/src/pages/Stats/UsageChart.tsx b/frontend/src/pages/Stats/UsageChart.tsx index f03ff3c..fbdf84c 100644 --- a/frontend/src/pages/Stats/UsageChart.tsx +++ b/frontend/src/pages/Stats/UsageChart.tsx @@ -1,43 +1,43 @@ -import { AreaChart, Area, XAxis, YAxis, CartesianGrid, ResponsiveContainer, Tooltip } from 'recharts'; -import { Card } from 'tdesign-react'; -import type { UsageStats } from '@/types'; +import { AreaChart, Area, XAxis, YAxis, CartesianGrid, ResponsiveContainer, Tooltip } from 'recharts' +import { Card } from 'tdesign-react' +import type { UsageStats } from '@/types' interface UsageChartProps { - stats: UsageStats[]; - isLoading?: boolean; + stats: UsageStats[] + isLoading?: boolean } export function UsageChart({ stats, isLoading }: UsageChartProps) { const chartData = Object.entries( stats.reduce>((acc, s) => { - acc[s.date] = (acc[s.date] || 0) + s.requestCount; - return acc; + acc[s.date] = (acc[s.date] || 0) + s.requestCount + return acc }, {}) ) .map(([date, requestCount]) => ({ date, requestCount })) - .sort((a, b) => a.date.localeCompare(b.date)); + .sort((a, b) => a.date.localeCompare(b.date)) return ( - + {chartData.length > 0 ? ( - + - - - + + + - - + + @@ -47,5 +47,5 @@ export function UsageChart({ stats, isLoading }: UsageChartProps) {
)} - ); + ) } diff --git a/frontend/src/pages/Stats/index.tsx b/frontend/src/pages/Stats/index.tsx index 57f6449..89aca57 100644 --- a/frontend/src/pages/Stats/index.tsx +++ b/frontend/src/pages/Stats/index.tsx @@ -1,16 +1,16 @@ -import { useState, useMemo } from 'react'; -import { useProviders } from '@/hooks/useProviders'; -import { useStats } from '@/hooks/useStats'; -import { StatCards } from './StatCards'; -import { StatsTable } from './StatsTable'; -import { UsageChart } from './UsageChart'; +import { useState, useMemo } from 'react' +import { useProviders } from '@/hooks/useProviders' +import { useStats } from '@/hooks/useStats' +import { StatCards } from './StatCards' +import { StatsTable } from './StatsTable' +import { UsageChart } from './UsageChart' export default function StatsPage() { - const { data: providers = [] } = useProviders(); + const { data: providers = [] } = useProviders() - const [providerId, setProviderId] = useState(); - const [modelName, setModelName] = useState(); - const [dateRange, setDateRange] = useState<[Date | null, Date | null] | null>(null); + const [providerId, setProviderId] = useState() + const [modelName, setModelName] = useState() + const [dateRange, setDateRange] = useState<[Date | null, Date | null] | null>(null) const params = useMemo( () => ({ @@ -19,10 +19,10 @@ export default function StatsPage() { startDate: dateRange?.[0]?.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 (
@@ -40,5 +40,5 @@ export default function StatsPage() { onDateRangeChange={setDateRange} />
- ); + ) } diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx index b2c518d..05dc5b4 100644 --- a/frontend/src/routes/index.tsx +++ b/frontend/src/routes/index.tsx @@ -1,25 +1,25 @@ -import { lazy, Suspense } from 'react'; -import { Routes, Route, Navigate } from 'react-router'; -import { Loading } from 'tdesign-react'; -import { AppLayout } from '@/components/AppLayout'; +import { lazy, Suspense } from 'react' +import { Routes, Route, Navigate } from 'react-router' +import { Loading } from 'tdesign-react' +import { AppLayout } from '@/components/AppLayout' -const ProvidersPage = lazy(() => import('@/pages/Providers')); -const StatsPage = lazy(() => import('@/pages/Stats')); -const SettingsPage = lazy(() => import('@/pages/Settings')); -const NotFound = lazy(() => import('@/pages/NotFound')); +const ProvidersPage = lazy(() => import('@/pages/Providers')) +const StatsPage = lazy(() => import('@/pages/Stats')) +const SettingsPage = lazy(() => import('@/pages/Settings')) +const NotFound = lazy(() => import('@/pages/NotFound')) export function AppRoutes() { return ( }> }> - } /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> - ); + ) } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 5f7725e..d108459 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1,84 +1,80 @@ export interface Provider { - id: string; - name: string; - apiKey: string; - baseUrl: string; - protocol: 'openai' | 'anthropic'; - enabled: boolean; - createdAt: string; - updatedAt: string; + id: string + name: string + apiKey: string + baseUrl: string + protocol: 'openai' | 'anthropic' + enabled: boolean + createdAt: string + updatedAt: string } export interface Model { - id: string; - providerId: string; - modelName: string; - enabled: boolean; - createdAt: string; - unifiedId?: string; + id: string + providerId: string + modelName: string + enabled: boolean + createdAt: string + unifiedId?: string } export interface UsageStats { - id: number; - providerId: string; - modelName: string; - requestCount: number; - date: string; + id: number + providerId: string + modelName: string + requestCount: number + date: string } export interface CreateProviderInput { - id: string; - name: string; - apiKey: string; - baseUrl: string; - protocol: 'openai' | 'anthropic'; - enabled: boolean; + id: string + name: string + apiKey: string + baseUrl: string + protocol: 'openai' | 'anthropic' + enabled: boolean } export interface UpdateProviderInput { - name?: string; - apiKey?: string; - baseUrl?: string; - protocol?: 'openai' | 'anthropic'; - enabled?: boolean; + name?: string + apiKey?: string + baseUrl?: string + protocol?: 'openai' | 'anthropic' + enabled?: boolean } export interface CreateModelInput { - providerId: string; - modelName: string; - enabled: boolean; + providerId: string + modelName: string + enabled: boolean } export interface UpdateModelInput { - providerId?: string; - modelName?: string; - enabled?: boolean; + providerId?: string + modelName?: string + enabled?: boolean } export interface StatsQueryParams { - providerId?: string; - modelName?: string; - startDate?: string; - endDate?: string; + providerId?: string + modelName?: string + startDate?: string + endDate?: string } export class ApiError extends Error { - status: number; - code?: string; + status: number + code?: string - constructor( - status: number, - message: string, - code?: string, - ) { - super(message); - this.name = 'ApiError'; - this.status = status; - this.code = code; + constructor(status: number, message: string, code?: string) { + super(message) + this.name = 'ApiError' + this.status = status + this.code = code } } export interface ApiErrorResponse { - error: string; - code?: string; + error: string + code?: string } diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index 7756efe..827702f 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -17,12 +17,7 @@ export default defineConfig({ coverage: { provider: 'v8', include: ['src/**/*.{ts,tsx}'], - exclude: [ - 'src/__tests__/**', - 'src/main.tsx', - 'src/**/*.module.scss', - 'src/types/**', - ], + exclude: ['src/__tests__/**', 'src/main.tsx', 'src/**/*.module.scss', 'src/types/**'], }, }, }) diff --git a/openspec/specs/frontend-lint-rules/spec.md b/openspec/specs/frontend-lint-rules/spec.md index 1329830..b225e9d 100644 --- a/openspec/specs/frontend-lint-rules/spec.md +++ b/openspec/specs/frontend-lint-rules/spec.md @@ -49,18 +49,20 @@ TBD - 定义前端 ESLint 规则配置、构建集成 lint 检查、以及自定 ### Requirement: 构建集成 lint 检查 -前端 SHALL 在 `build` 命令中集成 ESLint 检查。 +前端 SHALL 在 `build` 命令中集成 ESLint 检查和 Prettier 格式检查。 -#### Scenario: 构建时执行 lint +#### Scenario: 构建时执行 lint 和格式检查 - **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** 若 `prettier --check .` 报告任何格式问题,构建 SHALL 中断 #### Scenario: lint 警告不中断构建 - **WHEN** `eslint .` 仅报告警告(无错误) -- **THEN** 构建 SHALL 继续执行 `vite build` +- **THEN** 构建 SHALL 继续执行格式检查和 `vite build` #### Scenario: 单独执行 lint @@ -72,6 +74,19 @@ TBD - 定义前端 ESLint 规则配置、构建集成 lint 检查、以及自定 - **WHEN** 执行 `bun run lint: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: 自定义规则禁止硬编码颜色 前端 SHALL 提供自定义 ESLint 规则 `no-hardcoded-color-in-style`,检测 JSX style 属性中的硬编码颜色值。 @@ -112,3 +127,14 @@ TBD - 定义前端 ESLint 规则配置、构建集成 lint 检查、以及自定 - **THEN** 规则文件 SHALL 放置在 `frontend/eslint-rules/` 目录下 - **THEN** `eslint.config.js` SHALL 通过相对路径引用本地插件 - **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 重复检查同一规则 diff --git a/openspec/specs/frontend/spec.md b/openspec/specs/frontend/spec.md index a77e6e6..342cd5b 100644 --- a/openspec/specs/frontend/spec.md +++ b/openspec/specs/frontend/spec.md @@ -508,8 +508,30 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面 - **THEN** Vite SHALL 对业务代码执行混淆处理 - **THEN** 混淆 SHALL 仅应用于 src 目录下的业务代码 - **THEN** 混淆 SHALL NOT 应用于 node_modules 中的第三方库 -- **THEN** 构建流程 SHALL 在 vite build 之前执行 ESLint 检查 +- **THEN** 构建流程 SHALL 在 vite build 之前执行 ESLint 检查和 Prettier 格式检查 - **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 通信 diff --git a/openspec/specs/prettier-formatting/spec.md b/openspec/specs/prettier-formatting/spec.md new file mode 100644 index 0000000..8b88f91 --- /dev/null +++ b/openspec/specs/prettier-formatting/spec.md @@ -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` 中声明