chore: 合并 dev-code-format-frontend 到 master
This commit is contained in:
19
Makefile
19
Makefile
@@ -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
12
frontend/.editorconfig
Normal 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
1
frontend/.gitignore
vendored
@@ -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
16
frontend/.prettierignore
Normal 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
16
frontend/.prettierrc
Normal 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
3
frontend/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["esbenp.prettier-vscode", "dbaeumer.vscode-eslint"]
|
||||
}
|
||||
7
frontend/.vscode/settings.json
vendored
Normal file
7
frontend/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
}
|
||||
}
|
||||
@@ -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`)
|
||||
|
||||
### 添加新页面
|
||||
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
|
||||
13
frontend/eslint-rules/index.js
Normal file
13
frontend/eslint-rules/index.js
Normal 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
|
||||
107
frontend/eslint-rules/rules/no-hardcoded-color-in-style.js
Normal file
107
frontend/eslint-rules/rules/no-hardcoded-color-in-style.js
Normal 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 },
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
122
frontend/src/__tests__/eslint-rules/no-hardcoded-color.test.ts
Normal file
122
frontend/src/__tests__/eslint-rules/no-hardcoded-color.test.ts
Normal 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' } }],
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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/**'],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
140
openspec/specs/frontend-lint-rules/spec.md
Normal file
140
openspec/specs/frontend-lint-rules/spec.md
Normal 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 重复检查同一规则
|
||||
@@ -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 通信
|
||||
|
||||
|
||||
232
openspec/specs/prettier-formatting/spec.md
Normal file
232
openspec/specs/prettier-formatting/spec.md
Normal 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` 中声明
|
||||
Reference in New Issue
Block a user