1
0

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

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

12
frontend/.editorconfig Normal file
View File

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

1
frontend/.gitignore vendored
View File

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

16
frontend/.prettierignore Normal file
View File

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

16
frontend/.prettierrc Normal file
View File

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

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

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

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

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

View File

@@ -13,6 +13,7 @@ AI 网关管理前端,提供供应商配置和用量统计界面。
- **数据获取**: TanStack Query v5
- **样式**: 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<T>(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`
### 添加新页面

View File

@@ -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=="],

View File

@@ -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,
@@ -90,10 +82,12 @@ export async function seedUsageStats(statsData: SeedStatsInput[]) {
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()

View File

@@ -47,7 +47,10 @@ 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()
@@ -55,10 +58,14 @@ test.describe('模型管理', () => {
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 }) => {
@@ -101,10 +108,14 @@ test.describe('模型管理', () => {
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,
})
})
})

View File

@@ -61,7 +61,9 @@ test.describe('供应商管理', () => {
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 })
@@ -73,7 +75,9 @@ test.describe('供应商管理', () => {
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 })

View File

@@ -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)) {

View File

@@ -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
)

View File

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

View File

@@ -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",

View File

@@ -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 (
<QueryClientProvider client={queryClient}>
<ConfigProvider globalConfig={{
animation: { include: ['ripple', 'expand', 'fade'] },
table: { size: 'medium' },
}}>
<ConfigProvider
globalConfig={{
animation: { include: ['ripple', 'expand', 'fade'] },
table: { size: 'medium' },
}}
>
<BrowserRouter>
<AppRoutes />
</BrowserRouter>
</ConfigProvider>
</QueryClientProvider>
);
)
}
export default App;
export default App

View File

@@ -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<Array<{ itemName: string }>>(input);
expect(result).toEqual([{ itemName: 'a' }, { itemName: 'b' }]);
});
const input = [{ item_name: 'a' }, { item_name: 'b' }]
const result = fromApi<Array<{ itemName: string }>>(input)
expect(result).toEqual([{ itemName: 'a' }, { itemName: 'b' }])
})
it('returns primitives unchanged', () => {
expect(fromApi<string>('hello')).toBe('hello');
expect(fromApi<number>(42)).toBe(42);
expect(fromApi<null>(null)).toBeNull();
});
});
expect(fromApi<string>('hello')).toBe('hello')
expect(fromApi<number>(42)).toBe(42)
expect(fromApi<null>(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<Array<{ item_name: string }>>(input);
expect(result).toEqual([{ item_name: 'a' }, { item_name: 'b' }]);
});
const input = [{ itemName: 'a' }, { itemName: 'b' }]
const result = toApi<Array<{ item_name: string }>>(input)
expect(result).toEqual([{ item_name: 'a' }, { item_name: 'b' }])
})
it('returns primitives unchanged', () => {
expect(toApi<string>('hello')).toBe('hello');
expect(toApi<number>(42)).toBe(42);
expect(toApi<null>(null)).toBeNull();
});
});
expect(toApi<string>('hello')).toBe('hello')
expect(toApi<number>(42)).toBe(42)
expect(toApi<null>(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<string, unknown> | null = null;
let receivedBody: Record<string, unknown> | null = null
mswServer.use(
http.post('http://localhost:3000/api/test', async ({ request }) => {
receivedBody = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ id: '1' });
}),
);
receivedBody = (await request.json()) as Record<string, unknown>
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')
})
})

View File

@@ -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<string, unknown> | null = null;
let receivedMethod: string | null = null
let receivedBody: Record<string, unknown> | null = null
server.use(
http.post('http://localhost:3000/api/models', async ({ request }) => {
receivedMethod = request.method;
receivedBody = (await request.json()) as Record<string, unknown>;
return HttpResponse.json(mockModels[0]);
}),
);
receivedMethod = request.method
receivedBody = (await request.json()) as Record<string, unknown>
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<string, unknown> | null = null;
let receivedMethod: string | null = null
let receivedUrl: string | null = null
let receivedBody: Record<string, unknown> | 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<string, unknown>;
receivedMethod = request.method
receivedUrl = new URL(request.url).pathname
receivedBody = (await request.json()) as Record<string, unknown>
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()
})
})
})

View File

@@ -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<string, unknown> | null = null;
let receivedMethod: string | null = null
let receivedBody: Record<string, unknown> | null = null
server.use(
http.post('http://localhost:3000/api/providers', async ({ request }) => {
receivedMethod = request.method;
receivedBody = (await request.json()) as Record<string, unknown>;
return HttpResponse.json(mockProviders[0]);
}),
);
receivedMethod = request.method
receivedBody = (await request.json()) as Record<string, unknown>
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<string, unknown> | null = null;
let receivedMethod: string | null = null
let receivedUrl: string | null = null
let receivedBody: Record<string, unknown> | 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<string, unknown>;
receivedMethod = request.method
receivedUrl = new URL(request.url).pathname
receivedBody = (await request.json()) as Record<string, unknown>
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()
})
})
})

View File

@@ -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',
});
});
});
});
})
})
})
})

View File

@@ -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(<BrowserRouter>{component}</BrowserRouter>);
};
return render(<BrowserRouter>{component}</BrowserRouter>)
}
describe('AppLayout', () => {
it('renders sidebar with app name', () => {
renderWithRouter(<AppLayout />);
renderWithRouter(<AppLayout />)
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(<AppLayout />);
renderWithRouter(<AppLayout />)
expect(screen.getByText('供应商管理')).toBeInTheDocument();
expect(screen.getByText('用量统计')).toBeInTheDocument();
});
expect(screen.getByText('供应商管理')).toBeInTheDocument()
expect(screen.getByText('用量统计')).toBeInTheDocument()
})
it('renders settings menu item', () => {
renderWithRouter(<AppLayout />);
renderWithRouter(<AppLayout />)
expect(screen.getByText('设置')).toBeInTheDocument();
});
expect(screen.getByText('设置')).toBeInTheDocument()
})
it('renders content outlet', () => {
const { container } = renderWithRouter(<AppLayout />);
const { container } = renderWithRouter(<AppLayout />)
// TDesign Layout content
expect(container.querySelector('.t-layout__content')).toBeInTheDocument();
});
expect(container.querySelector('.t-layout__content')).toBeInTheDocument()
})
it('renders sidebar', () => {
const { container } = renderWithRouter(<AppLayout />);
const { container } = renderWithRouter(<AppLayout />)
// 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(<AppLayout />);
const { container } = renderWithRouter(<AppLayout />)
// TDesign Layout header
expect(container.querySelector('.t-layout__header')).toBeInTheDocument();
});
});
expect(container.querySelector('.t-layout__header')).toBeInTheDocument()
})
})

View File

@@ -1,8 +1,8 @@
import { render, screen, within } from '@testing-library/react';
import 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(<ModelForm {...defaultProps} />);
render(<ModelForm {...defaultProps} />)
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(<ModelForm {...defaultProps} />);
render(<ModelForm {...defaultProps} />)
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(
<ModelForm
{...defaultProps}
providerId={undefined as unknown as string}
providers={[]}
/>,
);
const user = userEvent.setup()
render(<ModelForm {...defaultProps} providerId={undefined as unknown as string} providers={[]} />)
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(<ModelForm {...defaultProps} onSave={onSave} />);
const user = userEvent.setup()
const onSave = vi.fn()
render(<ModelForm {...defaultProps} onSave={onSave} />)
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(<ModelForm {...defaultProps} model={mockModel} />);
render(<ModelForm {...defaultProps} model={mockModel} />)
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(<ModelForm {...defaultProps} onCancel={onCancel} />);
const user = userEvent.setup()
const onCancel = vi.fn()
render(<ModelForm {...defaultProps} onCancel={onCancel} />)
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)
})
})

View File

@@ -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(<ModelTable {...defaultProps} />);
render(<ModelTable {...defaultProps} />)
expect(screen.getByText(/关联模型/)).toBeInTheDocument();
expect(screen.getByText('openai/gpt-4o')).toBeInTheDocument();
expect(screen.getByText('openai/gpt-3.5-turbo')).toBeInTheDocument();
expect(screen.getByText('gpt-4o')).toBeInTheDocument();
expect(screen.getByText('gpt-3.5-turbo')).toBeInTheDocument();
});
expect(screen.getByText(/关联模型/)).toBeInTheDocument()
expect(screen.getByText('openai/gpt-4o')).toBeInTheDocument()
expect(screen.getByText('openai/gpt-3.5-turbo')).toBeInTheDocument()
expect(screen.getByText('gpt-4o')).toBeInTheDocument()
expect(screen.getByText('gpt-3.5-turbo')).toBeInTheDocument()
})
it('renders status tags correctly', () => {
render(<ModelTable {...defaultProps} />);
render(<ModelTable {...defaultProps} />)
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(<ModelTable {...defaultProps} onAdd={onAdd} />);
const user = userEvent.setup()
const onAdd = vi.fn()
render(<ModelTable {...defaultProps} onAdd={onAdd} />)
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(<ModelTable {...defaultProps} onEdit={onEdit} />);
const user = userEvent.setup()
const onEdit = vi.fn()
render(<ModelTable {...defaultProps} onEdit={onEdit} />)
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(<ModelTable {...defaultProps} />);
render(<ModelTable {...defaultProps} />)
// 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(<ModelTable providerId="anthropic" />);
expect(screen.getByText('暂无模型,点击上方按钮添加')).toBeInTheDocument();
});
render(<ModelTable providerId='anthropic' />)
expect(screen.getByText('暂无模型,点击上方按钮添加')).toBeInTheDocument()
})
it('does not render add button when onAdd is not provided', () => {
render(<ModelTable providerId="openai" />);
render(<ModelTable providerId='openai' />)
expect(screen.queryByRole('button', { name: '添加模型' })).not.toBeInTheDocument();
});
expect(screen.queryByRole('button', { name: '添加模型' })).not.toBeInTheDocument()
})
it('does not render edit button when onEdit is not provided', () => {
render(<ModelTable providerId="openai" onAdd={vi.fn()} />);
render(<ModelTable providerId='openai' onAdd={vi.fn()} />)
expect(screen.queryByRole('button', { name: /编 ?辑/ })).not.toBeInTheDocument();
});
});
expect(screen.queryByRole('button', { name: /编 ?辑/ })).not.toBeInTheDocument()
})
})

View File

@@ -1,8 +1,8 @@
import { render, screen, within, fireEvent } from '@testing-library/react';
import 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(<ProviderForm {...defaultProps} />);
render(<ProviderForm {...defaultProps} />)
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(<ProviderForm {...defaultProps} provider={mockProvider} />);
render(<ProviderForm {...defaultProps} provider={mockProvider} />)
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(<ProviderForm {...defaultProps} provider={mockProvider} />);
render(<ProviderForm {...defaultProps} provider={mockProvider} />)
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(<ProviderForm {...defaultProps} />);
const user = userEvent.setup()
render(<ProviderForm {...defaultProps} />)
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(<ProviderForm {...defaultProps} onSave={onSave} />);
const onSave = vi.fn()
render(<ProviderForm {...defaultProps} onSave={onSave} />)
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(<ProviderForm {...defaultProps} onCancel={onCancel} />);
const user = userEvent.setup()
const onCancel = vi.fn()
render(<ProviderForm {...defaultProps} onCancel={onCancel} />)
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(<ProviderForm {...defaultProps} loading={true} />);
const dialog = getDialog();
const okButton = within(dialog).getByRole('button', { name: /保/ });
render(<ProviderForm {...defaultProps} loading={true} />)
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(<ProviderForm {...defaultProps} />);
const user = userEvent.setup()
render(<ProviderForm {...defaultProps} />)
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(<ProviderForm {...defaultProps} />);
render(<ProviderForm {...defaultProps} />)
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(<ProviderForm {...defaultProps} onSave={onSave} />);
const onSave = vi.fn()
render(<ProviderForm {...defaultProps} onSave={onSave} />)
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)
})

View File

@@ -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(<ProviderTable {...defaultProps} />);
render(<ProviderTable {...defaultProps} />)
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(<ProviderTable {...defaultProps} />);
const { container } = render(<ProviderTable {...defaultProps} />)
// 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(<ProviderTable {...defaultProps} providers={shortKeyProvider} />);
]
render(<ProviderTable {...defaultProps} providers={shortKeyProvider} />)
expect(screen.getByText('ab')).toBeInTheDocument();
});
expect(screen.getByText('ab')).toBeInTheDocument()
})
it('calls onAdd when clicking "添加供应商" button', async () => {
const user = userEvent.setup();
const onAdd = vi.fn();
render(<ProviderTable {...defaultProps} onAdd={onAdd} />);
const user = userEvent.setup()
const onAdd = vi.fn()
render(<ProviderTable {...defaultProps} onAdd={onAdd} />)
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(<ProviderTable {...defaultProps} onEdit={onEdit} />);
const user = userEvent.setup()
const onEdit = vi.fn()
render(<ProviderTable {...defaultProps} onEdit={onEdit} />)
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(<ProviderTable {...defaultProps} onDelete={onDelete} />);
const user = userEvent.setup()
const onDelete = vi.fn()
render(<ProviderTable {...defaultProps} onDelete={onDelete} />)
// 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(<ProviderTable {...defaultProps} loading={true} />);
const { container } = render(<ProviderTable {...defaultProps} loading={true} />)
// 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(<ProviderTable {...defaultProps} />);
const user = userEvent.setup()
const { container } = render(<ProviderTable {...defaultProps} />)
// 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(<ProviderTable {...defaultProps} />);
const { container } = render(<ProviderTable {...defaultProps} />)
// 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(<ProviderTable {...defaultProps} providers={[]} />);
expect(screen.getByText('暂无供应商,点击上方按钮添加')).toBeInTheDocument();
});
render(<ProviderTable {...defaultProps} providers={[]} />)
expect(screen.getByText('暂无供应商,点击上方按钮添加')).toBeInTheDocument()
})
it('renders protocol column with correct tags', () => {
const { container } = render(<ProviderTable {...defaultProps} />);
const { container } = render(<ProviderTable {...defaultProps} />)
// Check that protocol tags are displayed in the table
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(<ProviderTable {...defaultProps} providers={singleProvider} />);
]
const { container } = render(<ProviderTable {...defaultProps} providers={singleProvider} />)
// Should display protocol column
const protocolCell = container.querySelector('[data-colkey="protocol"]');
expect(protocolCell).toBeInTheDocument();
});
});
const protocolCell = container.querySelector('[data-colkey="protocol"]')
expect(protocolCell).toBeInTheDocument()
})
})

View File

@@ -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(<StatCards stats={mockStats} />);
render(<StatCards stats={mockStats} />)
expect(screen.getByText('总请求量')).toBeInTheDocument();
expect(screen.getByText('活跃模型数')).toBeInTheDocument();
expect(screen.getByText('活跃供应商数')).toBeInTheDocument();
expect(screen.getByText('今日请求量')).toBeInTheDocument();
});
expect(screen.getByText('总请求量')).toBeInTheDocument()
expect(screen.getByText('活跃模型数')).toBeInTheDocument()
expect(screen.getByText('活跃供应商数')).toBeInTheDocument()
expect(screen.getByText('今日请求量')).toBeInTheDocument()
})
it('renders with empty stats', () => {
render(<StatCards stats={[]} />);
render(<StatCards stats={[]} />)
expect(screen.getByText('总请求量')).toBeInTheDocument();
expect(screen.getByText('活跃模型数')).toBeInTheDocument();
expect(screen.getByText('活跃供应商数')).toBeInTheDocument();
expect(screen.getByText('今日请求量')).toBeInTheDocument();
});
expect(screen.getByText('总请求量')).toBeInTheDocument()
expect(screen.getByText('活跃模型数')).toBeInTheDocument()
expect(screen.getByText('活跃供应商数')).toBeInTheDocument()
expect(screen.getByText('今日请求量')).toBeInTheDocument()
})
it('renders suffix units', () => {
render(<StatCards stats={mockStats} />);
render(<StatCards stats={mockStats} />)
expect(screen.getAllByText('次').length).toBeGreaterThan(0);
expect(screen.getAllByText('个').length).toBeGreaterThan(0);
});
});
expect(screen.getAllByText('次').length).toBeGreaterThan(0)
expect(screen.getAllByText('个').length).toBeGreaterThan(0)
})
})

View File

@@ -1,7 +1,7 @@
import { render, screen } from '@testing-library/react';
import { 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(<StatsTable {...defaultProps} />);
render(<StatsTable {...defaultProps} />)
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(<StatsTable {...defaultProps} />);
render(<StatsTable {...defaultProps} />)
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(<StatsTable {...defaultProps} />);
const { container } = render(<StatsTable {...defaultProps} />)
// 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(<StatsTable {...defaultProps} />);
render(<StatsTable {...defaultProps} />)
expect(screen.getAllByText('供应商').length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText('模型').length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText('日期').length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText('请求数').length).toBeGreaterThanOrEqual(1);
});
expect(screen.getAllByText('供应商').length).toBeGreaterThanOrEqual(1)
expect(screen.getAllByText('模型').length).toBeGreaterThanOrEqual(1)
expect(screen.getAllByText('日期').length).toBeGreaterThanOrEqual(1)
expect(screen.getAllByText('请求数').length).toBeGreaterThanOrEqual(1)
})
it('falls back to providerId when provider not found in providers prop', () => {
const limitedProviders = [mockProviders[0]];
render(<StatsTable {...defaultProps} providers={limitedProviders} />);
const limitedProviders = [mockProviders[0]]
render(<StatsTable {...defaultProps} providers={limitedProviders} />)
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(<StatsTable {...defaultProps} stats={[]} />);
render(<StatsTable {...defaultProps} stats={[]} />)
expect(screen.getAllByText('供应商').length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText('模型').length).toBeGreaterThanOrEqual(1);
});
expect(screen.getAllByText('供应商').length).toBeGreaterThanOrEqual(1)
expect(screen.getAllByText('模型').length).toBeGreaterThanOrEqual(1)
})
it('shows loading state', () => {
const { container } = render(<StatsTable {...defaultProps} loading={true} />);
const { container } = render(<StatsTable {...defaultProps} loading={true} />)
// TDesign Table loading indicator - could be .t-table__loading or .t-loading
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(<StatsTable {...defaultProps} stats={[]} />);
expect(screen.getByText('暂无统计数据')).toBeInTheDocument();
});
});
render(<StatsTable {...defaultProps} stats={[]} />)
expect(screen.getByText('暂无统计数据')).toBeInTheDocument()
})
})

View File

@@ -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 }) => <div data-testid="mock-chart-container">{children}</div>),
AreaChart: vi.fn(() => <div data-testid="mock-area-chart" />),
ResponsiveContainer: vi.fn(({ children }) => <div data-testid='mock-chart-container'>{children}</div>),
AreaChart: vi.fn(() => <div data-testid='mock-area-chart' />),
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(<UsageChart stats={mockStats} />);
render(<UsageChart stats={mockStats} />)
expect(screen.getByText('请求趋势')).toBeInTheDocument();
});
expect(screen.getByText('请求趋势')).toBeInTheDocument()
})
it('renders with data', () => {
const { container } = render(<UsageChart stats={mockStats} />);
const { container } = render(<UsageChart stats={mockStats} />)
// 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(<UsageChart stats={[]} />);
render(<UsageChart stats={[]} />)
expect(screen.getByText('暂无数据')).toBeInTheDocument();
});
expect(screen.getByText('暂无数据')).toBeInTheDocument()
})
it('aggregates data by date correctly', () => {
const { container } = render(<UsageChart stats={mockStats} />);
const { container } = render(<UsageChart stats={mockStats} />)
// 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()
})
})

View File

@@ -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

View File

@@ -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<string, unknown>;
const body = (await request.json()) as Record<string, unknown>
return HttpResponse.json({
...mockCreatedModel,
...body,
});
})
}),
http.put('/api/models/:id', async ({ request, params }) => {
const body = await request.json() as Record<string, unknown>;
const existing = mockModels.find((m) => m.id === params['id']);
return HttpResponse.json({ ...existing, ...body });
const body = (await request.json()) as Record<string, unknown>
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 (
<QueryClientProvider client={testQueryClient}>
{children}
</QueryClientProvider>
);
};
return <QueryClientProvider client={testQueryClient}>{children}</QueryClientProvider>
}
}
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 (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
}
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 (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
}
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 (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
}
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()
})
})

View File

@@ -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<string, unknown>;
const body = (await request.json()) as Record<string, unknown>
return HttpResponse.json({
...mockCreatedProvider,
...body,
});
})
}),
http.put('/api/providers/:id', async ({ request, params }) => {
const body = await request.json() as Record<string, unknown>;
const existing = mockProviders.find((p) => p.id === params['id']);
return HttpResponse.json({ ...existing, ...body });
const body = (await request.json()) as Record<string, unknown>
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 (
<QueryClientProvider client={testQueryClient}>
{children}
</QueryClientProvider>
);
};
return <QueryClientProvider client={testQueryClient}>{children}</QueryClientProvider>
}
}
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 (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
}
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 (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
}
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 (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
}
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()
})
})

View File

@@ -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 (
<QueryClientProvider client={testQueryClient}>
{children}
</QueryClientProvider>
);
};
return <QueryClientProvider client={testQueryClient}>{children}</QueryClientProvider>
}
}
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()
})
})

View File

@@ -1,8 +1,8 @@
import '@testing-library/jest-dom/vitest';
import '@testing-library/jest-dom/vitest'
// Ensure happy-dom environment is properly initialized
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)
}

View File

@@ -1,81 +1,77 @@
import { ApiError } from '@/types';
import { ApiError } from '@/types'
const API_BASE = import.meta.env.VITE_API_BASE || '';
const API_BASE = import.meta.env.VITE_API_BASE || ''
function toCamelCase(str: string): string {
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<T>(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<string, unknown> = {};
const result: Record<string, unknown> = {}
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
result[transformer(key)] = transformKeys(value, transformer);
result[transformer(key)] = transformKeys(value, transformer)
}
return result as T;
return result as T
}
return obj as T;
return obj as T
}
export function fromApi<T>(data: unknown): T {
return transformKeys<T>(data, toCamelCase);
return transformKeys<T>(data, toCamelCase)
}
export function toApi<T>(data: unknown): T {
return transformKeys<T>(data, toSnakeCase);
return transformKeys<T>(data, toSnakeCase)
}
export async function request<T>(
method: string,
path: string,
body?: unknown,
): Promise<T> {
const url = `${API_BASE}${path}`;
export async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
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<T>(data);
const data = await response.json()
return fromApi<T>(data)
}

View File

@@ -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<Model[]> {
const path = providerId
? `/api/models?provider_id=${encodeURIComponent(providerId)}`
: '/api/models';
return request<Model[]>('GET', path);
const path = providerId ? `/api/models?provider_id=${encodeURIComponent(providerId)}` : '/api/models'
return request<Model[]>('GET', path)
}
export async function createModel(input: CreateModelInput): Promise<Model> {
return request<Model>('POST', '/api/models', input);
return request<Model>('POST', '/api/models', input)
}
export async function updateModel(
id: string,
input: UpdateModelInput,
): Promise<Model> {
return request<Model>('PUT', `/api/models/${id}`, input);
export async function updateModel(id: string, input: UpdateModelInput): Promise<Model> {
return request<Model>('PUT', `/api/models/${id}`, input)
}
export async function deleteModel(id: string): Promise<void> {
return request<void>('DELETE', `/api/models/${id}`);
return request<void>('DELETE', `/api/models/${id}`)
}

View File

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

View File

@@ -1,26 +1,26 @@
import type { UsageStats, StatsQueryParams } from '@/types';
import { request } from './client';
import type { UsageStats, StatsQueryParams } from '@/types'
import { request } from './client'
export async function getStats(params?: StatsQueryParams): Promise<UsageStats[]> {
if (!params) {
return request<UsageStats[]>('GET', '/api/stats');
return request<UsageStats[]>('GET', '/api/stats')
}
const query = new URLSearchParams();
const query = new URLSearchParams()
const snakeParams: Record<string, string | undefined> = {
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<UsageStats[]>('GET', path);
const queryString = query.toString()
const path = queryString ? `/api/stats?${queryString}` : '/api/stats'
return request<UsageStats[]>('GET', path)
}

View File

@@ -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 (
<Layout style={{ minHeight: '100vh' }}>
@@ -38,34 +38,36 @@ export function AppLayout() {
collapsed={collapsed}
width={['232px', '64px']}
logo={
<div style={{
height: 64,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '1.25rem',
fontWeight: 600,
}}>
<div
style={{
height: 64,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '1.25rem',
fontWeight: 600,
}}
>
{!collapsed && 'AI Gateway'}
</div>
}
operations={
<Button
variant="text"
shape="square"
variant='text'
shape='square'
icon={collapsed ? <ChevronRightIcon /> : <ChevronLeftIcon />}
onClick={() => setCollapsed(!collapsed)}
/>
}
style={{ height: '100%' }}
>
<MenuItem value="/providers" icon={<ServerIcon />}>
<MenuItem value='/providers' icon={<ServerIcon />}>
</MenuItem>
<MenuItem value="/stats" icon={<ChartLineIcon />}>
<MenuItem value='/stats' icon={<ChartLineIcon />}>
</MenuItem>
<MenuItem value="/settings" icon={<SettingIcon />}>
<MenuItem value='/settings' icon={<SettingIcon />}>
</MenuItem>
</Menu>
@@ -95,5 +97,5 @@ export function AppLayout() {
</Layout.Content>
</Layout>
</Layout>
);
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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> | void;
onCancel: () => void;
loading: boolean;
open: boolean
model?: Model
providerId: string
providers: Provider[]
onSave: (values: ModelFormValues) => Promise<void> | 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 (
<Dialog
header={isEdit ? '编辑模型' : '添加模型'}
visible={open}
placement="center"
width="520px"
placement='center'
width='520px'
closeOnOverlayClick={false}
closeOnEscKeydown={false}
lazy={false}
onConfirm={() => { form?.submit(); return false; }}
onConfirm={() => {
form?.submit()
return false
}}
onClose={onCancel}
confirmLoading={loading}
confirmBtn="保存"
cancelBtn="取消"
confirmBtn='保存'
cancelBtn='取消'
>
<Form form={form} layout="vertical" onSubmit={handleSubmit}>
<Form.FormItem
label="供应商"
name="providerId"
rules={[{ required: true, message: '请选择供应商' }]}
>
<Select
options={providers.map((p) => ({ label: p.name, value: p.id }))}
/>
<Form form={form} layout='vertical' onSubmit={handleSubmit}>
<Form.FormItem label='供应商' name='providerId' rules={[{ required: true, message: '请选择供应商' }]}>
<Select options={providers.map((p) => ({ label: p.name, value: p.id }))} />
</Form.FormItem>
<Form.FormItem
label="模型名称"
name="modelName"
rules={[{ required: true, message: '请输入模型名称' }]}
>
<Input placeholder="例如: gpt-4o" />
<Form.FormItem label='模型名称' name='modelName' rules={[{ required: true, message: '请输入模型名称' }]}>
<Input placeholder='例如: gpt-4o' />
</Form.FormItem>
<Form.FormItem label="启用" name="enabled">
<Form.FormItem label='启用' name='enabled'>
<Switch />
</Form.FormItem>
</Form>
</Dialog>
);
)
}

View File

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

View File

@@ -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> | void;
onCancel: () => void;
loading: boolean;
open: boolean
provider?: Provider
onSave: (values: ProviderFormValues) => Promise<void> | 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 (
<Dialog
header={isEdit ? '编辑供应商' : '添加供应商'}
visible={open}
placement="center"
width="520px"
placement='center'
width='520px'
closeOnOverlayClick={false}
closeOnEscKeydown={false}
lazy={false}
onConfirm={() => { form?.submit(); return false; }}
onConfirm={() => {
form?.submit()
return false
}}
onClose={onCancel}
confirmLoading={loading}
confirmBtn="保存"
cancelBtn="取消"
confirmBtn='保存'
cancelBtn='取消'
>
<Form form={form} layout="vertical" onSubmit={handleSubmit}>
<Form.FormItem label="ID" name="id" rules={[{ required: true, message: '请输入供应商 ID' }]}>
<Input disabled={isEdit} placeholder="例如: openai" />
<Form form={form} layout='vertical' onSubmit={handleSubmit}>
<Form.FormItem label='ID' name='id' rules={[{ required: true, message: '请输入供应商 ID' }]}>
<Input disabled={isEdit} placeholder='例如: openai' />
</Form.FormItem>
<Form.FormItem label="名称" name="name" rules={[{ required: true, message: '请输入名称' }]}>
<Input placeholder="例如: OpenAI" />
<Form.FormItem label='名称' name='name' rules={[{ required: true, message: '请输入名称' }]}>
<Input placeholder='例如: OpenAI' />
</Form.FormItem>
<Form.FormItem label='API Key' name='apiKey' rules={[{ required: true, message: '请输入 API Key' }]}>
<Input placeholder='sk-...' />
</Form.FormItem>
<Form.FormItem
label="API Key"
name="apiKey"
rules={[{ required: true, message: '请输入 API Key' }]}
>
<Input placeholder="sk-..." />
</Form.FormItem>
<Form.FormItem
label="Base URL"
name="baseUrl"
label='Base URL'
name='baseUrl'
rules={[
{ required: true, message: '请输入 Base URL' },
{ url: true, message: '请输入有效的 URL' },
]}
>
<Input placeholder="例如: https://api.openai.com/v1" />
<Input placeholder='例如: https://api.openai.com/v1' />
</Form.FormItem>
<Form.FormItem label="协议" name="protocol" rules={[{ required: true, message: '请选择协议' }]}>
<Form.FormItem label='协议' name='protocol' rules={[{ required: true, message: '请选择协议' }]}>
<Select>
<Select.Option value="openai">OpenAI</Select.Option>
<Select.Option value="anthropic">Anthropic</Select.Option>
<Select.Option value='openai'>OpenAI</Select.Option>
<Select.Option value='anthropic'>Anthropic</Select.Option>
</Select>
</Form.FormItem>
<Form.FormItem label="启用" name="enabled">
<Form.FormItem label='启用' name='enabled'>
<Switch />
</Form.FormItem>
</Form>
</Dialog>
);
)
}

View File

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

View File

@@ -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<Provider | undefined>();
const [modelFormOpen, setModelFormOpen] = useState(false);
const [editingModel, setEditingModel] = useState<Model | undefined>();
const [modelFormProviderId, setModelFormProviderId] = useState('');
const [providerFormOpen, setProviderFormOpen] = useState(false)
const [editingProvider, setEditingProvider] = useState<Provider | undefined>()
const [modelFormOpen, setModelFormOpen] = useState(false)
const [editingModel, setEditingModel] = useState<Model | undefined>()
const [modelFormProviderId, setModelFormProviderId] = useState('')
return (
<div>
@@ -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<UpdateProviderInput> = {};
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<UpdateProviderInput> = {}
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<UpdateModelInput> = {};
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<UpdateModelInput> = {}
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)}
/>
</div>
);
)
}

View File

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

View File

@@ -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 (
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col xs={12} md={6}>
<Card bordered={false} hoverShadow>
<Statistic
title="总请求量"
title='总请求量'
value={totalRequests}
color="blue"
color='blue'
prefix={<ChartBarIcon />}
suffix="次"
suffix='次'
animation={{ duration: 800, valueFrom: 0 }}
animationStart
/>
@@ -34,11 +32,11 @@ export function StatCards({ stats }: StatCardsProps) {
<Col xs={12} md={6}>
<Card bordered={false} hoverShadow>
<Statistic
title="活跃模型数"
title='活跃模型数'
value={activeModels}
color="green"
color='green'
prefix={<ChartLineIcon />}
suffix="个"
suffix='个'
animation={{ duration: 800, valueFrom: 0 }}
animationStart
/>
@@ -47,11 +45,11 @@ export function StatCards({ stats }: StatCardsProps) {
<Col xs={12} md={6}>
<Card bordered={false} hoverShadow>
<Statistic
title="活跃供应商数"
title='活跃供应商数'
value={activeProviders}
color="orange"
color='orange'
prefix={<ServerIcon />}
suffix="个"
suffix='个'
animation={{ duration: 800, valueFrom: 0 }}
animationStart
/>
@@ -60,16 +58,16 @@ export function StatCards({ stats }: StatCardsProps) {
<Col xs={12} md={6}>
<Card bordered={false} hoverShadow>
<Statistic
title="今日请求量"
title='今日请求量'
value={todayRequests}
color="red"
color='red'
prefix={<Calendar1Icon />}
suffix="次"
suffix='次'
animation={{ duration: 800, valueFrom: 0 }}
animationStart
/>
</Card>
</Col>
</Row>
);
)
}

View File

@@ -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<string, string>();
const map = new Map<string, string>()
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<UsageStats>[] = [
{
@@ -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 (
<Card title="统计数据" headerBordered hoverShadow>
<Space style={{ marginBottom: 16 }} size="medium" breakLine>
<Card title='统计数据' headerBordered hoverShadow>
<Space style={{ marginBottom: 16 }} size='medium' breakLine>
<Select
clearable
placeholder="所有供应商"
placeholder='所有供应商'
style={{ width: 200 }}
value={providerId}
onChange={(value) => onProviderIdChange(value as string | undefined)}
@@ -90,13 +90,13 @@ export function StatsTable({
/>
<Input
clearable
placeholder="模型名称"
placeholder='模型名称'
style={{ width: 200 }}
value={modelName ?? ''}
onChange={(value) => onModelNameChange((value as string) || undefined)}
/>
<DateRangePicker
mode="date"
mode='date'
value={dateRange && dateRange[0] && dateRange[1] ? [dateRange[0], dateRange[1]] : []}
onChange={handleDateChange}
/>
@@ -105,12 +105,12 @@ export function StatsTable({
<Table<UsageStats>
columns={columns}
data={stats}
rowKey="id"
rowKey='id'
loading={loading}
stripe
pagination={{ pageSize: 20 }}
empty="暂无统计数据"
empty='暂无统计数据'
/>
</Card>
);
)
}

View File

@@ -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<Record<string, number>>((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 (
<Card title="请求趋势" headerBordered hoverShadow loading={isLoading} style={{ marginBottom: 16 }}>
<Card title='请求趋势' headerBordered hoverShadow loading={isLoading} style={{ marginBottom: 16 }}>
{chartData.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<ResponsiveContainer width='100%' height={300}>
<AreaChart data={chartData}>
<defs>
<linearGradient id="requestGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#0052D9" stopOpacity={0.4} />
<stop offset="100%" stopColor="#0052D9" stopOpacity={0} />
<linearGradient id='requestGradient' x1='0' y1='0' x2='0' y2='1'>
<stop offset='0%' stopColor='#0052D9' stopOpacity={0.4} />
<stop offset='100%' stopColor='#0052D9' stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#e8e8e8" />
<XAxis dataKey="date" />
<CartesianGrid strokeDasharray='3 3' stroke='#e8e8e8' />
<XAxis dataKey='date' />
<YAxis />
<Tooltip />
<Area
type="monotone"
dataKey="requestCount"
stroke="#0052D9"
type='monotone'
dataKey='requestCount'
stroke='#0052D9'
strokeWidth={2}
fill="url(#requestGradient)"
fill='url(#requestGradient)'
/>
</AreaChart>
</ResponsiveContainer>
@@ -47,5 +47,5 @@ export function UsageChart({ stats, isLoading }: UsageChartProps) {
</div>
)}
</Card>
);
)
}

View File

@@ -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<string | undefined>();
const [modelName, setModelName] = useState<string | undefined>();
const [dateRange, setDateRange] = useState<[Date | null, Date | null] | null>(null);
const [providerId, setProviderId] = useState<string | undefined>()
const [modelName, setModelName] = useState<string | undefined>()
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 (
<div>
@@ -40,5 +40,5 @@ export default function StatsPage() {
onDateRangeChange={setDateRange}
/>
</div>
);
)
}

View File

@@ -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 (
<Suspense fallback={<Loading />}>
<Routes>
<Route element={<AppLayout />}>
<Route index element={<Navigate to="/providers" replace />} />
<Route path="providers" element={<ProvidersPage />} />
<Route path="stats" element={<StatsPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="*" element={<NotFound />} />
<Route index element={<Navigate to='/providers' replace />} />
<Route path='providers' element={<ProvidersPage />} />
<Route path='stats' element={<StatsPage />} />
<Route path='settings' element={<SettingsPage />} />
<Route path='*' element={<NotFound />} />
</Route>
</Routes>
</Suspense>
);
)
}

View File

@@ -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
}

View File

@@ -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/**'],
},
},
})

View File

@@ -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 重复检查同一规则

View File

@@ -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 通信

View File

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