1
0

chore: 合并 dev-code-format-frontend 到 master

This commit is contained in:
2026-04-24 18:21:27 +08:00
64 changed files with 2456 additions and 1707 deletions

View File

@@ -122,25 +122,28 @@ test-mysql-quick:
# 前端
# ============================================
frontend-build:
cd frontend && bun install && bun run build
frontend-install:
cd frontend && bun install
frontend-dev:
frontend-build: frontend-install
cd frontend && bun run build
frontend-dev: frontend-install
cd frontend && bun dev
frontend-test:
frontend-test: frontend-install
cd frontend && bun run test
frontend-test-watch:
frontend-test-watch: frontend-install
cd frontend && bun run test:watch
frontend-test-coverage:
frontend-test-coverage: frontend-install
cd frontend && bun run test:coverage
frontend-test-e2e:
frontend-test-e2e: frontend-install
cd frontend && bun run test:e2e
frontend-lint:
frontend-lint: frontend-install
cd frontend && bun run lint
frontend-clean:

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

@@ -25,9 +25,11 @@
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/sql.js": "^1.4.11",
"@typescript-eslint/rule-tester": "^8.59.0",
"@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",
@@ -36,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",
@@ -425,23 +428,25 @@
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.58.2", "https://registry.npmmirror.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.2.tgz", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.58.2", "@typescript-eslint/type-utils": "8.58.2", "@typescript-eslint/utils": "8.58.2", "@typescript-eslint/visitor-keys": "8.58.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.58.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.58.2", "https://registry.npmmirror.com/@typescript-eslint/parser/-/parser-8.58.2.tgz", { "dependencies": { "@typescript-eslint/scope-manager": "8.58.2", "@typescript-eslint/types": "8.58.2", "@typescript-eslint/typescript-estree": "8.58.2", "@typescript-eslint/visitor-keys": "8.58.2", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.59.0", "https://registry.npmmirror.com/@typescript-eslint/parser/-/parser-8.59.0.tgz", { "dependencies": { "@typescript-eslint/scope-manager": "8.59.0", "@typescript-eslint/types": "8.59.0", "@typescript-eslint/typescript-estree": "8.59.0", "@typescript-eslint/visitor-keys": "8.59.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg=="],
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.58.2", "https://registry.npmmirror.com/@typescript-eslint/project-service/-/project-service-8.58.2.tgz", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.58.2", "@typescript-eslint/types": "^8.58.2", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg=="],
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.59.0", "https://registry.npmmirror.com/@typescript-eslint/project-service/-/project-service-8.59.0.tgz", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.59.0", "@typescript-eslint/types": "^8.59.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Lw5ITrR5s5TbC19YSvlr63ZfLaJoU6vtKTHyB0GQOpX0W7d5/Ir6vUahWi/8Sps/nOukZQ0IB3SmlxZnjaKVnw=="],
"@typescript-eslint/rule-tester": ["@typescript-eslint/rule-tester@8.59.0", "https://registry.npmmirror.com/@typescript-eslint/rule-tester/-/rule-tester-8.59.0.tgz", { "dependencies": { "@typescript-eslint/parser": "8.59.0", "@typescript-eslint/typescript-estree": "8.59.0", "@typescript-eslint/utils": "8.59.0", "ajv": "^6.12.6", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "4.6.2", "semver": "^7.7.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0" } }, "sha512-2Ej6W28DqObFuEUQ+puEpDZFWFXAW7jIZ4TsgfLUCTNz1FID0NMfp1sXc+fQq8m5ysfPdhXAPjti6jYEu1oRcg=="],
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.58.2", "https://registry.npmmirror.com/@typescript-eslint/scope-manager/-/scope-manager-8.58.2.tgz", { "dependencies": { "@typescript-eslint/types": "8.58.2", "@typescript-eslint/visitor-keys": "8.58.2" } }, "sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q=="],
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.58.2", "https://registry.npmmirror.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.2.tgz", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A=="],
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.59.0", "https://registry.npmmirror.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.0.tgz", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-91Sbl3s4Kb3SybliIY6muFBmHVv+pYXfybC4Oolp3dvk8BvIE3wOPc+403CWIT7mJNkfQRGtdqghzs2+Z91Tqg=="],
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.58.2", "https://registry.npmmirror.com/@typescript-eslint/type-utils/-/type-utils-8.58.2.tgz", { "dependencies": { "@typescript-eslint/types": "8.58.2", "@typescript-eslint/typescript-estree": "8.58.2", "@typescript-eslint/utils": "8.58.2", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Z7EloNR/B389FvabdGeTo2XMs4W9TjtPiO9DAsmT0yom0bwlPyRjkJ1uCdW1DvrrrYP50AJZ9Xc3sByZA9+dcg=="],
"@typescript-eslint/types": ["@typescript-eslint/types@8.58.2", "https://registry.npmmirror.com/@typescript-eslint/types/-/types-8.58.2.tgz", {}, "sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ=="],
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.58.2", "https://registry.npmmirror.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.2.tgz", { "dependencies": { "@typescript-eslint/project-service": "8.58.2", "@typescript-eslint/tsconfig-utils": "8.58.2", "@typescript-eslint/types": "8.58.2", "@typescript-eslint/visitor-keys": "8.58.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw=="],
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.59.0", "https://registry.npmmirror.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.0.tgz", { "dependencies": { "@typescript-eslint/project-service": "8.59.0", "@typescript-eslint/tsconfig-utils": "8.59.0", "@typescript-eslint/types": "8.59.0", "@typescript-eslint/visitor-keys": "8.59.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-O9Re9P1BmBLFJyikRbQpLku/QA3/AueZNO9WePLBwQrvkixTmDe8u76B6CYUAITRl/rHawggEqUGn5QIkVRLMw=="],
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.58.2", "https://registry.npmmirror.com/@typescript-eslint/utils/-/utils-8.58.2.tgz", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.58.2", "@typescript-eslint/types": "8.58.2", "@typescript-eslint/typescript-estree": "8.58.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA=="],
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.58.2", "https://registry.npmmirror.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.2.tgz", { "dependencies": { "@typescript-eslint/types": "8.58.2", "eslint-visitor-keys": "^5.0.0" } }, "sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA=="],
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.0", "https://registry.npmmirror.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.0.tgz", { "dependencies": { "@typescript-eslint/types": "8.59.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q=="],
"@vercel/blob": ["@vercel/blob@2.3.3", "https://registry.npmmirror.com/@vercel/blob/-/blob-2.3.3.tgz", { "dependencies": { "async-retry": "^1.3.3", "is-buffer": "^2.0.5", "is-node-process": "^1.2.0", "throttleit": "^2.1.0", "undici": "^6.23.0" } }, "sha512-MtD7VLo6hU07eHR7bmk5SIMD290q574UaNYTe46qeyRT+hWrCy26CoAqfd7PnIefVXvRehRZBzukxuTO9iGTVg=="],
@@ -685,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=="],
@@ -1077,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=="],
@@ -1153,7 +1162,7 @@
"scheduler": ["scheduler@0.27.0", "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"semver": ["semver@6.3.1", "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"semver": ["semver@7.7.4", "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"set-cookie-parser": ["set-cookie-parser@2.7.2", "https://registry.npmmirror.com/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
@@ -1379,6 +1388,10 @@
"@babel/core/json5": ["json5@2.2.3", "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"@babel/core/semver": ["semver@6.3.1", "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@eslint/eslintrc/globals": ["globals@14.0.0", "https://registry.npmmirror.com/globals/-/globals-14.0.0.tgz", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
@@ -1397,11 +1410,29 @@
"@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "https://registry.npmmirror.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
"@typescript-eslint/eslint-plugin/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.58.2", "https://registry.npmmirror.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.2.tgz", { "dependencies": { "@typescript-eslint/types": "8.58.2", "eslint-visitor-keys": "^5.0.0" } }, "sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA=="],
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "https://registry.npmmirror.com/ignore/-/ignore-7.0.5.tgz", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
"@typescript-eslint/parser/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.0", "https://registry.npmmirror.com/@typescript-eslint/scope-manager/-/scope-manager-8.59.0.tgz", { "dependencies": { "@typescript-eslint/types": "8.59.0", "@typescript-eslint/visitor-keys": "8.59.0" } }, "sha512-UzR16Ut8IpA3Mc4DbgAShlPPkVm8xXMWafXxB0BocaVRHs8ZGakAxGRskF7FId3sdk9lgGD73GSFaWmWFDE4dg=="],
"@typescript-eslint/parser/@typescript-eslint/types": ["@typescript-eslint/types@8.59.0", "https://registry.npmmirror.com/@typescript-eslint/types/-/types-8.59.0.tgz", {}, "sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A=="],
"@typescript-eslint/project-service/@typescript-eslint/types": ["@typescript-eslint/types@8.59.0", "https://registry.npmmirror.com/@typescript-eslint/types/-/types-8.59.0.tgz", {}, "sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A=="],
"@typescript-eslint/rule-tester/@typescript-eslint/utils": ["@typescript-eslint/utils@8.59.0", "https://registry.npmmirror.com/@typescript-eslint/utils/-/utils-8.59.0.tgz", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.59.0", "@typescript-eslint/types": "8.59.0", "@typescript-eslint/typescript-estree": "8.59.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-I1R/K7V07XsMJ12Oaxg/O9GfrysGTmCRhvZJBv0RE0NcULMzjqVpR5kRRQjHsz3J/bElU7HwCO7zkqL+MSUz+g=="],
"@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.58.2", "https://registry.npmmirror.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.2.tgz", { "dependencies": { "@typescript-eslint/types": "8.58.2", "eslint-visitor-keys": "^5.0.0" } }, "sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA=="],
"@typescript-eslint/type-utils/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.58.2", "https://registry.npmmirror.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.2.tgz", { "dependencies": { "@typescript-eslint/project-service": "8.58.2", "@typescript-eslint/tsconfig-utils": "8.58.2", "@typescript-eslint/types": "8.58.2", "@typescript-eslint/visitor-keys": "8.58.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw=="],
"@typescript-eslint/typescript-estree/@typescript-eslint/types": ["@typescript-eslint/types@8.59.0", "https://registry.npmmirror.com/@typescript-eslint/types/-/types-8.59.0.tgz", {}, "sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A=="],
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.5.tgz", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
"@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"@typescript-eslint/utils/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.58.2", "https://registry.npmmirror.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.2.tgz", { "dependencies": { "@typescript-eslint/project-service": "8.58.2", "@typescript-eslint/tsconfig-utils": "8.58.2", "@typescript-eslint/types": "8.58.2", "@typescript-eslint/visitor-keys": "8.58.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw=="],
"@typescript-eslint/visitor-keys/@typescript-eslint/types": ["@typescript-eslint/types@8.59.0", "https://registry.npmmirror.com/@typescript-eslint/types/-/types-8.59.0.tgz", {}, "sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A=="],
"@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
@@ -1415,8 +1446,6 @@
"conf/env-paths": ["env-paths@3.0.0", "https://registry.npmmirror.com/env-paths/-/env-paths-3.0.0.tgz", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
"conf/semver": ["semver@7.7.4", "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"data-urls/whatwg-mimetype": ["whatwg-mimetype@4.0.0", "https://registry.npmmirror.com/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
"eslint-import-resolver-node/debug": ["debug@3.2.7", "https://registry.npmmirror.com/debug/-/debug-3.2.7.tgz", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
@@ -1425,6 +1454,8 @@
"eslint-plugin-import/debug": ["debug@3.2.7", "https://registry.npmmirror.com/debug/-/debug-3.2.7.tgz", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
"eslint-plugin-import/semver": ["semver@6.3.1", "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"espree/acorn": ["acorn@8.16.0", "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"glob/minimatch": ["minimatch@9.0.9", "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
@@ -1435,12 +1466,12 @@
"loose-envify/js-tokens": ["js-tokens@4.0.0", "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"make-dir/semver": ["semver@7.7.4", "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"md5/is-buffer": ["is-buffer@1.1.6", "https://registry.npmmirror.com/is-buffer/-/is-buffer-1.1.6.tgz", {}, "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w=="],
"msw/tough-cookie": ["tough-cookie@6.0.1", "https://registry.npmmirror.com/tough-cookie/-/tough-cookie-6.0.1.tgz", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw=="],
"node-exports-info/semver": ["semver@6.3.1", "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"parse5/entities": ["entities@6.0.1", "https://registry.npmmirror.com/entities/-/entities-6.0.1.tgz", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
"path-scurry/lru-cache": ["lru-cache@10.4.3", "https://registry.npmmirror.com/lru-cache/-/lru-cache-10.4.3.tgz", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
@@ -1463,6 +1494,10 @@
"test-exclude/minimatch": ["minimatch@10.2.5", "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.5.tgz", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
"typescript-eslint/@typescript-eslint/parser": ["@typescript-eslint/parser@8.58.2", "https://registry.npmmirror.com/@typescript-eslint/parser/-/parser-8.58.2.tgz", { "dependencies": { "@typescript-eslint/scope-manager": "8.58.2", "@typescript-eslint/types": "8.58.2", "@typescript-eslint/typescript-estree": "8.58.2", "@typescript-eslint/visitor-keys": "8.58.2", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg=="],
"typescript-eslint/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.58.2", "https://registry.npmmirror.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.2.tgz", { "dependencies": { "@typescript-eslint/project-service": "8.58.2", "@typescript-eslint/tsconfig-utils": "8.58.2", "@typescript-eslint/types": "8.58.2", "@typescript-eslint/visitor-keys": "8.58.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw=="],
"vite-node/vite": ["vite@7.3.2", "https://registry.npmmirror.com/vite/-/vite-7.3.2.tgz", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg=="],
"vite-plugin-javascript-obfuscator/javascript-obfuscator": ["javascript-obfuscator@4.2.2", "https://registry.npmmirror.com/javascript-obfuscator/-/javascript-obfuscator-4.2.2.tgz", { "dependencies": { "@javascript-obfuscator/escodegen": "2.3.1", "@javascript-obfuscator/estraverse": "5.4.0", "acorn": "8.15.0", "assert": "2.1.0", "chalk": "4.1.2", "chance": "1.1.13", "class-validator": "0.14.3", "commander": "12.1.0", "conf": "15.0.2", "eslint-scope": "8.4.0", "eslint-visitor-keys": "4.2.1", "fast-deep-equal": "3.1.3", "inversify": "6.1.4", "js-string-escape": "1.0.1", "md5": "2.3.0", "mkdirp": "3.0.1", "multimatch": "5.0.0", "process": "0.11.10", "reflect-metadata": "0.2.2", "source-map-support": "0.5.21", "string-template": "1.0.0", "stringz": "2.1.0", "tslib": "2.8.1" }, "bin": { "javascript-obfuscator": "bin/javascript-obfuscator" } }, "sha512-+7oXAUnFCA6vS0omIGHcWpSr67dUBIF7FKGYSXyzxShSLqM6LBgdugWKFl0XrYtGWyJMGfQR5F4LL85iCefkRA=="],
@@ -1481,8 +1516,32 @@
"@javascript-obfuscator/escodegen/optionator/type-check": ["type-check@0.3.2", "https://registry.npmmirror.com/type-check/-/type-check-0.3.2.tgz", { "dependencies": { "prelude-ls": "~1.1.2" } }, "sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg=="],
"@typescript-eslint/eslint-plugin/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
"@typescript-eslint/rule-tester/@typescript-eslint/utils/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.0", "https://registry.npmmirror.com/@typescript-eslint/scope-manager/-/scope-manager-8.59.0.tgz", { "dependencies": { "@typescript-eslint/types": "8.59.0", "@typescript-eslint/visitor-keys": "8.59.0" } }, "sha512-UzR16Ut8IpA3Mc4DbgAShlPPkVm8xXMWafXxB0BocaVRHs8ZGakAxGRskF7FId3sdk9lgGD73GSFaWmWFDE4dg=="],
"@typescript-eslint/rule-tester/@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.59.0", "https://registry.npmmirror.com/@typescript-eslint/types/-/types-8.59.0.tgz", {}, "sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A=="],
"@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
"@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.58.2", "https://registry.npmmirror.com/@typescript-eslint/project-service/-/project-service-8.58.2.tgz", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.58.2", "@typescript-eslint/types": "^8.58.2", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg=="],
"@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.58.2", "https://registry.npmmirror.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.2.tgz", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A=="],
"@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.58.2", "https://registry.npmmirror.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.2.tgz", { "dependencies": { "@typescript-eslint/types": "8.58.2", "eslint-visitor-keys": "^5.0.0" } }, "sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA=="],
"@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.5.tgz", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.5", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.5.tgz", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="],
"@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.58.2", "https://registry.npmmirror.com/@typescript-eslint/project-service/-/project-service-8.58.2.tgz", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.58.2", "@typescript-eslint/types": "^8.58.2", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg=="],
"@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.58.2", "https://registry.npmmirror.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.2.tgz", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A=="],
"@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.58.2", "https://registry.npmmirror.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.2.tgz", { "dependencies": { "@typescript-eslint/types": "8.58.2", "eslint-visitor-keys": "^5.0.0" } }, "sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA=="],
"@typescript-eslint/utils/@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.5.tgz", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
"ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"conf/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
@@ -1493,16 +1552,46 @@
"test-exclude/minimatch/brace-expansion": ["brace-expansion@5.0.5", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.5.tgz", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="],
"typescript-eslint/@typescript-eslint/parser/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.58.2", "https://registry.npmmirror.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.2.tgz", { "dependencies": { "@typescript-eslint/types": "8.58.2", "eslint-visitor-keys": "^5.0.0" } }, "sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA=="],
"typescript-eslint/@typescript-eslint/typescript-estree/@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.58.2", "https://registry.npmmirror.com/@typescript-eslint/project-service/-/project-service-8.58.2.tgz", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.58.2", "@typescript-eslint/types": "^8.58.2", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg=="],
"typescript-eslint/@typescript-eslint/typescript-estree/@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.58.2", "https://registry.npmmirror.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.2.tgz", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A=="],
"typescript-eslint/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.58.2", "https://registry.npmmirror.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.2.tgz", { "dependencies": { "@typescript-eslint/types": "8.58.2", "eslint-visitor-keys": "^5.0.0" } }, "sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA=="],
"typescript-eslint/@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.5.tgz", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
"vite-plugin-javascript-obfuscator/javascript-obfuscator/@javascript-obfuscator/escodegen": ["@javascript-obfuscator/escodegen@2.3.1", "https://registry.npmmirror.com/@javascript-obfuscator/escodegen/-/escodegen-2.3.1.tgz", { "dependencies": { "@javascript-obfuscator/estraverse": "^5.3.0", "esprima": "^4.0.1", "esutils": "^2.0.2", "optionator": "^0.8.1" }, "optionalDependencies": { "source-map": "~0.6.1" } }, "sha512-Z0HEAVwwafOume+6LFXirAVZeuEMKWuPzpFbQhCEU9++BMz0IwEa9bmedJ+rMn/IlXRBID9j3gQ0XYAa6jM10g=="],
"@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
"@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.5", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.5.tgz", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
"@typescript-eslint/utils/@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.5", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.5.tgz", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="],
"msw/tough-cookie/tldts/tldts-core": ["tldts-core@7.0.28", "https://registry.npmmirror.com/tldts-core/-/tldts-core-7.0.28.tgz", {}, "sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ=="],
"test-exclude/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"typescript-eslint/@typescript-eslint/parser/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
"typescript-eslint/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
"typescript-eslint/@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.5", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.5.tgz", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="],
"vite-plugin-javascript-obfuscator/javascript-obfuscator/@javascript-obfuscator/escodegen/optionator": ["optionator@0.8.3", "https://registry.npmmirror.com/optionator/-/optionator-0.8.3.tgz", { "dependencies": { "deep-is": "~0.1.3", "fast-levenshtein": "~2.0.6", "levn": "~0.3.0", "prelude-ls": "~1.1.2", "type-check": "~0.3.2", "word-wrap": "~1.2.3" } }, "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA=="],
"@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"@typescript-eslint/utils/@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"typescript-eslint/@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"vite-plugin-javascript-obfuscator/javascript-obfuscator/@javascript-obfuscator/escodegen/optionator/levn": ["levn@0.3.0", "https://registry.npmmirror.com/levn/-/levn-0.3.0.tgz", { "dependencies": { "prelude-ls": "~1.1.2", "type-check": "~0.3.2" } }, "sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA=="],
"vite-plugin-javascript-obfuscator/javascript-obfuscator/@javascript-obfuscator/escodegen/optionator/prelude-ls": ["prelude-ls@1.1.2", "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.1.2.tgz", {}, "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w=="],

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

@@ -0,0 +1,13 @@
import noHardcodedColorInStyle from './rules/no-hardcoded-color-in-style.js'
const plugin = {
rules: {
'no-hardcoded-color-in-style': noHardcodedColorInStyle,
},
configs: {},
meta: {
name: 'eslint-plugin-local',
},
}
export default plugin

View File

@@ -0,0 +1,107 @@
import { ESLintUtils } from '@typescript-eslint/utils'
const RE_HEX3 = /^#[0-9a-fA-F]{3}$/
const RE_HEX6 = /^#[0-9a-fA-F]{6}$/
const RE_HEX8 = /^#[0-9a-fA-F]{8}$/
const RE_RGB = /^rgb\s*\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*\)$/
const RE_RGBA = /^rgba\s*\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*[\d.]+\s*\)$/
const RE_HSL = /^hsl\s*\(\s*\d+\s*,\s*[\d.]+%?\s*,\s*[\d.]+%?\s*\)$/
const ALLOWED_KEYWORDS = new Set([
'inherit',
'transparent',
'currentColor',
'none',
'unset',
'initial',
'auto',
'contain',
'cover',
])
function isHardcodedColor(value) {
if (typeof value !== 'string') return false
const trimmed = value.trim()
if (ALLOWED_KEYWORDS.has(trimmed.toLowerCase())) return false
if (trimmed.startsWith('var(')) return false
if (/^\d+(\.\d+)?px?$/.test(trimmed)) return false
if (/^\d+(\.\d+)?\%$/.test(trimmed)) return false
return (
RE_HEX3.test(trimmed) ||
RE_HEX6.test(trimmed) ||
RE_HEX8.test(trimmed) ||
RE_RGB.test(trimmed) ||
RE_RGBA.test(trimmed) ||
RE_HSL.test(trimmed)
)
}
function extractStyleProperties(expression) {
const properties = []
if (expression.type === 'ObjectExpression' && expression.properties) {
for (const styleProp of expression.properties) {
if (
styleProp.type === 'Property' &&
styleProp.key?.type === 'Identifier' &&
styleProp.value?.type === 'Literal' &&
typeof styleProp.value.value === 'string'
) {
properties.push({
key: styleProp.key.name,
value: styleProp.value.value,
loc: styleProp.value.loc,
})
}
}
}
return properties
}
export const RULE_NAME = 'no-hardcoded-color-in-style'
export default ESLintUtils.RuleCreator((name) => {
return `https://eslint.dev/rules/#${name}`
})({
name: 'no-hardcoded-color-in-style',
meta: {
type: 'problem',
docs: {
description: 'Disallow hardcoded color values in JSX style properties',
recommended: false,
},
messages: {
hardcodedColor:
'硬编码的颜色值 "{{value}}" 不允许使用。请使用 TDesign CSS Token如 var(--td-text-color-placeholder))代替。',
},
schema: [],
},
create(context) {
return {
JSXAttribute(node) {
if (
node.name?.type === 'JSXIdentifier' &&
node.name.name === 'style' &&
node.value?.type === 'JSXExpressionContainer' &&
node.value.expression
) {
const styleProps = extractStyleProperties(node.value.expression)
for (const prop of styleProps) {
if (isHardcodedColor(prop.value)) {
context.report({
node: context.sourceCode.getLastToken(node),
messageId: 'hardcodedColor',
data: { value: prop.value },
})
}
}
}
},
}
},
})

View File

@@ -5,9 +5,12 @@ import reactRefresh from 'eslint-plugin-react-refresh'
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'] },
...tanstackQuery.configs['flat/recommended'],
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
@@ -20,32 +23,37 @@ export default tseslint.config(
'react-refresh': reactRefresh,
import: importPlugin,
'@tanstack/query': tanstackQuery,
local: localRules,
},
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',
{ prefer: 'type-imports', fixStyle: 'inline-type-imports' },
],
'@typescript-eslint/no-non-null-assertion': 'error',
'local/no-hardcoded-color-in-style': 'warn',
'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' }],
},
],
},
},
{
files: ['src/__tests__/**', 'e2e/**'],
rules: {
'@typescript-eslint/no-non-null-assertion': 'off',
'react-hooks/exhaustive-deps': 'off',
'@typescript-eslint/consistent-type-imports': 'off',
'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,8 +5,13 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && 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",
@@ -34,9 +39,11 @@
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/sql.js": "^1.4.11",
"@typescript-eslint/rule-tester": "^8.59.0",
"@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",
@@ -45,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,8 +1,8 @@
import { defineConfig, devices } from '@playwright/test'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { defineConfig, devices } from '@playwright/test'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)

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 { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest';
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
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 { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest';
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
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 { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest';
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
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, params }) => {
receivedMethod = request.method;
receivedUrl = new URL(request.url).pathname;
receivedBody = (await request.json()) as Record<string, unknown>;
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>
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, params }) => {
receivedMethod = request.method;
receivedUrl = new URL(request.url).pathname;
return new HttpResponse(null, { status: 204 });
}),
);
http.delete('http://localhost:3000/api/providers/:id', ({ request }) => {
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 { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest';
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
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 { describe, it, expect } from 'vitest';
import { BrowserRouter } from 'react-router';
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

@@ -0,0 +1,122 @@
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'
RuleTester.it = it
RuleTester.describe = describe
RuleTester.afterAll = afterAll
const ruleTester = new RuleTester({
languageOptions: {
parserOptions: {
ecmaFeatures: { jsx: true },
ecmaVersion: 2023,
sourceType: 'module',
},
},
})
describe('no-hardcoded-color-in-style (ESLint rule)', () => {
ruleTester.run(RULE_NAME, rule, {
valid: [
{
name: 'CSS var token',
code: `<div style={{ color: 'var(--td-text-color-placeholder)' }} />`,
},
{
name: 'numeric value 0',
code: `<div style={{ opacity: 0 }} />`,
},
{
name: 'numeric value 16',
code: `<div style={{ width: 16 }} />`,
},
{
name: 'inherit keyword',
code: `<div style={{ color: 'inherit' }} />`,
},
{
name: 'transparent keyword',
code: `<div style={{ color: 'transparent' }} />`,
},
{
name: 'currentColor keyword',
code: `<div style={{ color: 'currentColor' }} />`,
},
{
name: 'none keyword',
code: `<div style={{ display: 'none' }} />`,
},
{
name: 'unset keyword',
code: `<div style={{ color: 'unset' }} />`,
},
{
name: 'initial keyword',
code: `<div style={{ color: 'initial' }} />`,
},
{
name: 'pixel string value',
code: `<div style={{ width: '100px' }} />`,
},
{
name: 'percentage string value',
code: `<div style={{ width: '50%' }} />`,
},
{
name: 'plain numeric string value',
code: `<div style={{ zIndex: '10' }} />`,
},
{
name: 'auto keyword',
code: `<div style={{ width: 'auto' }} />`,
},
{
name: 'contain keyword',
code: `<div style={{ backgroundSize: 'contain' }} />`,
},
{
name: 'cover keyword',
code: `<div style={{ backgroundSize: 'cover' }} />`,
},
],
invalid: [
{
name: 'hex3 color #fff',
code: `<div style={{ color: '#fff' }} />`,
errors: [{ messageId: 'hardcodedColor', data: { value: '#fff' } }],
},
{
name: 'hex6 color #ffffff',
code: `<div style={{ color: '#ffffff' }} />`,
errors: [{ messageId: 'hardcodedColor', data: { value: '#ffffff' } }],
},
{
name: 'hex8 color #ffffffff',
code: `<div style={{ color: '#ffffffff' }} />`,
errors: [{ messageId: 'hardcodedColor', data: { value: '#ffffffff' } }],
},
{
name: 'rgb color',
code: `<div style={{ color: 'rgb(255, 255, 255)' }} />`,
errors: [{ messageId: 'hardcodedColor', data: { value: 'rgb(255, 255, 255)' } }],
},
{
name: 'rgba color',
code: `<div style={{ color: 'rgba(255, 255, 255, 0.5)' }} />`,
errors: [{ messageId: 'hardcodedColor', data: { value: 'rgba(255, 255, 255, 0.5)' } }],
},
{
name: 'hsl color',
code: `<div style={{ color: 'hsl(120, 50%, 50%)' }} />`,
errors: [{ messageId: 'hardcodedColor', data: { value: 'hsl(120, 50%, 50%)' } }],
},
{
name: 'multiple style properties with one hardcoded',
code: `<div style={{ width: 100, color: '#999', opacity: 0.5 }} />`,
errors: [{ messageId: 'hardcodedColor', data: { value: '#999' } }],
},
],
})
})

View File

@@ -1,11 +1,11 @@
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import { useModels, useCreateModel, useUpdateModel, useDeleteModel } from '@/hooks/useModels';
import type { Model, CreateModelInput, UpdateModelInput } from '@/types';
import { MessagePlugin } from 'tdesign-react';
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 { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import { useProviders, useCreateProvider, useUpdateProvider, useDeleteProvider } from '@/hooks/useProviders';
import type { Provider, CreateProviderInput, UpdateProviderInput } from '@/types';
import { MessagePlugin } from 'tdesign-react';
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 { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
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 { Layout, Menu, Button } from 'tdesign-react';
import { ServerIcon, ChartLineIcon, SettingIcon, ChevronLeftIcon, ChevronRightIcon } from 'tdesign-icons-react';
import { Outlet, useLocation, useNavigate } from 'react-router';
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 type { CreateModelInput, UpdateModelInput, ApiError } from '@/types';
import * as api from '@/api/models';
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 type { CreateProviderInput, UpdateProviderInput, ApiError } from '@/types';
import * as api from '@/api/providers';
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 type { StatsQueryParams } from '@/types';
import * as api from '@/api/stats';
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

@@ -5,8 +5,12 @@ import 'tdesign-react/es/_util/react-19-adapter'
import './index.scss'
import App from './App'
createRoot(document.getElementById('root')!).render(
const root = document.getElementById('root')
if (!root) {
throw new Error('Root element not found')
}
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
</StrictMode>
)

View File

@@ -1,25 +1,27 @@
import { Button } from 'tdesign-react';
import { useNavigate } from 'react-router';
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 type { PrimaryTableCol } from 'tdesign-react/es/table/type';
import type { Model } from '@/types';
import { useModels, useDeleteModel } from '@/hooks/useModels';
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 { PrimaryTableCol } from 'tdesign-react/es/table/type';
import type { Provider, Model } from '@/types';
import { ModelTable } from './ModelTable';
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 type { Provider, Model, UpdateProviderInput, UpdateModelInput } from '@/types';
import { useProviders, useCreateProvider, useUpdateProvider, useDeleteProvider } from '@/hooks/useProviders';
import { useCreateModel, useUpdateModel } from '@/hooks/useModels';
import { ProviderTable } from './ProviderTable';
import { ProviderForm } from './ProviderForm';
import { ModelForm } from './ModelForm';
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 { Row, Col, Card, Statistic } from 'tdesign-react';
import { ChartBarIcon, ChartLineIcon, ServerIcon, Calendar1Icon } from 'tdesign-icons-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 { PrimaryTableCol } from 'tdesign-react/es/table/type';
import type { UsageStats, Provider } from '@/types';
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 { Card } from 'tdesign-react';
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, ResponsiveContainer, Tooltip } from 'recharts';
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 { UsageChart } from './UsageChart';
import { StatsTable } from './StatsTable';
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

@@ -1,7 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import obfuscatorPlugin from 'vite-plugin-javascript-obfuscator'
import path from 'node:path'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
import obfuscatorPlugin from 'vite-plugin-javascript-obfuscator'
const vendorChunks: Record<string, string[]> = {
'vendor-react': ['react', 'react-dom', 'react-router'],

View File

@@ -1,6 +1,6 @@
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import path from 'node:path'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vitest/config'
export default defineConfig({
plugins: [react()],
@@ -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

@@ -0,0 +1,140 @@
# 前端 Lint 规则
## Purpose
TBD - 定义前端 ESLint 规则配置、构建集成 lint 检查、以及自定义规则
## Requirements
### Requirement: ESLint 规则配置
前端 SHALL 在 `eslint.config.js` 中配置以下规则:
- `@tanstack/query/exhaustive-deps`: `error` — queryFn 中使用的变量 SHALL 出现在 queryKey 中
- `@tanstack/query/no-void-query-fn`: `error` — queryFn SHALL 有返回值
- `@tanstack/query/stable-query-client`: `error` — QueryClient SHALL NOT 在组件渲染中创建
- `@tanstack/query/no-unstable-deps`: `error` — query 选项中 SHALL NOT 有不稳定引用
- `@tanstack/query/infinite-query-property-order`: `error` — infinite query 属性顺序 SHALL 规范
- `@tanstack/query/mutation-property-order`: `error` — mutation 回调顺序 SHALL 规范
- `@tanstack/query/no-rest-destructuring`: `warn` — query 结果 SHALL NOT 使用 rest 解构
- `no-console`: `['error', { allow: ['warn', 'error'] }]` — 代码中 SHALL NOT 使用 `console.log``console.info``console.debug` 等,仅允许 `console.warn``console.error`
- `@typescript-eslint/consistent-type-imports`: `['error', { prefer: 'type-imports', fixStyle: 'inline-type-imports' }]` — type import SHALL 使用内联风格 `import { type Foo }`
- `@typescript-eslint/no-non-null-assertion`: `error` — 代码中 SHALL NOT 使用 `foo!` 非空断言
#### Scenario: TanStack Query 规则未启用时构建失败
- **WHEN** `eslint.config.js` 中未配置 TanStack Query 的 `flat/recommended` 规则
- **THEN** 前端构建 SHALL 失败
#### Scenario: 使用 console.log 时构建失败
- **WHEN** 源代码中出现 `console.log(...)` 调用
- **THEN** ESLint SHALL 报告错误
- **THEN** 前端构建 SHALL 失败
#### Scenario: 使用 console.warn 时不报错
- **WHEN** 源代码中出现 `console.warn(...)` 调用
- **THEN** ESLint SHALL NOT 报告错误
#### Scenario: 使用独立 type import 时自动修复
- **WHEN** 源代码中出现 `import type { Foo } from 'module'`
- **THEN** `eslint --fix` SHALL 自动修复为 `import { type Foo } from 'module'`
#### Scenario: 使用非空断言时构建失败
- **WHEN** 源代码中出现 `foo!.bar` 非空断言
- **THEN** ESLint SHALL 报告错误
### Requirement: 构建集成 lint 检查
前端 SHALL 在 `build` 命令中集成 ESLint 检查和 Prettier 格式检查。
#### Scenario: 构建时执行 lint 和格式检查
- **WHEN** 执行 `bun run 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`
#### Scenario: 单独执行 lint
- **WHEN** 执行 `bun run lint`
- **THEN** SHALL 运行 `eslint .`
#### Scenario: 自动修复 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 属性中的硬编码颜色值。
#### Scenario: 检测十六进制颜色
- **WHEN** JSX style 属性值匹配 `#xxx``#xxxxxx` 格式
- **THEN** 规则 SHALL 报告警告
- **THEN** 警告消息 SHALL 提示使用 `var(--td-*)` CSS Token
#### Scenario: 检测 rgb/rgba/hsl 颜色函数
- **WHEN** JSX style 属性值匹配 `rgb()``rgba()``hsl()` 格式
- **THEN** 规则 SHALL 报告警告
#### Scenario: 允许 CSS Token 引用
- **WHEN** JSX style 属性值为 `var(--td-*)` 格式
- **THEN** 规则 SHALL NOT 报告
#### Scenario: 允许特殊颜色关键字
- **WHEN** JSX style 属性值为 `inherit``transparent``currentColor``none``unset``initial`
- **THEN** 规则 SHALL NOT 报告
#### Scenario: 允许数字值
- **WHEN** JSX style 属性值为数字(如 `0``16`
- **THEN** 规则 SHALL NOT 报告
### Requirement: 自定义规则存放位置
自定义 ESLint 规则 SHALL 存放在 `frontend/eslint-rules/` 目录中。
#### Scenario: 自定义规则目录结构
- **WHEN** 添加自定义 ESLint 规则
- **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

@@ -482,6 +482,9 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
- **THEN** TypeScript 配置 SHALL 开启 noUncheckedIndexedAccess
- **THEN** 所有代码 SHALL NOT 使用 any 类型
- **THEN** tsconfig SHALL 合并为单文件(不使用 project references
- **THEN** type import SHALL 使用内联风格 `import { type Foo }`
- **THEN** 代码 SHALL NOT 使用非空断言 `foo!`
- **THEN** 代码 SHALL NOT 使用 `console.log``console.info``console.debug`(仅允许 `console.warn``console.error`
#### Scenario: React 函数组件
@@ -505,6 +508,30 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
- **THEN** Vite SHALL 对业务代码执行混淆处理
- **THEN** 混淆 SHALL 仅应用于 src 目录下的业务代码
- **THEN** 混淆 SHALL NOT 应用于 node_modules 中的第三方库
- **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` 中声明