feat: 前端集成 Prettier 代码格式化
This commit is contained in:
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
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
|
!.vscode/settings.json
|
||||||
.idea
|
.idea
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.suo
|
*.suo
|
||||||
|
|||||||
16
frontend/.prettierignore
Normal file
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
|
- **数据获取**: TanStack Query v5
|
||||||
- **样式**: SCSS Modules(禁止使用纯 CSS)
|
- **样式**: SCSS Modules(禁止使用纯 CSS)
|
||||||
- **测试**: Vitest + React Testing Library + Playwright
|
- **测试**: Vitest + React Testing Library + Playwright
|
||||||
|
- **代码格式化**: Prettier
|
||||||
|
|
||||||
## API 层
|
## API 层
|
||||||
|
|
||||||
@@ -22,10 +23,10 @@ AI 网关管理前端,提供供应商配置和用量统计界面。
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// 发送请求时:camelCase → snake_case
|
// 发送请求时:camelCase → snake_case
|
||||||
toApi({ providerId: "openai" }) // → { provider_id: "openai" }
|
toApi({ providerId: 'openai' }) // → { provider_id: "openai" }
|
||||||
|
|
||||||
// 接收响应时:snake_case → camelCase
|
// 接收响应时:snake_case → camelCase
|
||||||
fromApi({ provider_id: "openai" }) // → { providerId: "openai" }
|
fromApi({ provider_id: 'openai' }) // → { providerId: "openai" }
|
||||||
```
|
```
|
||||||
|
|
||||||
### 统一请求函数
|
### 统一请求函数
|
||||||
@@ -42,9 +43,9 @@ export async function request<T>(method: string, path: string, body?: unknown):
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
class ApiError extends Error {
|
class ApiError extends Error {
|
||||||
status: number; // HTTP 状态码
|
status: number // HTTP 状态码
|
||||||
code?: string; // 业务错误码
|
code?: string // 业务错误码
|
||||||
message: string; // 错误消息
|
message: string // 错误消息
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -56,13 +57,13 @@ class ApiError extends Error {
|
|||||||
// src/hooks/useProviders.ts
|
// src/hooks/useProviders.ts
|
||||||
export const providerKeys = {
|
export const providerKeys = {
|
||||||
all: ['providers'] as const,
|
all: ['providers'] as const,
|
||||||
};
|
}
|
||||||
|
|
||||||
// src/hooks/useModels.ts
|
// src/hooks/useModels.ts
|
||||||
export const modelKeys = {
|
export const modelKeys = {
|
||||||
all: ['models'] as const,
|
all: ['models'] as const,
|
||||||
byProvider: (providerId: string) => [...modelKeys.all, { providerId }] as const,
|
byProvider: (providerId: string) => [...modelKeys.all, { providerId }] as const,
|
||||||
};
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Mutation 使用
|
### Mutation 使用
|
||||||
@@ -71,9 +72,9 @@ export const modelKeys = {
|
|||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: createProvider,
|
mutationFn: createProvider,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: providerKeys.all });
|
queryClient.invalidateQueries({ queryKey: providerKeys.all })
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
@@ -142,9 +143,20 @@ bun run build
|
|||||||
### 代码检查
|
### 代码检查
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run lint
|
bun run lint # ESLint 检查
|
||||||
|
bun run format:check # Prettier 格式检查
|
||||||
|
bun run check # 同时检查 lint 和格式
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 代码格式化
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run format # 格式化所有文件
|
||||||
|
bun run fix # 修复 lint 问题并格式化
|
||||||
|
```
|
||||||
|
|
||||||
|
VS Code 保存时自动格式化(需安装 Prettier 扩展)。
|
||||||
|
|
||||||
## 测试
|
## 测试
|
||||||
|
|
||||||
### 单元测试 + 组件测试
|
### 单元测试 + 组件测试
|
||||||
@@ -220,25 +232,29 @@ __tests__/
|
|||||||
## 环境变量
|
## 环境变量
|
||||||
|
|
||||||
| 变量 | 开发环境 | 生产环境 | 说明 |
|
| 变量 | 开发环境 | 生产环境 | 说明 |
|
||||||
|------|----------|----------|------|
|
| --------------- | -------- | -------- | ------------------------------- |
|
||||||
| `VITE_API_BASE` | (空) | `/api` | API 基础路径,空则走 Vite proxy |
|
| `VITE_API_BASE` | (空) | `/api` | API 基础路径,空则走 Vite proxy |
|
||||||
|
|
||||||
**E2E 测试特有**:
|
**E2E 测试特有**:
|
||||||
|
|
||||||
- `NEX_BACKEND_PORT` - E2E 后端端口(默认 19026)
|
- `NEX_BACKEND_PORT` - E2E 后端端口(默认 19026)
|
||||||
- `NEX_E2E_TEMP_DIR` - E2E 临时目录
|
- `NEX_E2E_TEMP_DIR` - E2E 临时目录
|
||||||
|
|
||||||
## 开发规范
|
## 开发规范
|
||||||
|
|
||||||
- 所有样式使用 SCSS,禁止使用纯 CSS 文件
|
- 所有样式使用 SCSS,禁止使用纯 CSS 文件
|
||||||
- 组件级样式使用 SCSS Modules(*.module.scss)
|
- 组件级样式使用 SCSS Modules(\*.module.scss)
|
||||||
- 图标优先使用 TDesign 图标(tdesign-icons-react)
|
- 图标优先使用 TDesign 图标(tdesign-icons-react)
|
||||||
- TypeScript strict 模式,禁止 any 类型
|
- TypeScript strict 模式,禁止 any 类型
|
||||||
- API 层自动处理 snake_case ↔ camelCase 字段转换
|
- API 层自动处理 snake_case ↔ camelCase 字段转换
|
||||||
- 使用路径别名 `@/` 引用 src 目录
|
- 使用路径别名 `@/` 引用 src 目录
|
||||||
|
- 代码格式化使用 Prettier,配置见 `.prettierrc`
|
||||||
|
- 编辑器配置见 `.editorconfig`(统一缩进、换行符、编码)
|
||||||
|
|
||||||
### 环境要求
|
### 环境要求
|
||||||
|
|
||||||
- Bun 1.0 或更高版本
|
- Bun 1.0 或更高版本
|
||||||
|
- VS Code 推荐安装 Prettier 和 ESLint 扩展(见 `.vscode/extensions.json`)
|
||||||
|
|
||||||
### 添加新页面
|
### 添加新页面
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"@vitest/coverage-v8": "^3.2.1",
|
"@vitest/coverage-v8": "^3.2.1",
|
||||||
"eslint": "^9.39.4",
|
"eslint": "^9.39.4",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-import": "^2.31.0",
|
"eslint-plugin-import": "^2.31.0",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
@@ -37,6 +38,7 @@
|
|||||||
"javascript-obfuscator": "^5.4.1",
|
"javascript-obfuscator": "^5.4.1",
|
||||||
"jsdom": "^26.1.0",
|
"jsdom": "^26.1.0",
|
||||||
"msw": "^2.8.2",
|
"msw": "^2.8.2",
|
||||||
|
"prettier": "^3.8.3",
|
||||||
"sass": "^1.99.0",
|
"sass": "^1.99.0",
|
||||||
"sql.js": "^1.14.1",
|
"sql.js": "^1.14.1",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
@@ -688,6 +690,8 @@
|
|||||||
|
|
||||||
"eslint": ["eslint@9.39.4", "https://registry.npmmirror.com/eslint/-/eslint-9.39.4.tgz", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.5", "@eslint/js": "9.39.4", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ=="],
|
"eslint": ["eslint@9.39.4", "https://registry.npmmirror.com/eslint/-/eslint-9.39.4.tgz", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.5", "@eslint/js": "9.39.4", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ=="],
|
||||||
|
|
||||||
|
"eslint-config-prettier": ["eslint-config-prettier@10.1.8", "https://registry.npmmirror.com/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", { "peerDependencies": { "eslint": ">=7.0.0" }, "bin": { "eslint-config-prettier": "bin/cli.js" } }, "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w=="],
|
||||||
|
|
||||||
"eslint-import-resolver-node": ["eslint-import-resolver-node@0.3.10", "https://registry.npmmirror.com/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz", { "dependencies": { "debug": "^3.2.7", "is-core-module": "^2.16.1", "resolve": "^2.0.0-next.6" } }, "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ=="],
|
"eslint-import-resolver-node": ["eslint-import-resolver-node@0.3.10", "https://registry.npmmirror.com/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz", { "dependencies": { "debug": "^3.2.7", "is-core-module": "^2.16.1", "resolve": "^2.0.0-next.6" } }, "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ=="],
|
||||||
|
|
||||||
"eslint-module-utils": ["eslint-module-utils@2.12.1", "https://registry.npmmirror.com/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", { "dependencies": { "debug": "^3.2.7" } }, "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw=="],
|
"eslint-module-utils": ["eslint-module-utils@2.12.1", "https://registry.npmmirror.com/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", { "dependencies": { "debug": "^3.2.7" } }, "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw=="],
|
||||||
@@ -1080,6 +1084,8 @@
|
|||||||
|
|
||||||
"prelude-ls": ["prelude-ls@1.2.1", "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.2.1.tgz", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
|
"prelude-ls": ["prelude-ls@1.2.1", "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.2.1.tgz", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
|
||||||
|
|
||||||
|
"prettier": ["prettier@3.8.3", "https://registry.npmmirror.com/prettier/-/prettier-3.8.3.tgz", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="],
|
||||||
|
|
||||||
"pretty-format": ["pretty-format@27.5.1", "https://registry.npmmirror.com/pretty-format/-/pretty-format-27.5.1.tgz", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="],
|
"pretty-format": ["pretty-format@27.5.1", "https://registry.npmmirror.com/pretty-format/-/pretty-format-27.5.1.tgz", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="],
|
||||||
|
|
||||||
"process": ["process@0.11.10", "https://registry.npmmirror.com/process/-/process-0.11.10.tgz", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="],
|
"process": ["process@0.11.10", "https://registry.npmmirror.com/process/-/process-0.11.10.tgz", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="],
|
||||||
|
|||||||
@@ -27,9 +27,7 @@ export interface SeedStatsInput {
|
|||||||
date: string
|
date: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clearDatabase(
|
export async function clearDatabase(request: import('@playwright/test').APIRequestContext) {
|
||||||
request: import('@playwright/test').APIRequestContext,
|
|
||||||
) {
|
|
||||||
const providers = await request.get(`${API_BASE}/api/providers`)
|
const providers = await request.get(`${API_BASE}/api/providers`)
|
||||||
if (providers.ok()) {
|
if (providers.ok()) {
|
||||||
const data = await providers.json()
|
const data = await providers.json()
|
||||||
@@ -39,10 +37,7 @@ export async function clearDatabase(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function seedProvider(
|
export async function seedProvider(request: import('@playwright/test').APIRequestContext, data: SeedProviderInput) {
|
||||||
request: import('@playwright/test').APIRequestContext,
|
|
||||||
data: SeedProviderInput,
|
|
||||||
) {
|
|
||||||
const resp = await request.post(`${API_BASE}/api/providers`, {
|
const resp = await request.post(`${API_BASE}/api/providers`, {
|
||||||
data: {
|
data: {
|
||||||
id: data.id,
|
id: data.id,
|
||||||
@@ -59,10 +54,7 @@ export async function seedProvider(
|
|||||||
return resp.json()
|
return resp.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function seedModel(
|
export async function seedModel(request: import('@playwright/test').APIRequestContext, data: SeedModelInput) {
|
||||||
request: import('@playwright/test').APIRequestContext,
|
|
||||||
data: SeedModelInput,
|
|
||||||
) {
|
|
||||||
const resp = await request.post(`${API_BASE}/api/models`, {
|
const resp = await request.post(`${API_BASE}/api/models`, {
|
||||||
data: {
|
data: {
|
||||||
provider_id: data.providerId,
|
provider_id: data.providerId,
|
||||||
@@ -90,10 +82,12 @@ export async function seedUsageStats(statsData: SeedStatsInput[]) {
|
|||||||
const db = new SQL.Database(buf)
|
const db = new SQL.Database(buf)
|
||||||
|
|
||||||
for (const row of statsData) {
|
for (const row of statsData) {
|
||||||
db.run(
|
db.run('INSERT OR REPLACE INTO usage_stats (provider_id, model_name, request_count, date) VALUES (?, ?, ?, ?)', [
|
||||||
'INSERT OR REPLACE INTO usage_stats (provider_id, model_name, request_count, date) VALUES (?, ?, ?, ?)',
|
row.providerId,
|
||||||
[row.providerId, row.modelName, row.requestCount, row.date],
|
row.modelName,
|
||||||
)
|
row.requestCount,
|
||||||
|
row.date,
|
||||||
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = db.export()
|
const data = db.export()
|
||||||
|
|||||||
@@ -47,7 +47,10 @@ test.describe('模型管理', () => {
|
|||||||
await page.locator('.t-table__expand-box').first().click()
|
await page.locator('.t-table__expand-box').first().click()
|
||||||
await expect(page.locator('.t-table__expanded-row').first()).toBeVisible()
|
await expect(page.locator('.t-table__expanded-row').first()).toBeVisible()
|
||||||
|
|
||||||
await page.locator('.t-dialog:visible').waitFor({ state: 'hidden', timeout: 3000 }).catch(() => {})
|
await page
|
||||||
|
.locator('.t-dialog:visible')
|
||||||
|
.waitFor({ state: 'hidden', timeout: 3000 })
|
||||||
|
.catch(() => {})
|
||||||
|
|
||||||
await page.locator('.t-table__expanded-row button:has-text("添加模型")').first().click()
|
await page.locator('.t-table__expanded-row button:has-text("添加模型")').first().click()
|
||||||
await expect(page.locator('.t-dialog:visible')).toBeVisible()
|
await expect(page.locator('.t-dialog:visible')).toBeVisible()
|
||||||
@@ -55,10 +58,14 @@ test.describe('模型管理', () => {
|
|||||||
const inputs = modelFormInputs(page)
|
const inputs = modelFormInputs(page)
|
||||||
await inputs.modelName.fill('gpt_4_turbo')
|
await inputs.modelName.fill('gpt_4_turbo')
|
||||||
|
|
||||||
const responsePromise = page.waitForResponse(resp => resp.url().includes('/api/models') && resp.request().method() === 'POST')
|
const responsePromise = page.waitForResponse(
|
||||||
|
(resp) => resp.url().includes('/api/models') && resp.request().method() === 'POST'
|
||||||
|
)
|
||||||
await inputs.saveBtn.click()
|
await inputs.saveBtn.click()
|
||||||
await responsePromise
|
await responsePromise
|
||||||
await expect(page.locator('.t-table__expanded-row').getByText('gpt_4_turbo', { exact: true })).toBeVisible({ timeout: 5000 })
|
await expect(page.locator('.t-table__expanded-row').getByText('gpt_4_turbo', { exact: true })).toBeVisible({
|
||||||
|
timeout: 5000,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test('应显示统一模型 ID', async ({ page, request }) => {
|
test('应显示统一模型 ID', async ({ page, request }) => {
|
||||||
@@ -101,10 +108,14 @@ test.describe('模型管理', () => {
|
|||||||
await inputs.modelName.clear()
|
await inputs.modelName.clear()
|
||||||
await inputs.modelName.fill('gpt_4o')
|
await inputs.modelName.fill('gpt_4o')
|
||||||
|
|
||||||
const responsePromise = page.waitForResponse(resp => resp.url().includes('/api/models') && resp.request().method() === 'PUT')
|
const responsePromise = page.waitForResponse(
|
||||||
|
(resp) => resp.url().includes('/api/models') && resp.request().method() === 'PUT'
|
||||||
|
)
|
||||||
await inputs.saveBtn.click()
|
await inputs.saveBtn.click()
|
||||||
await responsePromise
|
await responsePromise
|
||||||
await expect(page.locator('.t-table__expanded-row').getByText('gpt_4o', { exact: true })).toBeVisible({ timeout: 5000 })
|
await expect(page.locator('.t-table__expanded-row').getByText('gpt_4o', { exact: true })).toBeVisible({
|
||||||
|
timeout: 5000,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test('应能删除模型', async ({ page, request }) => {
|
test('应能删除模型', async ({ page, request }) => {
|
||||||
@@ -126,6 +137,8 @@ test.describe('模型管理', () => {
|
|||||||
await page.locator('.t-table__expanded-row button:has-text("删除")').first().click()
|
await page.locator('.t-table__expanded-row button:has-text("删除")').first().click()
|
||||||
await expect(page.getByText(/确定要删除/)).toBeVisible()
|
await expect(page.getByText(/确定要删除/)).toBeVisible()
|
||||||
await page.locator('.t-popconfirm').getByRole('button', { name: '确定' }).click()
|
await page.locator('.t-popconfirm').getByRole('button', { name: '确定' }).click()
|
||||||
await expect(page.locator('.t-table__expanded-row').getByText('to_delete_model', { exact: true })).not.toBeVisible({ timeout: 5000 })
|
await expect(page.locator('.t-table__expanded-row').getByText('to_delete_model', { exact: true })).not.toBeVisible({
|
||||||
|
timeout: 5000,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -61,7 +61,9 @@ test.describe('供应商管理', () => {
|
|||||||
await page.locator('.t-select__dropdown .t-select-option').first().click()
|
await page.locator('.t-select__dropdown .t-select-option').first().click()
|
||||||
await page.waitForSelector('.t-select__dropdown', { state: 'hidden', timeout: 3000 })
|
await page.waitForSelector('.t-select__dropdown', { state: 'hidden', timeout: 3000 })
|
||||||
|
|
||||||
const responsePromise = page.waitForResponse(resp => resp.url().includes('/api/providers') && resp.request().method() === 'POST')
|
const responsePromise = page.waitForResponse(
|
||||||
|
(resp) => resp.url().includes('/api/providers') && resp.request().method() === 'POST'
|
||||||
|
)
|
||||||
await inputs.saveBtn.click()
|
await inputs.saveBtn.click()
|
||||||
await responsePromise
|
await responsePromise
|
||||||
await expect(page.locator('.t-table__body').getByText('Before Edit')).toBeVisible({ timeout: 5000 })
|
await expect(page.locator('.t-table__body').getByText('Before Edit')).toBeVisible({ timeout: 5000 })
|
||||||
@@ -73,7 +75,9 @@ test.describe('供应商管理', () => {
|
|||||||
await editInputs.name.clear()
|
await editInputs.name.clear()
|
||||||
await editInputs.name.fill('After Edit')
|
await editInputs.name.fill('After Edit')
|
||||||
|
|
||||||
const updateResponsePromise = page.waitForResponse(resp => resp.url().includes('/api/providers') && resp.request().method() === 'PUT')
|
const updateResponsePromise = page.waitForResponse(
|
||||||
|
(resp) => resp.url().includes('/api/providers') && resp.request().method() === 'PUT'
|
||||||
|
)
|
||||||
await editInputs.saveBtn.click()
|
await editInputs.saveBtn.click()
|
||||||
await updateResponsePromise
|
await updateResponsePromise
|
||||||
await expect(page.locator('.t-table__body').getByText('After Edit')).toBeVisible({ timeout: 5000 })
|
await expect(page.locator('.t-table__body').getByText('After Edit')).toBeVisible({ timeout: 5000 })
|
||||||
|
|||||||
@@ -42,10 +42,7 @@ function isHardcodedColor(value) {
|
|||||||
function extractStyleProperties(expression) {
|
function extractStyleProperties(expression) {
|
||||||
const properties = []
|
const properties = []
|
||||||
|
|
||||||
if (
|
if (expression.type === 'ObjectExpression' && expression.properties) {
|
||||||
expression.type === 'ObjectExpression' &&
|
|
||||||
expression.properties
|
|
||||||
) {
|
|
||||||
for (const styleProp of expression.properties) {
|
for (const styleProp of expression.properties) {
|
||||||
if (
|
if (
|
||||||
styleProp.type === 'Property' &&
|
styleProp.type === 'Property' &&
|
||||||
@@ -92,9 +89,7 @@ export default ESLintUtils.RuleCreator((name) => {
|
|||||||
node.value?.type === 'JSXExpressionContainer' &&
|
node.value?.type === 'JSXExpressionContainer' &&
|
||||||
node.value.expression
|
node.value.expression
|
||||||
) {
|
) {
|
||||||
const styleProps = extractStyleProperties(
|
const styleProps = extractStyleProperties(node.value.expression)
|
||||||
node.value.expression,
|
|
||||||
)
|
|
||||||
|
|
||||||
for (const prop of styleProps) {
|
for (const prop of styleProps) {
|
||||||
if (isHardcodedColor(prop.value)) {
|
if (isHardcodedColor(prop.value)) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import tseslint from 'typescript-eslint'
|
|||||||
import importPlugin from 'eslint-plugin-import'
|
import importPlugin from 'eslint-plugin-import'
|
||||||
import tanstackQuery from '@tanstack/eslint-plugin-query'
|
import tanstackQuery from '@tanstack/eslint-plugin-query'
|
||||||
import localRules from './eslint-rules/index.js'
|
import localRules from './eslint-rules/index.js'
|
||||||
|
import eslintConfigPrettier from 'eslint-config-prettier'
|
||||||
|
|
||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
{ ignores: ['dist'] },
|
{ ignores: ['dist'] },
|
||||||
@@ -26,10 +27,7 @@ export default tseslint.config(
|
|||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
...reactHooks.configs.recommended.rules,
|
...reactHooks.configs.recommended.rules,
|
||||||
'react-refresh/only-export-components': [
|
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
|
||||||
'warn',
|
|
||||||
{ allowConstantExport: true },
|
|
||||||
],
|
|
||||||
'no-console': ['error', { allow: ['warn', 'error'] }],
|
'no-console': ['error', { allow: ['warn', 'error'] }],
|
||||||
'@typescript-eslint/consistent-type-imports': [
|
'@typescript-eslint/consistent-type-imports': [
|
||||||
'error',
|
'error',
|
||||||
@@ -40,20 +38,10 @@ export default tseslint.config(
|
|||||||
'import/order': [
|
'import/order': [
|
||||||
'warn',
|
'warn',
|
||||||
{
|
{
|
||||||
groups: [
|
groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index', 'type'],
|
||||||
'builtin',
|
|
||||||
'external',
|
|
||||||
'internal',
|
|
||||||
'parent',
|
|
||||||
'sibling',
|
|
||||||
'index',
|
|
||||||
'type',
|
|
||||||
],
|
|
||||||
'newlines-between': 'never',
|
'newlines-between': 'never',
|
||||||
alphabetize: { order: 'asc', caseInsensitive: true },
|
alphabetize: { order: 'asc', caseInsensitive: true },
|
||||||
pathGroups: [
|
pathGroups: [{ pattern: '@/**', group: 'internal', position: 'before' }],
|
||||||
{ pattern: '@/**', group: 'internal', position: 'before' },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -67,4 +55,5 @@ export default tseslint.config(
|
|||||||
'no-console': 'off',
|
'no-console': 'off',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
eslintConfigPrettier
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
|
|||||||
@@ -5,9 +5,13 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && eslint . && vite build",
|
"build": "tsc -b && bun run check && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"lint:fix": "eslint . --fix",
|
"lint:fix": "eslint . --fix",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"format:check": "prettier --check .",
|
||||||
|
"check": "bun run lint && bun run format:check",
|
||||||
|
"fix": "bun run lint:fix && bun run format",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
@@ -39,6 +43,7 @@
|
|||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"@vitest/coverage-v8": "^3.2.1",
|
"@vitest/coverage-v8": "^3.2.1",
|
||||||
"eslint": "^9.39.4",
|
"eslint": "^9.39.4",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-import": "^2.31.0",
|
"eslint-plugin-import": "^2.31.0",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
@@ -47,6 +52,7 @@
|
|||||||
"javascript-obfuscator": "^5.4.1",
|
"javascript-obfuscator": "^5.4.1",
|
||||||
"jsdom": "^26.1.0",
|
"jsdom": "^26.1.0",
|
||||||
"msw": "^2.8.2",
|
"msw": "^2.8.2",
|
||||||
|
"prettier": "^3.8.3",
|
||||||
"sass": "^1.99.0",
|
"sass": "^1.99.0",
|
||||||
"sql.js": "^1.14.1",
|
"sql.js": "^1.14.1",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { BrowserRouter } from 'react-router';
|
import { BrowserRouter } from 'react-router'
|
||||||
import { ConfigProvider } from 'tdesign-react';
|
import { ConfigProvider } from 'tdesign-react'
|
||||||
import { AppRoutes } from '@/routes';
|
import { AppRoutes } from '@/routes'
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
@@ -11,21 +11,23 @@ const queryClient = new QueryClient({
|
|||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<ConfigProvider globalConfig={{
|
<ConfigProvider
|
||||||
|
globalConfig={{
|
||||||
animation: { include: ['ripple', 'expand', 'fade'] },
|
animation: { include: ['ripple', 'expand', 'fade'] },
|
||||||
table: { size: 'medium' },
|
table: { size: 'medium' },
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<AppRoutes />
|
<AppRoutes />
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
export default App
|
||||||
|
|||||||
@@ -1,88 +1,85 @@
|
|||||||
import { http, HttpResponse } from 'msw';
|
import { http, HttpResponse } from 'msw'
|
||||||
import { setupServer } from 'msw/node';
|
import { setupServer } from 'msw/node'
|
||||||
import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest';
|
import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest'
|
||||||
import { request, fromApi, toApi } from '@/api/client';
|
import { request, fromApi, toApi } from '@/api/client'
|
||||||
import { ApiError } from '@/types';
|
import { ApiError } from '@/types'
|
||||||
|
|
||||||
describe('fromApi', () => {
|
describe('fromApi', () => {
|
||||||
it('converts snake_case keys to camelCase', () => {
|
it('converts snake_case keys to camelCase', () => {
|
||||||
const input = { first_name: 'John', last_name: 'Doe' };
|
const input = { first_name: 'John', last_name: 'Doe' }
|
||||||
const result = fromApi<{ firstName: string; lastName: string }>(input);
|
const result = fromApi<{ firstName: string; lastName: string }>(input)
|
||||||
expect(result).toEqual({ firstName: 'John', lastName: 'Doe' });
|
expect(result).toEqual({ firstName: 'John', lastName: 'Doe' })
|
||||||
});
|
})
|
||||||
|
|
||||||
it('converts nested objects recursively', () => {
|
it('converts nested objects recursively', () => {
|
||||||
const input = {
|
const input = {
|
||||||
user_name: 'alice',
|
user_name: 'alice',
|
||||||
contact_info: { email_address: 'alice@example.com' },
|
contact_info: { email_address: 'alice@example.com' },
|
||||||
};
|
}
|
||||||
const result = fromApi<{
|
const result = fromApi<{
|
||||||
userName: string;
|
userName: string
|
||||||
contactInfo: { emailAddress: string };
|
contactInfo: { emailAddress: string }
|
||||||
}>(input);
|
}>(input)
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
userName: 'alice',
|
userName: 'alice',
|
||||||
contactInfo: { emailAddress: 'alice@example.com' },
|
contactInfo: { emailAddress: 'alice@example.com' },
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|
||||||
it('converts arrays recursively', () => {
|
it('converts arrays recursively', () => {
|
||||||
const input = [
|
const input = [{ item_name: 'a' }, { item_name: 'b' }]
|
||||||
{ item_name: 'a' },
|
const result = fromApi<Array<{ itemName: string }>>(input)
|
||||||
{ item_name: 'b' },
|
expect(result).toEqual([{ itemName: 'a' }, { itemName: 'b' }])
|
||||||
];
|
})
|
||||||
const result = fromApi<Array<{ itemName: string }>>(input);
|
|
||||||
expect(result).toEqual([{ itemName: 'a' }, { itemName: 'b' }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns primitives unchanged', () => {
|
it('returns primitives unchanged', () => {
|
||||||
expect(fromApi<string>('hello')).toBe('hello');
|
expect(fromApi<string>('hello')).toBe('hello')
|
||||||
expect(fromApi<number>(42)).toBe(42);
|
expect(fromApi<number>(42)).toBe(42)
|
||||||
expect(fromApi<null>(null)).toBeNull();
|
expect(fromApi<null>(null)).toBeNull()
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|
||||||
describe('toApi', () => {
|
describe('toApi', () => {
|
||||||
it('converts camelCase keys to snake_case', () => {
|
it('converts camelCase keys to snake_case', () => {
|
||||||
const input = { firstName: 'John', lastName: 'Doe' };
|
const input = { firstName: 'John', lastName: 'Doe' }
|
||||||
const result = toApi<{ first_name: string; last_name: string }>(input);
|
const result = toApi<{ first_name: string; last_name: string }>(input)
|
||||||
expect(result).toEqual({ first_name: 'John', last_name: 'Doe' });
|
expect(result).toEqual({ first_name: 'John', last_name: 'Doe' })
|
||||||
});
|
})
|
||||||
|
|
||||||
it('converts nested objects recursively', () => {
|
it('converts nested objects recursively', () => {
|
||||||
const input = {
|
const input = {
|
||||||
userName: 'alice',
|
userName: 'alice',
|
||||||
contactInfo: { emailAddress: 'alice@example.com' },
|
contactInfo: { emailAddress: 'alice@example.com' },
|
||||||
};
|
}
|
||||||
const result = toApi<{
|
const result = toApi<{
|
||||||
user_name: string;
|
user_name: string
|
||||||
contact_info: { email_address: string };
|
contact_info: { email_address: string }
|
||||||
}>(input);
|
}>(input)
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
user_name: 'alice',
|
user_name: 'alice',
|
||||||
contact_info: { email_address: 'alice@example.com' },
|
contact_info: { email_address: 'alice@example.com' },
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|
||||||
it('converts arrays recursively', () => {
|
it('converts arrays recursively', () => {
|
||||||
const input = [{ itemName: 'a' }, { itemName: 'b' }];
|
const input = [{ itemName: 'a' }, { itemName: 'b' }]
|
||||||
const result = toApi<Array<{ item_name: string }>>(input);
|
const result = toApi<Array<{ item_name: string }>>(input)
|
||||||
expect(result).toEqual([{ item_name: 'a' }, { item_name: 'b' }]);
|
expect(result).toEqual([{ item_name: 'a' }, { item_name: 'b' }])
|
||||||
});
|
})
|
||||||
|
|
||||||
it('returns primitives unchanged', () => {
|
it('returns primitives unchanged', () => {
|
||||||
expect(toApi<string>('hello')).toBe('hello');
|
expect(toApi<string>('hello')).toBe('hello')
|
||||||
expect(toApi<number>(42)).toBe(42);
|
expect(toApi<number>(42)).toBe(42)
|
||||||
expect(toApi<null>(null)).toBeNull();
|
expect(toApi<null>(null)).toBeNull()
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|
||||||
describe('request', () => {
|
describe('request', () => {
|
||||||
const mswServer = setupServer();
|
const mswServer = setupServer()
|
||||||
|
|
||||||
beforeAll(() => mswServer.listen({ onUnhandledRequest: 'bypass' }));
|
beforeAll(() => mswServer.listen({ onUnhandledRequest: 'bypass' }))
|
||||||
afterEach(() => mswServer.resetHandlers());
|
afterEach(() => mswServer.resetHandlers())
|
||||||
afterAll(() => mswServer.close());
|
afterAll(() => mswServer.close())
|
||||||
|
|
||||||
it('parses JSON and converts snake_case keys to camelCase on success', async () => {
|
it('parses JSON and converts snake_case keys to camelCase on success', async () => {
|
||||||
mswServer.use(
|
mswServer.use(
|
||||||
@@ -91,139 +88,130 @@ describe('request', () => {
|
|||||||
id: '1',
|
id: '1',
|
||||||
created_at: '2025-01-01',
|
created_at: '2025-01-01',
|
||||||
nested_obj: { inner_key: 'value' },
|
nested_obj: { inner_key: 'value' },
|
||||||
});
|
})
|
||||||
}),
|
})
|
||||||
);
|
)
|
||||||
|
|
||||||
const result = await request<{
|
const result = await request<{
|
||||||
id: string;
|
id: string
|
||||||
createdAt: string;
|
createdAt: string
|
||||||
nestedObj: { innerKey: string };
|
nestedObj: { innerKey: string }
|
||||||
}>('GET', '/api/test');
|
}>('GET', '/api/test')
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
id: '1',
|
id: '1',
|
||||||
createdAt: '2025-01-01',
|
createdAt: '2025-01-01',
|
||||||
nestedObj: { innerKey: 'value' },
|
nestedObj: { innerKey: 'value' },
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|
||||||
it('throws ApiError with status and message on HTTP error', async () => {
|
it('throws ApiError with status and message on HTTP error', async () => {
|
||||||
mswServer.use(
|
mswServer.use(
|
||||||
http.get('http://localhost:3000/api/test', () => {
|
http.get('http://localhost:3000/api/test', () => {
|
||||||
return HttpResponse.json(
|
return HttpResponse.json({ message: 'Not found' }, { status: 404 })
|
||||||
{ message: 'Not found' },
|
})
|
||||||
{ status: 404 },
|
)
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(request('GET', '/api/test')).rejects.toThrow(ApiError);
|
await expect(request('GET', '/api/test')).rejects.toThrow(ApiError)
|
||||||
try {
|
try {
|
||||||
await request('GET', '/api/test');
|
await request('GET', '/api/test')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
expect(error).toBeInstanceOf(ApiError);
|
expect(error).toBeInstanceOf(ApiError)
|
||||||
const apiError = error as ApiError;
|
const apiError = error as ApiError
|
||||||
expect(apiError.status).toBe(404);
|
expect(apiError.status).toBe(404)
|
||||||
expect(apiError.message).toBe('Not found');
|
expect(apiError.message).toBe('Not found')
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
|
||||||
it('throws ApiError with default message when error body has no message', async () => {
|
it('throws ApiError with default message when error body has no message', async () => {
|
||||||
mswServer.use(
|
mswServer.use(
|
||||||
http.get('http://localhost:3000/api/test', () => {
|
http.get('http://localhost:3000/api/test', () => {
|
||||||
return HttpResponse.json(
|
return HttpResponse.json({ details: 'something' }, { status: 500 })
|
||||||
{ details: 'something' },
|
})
|
||||||
{ status: 500 },
|
)
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await request('GET', '/api/test');
|
await request('GET', '/api/test')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
expect(error).toBeInstanceOf(ApiError);
|
expect(error).toBeInstanceOf(ApiError)
|
||||||
const apiError = error as ApiError;
|
const apiError = error as ApiError
|
||||||
expect(apiError.status).toBe(500);
|
expect(apiError.status).toBe(500)
|
||||||
expect(apiError.message).toContain('500');
|
expect(apiError.message).toContain('500')
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
|
||||||
it('throws ApiError with code field when error response includes code', async () => {
|
it('throws ApiError with code field when error response includes code', async () => {
|
||||||
mswServer.use(
|
mswServer.use(
|
||||||
http.get('http://localhost:3000/api/test', () => {
|
http.get('http://localhost:3000/api/test', () => {
|
||||||
return HttpResponse.json(
|
return HttpResponse.json({ error: 'Model not found', code: 'MODEL_NOT_FOUND' }, { status: 404 })
|
||||||
{ error: 'Model not found', code: 'MODEL_NOT_FOUND' },
|
})
|
||||||
{ status: 404 },
|
)
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await request('GET', '/api/test');
|
await request('GET', '/api/test')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
expect(error).toBeInstanceOf(ApiError);
|
expect(error).toBeInstanceOf(ApiError)
|
||||||
const apiError = error as ApiError;
|
const apiError = error as ApiError
|
||||||
expect(apiError.status).toBe(404);
|
expect(apiError.status).toBe(404)
|
||||||
expect(apiError.message).toBe('Model not found');
|
expect(apiError.message).toBe('Model not found')
|
||||||
expect(apiError.code).toBe('MODEL_NOT_FOUND');
|
expect(apiError.code).toBe('MODEL_NOT_FOUND')
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
|
||||||
it('throws Error on network failure', async () => {
|
it('throws Error on network failure', async () => {
|
||||||
mswServer.use(
|
mswServer.use(
|
||||||
http.get('http://localhost:3000/api/test', () => {
|
http.get('http://localhost:3000/api/test', () => {
|
||||||
return HttpResponse.error();
|
return HttpResponse.error()
|
||||||
}),
|
})
|
||||||
);
|
)
|
||||||
|
|
||||||
await expect(request('GET', '/api/test')).rejects.toThrow();
|
await expect(request('GET', '/api/test')).rejects.toThrow()
|
||||||
});
|
})
|
||||||
|
|
||||||
it('returns undefined for 204 No Content', async () => {
|
it('returns undefined for 204 No Content', async () => {
|
||||||
mswServer.use(
|
mswServer.use(
|
||||||
http.delete('http://localhost:3000/api/test/1', () => {
|
http.delete('http://localhost:3000/api/test/1', () => {
|
||||||
return new HttpResponse(null, { status: 204 });
|
return new HttpResponse(null, { status: 204 })
|
||||||
}),
|
})
|
||||||
);
|
)
|
||||||
|
|
||||||
const result = await request('DELETE', '/api/test/1');
|
const result = await request('DELETE', '/api/test/1')
|
||||||
expect(result).toBeUndefined();
|
expect(result).toBeUndefined()
|
||||||
});
|
})
|
||||||
|
|
||||||
it('sends body with camelCase keys converted to snake_case', async () => {
|
it('sends body with camelCase keys converted to snake_case', async () => {
|
||||||
let receivedBody: Record<string, unknown> | null = null;
|
let receivedBody: Record<string, unknown> | null = null
|
||||||
|
|
||||||
mswServer.use(
|
mswServer.use(
|
||||||
http.post('http://localhost:3000/api/test', async ({ request }) => {
|
http.post('http://localhost:3000/api/test', async ({ request }) => {
|
||||||
receivedBody = (await request.json()) as Record<string, unknown>;
|
receivedBody = (await request.json()) as Record<string, unknown>
|
||||||
return HttpResponse.json({ id: '1' });
|
return HttpResponse.json({ id: '1' })
|
||||||
}),
|
})
|
||||||
);
|
)
|
||||||
|
|
||||||
await request('POST', '/api/test', {
|
await request('POST', '/api/test', {
|
||||||
providerId: 'prov-1',
|
providerId: 'prov-1',
|
||||||
modelName: 'gpt-4',
|
modelName: 'gpt-4',
|
||||||
});
|
})
|
||||||
|
|
||||||
expect(receivedBody).toEqual({
|
expect(receivedBody).toEqual({
|
||||||
provider_id: 'prov-1',
|
provider_id: 'prov-1',
|
||||||
model_name: 'gpt-4',
|
model_name: 'gpt-4',
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|
||||||
it('sends Content-Type header as application/json', async () => {
|
it('sends Content-Type header as application/json', async () => {
|
||||||
let contentType: string | null = null;
|
let contentType: string | null = null
|
||||||
|
|
||||||
mswServer.use(
|
mswServer.use(
|
||||||
http.post('http://localhost:3000/api/test', async ({ request }) => {
|
http.post('http://localhost:3000/api/test', async ({ request }) => {
|
||||||
contentType = request.headers.get('Content-Type');
|
contentType = request.headers.get('Content-Type')
|
||||||
return HttpResponse.json({ id: '1' });
|
return HttpResponse.json({ id: '1' })
|
||||||
}),
|
})
|
||||||
);
|
)
|
||||||
|
|
||||||
await request('POST', '/api/test', { name: 'test' });
|
await request('POST', '/api/test', { name: 'test' })
|
||||||
|
|
||||||
expect(contentType).toBe('application/json');
|
expect(contentType).toBe('application/json')
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { http, HttpResponse } from 'msw';
|
import { http, HttpResponse } from 'msw'
|
||||||
import { setupServer } from 'msw/node';
|
import { setupServer } from 'msw/node'
|
||||||
import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest';
|
import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest'
|
||||||
import { listModels, createModel, updateModel, deleteModel } from '@/api/models';
|
import { listModels, createModel, updateModel, deleteModel } from '@/api/models'
|
||||||
|
|
||||||
const mockModels = [
|
const mockModels = [
|
||||||
{
|
{
|
||||||
@@ -20,24 +20,24 @@ const mockModels = [
|
|||||||
enabled: false,
|
enabled: false,
|
||||||
created_at: '2025-01-02T00:00:00Z',
|
created_at: '2025-01-02T00:00:00Z',
|
||||||
},
|
},
|
||||||
];
|
]
|
||||||
|
|
||||||
describe('models API', () => {
|
describe('models API', () => {
|
||||||
const server = setupServer();
|
const server = setupServer()
|
||||||
|
|
||||||
beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' }));
|
beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' }))
|
||||||
afterEach(() => server.resetHandlers());
|
afterEach(() => server.resetHandlers())
|
||||||
afterAll(() => server.close());
|
afterAll(() => server.close())
|
||||||
|
|
||||||
describe('listModels', () => {
|
describe('listModels', () => {
|
||||||
it('returns array of Model objects with camelCase keys', async () => {
|
it('returns array of Model objects with camelCase keys', async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.get('http://localhost:3000/api/models', () => {
|
http.get('http://localhost:3000/api/models', () => {
|
||||||
return HttpResponse.json(mockModels);
|
return HttpResponse.json(mockModels)
|
||||||
}),
|
})
|
||||||
);
|
)
|
||||||
|
|
||||||
const result = await listModels();
|
const result = await listModels()
|
||||||
|
|
||||||
expect(result).toEqual([
|
expect(result).toEqual([
|
||||||
{
|
{
|
||||||
@@ -56,114 +56,114 @@ describe('models API', () => {
|
|||||||
enabled: false,
|
enabled: false,
|
||||||
createdAt: '2025-01-02T00:00:00Z',
|
createdAt: '2025-01-02T00:00:00Z',
|
||||||
},
|
},
|
||||||
]);
|
])
|
||||||
});
|
})
|
||||||
|
|
||||||
it('appends provider_id query parameter when providerId is given', async () => {
|
it('appends provider_id query parameter when providerId is given', async () => {
|
||||||
let receivedUrl: string | null = null;
|
let receivedUrl: string | null = null
|
||||||
|
|
||||||
server.use(
|
server.use(
|
||||||
http.get('http://localhost:3000/api/models', ({ request }) => {
|
http.get('http://localhost:3000/api/models', ({ request }) => {
|
||||||
receivedUrl = request.url;
|
receivedUrl = request.url
|
||||||
return HttpResponse.json([mockModels[0]]);
|
return HttpResponse.json([mockModels[0]])
|
||||||
}),
|
})
|
||||||
);
|
)
|
||||||
|
|
||||||
const result = await listModels('prov-1');
|
const result = await listModels('prov-1')
|
||||||
|
|
||||||
expect(receivedUrl).toContain('provider_id=prov-1');
|
expect(receivedUrl).toContain('provider_id=prov-1')
|
||||||
expect(result).toHaveLength(1);
|
expect(result).toHaveLength(1)
|
||||||
expect(result[0].providerId).toBe('prov-1');
|
expect(result[0].providerId).toBe('prov-1')
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|
||||||
describe('createModel', () => {
|
describe('createModel', () => {
|
||||||
it('sends POST with correct body and returns model', async () => {
|
it('sends POST with correct body and returns model', async () => {
|
||||||
let receivedMethod: string | null = null;
|
let receivedMethod: string | null = null
|
||||||
let receivedBody: Record<string, unknown> | null = null;
|
let receivedBody: Record<string, unknown> | null = null
|
||||||
|
|
||||||
server.use(
|
server.use(
|
||||||
http.post('http://localhost:3000/api/models', async ({ request }) => {
|
http.post('http://localhost:3000/api/models', async ({ request }) => {
|
||||||
receivedMethod = request.method;
|
receivedMethod = request.method
|
||||||
receivedBody = (await request.json()) as Record<string, unknown>;
|
receivedBody = (await request.json()) as Record<string, unknown>
|
||||||
return HttpResponse.json(mockModels[0]);
|
return HttpResponse.json(mockModels[0])
|
||||||
}),
|
})
|
||||||
);
|
)
|
||||||
|
|
||||||
const input = {
|
const input = {
|
||||||
providerId: 'prov-1',
|
providerId: 'prov-1',
|
||||||
modelName: 'gpt-4',
|
modelName: 'gpt-4',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
};
|
}
|
||||||
|
|
||||||
const result = await createModel(input);
|
const result = await createModel(input)
|
||||||
|
|
||||||
expect(receivedMethod).toBe('POST');
|
expect(receivedMethod).toBe('POST')
|
||||||
expect(receivedBody).toEqual({
|
expect(receivedBody).toEqual({
|
||||||
provider_id: 'prov-1',
|
provider_id: 'prov-1',
|
||||||
model_name: 'gpt-4',
|
model_name: 'gpt-4',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
});
|
})
|
||||||
expect(result.id).toBe('gpt-4');
|
expect(result.id).toBe('gpt-4')
|
||||||
expect(result.providerId).toBe('prov-1');
|
expect(result.providerId).toBe('prov-1')
|
||||||
expect(result.modelName).toBe('gpt-4');
|
expect(result.modelName).toBe('gpt-4')
|
||||||
expect(result.unifiedId).toBe('prov-1/gpt-4');
|
expect(result.unifiedId).toBe('prov-1/gpt-4')
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|
||||||
describe('updateModel', () => {
|
describe('updateModel', () => {
|
||||||
it('sends PUT with correct body and returns model', async () => {
|
it('sends PUT with correct body and returns model', async () => {
|
||||||
let receivedMethod: string | null = null;
|
let receivedMethod: string | null = null
|
||||||
let receivedUrl: string | null = null;
|
let receivedUrl: string | null = null
|
||||||
let receivedBody: Record<string, unknown> | null = null;
|
let receivedBody: Record<string, unknown> | null = null
|
||||||
|
|
||||||
server.use(
|
server.use(
|
||||||
http.put('http://localhost:3000/api/models/:id', async ({ request }) => {
|
http.put('http://localhost:3000/api/models/:id', async ({ request }) => {
|
||||||
receivedMethod = request.method;
|
receivedMethod = request.method
|
||||||
receivedUrl = new URL(request.url).pathname;
|
receivedUrl = new URL(request.url).pathname
|
||||||
receivedBody = (await request.json()) as Record<string, unknown>;
|
receivedBody = (await request.json()) as Record<string, unknown>
|
||||||
return HttpResponse.json({
|
return HttpResponse.json({
|
||||||
...mockModels[0],
|
...mockModels[0],
|
||||||
model_name: 'gpt-4-turbo',
|
model_name: 'gpt-4-turbo',
|
||||||
enabled: false,
|
enabled: false,
|
||||||
});
|
})
|
||||||
}),
|
})
|
||||||
);
|
)
|
||||||
|
|
||||||
const result = await updateModel('gpt-4', {
|
const result = await updateModel('gpt-4', {
|
||||||
modelName: 'gpt-4-turbo',
|
modelName: 'gpt-4-turbo',
|
||||||
enabled: false,
|
enabled: false,
|
||||||
});
|
})
|
||||||
|
|
||||||
expect(receivedMethod).toBe('PUT');
|
expect(receivedMethod).toBe('PUT')
|
||||||
expect(receivedUrl).toBe('/api/models/gpt-4');
|
expect(receivedUrl).toBe('/api/models/gpt-4')
|
||||||
expect(receivedBody).toEqual({
|
expect(receivedBody).toEqual({
|
||||||
model_name: 'gpt-4-turbo',
|
model_name: 'gpt-4-turbo',
|
||||||
enabled: false,
|
enabled: false,
|
||||||
});
|
})
|
||||||
expect(result.modelName).toBe('gpt-4-turbo');
|
expect(result.modelName).toBe('gpt-4-turbo')
|
||||||
expect(result.enabled).toBe(false);
|
expect(result.enabled).toBe(false)
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|
||||||
describe('deleteModel', () => {
|
describe('deleteModel', () => {
|
||||||
it('sends DELETE and returns void', async () => {
|
it('sends DELETE and returns void', async () => {
|
||||||
let receivedMethod: string | null = null;
|
let receivedMethod: string | null = null
|
||||||
let receivedUrl: string | null = null;
|
let receivedUrl: string | null = null
|
||||||
|
|
||||||
server.use(
|
server.use(
|
||||||
http.delete('http://localhost:3000/api/models/:id', ({ request }) => {
|
http.delete('http://localhost:3000/api/models/:id', ({ request }) => {
|
||||||
receivedMethod = request.method;
|
receivedMethod = request.method
|
||||||
receivedUrl = new URL(request.url).pathname;
|
receivedUrl = new URL(request.url).pathname
|
||||||
return new HttpResponse(null, { status: 204 });
|
return new HttpResponse(null, { status: 204 })
|
||||||
}),
|
})
|
||||||
);
|
)
|
||||||
|
|
||||||
const result = await deleteModel('gpt-4');
|
const result = await deleteModel('gpt-4')
|
||||||
|
|
||||||
expect(receivedMethod).toBe('DELETE');
|
expect(receivedMethod).toBe('DELETE')
|
||||||
expect(receivedUrl).toBe('/api/models/gpt-4');
|
expect(receivedUrl).toBe('/api/models/gpt-4')
|
||||||
expect(result).toBeUndefined();
|
expect(result).toBeUndefined()
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { http, HttpResponse } from 'msw';
|
import { http, HttpResponse } from 'msw'
|
||||||
import { setupServer } from 'msw/node';
|
import { setupServer } from 'msw/node'
|
||||||
import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest';
|
import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest'
|
||||||
import { listProviders, createProvider, updateProvider, deleteProvider } from '@/api/providers';
|
import { listProviders, createProvider, updateProvider, deleteProvider } from '@/api/providers'
|
||||||
|
|
||||||
const mockProviders = [
|
const mockProviders = [
|
||||||
{
|
{
|
||||||
@@ -24,24 +24,24 @@ const mockProviders = [
|
|||||||
created_at: '2025-01-02T00:00:00Z',
|
created_at: '2025-01-02T00:00:00Z',
|
||||||
updated_at: '2025-01-02T00:00:00Z',
|
updated_at: '2025-01-02T00:00:00Z',
|
||||||
},
|
},
|
||||||
];
|
]
|
||||||
|
|
||||||
describe('providers API', () => {
|
describe('providers API', () => {
|
||||||
const server = setupServer();
|
const server = setupServer()
|
||||||
|
|
||||||
beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' }));
|
beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' }))
|
||||||
afterEach(() => server.resetHandlers());
|
afterEach(() => server.resetHandlers())
|
||||||
afterAll(() => server.close());
|
afterAll(() => server.close())
|
||||||
|
|
||||||
describe('listProviders', () => {
|
describe('listProviders', () => {
|
||||||
it('returns array of Provider objects with camelCase keys', async () => {
|
it('returns array of Provider objects with camelCase keys', async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.get('http://localhost:3000/api/providers', () => {
|
http.get('http://localhost:3000/api/providers', () => {
|
||||||
return HttpResponse.json(mockProviders);
|
return HttpResponse.json(mockProviders)
|
||||||
}),
|
})
|
||||||
);
|
)
|
||||||
|
|
||||||
const result = await listProviders();
|
const result = await listProviders()
|
||||||
|
|
||||||
expect(result).toEqual([
|
expect(result).toEqual([
|
||||||
{
|
{
|
||||||
@@ -64,22 +64,22 @@ describe('providers API', () => {
|
|||||||
createdAt: '2025-01-02T00:00:00Z',
|
createdAt: '2025-01-02T00:00:00Z',
|
||||||
updatedAt: '2025-01-02T00:00:00Z',
|
updatedAt: '2025-01-02T00:00:00Z',
|
||||||
},
|
},
|
||||||
]);
|
])
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|
||||||
describe('createProvider', () => {
|
describe('createProvider', () => {
|
||||||
it('sends POST with correct body and returns provider', async () => {
|
it('sends POST with correct body and returns provider', async () => {
|
||||||
let receivedMethod: string | null = null;
|
let receivedMethod: string | null = null
|
||||||
let receivedBody: Record<string, unknown> | null = null;
|
let receivedBody: Record<string, unknown> | null = null
|
||||||
|
|
||||||
server.use(
|
server.use(
|
||||||
http.post('http://localhost:3000/api/providers', async ({ request }) => {
|
http.post('http://localhost:3000/api/providers', async ({ request }) => {
|
||||||
receivedMethod = request.method;
|
receivedMethod = request.method
|
||||||
receivedBody = (await request.json()) as Record<string, unknown>;
|
receivedBody = (await request.json()) as Record<string, unknown>
|
||||||
return HttpResponse.json(mockProviders[0]);
|
return HttpResponse.json(mockProviders[0])
|
||||||
}),
|
})
|
||||||
);
|
)
|
||||||
|
|
||||||
const input = {
|
const input = {
|
||||||
id: 'prov-1',
|
id: 'prov-1',
|
||||||
@@ -87,18 +87,18 @@ describe('providers API', () => {
|
|||||||
apiKey: 'sk-xxx',
|
apiKey: 'sk-xxx',
|
||||||
baseUrl: 'https://api.openai.com',
|
baseUrl: 'https://api.openai.com',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
};
|
}
|
||||||
|
|
||||||
const result = await createProvider(input);
|
const result = await createProvider(input)
|
||||||
|
|
||||||
expect(receivedMethod).toBe('POST');
|
expect(receivedMethod).toBe('POST')
|
||||||
expect(receivedBody).toEqual({
|
expect(receivedBody).toEqual({
|
||||||
id: 'prov-1',
|
id: 'prov-1',
|
||||||
name: 'OpenAI',
|
name: 'OpenAI',
|
||||||
api_key: 'sk-xxx',
|
api_key: 'sk-xxx',
|
||||||
base_url: 'https://api.openai.com',
|
base_url: 'https://api.openai.com',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
});
|
})
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
id: 'prov-1',
|
id: 'prov-1',
|
||||||
name: 'OpenAI',
|
name: 'OpenAI',
|
||||||
@@ -108,63 +108,63 @@ describe('providers API', () => {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
createdAt: '2025-01-01T00:00:00Z',
|
createdAt: '2025-01-01T00:00:00Z',
|
||||||
updatedAt: '2025-01-01T00:00:00Z',
|
updatedAt: '2025-01-01T00:00:00Z',
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|
||||||
describe('updateProvider', () => {
|
describe('updateProvider', () => {
|
||||||
it('sends PUT with correct body and returns provider', async () => {
|
it('sends PUT with correct body and returns provider', async () => {
|
||||||
let receivedMethod: string | null = null;
|
let receivedMethod: string | null = null
|
||||||
let receivedUrl: string | null = null;
|
let receivedUrl: string | null = null
|
||||||
let receivedBody: Record<string, unknown> | null = null;
|
let receivedBody: Record<string, unknown> | null = null
|
||||||
|
|
||||||
server.use(
|
server.use(
|
||||||
http.put('http://localhost:3000/api/providers/:id', async ({ request }) => {
|
http.put('http://localhost:3000/api/providers/:id', async ({ request }) => {
|
||||||
receivedMethod = request.method;
|
receivedMethod = request.method
|
||||||
receivedUrl = new URL(request.url).pathname;
|
receivedUrl = new URL(request.url).pathname
|
||||||
receivedBody = (await request.json()) as Record<string, unknown>;
|
receivedBody = (await request.json()) as Record<string, unknown>
|
||||||
return HttpResponse.json({
|
return HttpResponse.json({
|
||||||
...mockProviders[0],
|
...mockProviders[0],
|
||||||
name: 'Updated',
|
name: 'Updated',
|
||||||
api_key: 'sk-updated',
|
api_key: 'sk-updated',
|
||||||
});
|
})
|
||||||
}),
|
})
|
||||||
);
|
)
|
||||||
|
|
||||||
const result = await updateProvider('prov-1', {
|
const result = await updateProvider('prov-1', {
|
||||||
name: 'Updated',
|
name: 'Updated',
|
||||||
apiKey: 'sk-updated',
|
apiKey: 'sk-updated',
|
||||||
});
|
})
|
||||||
|
|
||||||
expect(receivedMethod).toBe('PUT');
|
expect(receivedMethod).toBe('PUT')
|
||||||
expect(receivedUrl).toBe('/api/providers/prov-1');
|
expect(receivedUrl).toBe('/api/providers/prov-1')
|
||||||
expect(receivedBody).toEqual({
|
expect(receivedBody).toEqual({
|
||||||
name: 'Updated',
|
name: 'Updated',
|
||||||
api_key: 'sk-updated',
|
api_key: 'sk-updated',
|
||||||
});
|
})
|
||||||
expect(result.name).toBe('Updated');
|
expect(result.name).toBe('Updated')
|
||||||
expect(result.apiKey).toBe('sk-updated');
|
expect(result.apiKey).toBe('sk-updated')
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|
||||||
describe('deleteProvider', () => {
|
describe('deleteProvider', () => {
|
||||||
it('sends DELETE and returns void', async () => {
|
it('sends DELETE and returns void', async () => {
|
||||||
let receivedMethod: string | null = null;
|
let receivedMethod: string | null = null
|
||||||
let receivedUrl: string | null = null;
|
let receivedUrl: string | null = null
|
||||||
|
|
||||||
server.use(
|
server.use(
|
||||||
http.delete('http://localhost:3000/api/providers/:id', ({ request }) => {
|
http.delete('http://localhost:3000/api/providers/:id', ({ request }) => {
|
||||||
receivedMethod = request.method;
|
receivedMethod = request.method
|
||||||
receivedUrl = new URL(request.url).pathname;
|
receivedUrl = new URL(request.url).pathname
|
||||||
return new HttpResponse(null, { status: 204 });
|
return new HttpResponse(null, { status: 204 })
|
||||||
}),
|
})
|
||||||
);
|
)
|
||||||
|
|
||||||
const result = await deleteProvider('prov-1');
|
const result = await deleteProvider('prov-1')
|
||||||
|
|
||||||
expect(receivedMethod).toBe('DELETE');
|
expect(receivedMethod).toBe('DELETE')
|
||||||
expect(receivedUrl).toBe('/api/providers/prov-1');
|
expect(receivedUrl).toBe('/api/providers/prov-1')
|
||||||
expect(result).toBeUndefined();
|
expect(result).toBeUndefined()
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { http, HttpResponse } from 'msw';
|
import { http, HttpResponse } from 'msw'
|
||||||
import { setupServer } from 'msw/node';
|
import { setupServer } from 'msw/node'
|
||||||
import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest';
|
import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest'
|
||||||
import { getStats } from '@/api/stats';
|
import { getStats } from '@/api/stats'
|
||||||
|
|
||||||
const mockStats = [
|
const mockStats = [
|
||||||
{
|
{
|
||||||
@@ -18,29 +18,29 @@ const mockStats = [
|
|||||||
request_count: 50,
|
request_count: 50,
|
||||||
date: '2025-01-16',
|
date: '2025-01-16',
|
||||||
},
|
},
|
||||||
];
|
]
|
||||||
|
|
||||||
describe('stats API', () => {
|
describe('stats API', () => {
|
||||||
const server = setupServer();
|
const server = setupServer()
|
||||||
|
|
||||||
beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' }));
|
beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' }))
|
||||||
afterEach(() => server.resetHandlers());
|
afterEach(() => server.resetHandlers())
|
||||||
afterAll(() => server.close());
|
afterAll(() => server.close())
|
||||||
|
|
||||||
describe('getStats', () => {
|
describe('getStats', () => {
|
||||||
it('calls /api/stats without params', async () => {
|
it('calls /api/stats without params', async () => {
|
||||||
let receivedUrl: string | null = null;
|
let receivedUrl: string | null = null
|
||||||
|
|
||||||
server.use(
|
server.use(
|
||||||
http.get('http://localhost:3000/api/stats', ({ request }) => {
|
http.get('http://localhost:3000/api/stats', ({ request }) => {
|
||||||
receivedUrl = request.url;
|
receivedUrl = request.url
|
||||||
return HttpResponse.json(mockStats);
|
return HttpResponse.json(mockStats)
|
||||||
}),
|
})
|
||||||
);
|
)
|
||||||
|
|
||||||
const result = await getStats();
|
const result = await getStats()
|
||||||
|
|
||||||
expect(receivedUrl).toMatch(/\/api\/stats$/);
|
expect(receivedUrl).toMatch(/\/api\/stats$/)
|
||||||
expect(result).toEqual([
|
expect(result).toEqual([
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
@@ -56,76 +56,76 @@ describe('stats API', () => {
|
|||||||
requestCount: 50,
|
requestCount: 50,
|
||||||
date: '2025-01-16',
|
date: '2025-01-16',
|
||||||
},
|
},
|
||||||
]);
|
])
|
||||||
});
|
})
|
||||||
|
|
||||||
it('builds correct query string with snake_case keys when params are provided', async () => {
|
it('builds correct query string with snake_case keys when params are provided', async () => {
|
||||||
let receivedUrl: string | null = null;
|
let receivedUrl: string | null = null
|
||||||
|
|
||||||
server.use(
|
server.use(
|
||||||
http.get('http://localhost:3000/api/stats', ({ request }) => {
|
http.get('http://localhost:3000/api/stats', ({ request }) => {
|
||||||
receivedUrl = request.url;
|
receivedUrl = request.url
|
||||||
return HttpResponse.json([]);
|
return HttpResponse.json([])
|
||||||
}),
|
})
|
||||||
);
|
)
|
||||||
|
|
||||||
await getStats({
|
await getStats({
|
||||||
providerId: 'prov-1',
|
providerId: 'prov-1',
|
||||||
modelName: 'gpt-4',
|
modelName: 'gpt-4',
|
||||||
startDate: '2025-01-01',
|
startDate: '2025-01-01',
|
||||||
endDate: '2025-01-31',
|
endDate: '2025-01-31',
|
||||||
});
|
})
|
||||||
|
|
||||||
expect(receivedUrl).toContain('provider_id=prov-1');
|
expect(receivedUrl).toContain('provider_id=prov-1')
|
||||||
expect(receivedUrl).toContain('model_name=gpt-4');
|
expect(receivedUrl).toContain('model_name=gpt-4')
|
||||||
expect(receivedUrl).toContain('start_date=2025-01-01');
|
expect(receivedUrl).toContain('start_date=2025-01-01')
|
||||||
expect(receivedUrl).toContain('end_date=2025-01-31');
|
expect(receivedUrl).toContain('end_date=2025-01-31')
|
||||||
});
|
})
|
||||||
|
|
||||||
it('omits undefined params from query string', async () => {
|
it('omits undefined params from query string', async () => {
|
||||||
let receivedUrl: string | null = null;
|
let receivedUrl: string | null = null
|
||||||
|
|
||||||
server.use(
|
server.use(
|
||||||
http.get('http://localhost:3000/api/stats', ({ request }) => {
|
http.get('http://localhost:3000/api/stats', ({ request }) => {
|
||||||
receivedUrl = request.url;
|
receivedUrl = request.url
|
||||||
return HttpResponse.json([]);
|
return HttpResponse.json([])
|
||||||
}),
|
})
|
||||||
);
|
)
|
||||||
|
|
||||||
await getStats({
|
await getStats({
|
||||||
providerId: 'prov-1',
|
providerId: 'prov-1',
|
||||||
});
|
})
|
||||||
|
|
||||||
expect(receivedUrl).toContain('provider_id=prov-1');
|
expect(receivedUrl).toContain('provider_id=prov-1')
|
||||||
expect(receivedUrl).not.toContain('model_name');
|
expect(receivedUrl).not.toContain('model_name')
|
||||||
expect(receivedUrl).not.toContain('start_date');
|
expect(receivedUrl).not.toContain('start_date')
|
||||||
expect(receivedUrl).not.toContain('end_date');
|
expect(receivedUrl).not.toContain('end_date')
|
||||||
});
|
})
|
||||||
|
|
||||||
it('returns UsageStats array with camelCase keys', async () => {
|
it('returns UsageStats array with camelCase keys', async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.get('http://localhost:3000/api/stats', () => {
|
http.get('http://localhost:3000/api/stats', () => {
|
||||||
return HttpResponse.json(mockStats);
|
return HttpResponse.json(mockStats)
|
||||||
}),
|
})
|
||||||
);
|
)
|
||||||
|
|
||||||
const result = await getStats();
|
const result = await getStats()
|
||||||
|
|
||||||
expect(result).toHaveLength(2);
|
expect(result).toHaveLength(2)
|
||||||
expect(result[0]).toEqual({
|
expect(result[0]).toEqual({
|
||||||
id: 1,
|
id: 1,
|
||||||
providerId: 'prov-1',
|
providerId: 'prov-1',
|
||||||
modelName: 'gpt-4',
|
modelName: 'gpt-4',
|
||||||
requestCount: 100,
|
requestCount: 100,
|
||||||
date: '2025-01-15',
|
date: '2025-01-15',
|
||||||
});
|
})
|
||||||
expect(result[1]).toEqual({
|
expect(result[1]).toEqual({
|
||||||
id: 2,
|
id: 2,
|
||||||
providerId: 'prov-2',
|
providerId: 'prov-2',
|
||||||
modelName: 'claude-3',
|
modelName: 'claude-3',
|
||||||
requestCount: 50,
|
requestCount: 50,
|
||||||
date: '2025-01-16',
|
date: '2025-01-16',
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|||||||
@@ -1,52 +1,52 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react'
|
||||||
import { BrowserRouter } from 'react-router';
|
import { BrowserRouter } from 'react-router'
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest'
|
||||||
import { AppLayout } from '@/components/AppLayout';
|
import { AppLayout } from '@/components/AppLayout'
|
||||||
|
|
||||||
const renderWithRouter = (component: React.ReactNode) => {
|
const renderWithRouter = (component: React.ReactNode) => {
|
||||||
return render(<BrowserRouter>{component}</BrowserRouter>);
|
return render(<BrowserRouter>{component}</BrowserRouter>)
|
||||||
};
|
}
|
||||||
|
|
||||||
describe('AppLayout', () => {
|
describe('AppLayout', () => {
|
||||||
it('renders sidebar with app name', () => {
|
it('renders sidebar with app name', () => {
|
||||||
renderWithRouter(<AppLayout />);
|
renderWithRouter(<AppLayout />)
|
||||||
|
|
||||||
const appNames = screen.getAllByText('AI Gateway');
|
const appNames = screen.getAllByText('AI Gateway')
|
||||||
expect(appNames.length).toBeGreaterThan(0);
|
expect(appNames.length).toBeGreaterThan(0)
|
||||||
});
|
})
|
||||||
|
|
||||||
it('renders navigation menu items', () => {
|
it('renders navigation menu items', () => {
|
||||||
renderWithRouter(<AppLayout />);
|
renderWithRouter(<AppLayout />)
|
||||||
|
|
||||||
expect(screen.getByText('供应商管理')).toBeInTheDocument();
|
expect(screen.getByText('供应商管理')).toBeInTheDocument()
|
||||||
expect(screen.getByText('用量统计')).toBeInTheDocument();
|
expect(screen.getByText('用量统计')).toBeInTheDocument()
|
||||||
});
|
})
|
||||||
|
|
||||||
it('renders settings menu item', () => {
|
it('renders settings menu item', () => {
|
||||||
renderWithRouter(<AppLayout />);
|
renderWithRouter(<AppLayout />)
|
||||||
|
|
||||||
expect(screen.getByText('设置')).toBeInTheDocument();
|
expect(screen.getByText('设置')).toBeInTheDocument()
|
||||||
});
|
})
|
||||||
|
|
||||||
it('renders content outlet', () => {
|
it('renders content outlet', () => {
|
||||||
const { container } = renderWithRouter(<AppLayout />);
|
const { container } = renderWithRouter(<AppLayout />)
|
||||||
|
|
||||||
// TDesign Layout content
|
// TDesign Layout content
|
||||||
expect(container.querySelector('.t-layout__content')).toBeInTheDocument();
|
expect(container.querySelector('.t-layout__content')).toBeInTheDocument()
|
||||||
});
|
})
|
||||||
|
|
||||||
it('renders sidebar', () => {
|
it('renders sidebar', () => {
|
||||||
const { container } = renderWithRouter(<AppLayout />);
|
const { container } = renderWithRouter(<AppLayout />)
|
||||||
|
|
||||||
// TDesign Layout.Aside might render with different class names
|
// TDesign Layout.Aside might render with different class names
|
||||||
// Check for Menu component which is in the sidebar
|
// Check for Menu component which is in the sidebar
|
||||||
expect(container.querySelector('.t-menu')).toBeInTheDocument();
|
expect(container.querySelector('.t-menu')).toBeInTheDocument()
|
||||||
});
|
})
|
||||||
|
|
||||||
it('renders header with page title', () => {
|
it('renders header with page title', () => {
|
||||||
const { container } = renderWithRouter(<AppLayout />);
|
const { container } = renderWithRouter(<AppLayout />)
|
||||||
|
|
||||||
// TDesign Layout header
|
// TDesign Layout header
|
||||||
expect(container.querySelector('.t-layout__header')).toBeInTheDocument();
|
expect(container.querySelector('.t-layout__header')).toBeInTheDocument()
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { render, screen, within } from '@testing-library/react';
|
import { render, screen, within } from '@testing-library/react'
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event'
|
||||||
import { describe, it, expect, vi } from 'vitest';
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
import { ModelForm } from '@/pages/Providers/ModelForm';
|
import { ModelForm } from '@/pages/Providers/ModelForm'
|
||||||
import type { Provider, Model } from '@/types';
|
import type { Provider, Model } from '@/types'
|
||||||
|
|
||||||
const mockProviders: Provider[] = [
|
const mockProviders: Provider[] = [
|
||||||
{
|
{
|
||||||
@@ -25,7 +25,7 @@ const mockProviders: Provider[] = [
|
|||||||
createdAt: '2024-01-02T00:00:00Z',
|
createdAt: '2024-01-02T00:00:00Z',
|
||||||
updatedAt: '2024-01-02T00:00:00Z',
|
updatedAt: '2024-01-02T00:00:00Z',
|
||||||
},
|
},
|
||||||
];
|
]
|
||||||
|
|
||||||
const mockModel: Model = {
|
const mockModel: Model = {
|
||||||
id: 'gpt-4o',
|
id: 'gpt-4o',
|
||||||
@@ -34,7 +34,7 @@ const mockModel: Model = {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
createdAt: '2024-01-01T00:00:00Z',
|
createdAt: '2024-01-01T00:00:00Z',
|
||||||
unifiedId: 'openai/gpt-4o',
|
unifiedId: 'openai/gpt-4o',
|
||||||
};
|
}
|
||||||
|
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
open: true,
|
open: true,
|
||||||
@@ -43,69 +43,63 @@ const defaultProps = {
|
|||||||
onSave: vi.fn(),
|
onSave: vi.fn(),
|
||||||
onCancel: vi.fn(),
|
onCancel: vi.fn(),
|
||||||
loading: false,
|
loading: false,
|
||||||
};
|
}
|
||||||
|
|
||||||
function getDialog() {
|
function getDialog() {
|
||||||
// TDesign Dialog doesn't have role="dialog", use class selector
|
// TDesign Dialog doesn't have role="dialog", use class selector
|
||||||
const dialog = document.querySelector('.t-dialog');
|
const dialog = document.querySelector('.t-dialog')
|
||||||
if (!dialog) {
|
if (!dialog) {
|
||||||
throw new Error('Dialog not found');
|
throw new Error('Dialog not found')
|
||||||
}
|
}
|
||||||
return dialog;
|
return dialog
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('ModelForm', () => {
|
describe('ModelForm', () => {
|
||||||
it('renders form with provider select', () => {
|
it('renders form with provider select', () => {
|
||||||
render(<ModelForm {...defaultProps} />);
|
render(<ModelForm {...defaultProps} />)
|
||||||
|
|
||||||
const dialog = getDialog();
|
const dialog = getDialog()
|
||||||
expect(within(dialog).getByText('添加模型')).toBeInTheDocument();
|
expect(within(dialog).getByText('添加模型')).toBeInTheDocument()
|
||||||
expect(within(dialog).getByText('供应商')).toBeInTheDocument();
|
expect(within(dialog).getByText('供应商')).toBeInTheDocument()
|
||||||
expect(within(dialog).getByText('模型名称')).toBeInTheDocument();
|
expect(within(dialog).getByText('模型名称')).toBeInTheDocument()
|
||||||
expect(within(dialog).getByText('启用')).toBeInTheDocument();
|
expect(within(dialog).getByText('启用')).toBeInTheDocument()
|
||||||
});
|
})
|
||||||
|
|
||||||
it('defaults providerId to the passed providerId in create mode', () => {
|
it('defaults providerId to the passed providerId in create mode', () => {
|
||||||
render(<ModelForm {...defaultProps} />);
|
render(<ModelForm {...defaultProps} />)
|
||||||
|
|
||||||
const dialog = getDialog();
|
const dialog = getDialog()
|
||||||
// Form renders with provider select
|
// Form renders with provider select
|
||||||
expect(within(dialog).getByText('供应商')).toBeInTheDocument();
|
expect(within(dialog).getByText('供应商')).toBeInTheDocument()
|
||||||
});
|
})
|
||||||
|
|
||||||
it('shows validation error messages for required fields', async () => {
|
it('shows validation error messages for required fields', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup()
|
||||||
render(
|
render(<ModelForm {...defaultProps} providerId={undefined as unknown as string} providers={[]} />)
|
||||||
<ModelForm
|
|
||||||
{...defaultProps}
|
|
||||||
providerId={undefined as unknown as string}
|
|
||||||
providers={[]}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const dialog = getDialog();
|
const dialog = getDialog()
|
||||||
const okButton = within(dialog).getByRole('button', { name: /保/ });
|
const okButton = within(dialog).getByRole('button', { name: /保/ })
|
||||||
await user.click(okButton);
|
await user.click(okButton)
|
||||||
|
|
||||||
expect(await screen.findByText('请选择供应商')).toBeInTheDocument();
|
expect(await screen.findByText('请选择供应商')).toBeInTheDocument()
|
||||||
expect(screen.getByText('请输入模型名称')).toBeInTheDocument();
|
expect(screen.getByText('请输入模型名称')).toBeInTheDocument()
|
||||||
});
|
})
|
||||||
|
|
||||||
it('calls onSave with form values on successful submission', async () => {
|
it('calls onSave with form values on successful submission', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup()
|
||||||
const onSave = vi.fn();
|
const onSave = vi.fn()
|
||||||
render(<ModelForm {...defaultProps} onSave={onSave} />);
|
render(<ModelForm {...defaultProps} onSave={onSave} />)
|
||||||
|
|
||||||
const dialog = getDialog();
|
const dialog = getDialog()
|
||||||
// Only one input with placeholder "例如: gpt-4o" for model name
|
// Only one input with placeholder "例如: gpt-4o" for model name
|
||||||
const modelNameInput = within(dialog).getByPlaceholderText('例如: gpt-4o');
|
const modelNameInput = within(dialog).getByPlaceholderText('例如: gpt-4o')
|
||||||
|
|
||||||
// Type into the model name field
|
// Type into the model name field
|
||||||
await user.clear(modelNameInput);
|
await user.clear(modelNameInput)
|
||||||
await user.type(modelNameInput, 'gpt-4o-mini');
|
await user.type(modelNameInput, 'gpt-4o-mini')
|
||||||
|
|
||||||
const okButton = within(dialog).getByRole('button', { name: /保/ });
|
const okButton = within(dialog).getByRole('button', { name: /保/ })
|
||||||
await user.click(okButton);
|
await user.click(okButton)
|
||||||
|
|
||||||
// Wait for the onSave to be called
|
// Wait for the onSave to be called
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
@@ -114,30 +108,30 @@ describe('ModelForm', () => {
|
|||||||
providerId: 'openai',
|
providerId: 'openai',
|
||||||
modelName: 'gpt-4o-mini',
|
modelName: 'gpt-4o-mini',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
}),
|
})
|
||||||
);
|
)
|
||||||
});
|
})
|
||||||
}, 10000);
|
}, 10000)
|
||||||
|
|
||||||
it('renders pre-filled fields in edit mode', () => {
|
it('renders pre-filled fields in edit mode', () => {
|
||||||
render(<ModelForm {...defaultProps} model={mockModel} />);
|
render(<ModelForm {...defaultProps} model={mockModel} />)
|
||||||
|
|
||||||
const dialog = getDialog();
|
const dialog = getDialog()
|
||||||
expect(within(dialog).getByText('编辑模型')).toBeInTheDocument();
|
expect(within(dialog).getByText('编辑模型')).toBeInTheDocument()
|
||||||
|
|
||||||
// Check model name input
|
// Check model name input
|
||||||
const modelNameInput = within(dialog).getByPlaceholderText('例如: gpt-4o') as HTMLInputElement;
|
const modelNameInput = within(dialog).getByPlaceholderText('例如: gpt-4o') as HTMLInputElement
|
||||||
expect(modelNameInput.value).toBe('gpt-4o');
|
expect(modelNameInput.value).toBe('gpt-4o')
|
||||||
});
|
})
|
||||||
|
|
||||||
it('calls onCancel when clicking cancel button', async () => {
|
it('calls onCancel when clicking cancel button', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup()
|
||||||
const onCancel = vi.fn();
|
const onCancel = vi.fn()
|
||||||
render(<ModelForm {...defaultProps} onCancel={onCancel} />);
|
render(<ModelForm {...defaultProps} onCancel={onCancel} />)
|
||||||
|
|
||||||
const dialog = getDialog();
|
const dialog = getDialog()
|
||||||
const cancelButton = within(dialog).getByRole('button', { name: /取/ });
|
const cancelButton = within(dialog).getByRole('button', { name: /取/ })
|
||||||
await user.click(cancelButton);
|
await user.click(cancelButton)
|
||||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react'
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event'
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
import { ModelTable } from '@/pages/Providers/ModelTable';
|
import { ModelTable } from '@/pages/Providers/ModelTable'
|
||||||
import type { Model } from '@/types';
|
import type { Model } from '@/types'
|
||||||
|
|
||||||
const mockModels: Model[] = [
|
const mockModels: Model[] = [
|
||||||
{
|
{
|
||||||
@@ -21,103 +21,103 @@ const mockModels: Model[] = [
|
|||||||
createdAt: '2024-01-02T00:00:00Z',
|
createdAt: '2024-01-02T00:00:00Z',
|
||||||
unifiedId: 'openai/gpt-3.5-turbo',
|
unifiedId: 'openai/gpt-3.5-turbo',
|
||||||
},
|
},
|
||||||
];
|
]
|
||||||
|
|
||||||
const mockMutate = vi.fn();
|
const mockMutate = vi.fn()
|
||||||
|
|
||||||
vi.mock('@/hooks/useModels', () => ({
|
vi.mock('@/hooks/useModels', () => ({
|
||||||
useModels: vi.fn((providerId: string) => {
|
useModels: vi.fn((providerId: string) => {
|
||||||
if (providerId === 'openai') {
|
if (providerId === 'openai') {
|
||||||
return { data: mockModels, isLoading: false };
|
return { data: mockModels, isLoading: false }
|
||||||
}
|
}
|
||||||
return { data: [], isLoading: false };
|
return { data: [], isLoading: false }
|
||||||
}),
|
}),
|
||||||
useDeleteModel: vi.fn(() => ({ mutate: mockMutate })),
|
useDeleteModel: vi.fn(() => ({ mutate: mockMutate })),
|
||||||
}));
|
}))
|
||||||
|
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
providerId: 'openai',
|
providerId: 'openai',
|
||||||
onAdd: vi.fn(),
|
onAdd: vi.fn(),
|
||||||
onEdit: vi.fn(),
|
onEdit: vi.fn(),
|
||||||
};
|
}
|
||||||
|
|
||||||
describe('ModelTable', () => {
|
describe('ModelTable', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockMutate.mockClear();
|
mockMutate.mockClear()
|
||||||
});
|
})
|
||||||
|
|
||||||
it('renders model list with unified ID and model name', () => {
|
it('renders model list with unified ID and model name', () => {
|
||||||
render(<ModelTable {...defaultProps} />);
|
render(<ModelTable {...defaultProps} />)
|
||||||
|
|
||||||
expect(screen.getByText(/关联模型/)).toBeInTheDocument();
|
expect(screen.getByText(/关联模型/)).toBeInTheDocument()
|
||||||
expect(screen.getByText('openai/gpt-4o')).toBeInTheDocument();
|
expect(screen.getByText('openai/gpt-4o')).toBeInTheDocument()
|
||||||
expect(screen.getByText('openai/gpt-3.5-turbo')).toBeInTheDocument();
|
expect(screen.getByText('openai/gpt-3.5-turbo')).toBeInTheDocument()
|
||||||
expect(screen.getByText('gpt-4o')).toBeInTheDocument();
|
expect(screen.getByText('gpt-4o')).toBeInTheDocument()
|
||||||
expect(screen.getByText('gpt-3.5-turbo')).toBeInTheDocument();
|
expect(screen.getByText('gpt-3.5-turbo')).toBeInTheDocument()
|
||||||
});
|
})
|
||||||
|
|
||||||
it('renders status tags correctly', () => {
|
it('renders status tags correctly', () => {
|
||||||
render(<ModelTable {...defaultProps} />);
|
render(<ModelTable {...defaultProps} />)
|
||||||
|
|
||||||
const enabledTags = screen.getAllByText('启用');
|
const enabledTags = screen.getAllByText('启用')
|
||||||
const disabledTags = screen.getAllByText('禁用');
|
const disabledTags = screen.getAllByText('禁用')
|
||||||
expect(enabledTags.length).toBeGreaterThanOrEqual(1);
|
expect(enabledTags.length).toBeGreaterThanOrEqual(1)
|
||||||
expect(disabledTags.length).toBeGreaterThanOrEqual(1);
|
expect(disabledTags.length).toBeGreaterThanOrEqual(1)
|
||||||
});
|
})
|
||||||
|
|
||||||
it('calls onAdd when clicking "添加模型" button', async () => {
|
it('calls onAdd when clicking "添加模型" button', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup()
|
||||||
const onAdd = vi.fn();
|
const onAdd = vi.fn()
|
||||||
render(<ModelTable {...defaultProps} onAdd={onAdd} />);
|
render(<ModelTable {...defaultProps} onAdd={onAdd} />)
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: '添加模型' }));
|
await user.click(screen.getByRole('button', { name: '添加模型' }))
|
||||||
expect(onAdd).toHaveBeenCalledTimes(1);
|
expect(onAdd).toHaveBeenCalledTimes(1)
|
||||||
});
|
})
|
||||||
|
|
||||||
it('calls onEdit with correct model when clicking "编辑"', async () => {
|
it('calls onEdit with correct model when clicking "编辑"', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup()
|
||||||
const onEdit = vi.fn();
|
const onEdit = vi.fn()
|
||||||
render(<ModelTable {...defaultProps} onEdit={onEdit} />);
|
render(<ModelTable {...defaultProps} onEdit={onEdit} />)
|
||||||
|
|
||||||
const editButtons = screen.getAllByRole('button', { name: /编 ?辑/ });
|
const editButtons = screen.getAllByRole('button', { name: /编 ?辑/ })
|
||||||
await user.click(editButtons[0]);
|
await user.click(editButtons[0])
|
||||||
|
|
||||||
expect(onEdit).toHaveBeenCalledTimes(1);
|
expect(onEdit).toHaveBeenCalledTimes(1)
|
||||||
expect(onEdit).toHaveBeenCalledWith(mockModels[0]);
|
expect(onEdit).toHaveBeenCalledWith(mockModels[0])
|
||||||
});
|
})
|
||||||
|
|
||||||
it('calls deleteModel.mutate with correct model ID when delete is confirmed', async () => {
|
it('calls deleteModel.mutate with correct model ID when delete is confirmed', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup()
|
||||||
|
|
||||||
render(<ModelTable {...defaultProps} />);
|
render(<ModelTable {...defaultProps} />)
|
||||||
|
|
||||||
// Find and click the delete button for the first row
|
// Find and click the delete button for the first row
|
||||||
const deleteButtons = screen.getAllByRole('button', { name: '删除' });
|
const deleteButtons = screen.getAllByRole('button', { name: '删除' })
|
||||||
await user.click(deleteButtons[0]);
|
await user.click(deleteButtons[0])
|
||||||
|
|
||||||
// TDesign Popconfirm renders confirmation popup with "确定" button
|
// TDesign Popconfirm renders confirmation popup with "确定" button
|
||||||
const confirmButton = await screen.findByRole('button', { name: '确定' });
|
const confirmButton = await screen.findByRole('button', { name: '确定' })
|
||||||
await user.click(confirmButton);
|
await user.click(confirmButton)
|
||||||
|
|
||||||
// Assert that deleteModel.mutate was called with the correct model ID
|
// Assert that deleteModel.mutate was called with the correct model ID
|
||||||
expect(mockMutate).toHaveBeenCalledTimes(1);
|
expect(mockMutate).toHaveBeenCalledTimes(1)
|
||||||
expect(mockMutate).toHaveBeenCalledWith('model-1');
|
expect(mockMutate).toHaveBeenCalledWith('model-1')
|
||||||
}, 10000);
|
}, 10000)
|
||||||
|
|
||||||
it('shows custom empty text when models list is empty', () => {
|
it('shows custom empty text when models list is empty', () => {
|
||||||
render(<ModelTable providerId="anthropic" />);
|
render(<ModelTable providerId='anthropic' />)
|
||||||
expect(screen.getByText('暂无模型,点击上方按钮添加')).toBeInTheDocument();
|
expect(screen.getByText('暂无模型,点击上方按钮添加')).toBeInTheDocument()
|
||||||
});
|
})
|
||||||
|
|
||||||
it('does not render add button when onAdd is not provided', () => {
|
it('does not render add button when onAdd is not provided', () => {
|
||||||
render(<ModelTable providerId="openai" />);
|
render(<ModelTable providerId='openai' />)
|
||||||
|
|
||||||
expect(screen.queryByRole('button', { name: '添加模型' })).not.toBeInTheDocument();
|
expect(screen.queryByRole('button', { name: '添加模型' })).not.toBeInTheDocument()
|
||||||
});
|
})
|
||||||
|
|
||||||
it('does not render edit button when onEdit is not provided', () => {
|
it('does not render edit button when onEdit is not provided', () => {
|
||||||
render(<ModelTable providerId="openai" onAdd={vi.fn()} />);
|
render(<ModelTable providerId='openai' onAdd={vi.fn()} />)
|
||||||
|
|
||||||
expect(screen.queryByRole('button', { name: /编 ?辑/ })).not.toBeInTheDocument();
|
expect(screen.queryByRole('button', { name: /编 ?辑/ })).not.toBeInTheDocument()
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { render, screen, within, fireEvent } from '@testing-library/react';
|
import { render, screen, within, fireEvent } from '@testing-library/react'
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event'
|
||||||
import { describe, it, expect, vi } from 'vitest';
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
import { ProviderForm } from '@/pages/Providers/ProviderForm';
|
import { ProviderForm } from '@/pages/Providers/ProviderForm'
|
||||||
import type { Provider } from '@/types';
|
import type { Provider } from '@/types'
|
||||||
|
|
||||||
const mockProvider: Provider = {
|
const mockProvider: Provider = {
|
||||||
id: 'openai',
|
id: 'openai',
|
||||||
@@ -13,187 +13,193 @@ const mockProvider: Provider = {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
createdAt: '2024-01-01T00:00:00Z',
|
createdAt: '2024-01-01T00:00:00Z',
|
||||||
updatedAt: '2024-01-01T00:00:00Z',
|
updatedAt: '2024-01-01T00:00:00Z',
|
||||||
};
|
}
|
||||||
|
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
open: true,
|
open: true,
|
||||||
onSave: vi.fn(),
|
onSave: vi.fn(),
|
||||||
onCancel: vi.fn(),
|
onCancel: vi.fn(),
|
||||||
loading: false,
|
loading: false,
|
||||||
};
|
}
|
||||||
|
|
||||||
function getDialog() {
|
function getDialog() {
|
||||||
// TDesign Dialog doesn't have role="dialog", use class selector
|
// TDesign Dialog doesn't have role="dialog", use class selector
|
||||||
const dialog = document.querySelector('.t-dialog');
|
const dialog = document.querySelector('.t-dialog')
|
||||||
if (!dialog) {
|
if (!dialog) {
|
||||||
throw new Error('Dialog not found');
|
throw new Error('Dialog not found')
|
||||||
}
|
}
|
||||||
return dialog;
|
return dialog
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('ProviderForm', () => {
|
describe('ProviderForm', () => {
|
||||||
it('renders form fields in create mode', () => {
|
it('renders form fields in create mode', () => {
|
||||||
render(<ProviderForm {...defaultProps} />);
|
render(<ProviderForm {...defaultProps} />)
|
||||||
|
|
||||||
const dialog = getDialog();
|
const dialog = getDialog()
|
||||||
expect(within(dialog).getByText('添加供应商')).toBeInTheDocument();
|
expect(within(dialog).getByText('添加供应商')).toBeInTheDocument()
|
||||||
expect(within(dialog).getByText('ID')).toBeInTheDocument();
|
expect(within(dialog).getByText('ID')).toBeInTheDocument()
|
||||||
expect(within(dialog).getByText('名称')).toBeInTheDocument();
|
expect(within(dialog).getByText('名称')).toBeInTheDocument()
|
||||||
expect(within(dialog).getByText('API Key')).toBeInTheDocument();
|
expect(within(dialog).getByText('API Key')).toBeInTheDocument()
|
||||||
expect(within(dialog).getByText('Base URL')).toBeInTheDocument();
|
expect(within(dialog).getByText('Base URL')).toBeInTheDocument()
|
||||||
expect(within(dialog).getByText('协议')).toBeInTheDocument();
|
expect(within(dialog).getByText('协议')).toBeInTheDocument()
|
||||||
expect(within(dialog).getByText('启用')).toBeInTheDocument();
|
expect(within(dialog).getByText('启用')).toBeInTheDocument()
|
||||||
expect(within(dialog).getByPlaceholderText('例如: openai')).toBeInTheDocument();
|
expect(within(dialog).getByPlaceholderText('例如: openai')).toBeInTheDocument()
|
||||||
expect(within(dialog).getByPlaceholderText('例如: OpenAI')).toBeInTheDocument();
|
expect(within(dialog).getByPlaceholderText('例如: OpenAI')).toBeInTheDocument()
|
||||||
expect(within(dialog).getByPlaceholderText('例如: https://api.openai.com/v1')).toBeInTheDocument();
|
expect(within(dialog).getByPlaceholderText('例如: https://api.openai.com/v1')).toBeInTheDocument()
|
||||||
});
|
})
|
||||||
|
|
||||||
it('renders pre-filled fields in edit mode', () => {
|
it('renders pre-filled fields in edit mode', () => {
|
||||||
render(<ProviderForm {...defaultProps} provider={mockProvider} />);
|
render(<ProviderForm {...defaultProps} provider={mockProvider} />)
|
||||||
|
|
||||||
const dialog = getDialog();
|
const dialog = getDialog()
|
||||||
expect(within(dialog).getByText('编辑供应商')).toBeInTheDocument();
|
expect(within(dialog).getByText('编辑供应商')).toBeInTheDocument()
|
||||||
|
|
||||||
const idInput = within(dialog).getByPlaceholderText('例如: openai') as HTMLInputElement;
|
const idInput = within(dialog).getByPlaceholderText('例如: openai') as HTMLInputElement
|
||||||
expect(idInput.value).toBe('openai');
|
expect(idInput.value).toBe('openai')
|
||||||
expect(idInput).toBeDisabled();
|
expect(idInput).toBeDisabled()
|
||||||
|
|
||||||
const nameInput = within(dialog).getByPlaceholderText('例如: OpenAI') as HTMLInputElement;
|
const nameInput = within(dialog).getByPlaceholderText('例如: OpenAI') as HTMLInputElement
|
||||||
expect(nameInput.value).toBe('OpenAI');
|
expect(nameInput.value).toBe('OpenAI')
|
||||||
|
|
||||||
const baseUrlInput = within(dialog).getByPlaceholderText('例如: https://api.openai.com/v1') as HTMLInputElement;
|
const baseUrlInput = within(dialog).getByPlaceholderText('例如: https://api.openai.com/v1') as HTMLInputElement
|
||||||
expect(baseUrlInput.value).toBe('https://api.openai.com/v1');
|
expect(baseUrlInput.value).toBe('https://api.openai.com/v1')
|
||||||
|
|
||||||
const apiKeyInput = within(dialog).getByPlaceholderText('sk-...') as HTMLInputElement;
|
const apiKeyInput = within(dialog).getByPlaceholderText('sk-...') as HTMLInputElement
|
||||||
expect(apiKeyInput.value).toBe('sk-old-key');
|
expect(apiKeyInput.value).toBe('sk-old-key')
|
||||||
});
|
})
|
||||||
|
|
||||||
it('shows API Key label in edit mode', () => {
|
it('shows API Key label in edit mode', () => {
|
||||||
render(<ProviderForm {...defaultProps} provider={mockProvider} />);
|
render(<ProviderForm {...defaultProps} provider={mockProvider} />)
|
||||||
|
|
||||||
const dialog = getDialog();
|
const dialog = getDialog()
|
||||||
expect(within(dialog).getByText('API Key')).toBeInTheDocument();
|
expect(within(dialog).getByText('API Key')).toBeInTheDocument()
|
||||||
});
|
})
|
||||||
|
|
||||||
it('shows validation error messages for required fields', async () => {
|
it('shows validation error messages for required fields', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup()
|
||||||
render(<ProviderForm {...defaultProps} />);
|
render(<ProviderForm {...defaultProps} />)
|
||||||
|
|
||||||
const dialog = getDialog();
|
const dialog = getDialog()
|
||||||
const okButton = within(dialog).getByRole('button', { name: /保/ });
|
const okButton = within(dialog).getByRole('button', { name: /保/ })
|
||||||
await user.click(okButton);
|
await user.click(okButton)
|
||||||
|
|
||||||
// Wait for validation messages to appear
|
// Wait for validation messages to appear
|
||||||
expect(await screen.findByText('请输入供应商 ID')).toBeInTheDocument();
|
expect(await screen.findByText('请输入供应商 ID')).toBeInTheDocument()
|
||||||
expect(screen.getByText('请输入名称')).toBeInTheDocument();
|
expect(screen.getByText('请输入名称')).toBeInTheDocument()
|
||||||
expect(screen.getByText('请输入 API Key')).toBeInTheDocument();
|
expect(screen.getByText('请输入 API Key')).toBeInTheDocument()
|
||||||
expect(screen.getByText('请输入 Base URL')).toBeInTheDocument();
|
expect(screen.getByText('请输入 Base URL')).toBeInTheDocument()
|
||||||
});
|
})
|
||||||
|
|
||||||
it('calls onSave with form values on successful submission', async () => {
|
it('calls onSave with form values on successful submission', async () => {
|
||||||
const onSave = vi.fn();
|
const onSave = vi.fn()
|
||||||
render(<ProviderForm {...defaultProps} onSave={onSave} />);
|
render(<ProviderForm {...defaultProps} onSave={onSave} />)
|
||||||
|
|
||||||
const dialog = getDialog();
|
const dialog = getDialog()
|
||||||
|
|
||||||
// Get form instance and set values directly
|
// Get form instance and set values directly
|
||||||
const idInput = within(dialog).getByPlaceholderText('例如: openai') as HTMLInputElement;
|
const idInput = within(dialog).getByPlaceholderText('例如: openai') as HTMLInputElement
|
||||||
const nameInput = within(dialog).getByPlaceholderText('例如: OpenAI') as HTMLInputElement;
|
const nameInput = within(dialog).getByPlaceholderText('例如: OpenAI') as HTMLInputElement
|
||||||
const apiKeyInput = within(dialog).getByPlaceholderText('sk-...') as HTMLInputElement;
|
const apiKeyInput = within(dialog).getByPlaceholderText('sk-...') as HTMLInputElement
|
||||||
const baseUrlInput = within(dialog).getByPlaceholderText('例如: https://api.openai.com/v1') as HTMLInputElement;
|
const baseUrlInput = within(dialog).getByPlaceholderText('例如: https://api.openai.com/v1') as HTMLInputElement
|
||||||
|
|
||||||
// Simulate user input by directly setting values
|
// Simulate user input by directly setting values
|
||||||
fireEvent.change(idInput, { target: { value: 'test-provider' } });
|
fireEvent.change(idInput, { target: { value: 'test-provider' } })
|
||||||
fireEvent.change(nameInput, { target: { value: 'Test Provider' } });
|
fireEvent.change(nameInput, { target: { value: 'Test Provider' } })
|
||||||
fireEvent.change(apiKeyInput, { target: { value: 'sk-test-key' } });
|
fireEvent.change(apiKeyInput, { target: { value: 'sk-test-key' } })
|
||||||
fireEvent.change(baseUrlInput, { target: { value: 'https://api.test.com/v1' } });
|
fireEvent.change(baseUrlInput, { target: { value: 'https://api.test.com/v1' } })
|
||||||
|
|
||||||
const okButton = within(dialog).getByRole('button', { name: /保/ });
|
const okButton = within(dialog).getByRole('button', { name: /保/ })
|
||||||
fireEvent.click(okButton);
|
fireEvent.click(okButton)
|
||||||
|
|
||||||
// Wait for the onSave to be called
|
// Wait for the onSave to be called
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(
|
||||||
expect(onSave).toHaveBeenCalled();
|
() => {
|
||||||
}, { timeout: 5000 });
|
expect(onSave).toHaveBeenCalled()
|
||||||
}, 10000);
|
},
|
||||||
|
{ timeout: 5000 }
|
||||||
|
)
|
||||||
|
}, 10000)
|
||||||
|
|
||||||
it('calls onCancel when clicking cancel button', async () => {
|
it('calls onCancel when clicking cancel button', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup()
|
||||||
const onCancel = vi.fn();
|
const onCancel = vi.fn()
|
||||||
render(<ProviderForm {...defaultProps} onCancel={onCancel} />);
|
render(<ProviderForm {...defaultProps} onCancel={onCancel} />)
|
||||||
|
|
||||||
const dialog = getDialog();
|
const dialog = getDialog()
|
||||||
const cancelButton = within(dialog).getByRole('button', { name: /取/ });
|
const cancelButton = within(dialog).getByRole('button', { name: /取/ })
|
||||||
await user.click(cancelButton);
|
await user.click(cancelButton)
|
||||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||||
});
|
})
|
||||||
|
|
||||||
it('shows confirm loading state', () => {
|
it('shows confirm loading state', () => {
|
||||||
render(<ProviderForm {...defaultProps} loading={true} />);
|
render(<ProviderForm {...defaultProps} loading={true} />)
|
||||||
const dialog = getDialog();
|
const dialog = getDialog()
|
||||||
const okButton = within(dialog).getByRole('button', { name: /保/ });
|
const okButton = within(dialog).getByRole('button', { name: /保/ })
|
||||||
// TDesign uses t-is-loading class for loading state
|
// TDesign uses t-is-loading class for loading state
|
||||||
expect(okButton).toHaveClass('t-is-loading');
|
expect(okButton).toHaveClass('t-is-loading')
|
||||||
});
|
})
|
||||||
|
|
||||||
it('shows validation error for invalid URL format', async () => {
|
it('shows validation error for invalid URL format', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup()
|
||||||
render(<ProviderForm {...defaultProps} />);
|
render(<ProviderForm {...defaultProps} />)
|
||||||
|
|
||||||
const dialog = getDialog();
|
const dialog = getDialog()
|
||||||
|
|
||||||
// Fill in required fields
|
// Fill in required fields
|
||||||
await user.type(within(dialog).getByPlaceholderText('例如: openai'), 'test-provider');
|
await user.type(within(dialog).getByPlaceholderText('例如: openai'), 'test-provider')
|
||||||
await user.type(within(dialog).getByPlaceholderText('例如: OpenAI'), 'Test Provider');
|
await user.type(within(dialog).getByPlaceholderText('例如: OpenAI'), 'Test Provider')
|
||||||
await user.type(within(dialog).getByPlaceholderText('sk-...'), 'sk-test-key');
|
await user.type(within(dialog).getByPlaceholderText('sk-...'), 'sk-test-key')
|
||||||
|
|
||||||
// Enter an invalid URL in the Base URL field
|
// Enter an invalid URL in the Base URL field
|
||||||
await user.type(within(dialog).getByPlaceholderText('例如: https://api.openai.com/v1'), 'not-a-url');
|
await user.type(within(dialog).getByPlaceholderText('例如: https://api.openai.com/v1'), 'not-a-url')
|
||||||
|
|
||||||
// Submit the form
|
// Submit the form
|
||||||
const okButton = within(dialog).getByRole('button', { name: /保/ });
|
const okButton = within(dialog).getByRole('button', { name: /保/ })
|
||||||
await user.click(okButton);
|
await user.click(okButton)
|
||||||
|
|
||||||
// Verify that a URL validation error message appears
|
// Verify that a URL validation error message appears
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(screen.getByText('请输入有效的 URL')).toBeInTheDocument();
|
expect(screen.getByText('请输入有效的 URL')).toBeInTheDocument()
|
||||||
});
|
})
|
||||||
}, 15000);
|
}, 15000)
|
||||||
|
|
||||||
it('renders protocol select field with default value', () => {
|
it('renders protocol select field with default value', () => {
|
||||||
render(<ProviderForm {...defaultProps} />);
|
render(<ProviderForm {...defaultProps} />)
|
||||||
|
|
||||||
const dialog = getDialog();
|
const dialog = getDialog()
|
||||||
expect(within(dialog).getByText('协议')).toBeInTheDocument();
|
expect(within(dialog).getByText('协议')).toBeInTheDocument()
|
||||||
});
|
})
|
||||||
|
|
||||||
it('includes protocol field in form submission', async () => {
|
it('includes protocol field in form submission', async () => {
|
||||||
const onSave = vi.fn();
|
const onSave = vi.fn()
|
||||||
render(<ProviderForm {...defaultProps} onSave={onSave} />);
|
render(<ProviderForm {...defaultProps} onSave={onSave} />)
|
||||||
|
|
||||||
const dialog = getDialog();
|
const dialog = getDialog()
|
||||||
|
|
||||||
// Get form instance and set values directly
|
// Get form instance and set values directly
|
||||||
const idInput = within(dialog).getByPlaceholderText('例如: openai') as HTMLInputElement;
|
const idInput = within(dialog).getByPlaceholderText('例如: openai') as HTMLInputElement
|
||||||
const nameInput = within(dialog).getByPlaceholderText('例如: OpenAI') as HTMLInputElement;
|
const nameInput = within(dialog).getByPlaceholderText('例如: OpenAI') as HTMLInputElement
|
||||||
const apiKeyInput = within(dialog).getByPlaceholderText('sk-...') as HTMLInputElement;
|
const apiKeyInput = within(dialog).getByPlaceholderText('sk-...') as HTMLInputElement
|
||||||
const baseUrlInput = within(dialog).getByPlaceholderText('例如: https://api.openai.com/v1') as HTMLInputElement;
|
const baseUrlInput = within(dialog).getByPlaceholderText('例如: https://api.openai.com/v1') as HTMLInputElement
|
||||||
|
|
||||||
// Simulate user input by directly setting values
|
// Simulate user input by directly setting values
|
||||||
fireEvent.change(idInput, { target: { value: 'test-provider' } });
|
fireEvent.change(idInput, { target: { value: 'test-provider' } })
|
||||||
fireEvent.change(nameInput, { target: { value: 'Test Provider' } });
|
fireEvent.change(nameInput, { target: { value: 'Test Provider' } })
|
||||||
fireEvent.change(apiKeyInput, { target: { value: 'sk-test-key' } });
|
fireEvent.change(apiKeyInput, { target: { value: 'sk-test-key' } })
|
||||||
fireEvent.change(baseUrlInput, { target: { value: 'https://api.test.com/v1' } });
|
fireEvent.change(baseUrlInput, { target: { value: 'https://api.test.com/v1' } })
|
||||||
|
|
||||||
const okButton = within(dialog).getByRole('button', { name: /保/ });
|
const okButton = within(dialog).getByRole('button', { name: /保/ })
|
||||||
fireEvent.click(okButton);
|
fireEvent.click(okButton)
|
||||||
|
|
||||||
// Wait for the onSave to be called
|
// Wait for the onSave to be called
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(
|
||||||
expect(onSave).toHaveBeenCalled();
|
() => {
|
||||||
|
expect(onSave).toHaveBeenCalled()
|
||||||
// Verify that the saved data includes a protocol field
|
// Verify that the saved data includes a protocol field
|
||||||
const savedData = onSave.mock.calls[0][0];
|
const savedData = onSave.mock.calls[0][0]
|
||||||
expect(savedData).toHaveProperty('protocol');
|
expect(savedData).toHaveProperty('protocol')
|
||||||
}, { timeout: 5000 });
|
},
|
||||||
}, 10000);
|
{ timeout: 5000 }
|
||||||
});
|
)
|
||||||
|
}, 10000)
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,18 +1,24 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react'
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event'
|
||||||
import { describe, it, expect, vi } from 'vitest';
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
import { ProviderTable } from '@/pages/Providers/ProviderTable';
|
import { ProviderTable } from '@/pages/Providers/ProviderTable'
|
||||||
import type { Provider } from '@/types';
|
import type { Provider } from '@/types'
|
||||||
|
|
||||||
const mockModelsData = [
|
const mockModelsData = [
|
||||||
{ id: 'model-1', providerId: 'openai', modelName: 'gpt-4o', enabled: true, unifiedId: 'openai/gpt-4o' },
|
{ id: 'model-1', providerId: 'openai', modelName: 'gpt-4o', enabled: true, unifiedId: 'openai/gpt-4o' },
|
||||||
{ id: 'model-2', providerId: 'openai', modelName: 'gpt-3.5-turbo', enabled: false, unifiedId: 'openai/gpt-3.5-turbo' },
|
{
|
||||||
];
|
id: 'model-2',
|
||||||
|
providerId: 'openai',
|
||||||
|
modelName: 'gpt-3.5-turbo',
|
||||||
|
enabled: false,
|
||||||
|
unifiedId: 'openai/gpt-3.5-turbo',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
vi.mock('@/hooks/useModels', () => ({
|
vi.mock('@/hooks/useModels', () => ({
|
||||||
useModels: vi.fn(() => ({ data: mockModelsData, isLoading: false })),
|
useModels: vi.fn(() => ({ data: mockModelsData, isLoading: false })),
|
||||||
useDeleteModel: vi.fn(() => ({ mutate: vi.fn() })),
|
useDeleteModel: vi.fn(() => ({ mutate: vi.fn() })),
|
||||||
}));
|
}))
|
||||||
|
|
||||||
const mockProviders: Provider[] = [
|
const mockProviders: Provider[] = [
|
||||||
{
|
{
|
||||||
@@ -35,7 +41,7 @@ const mockProviders: Provider[] = [
|
|||||||
createdAt: '2024-01-02T00:00:00Z',
|
createdAt: '2024-01-02T00:00:00Z',
|
||||||
updatedAt: '2024-01-02T00:00:00Z',
|
updatedAt: '2024-01-02T00:00:00Z',
|
||||||
},
|
},
|
||||||
];
|
]
|
||||||
|
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
providers: mockProviders,
|
providers: mockProviders,
|
||||||
@@ -45,36 +51,36 @@ const defaultProps = {
|
|||||||
onDelete: vi.fn(),
|
onDelete: vi.fn(),
|
||||||
onAddModel: vi.fn(),
|
onAddModel: vi.fn(),
|
||||||
onEditModel: vi.fn(),
|
onEditModel: vi.fn(),
|
||||||
};
|
}
|
||||||
|
|
||||||
describe('ProviderTable', () => {
|
describe('ProviderTable', () => {
|
||||||
it('renders provider list with name, baseUrl, apiKey, and status tags', () => {
|
it('renders provider list with name, baseUrl, apiKey, and status tags', () => {
|
||||||
render(<ProviderTable {...defaultProps} />);
|
render(<ProviderTable {...defaultProps} />)
|
||||||
|
|
||||||
expect(screen.getByText('供应商列表')).toBeInTheDocument();
|
expect(screen.getByText('供应商列表')).toBeInTheDocument()
|
||||||
|
|
||||||
expect(screen.getAllByText('OpenAI').length).toBeGreaterThan(0);
|
expect(screen.getAllByText('OpenAI').length).toBeGreaterThan(0)
|
||||||
expect(screen.getByText('https://api.openai.com/v1')).toBeInTheDocument();
|
expect(screen.getByText('https://api.openai.com/v1')).toBeInTheDocument()
|
||||||
expect(screen.getByText('sk-abcdefgh12345678')).toBeInTheDocument();
|
expect(screen.getByText('sk-abcdefgh12345678')).toBeInTheDocument()
|
||||||
|
|
||||||
expect(screen.getAllByText('Anthropic').length).toBeGreaterThan(0);
|
expect(screen.getAllByText('Anthropic').length).toBeGreaterThan(0)
|
||||||
expect(screen.getByText('https://api.anthropic.com')).toBeInTheDocument();
|
expect(screen.getByText('https://api.anthropic.com')).toBeInTheDocument()
|
||||||
expect(screen.getByText('sk-ant-test')).toBeInTheDocument();
|
expect(screen.getByText('sk-ant-test')).toBeInTheDocument()
|
||||||
|
|
||||||
const enabledTags = screen.getAllByText('启用');
|
const enabledTags = screen.getAllByText('启用')
|
||||||
const disabledTags = screen.getAllByText('禁用');
|
const disabledTags = screen.getAllByText('禁用')
|
||||||
expect(enabledTags.length).toBeGreaterThanOrEqual(1);
|
expect(enabledTags.length).toBeGreaterThanOrEqual(1)
|
||||||
expect(disabledTags.length).toBeGreaterThanOrEqual(1);
|
expect(disabledTags.length).toBeGreaterThanOrEqual(1)
|
||||||
});
|
})
|
||||||
|
|
||||||
it('renders within a Card component', () => {
|
it('renders within a Card component', () => {
|
||||||
const { container } = render(<ProviderTable {...defaultProps} />);
|
const { container } = render(<ProviderTable {...defaultProps} />)
|
||||||
|
|
||||||
// TDesign Card component
|
// TDesign Card component
|
||||||
expect(container.querySelector('.t-card')).toBeInTheDocument();
|
expect(container.querySelector('.t-card')).toBeInTheDocument()
|
||||||
expect(container.querySelector('.t-card__header')).toBeInTheDocument();
|
expect(container.querySelector('.t-card__header')).toBeInTheDocument()
|
||||||
expect(container.querySelector('.t-card__body')).toBeInTheDocument();
|
expect(container.querySelector('.t-card__body')).toBeInTheDocument()
|
||||||
});
|
})
|
||||||
|
|
||||||
it('renders short api keys directly', () => {
|
it('renders short api keys directly', () => {
|
||||||
const shortKeyProvider: Provider[] = [
|
const shortKeyProvider: Provider[] = [
|
||||||
@@ -84,99 +90,99 @@ describe('ProviderTable', () => {
|
|||||||
name: 'ShortKey',
|
name: 'ShortKey',
|
||||||
apiKey: 'ab',
|
apiKey: 'ab',
|
||||||
},
|
},
|
||||||
];
|
]
|
||||||
render(<ProviderTable {...defaultProps} providers={shortKeyProvider} />);
|
render(<ProviderTable {...defaultProps} providers={shortKeyProvider} />)
|
||||||
|
|
||||||
expect(screen.getByText('ab')).toBeInTheDocument();
|
expect(screen.getByText('ab')).toBeInTheDocument()
|
||||||
});
|
})
|
||||||
|
|
||||||
it('calls onAdd when clicking "添加供应商" button', async () => {
|
it('calls onAdd when clicking "添加供应商" button', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup()
|
||||||
const onAdd = vi.fn();
|
const onAdd = vi.fn()
|
||||||
render(<ProviderTable {...defaultProps} onAdd={onAdd} />);
|
render(<ProviderTable {...defaultProps} onAdd={onAdd} />)
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: '添加供应商' }));
|
await user.click(screen.getByRole('button', { name: '添加供应商' }))
|
||||||
expect(onAdd).toHaveBeenCalledTimes(1);
|
expect(onAdd).toHaveBeenCalledTimes(1)
|
||||||
});
|
})
|
||||||
|
|
||||||
it('calls onEdit with correct provider when clicking "编辑"', async () => {
|
it('calls onEdit with correct provider when clicking "编辑"', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup()
|
||||||
const onEdit = vi.fn();
|
const onEdit = vi.fn()
|
||||||
render(<ProviderTable {...defaultProps} onEdit={onEdit} />);
|
render(<ProviderTable {...defaultProps} onEdit={onEdit} />)
|
||||||
|
|
||||||
const editButtons = screen.getAllByRole('button', { name: /编 ?辑/ });
|
const editButtons = screen.getAllByRole('button', { name: /编 ?辑/ })
|
||||||
await user.click(editButtons[0]);
|
await user.click(editButtons[0])
|
||||||
|
|
||||||
expect(onEdit).toHaveBeenCalledTimes(1);
|
expect(onEdit).toHaveBeenCalledTimes(1)
|
||||||
expect(onEdit).toHaveBeenCalledWith(mockProviders[0]);
|
expect(onEdit).toHaveBeenCalledWith(mockProviders[0])
|
||||||
});
|
})
|
||||||
|
|
||||||
it('calls onDelete with correct provider ID when delete is confirmed', async () => {
|
it('calls onDelete with correct provider ID when delete is confirmed', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup()
|
||||||
const onDelete = vi.fn();
|
const onDelete = vi.fn()
|
||||||
render(<ProviderTable {...defaultProps} onDelete={onDelete} />);
|
render(<ProviderTable {...defaultProps} onDelete={onDelete} />)
|
||||||
|
|
||||||
// Find and click the delete button for the first row
|
// Find and click the delete button for the first row
|
||||||
const deleteButtons = screen.getAllByRole('button', { name: '删除' });
|
const deleteButtons = screen.getAllByRole('button', { name: '删除' })
|
||||||
await user.click(deleteButtons[0]);
|
await user.click(deleteButtons[0])
|
||||||
|
|
||||||
// TDesign Popconfirm renders confirmation popup with "确定" button
|
// TDesign Popconfirm renders confirmation popup with "确定" button
|
||||||
const confirmButton = await screen.findByRole('button', { name: '确定' });
|
const confirmButton = await screen.findByRole('button', { name: '确定' })
|
||||||
await user.click(confirmButton);
|
await user.click(confirmButton)
|
||||||
|
|
||||||
// Assert that onDelete was called with the correct provider ID
|
// Assert that onDelete was called with the correct provider ID
|
||||||
expect(onDelete).toHaveBeenCalledTimes(1);
|
expect(onDelete).toHaveBeenCalledTimes(1)
|
||||||
expect(onDelete).toHaveBeenCalledWith('openai');
|
expect(onDelete).toHaveBeenCalledWith('openai')
|
||||||
}, 10000);
|
}, 10000)
|
||||||
|
|
||||||
it('shows loading state', () => {
|
it('shows loading state', () => {
|
||||||
const { container } = render(<ProviderTable {...defaultProps} loading={true} />);
|
const { container } = render(<ProviderTable {...defaultProps} loading={true} />)
|
||||||
// TDesign Table loading indicator
|
// TDesign Table loading indicator
|
||||||
const loadingElement = container.querySelector('.t-table__loading') || container.querySelector('.t-loading');
|
const loadingElement = container.querySelector('.t-table__loading') || container.querySelector('.t-loading')
|
||||||
expect(loadingElement).toBeInTheDocument();
|
expect(loadingElement).toBeInTheDocument()
|
||||||
});
|
})
|
||||||
|
|
||||||
it('renders expandable ModelTable when row is expanded', async () => {
|
it('renders expandable ModelTable when row is expanded', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup()
|
||||||
const { container } = render(<ProviderTable {...defaultProps} />);
|
const { container } = render(<ProviderTable {...defaultProps} />)
|
||||||
|
|
||||||
// TDesign Table expand icon is rendered as a button with specific class
|
// TDesign Table expand icon is rendered as a button with specific class
|
||||||
const expandIcon = container.querySelector('.t-table__expandable-icon');
|
const expandIcon = container.querySelector('.t-table__expandable-icon')
|
||||||
if (expandIcon) {
|
if (expandIcon) {
|
||||||
await user.click(expandIcon);
|
await user.click(expandIcon)
|
||||||
|
|
||||||
// Verify that ModelTable content is rendered with data from mocked useModels
|
// Verify that ModelTable content is rendered with data from mocked useModels
|
||||||
expect(await screen.findByText('gpt-4o')).toBeInTheDocument();
|
expect(await screen.findByText('gpt-4o')).toBeInTheDocument()
|
||||||
expect(screen.getByText('gpt-3.5-turbo')).toBeInTheDocument();
|
expect(screen.getByText('gpt-3.5-turbo')).toBeInTheDocument()
|
||||||
} else {
|
} else {
|
||||||
// If no expand icon found, the test should still pass as expandable rows are optional
|
// If no expand icon found, the test should still pass as expandable rows are optional
|
||||||
expect(true).toBe(true);
|
expect(true).toBe(true)
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
|
||||||
it('sets fixed width and ellipsis on name column', () => {
|
it('sets fixed width and ellipsis on name column', () => {
|
||||||
const { container } = render(<ProviderTable {...defaultProps} />);
|
const { container } = render(<ProviderTable {...defaultProps} />)
|
||||||
// TDesign Table
|
// TDesign Table
|
||||||
const table = container.querySelector('.t-table');
|
const table = container.querySelector('.t-table')
|
||||||
expect(table).toBeInTheDocument();
|
expect(table).toBeInTheDocument()
|
||||||
});
|
})
|
||||||
|
|
||||||
it('shows custom empty text when providers list is empty', () => {
|
it('shows custom empty text when providers list is empty', () => {
|
||||||
render(<ProviderTable {...defaultProps} providers={[]} />);
|
render(<ProviderTable {...defaultProps} providers={[]} />)
|
||||||
expect(screen.getByText('暂无供应商,点击上方按钮添加')).toBeInTheDocument();
|
expect(screen.getByText('暂无供应商,点击上方按钮添加')).toBeInTheDocument()
|
||||||
});
|
})
|
||||||
|
|
||||||
it('renders protocol column with correct tags', () => {
|
it('renders protocol column with correct tags', () => {
|
||||||
const { container } = render(<ProviderTable {...defaultProps} />);
|
const { container } = render(<ProviderTable {...defaultProps} />)
|
||||||
|
|
||||||
// Check that protocol tags are displayed in the table
|
// Check that protocol tags are displayed in the table
|
||||||
const protocolCells = container.querySelectorAll('[data-colkey="protocol"]');
|
const protocolCells = container.querySelectorAll('[data-colkey="protocol"]')
|
||||||
expect(protocolCells.length).toBeGreaterThan(0);
|
expect(protocolCells.length).toBeGreaterThan(0)
|
||||||
|
|
||||||
// Verify protocol tags exist
|
// Verify protocol tags exist
|
||||||
const tags = container.querySelectorAll('.t-tag');
|
const tags = container.querySelectorAll('.t-tag')
|
||||||
expect(tags.length).toBeGreaterThan(0);
|
expect(tags.length).toBeGreaterThan(0)
|
||||||
});
|
})
|
||||||
|
|
||||||
it('displays protocol tag for each provider', () => {
|
it('displays protocol tag for each provider', () => {
|
||||||
const singleProvider: Provider[] = [
|
const singleProvider: Provider[] = [
|
||||||
@@ -190,11 +196,11 @@ describe('ProviderTable', () => {
|
|||||||
createdAt: '2024-01-01T00:00:00Z',
|
createdAt: '2024-01-01T00:00:00Z',
|
||||||
updatedAt: '2024-01-01T00:00:00Z',
|
updatedAt: '2024-01-01T00:00:00Z',
|
||||||
},
|
},
|
||||||
];
|
]
|
||||||
const { container } = render(<ProviderTable {...defaultProps} providers={singleProvider} />);
|
const { container } = render(<ProviderTable {...defaultProps} providers={singleProvider} />)
|
||||||
|
|
||||||
// Should display protocol column
|
// Should display protocol column
|
||||||
const protocolCell = container.querySelector('[data-colkey="protocol"]');
|
const protocolCell = container.querySelector('[data-colkey="protocol"]')
|
||||||
expect(protocolCell).toBeInTheDocument();
|
expect(protocolCell).toBeInTheDocument()
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react'
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest'
|
||||||
import { StatCards } from '@/pages/Stats/StatCards';
|
import { StatCards } from '@/pages/Stats/StatCards'
|
||||||
import type { UsageStats } from '@/types';
|
import type { UsageStats } from '@/types'
|
||||||
|
|
||||||
const mockStats: UsageStats[] = [
|
const mockStats: UsageStats[] = [
|
||||||
{
|
{
|
||||||
@@ -25,31 +25,31 @@ const mockStats: UsageStats[] = [
|
|||||||
requestCount: 150,
|
requestCount: 150,
|
||||||
date: '2024-01-02',
|
date: '2024-01-02',
|
||||||
},
|
},
|
||||||
];
|
]
|
||||||
|
|
||||||
describe('StatCards', () => {
|
describe('StatCards', () => {
|
||||||
it('renders all statistic cards', () => {
|
it('renders all statistic cards', () => {
|
||||||
render(<StatCards stats={mockStats} />);
|
render(<StatCards stats={mockStats} />)
|
||||||
|
|
||||||
expect(screen.getByText('总请求量')).toBeInTheDocument();
|
expect(screen.getByText('总请求量')).toBeInTheDocument()
|
||||||
expect(screen.getByText('活跃模型数')).toBeInTheDocument();
|
expect(screen.getByText('活跃模型数')).toBeInTheDocument()
|
||||||
expect(screen.getByText('活跃供应商数')).toBeInTheDocument();
|
expect(screen.getByText('活跃供应商数')).toBeInTheDocument()
|
||||||
expect(screen.getByText('今日请求量')).toBeInTheDocument();
|
expect(screen.getByText('今日请求量')).toBeInTheDocument()
|
||||||
});
|
})
|
||||||
|
|
||||||
it('renders with empty stats', () => {
|
it('renders with empty stats', () => {
|
||||||
render(<StatCards stats={[]} />);
|
render(<StatCards stats={[]} />)
|
||||||
|
|
||||||
expect(screen.getByText('总请求量')).toBeInTheDocument();
|
expect(screen.getByText('总请求量')).toBeInTheDocument()
|
||||||
expect(screen.getByText('活跃模型数')).toBeInTheDocument();
|
expect(screen.getByText('活跃模型数')).toBeInTheDocument()
|
||||||
expect(screen.getByText('活跃供应商数')).toBeInTheDocument();
|
expect(screen.getByText('活跃供应商数')).toBeInTheDocument()
|
||||||
expect(screen.getByText('今日请求量')).toBeInTheDocument();
|
expect(screen.getByText('今日请求量')).toBeInTheDocument()
|
||||||
});
|
})
|
||||||
|
|
||||||
it('renders suffix units', () => {
|
it('renders suffix units', () => {
|
||||||
render(<StatCards stats={mockStats} />);
|
render(<StatCards stats={mockStats} />)
|
||||||
|
|
||||||
expect(screen.getAllByText('次').length).toBeGreaterThan(0);
|
expect(screen.getAllByText('次').length).toBeGreaterThan(0)
|
||||||
expect(screen.getAllByText('个').length).toBeGreaterThan(0);
|
expect(screen.getAllByText('个').length).toBeGreaterThan(0)
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react'
|
||||||
import { describe, it, expect, vi } from 'vitest';
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
import { StatsTable } from '@/pages/Stats/StatsTable';
|
import { StatsTable } from '@/pages/Stats/StatsTable'
|
||||||
import type { Provider, UsageStats } from '@/types';
|
import type { Provider, UsageStats } from '@/types'
|
||||||
|
|
||||||
const mockProviders: Provider[] = [
|
const mockProviders: Provider[] = [
|
||||||
{
|
{
|
||||||
@@ -24,7 +24,7 @@ const mockProviders: Provider[] = [
|
|||||||
createdAt: '2024-01-02T00:00:00Z',
|
createdAt: '2024-01-02T00:00:00Z',
|
||||||
updatedAt: '2024-01-02T00:00:00Z',
|
updatedAt: '2024-01-02T00:00:00Z',
|
||||||
},
|
},
|
||||||
];
|
]
|
||||||
|
|
||||||
const mockStats: UsageStats[] = [
|
const mockStats: UsageStats[] = [
|
||||||
{
|
{
|
||||||
@@ -41,7 +41,7 @@ const mockStats: UsageStats[] = [
|
|||||||
requestCount: 50,
|
requestCount: 50,
|
||||||
date: '2024-01-15',
|
date: '2024-01-15',
|
||||||
},
|
},
|
||||||
];
|
]
|
||||||
|
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
providers: mockProviders,
|
providers: mockProviders,
|
||||||
@@ -53,80 +53,80 @@ const defaultProps = {
|
|||||||
onProviderIdChange: vi.fn(),
|
onProviderIdChange: vi.fn(),
|
||||||
onModelNameChange: vi.fn(),
|
onModelNameChange: vi.fn(),
|
||||||
onDateRangeChange: vi.fn(),
|
onDateRangeChange: vi.fn(),
|
||||||
};
|
}
|
||||||
|
|
||||||
describe('StatsTable', () => {
|
describe('StatsTable', () => {
|
||||||
it('renders stats table with data', () => {
|
it('renders stats table with data', () => {
|
||||||
render(<StatsTable {...defaultProps} />);
|
render(<StatsTable {...defaultProps} />)
|
||||||
|
|
||||||
expect(screen.getByText('gpt-4o')).toBeInTheDocument();
|
expect(screen.getByText('gpt-4o')).toBeInTheDocument()
|
||||||
expect(screen.getByText('claude-3-opus')).toBeInTheDocument();
|
expect(screen.getByText('claude-3-opus')).toBeInTheDocument()
|
||||||
const dateCells = screen.getAllByText('2024-01-15');
|
const dateCells = screen.getAllByText('2024-01-15')
|
||||||
expect(dateCells.length).toBe(2);
|
expect(dateCells.length).toBe(2)
|
||||||
expect(screen.getByText('100')).toBeInTheDocument();
|
expect(screen.getByText('100')).toBeInTheDocument()
|
||||||
expect(screen.getByText('50')).toBeInTheDocument();
|
expect(screen.getByText('50')).toBeInTheDocument()
|
||||||
});
|
})
|
||||||
|
|
||||||
it('shows provider name from providers prop instead of providerId', () => {
|
it('shows provider name from providers prop instead of providerId', () => {
|
||||||
render(<StatsTable {...defaultProps} />);
|
render(<StatsTable {...defaultProps} />)
|
||||||
|
|
||||||
expect(screen.getByText('OpenAI')).toBeInTheDocument();
|
expect(screen.getByText('OpenAI')).toBeInTheDocument()
|
||||||
const allAnthropic = screen.getAllByText('Anthropic');
|
const allAnthropic = screen.getAllByText('Anthropic')
|
||||||
expect(allAnthropic.length).toBeGreaterThanOrEqual(1);
|
expect(allAnthropic.length).toBeGreaterThanOrEqual(1)
|
||||||
});
|
})
|
||||||
|
|
||||||
it('renders filter controls with Select, Input, and DatePicker', () => {
|
it('renders filter controls with Select, Input, and DatePicker', () => {
|
||||||
const { container } = render(<StatsTable {...defaultProps} />);
|
const { container } = render(<StatsTable {...defaultProps} />)
|
||||||
|
|
||||||
// TDesign Select component
|
// TDesign Select component
|
||||||
const selects = document.querySelectorAll('.t-select');
|
const selects = document.querySelectorAll('.t-select')
|
||||||
expect(selects.length).toBeGreaterThanOrEqual(1);
|
expect(selects.length).toBeGreaterThanOrEqual(1)
|
||||||
|
|
||||||
const modelInput = screen.getByPlaceholderText('模型名称');
|
const modelInput = screen.getByPlaceholderText('模型名称')
|
||||||
expect(modelInput).toBeInTheDocument();
|
expect(modelInput).toBeInTheDocument()
|
||||||
|
|
||||||
// TDesign Select placeholder is shown in the input
|
// TDesign Select placeholder is shown in the input
|
||||||
const selectInput = document.querySelector('.t-select .t-input__inner');
|
const selectInput = document.querySelector('.t-select .t-input__inner')
|
||||||
expect(selectInput).toBeInTheDocument();
|
expect(selectInput).toBeInTheDocument()
|
||||||
|
|
||||||
// TDesign DateRangePicker - could be .t-date-picker or .t-range-input
|
// TDesign DateRangePicker - could be .t-date-picker or .t-range-input
|
||||||
const rangePicker = container.querySelector('.t-date-picker') || container.querySelector('.t-range-input');
|
const rangePicker = container.querySelector('.t-date-picker') || container.querySelector('.t-range-input')
|
||||||
expect(rangePicker).toBeInTheDocument();
|
expect(rangePicker).toBeInTheDocument()
|
||||||
});
|
})
|
||||||
|
|
||||||
it('renders table headers correctly', () => {
|
it('renders table headers correctly', () => {
|
||||||
render(<StatsTable {...defaultProps} />);
|
render(<StatsTable {...defaultProps} />)
|
||||||
|
|
||||||
expect(screen.getAllByText('供应商').length).toBeGreaterThanOrEqual(1);
|
expect(screen.getAllByText('供应商').length).toBeGreaterThanOrEqual(1)
|
||||||
expect(screen.getAllByText('模型').length).toBeGreaterThanOrEqual(1);
|
expect(screen.getAllByText('模型').length).toBeGreaterThanOrEqual(1)
|
||||||
expect(screen.getAllByText('日期').length).toBeGreaterThanOrEqual(1);
|
expect(screen.getAllByText('日期').length).toBeGreaterThanOrEqual(1)
|
||||||
expect(screen.getAllByText('请求数').length).toBeGreaterThanOrEqual(1);
|
expect(screen.getAllByText('请求数').length).toBeGreaterThanOrEqual(1)
|
||||||
});
|
})
|
||||||
|
|
||||||
it('falls back to providerId when provider not found in providers prop', () => {
|
it('falls back to providerId when provider not found in providers prop', () => {
|
||||||
const limitedProviders = [mockProviders[0]];
|
const limitedProviders = [mockProviders[0]]
|
||||||
render(<StatsTable {...defaultProps} providers={limitedProviders} />);
|
render(<StatsTable {...defaultProps} providers={limitedProviders} />)
|
||||||
|
|
||||||
expect(screen.getByText('OpenAI')).toBeInTheDocument();
|
expect(screen.getByText('OpenAI')).toBeInTheDocument()
|
||||||
expect(screen.getByText('anthropic')).toBeInTheDocument();
|
expect(screen.getByText('anthropic')).toBeInTheDocument()
|
||||||
});
|
})
|
||||||
|
|
||||||
it('renders with empty stats data', () => {
|
it('renders with empty stats data', () => {
|
||||||
render(<StatsTable {...defaultProps} stats={[]} />);
|
render(<StatsTable {...defaultProps} stats={[]} />)
|
||||||
|
|
||||||
expect(screen.getAllByText('供应商').length).toBeGreaterThanOrEqual(1);
|
expect(screen.getAllByText('供应商').length).toBeGreaterThanOrEqual(1)
|
||||||
expect(screen.getAllByText('模型').length).toBeGreaterThanOrEqual(1);
|
expect(screen.getAllByText('模型').length).toBeGreaterThanOrEqual(1)
|
||||||
});
|
})
|
||||||
|
|
||||||
it('shows loading state', () => {
|
it('shows loading state', () => {
|
||||||
const { container } = render(<StatsTable {...defaultProps} loading={true} />);
|
const { container } = render(<StatsTable {...defaultProps} loading={true} />)
|
||||||
// TDesign Table loading indicator - could be .t-table__loading or .t-loading
|
// TDesign Table loading indicator - could be .t-table__loading or .t-loading
|
||||||
const loadingElement = container.querySelector('.t-table__loading') || container.querySelector('.t-loading');
|
const loadingElement = container.querySelector('.t-table__loading') || container.querySelector('.t-loading')
|
||||||
expect(loadingElement).toBeInTheDocument();
|
expect(loadingElement).toBeInTheDocument()
|
||||||
});
|
})
|
||||||
|
|
||||||
it('shows custom empty text when stats data is empty', () => {
|
it('shows custom empty text when stats data is empty', () => {
|
||||||
render(<StatsTable {...defaultProps} stats={[]} />);
|
render(<StatsTable {...defaultProps} stats={[]} />)
|
||||||
expect(screen.getByText('暂无统计数据')).toBeInTheDocument();
|
expect(screen.getByText('暂无统计数据')).toBeInTheDocument()
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react'
|
||||||
import { describe, it, expect, vi } from 'vitest';
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
import { UsageChart } from '@/pages/Stats/UsageChart';
|
import { UsageChart } from '@/pages/Stats/UsageChart'
|
||||||
import type { UsageStats } from '@/types';
|
import type { UsageStats } from '@/types'
|
||||||
|
|
||||||
// Mock Recharts components
|
// Mock Recharts components
|
||||||
vi.mock('recharts', () => ({
|
vi.mock('recharts', () => ({
|
||||||
ResponsiveContainer: vi.fn(({ children }) => <div data-testid="mock-chart-container">{children}</div>),
|
ResponsiveContainer: vi.fn(({ children }) => <div data-testid='mock-chart-container'>{children}</div>),
|
||||||
AreaChart: vi.fn(() => <div data-testid="mock-area-chart" />),
|
AreaChart: vi.fn(() => <div data-testid='mock-area-chart' />),
|
||||||
Area: vi.fn(() => null),
|
Area: vi.fn(() => null),
|
||||||
XAxis: vi.fn(() => null),
|
XAxis: vi.fn(() => null),
|
||||||
YAxis: vi.fn(() => null),
|
YAxis: vi.fn(() => null),
|
||||||
CartesianGrid: vi.fn(() => null),
|
CartesianGrid: vi.fn(() => null),
|
||||||
Tooltip: vi.fn(() => null),
|
Tooltip: vi.fn(() => null),
|
||||||
}));
|
}))
|
||||||
|
|
||||||
const mockStats: UsageStats[] = [
|
const mockStats: UsageStats[] = [
|
||||||
{
|
{
|
||||||
@@ -36,36 +36,36 @@ const mockStats: UsageStats[] = [
|
|||||||
requestCount: 150,
|
requestCount: 150,
|
||||||
date: '2024-01-02',
|
date: '2024-01-02',
|
||||||
},
|
},
|
||||||
];
|
]
|
||||||
|
|
||||||
describe('UsageChart', () => {
|
describe('UsageChart', () => {
|
||||||
it('renders chart title', () => {
|
it('renders chart title', () => {
|
||||||
render(<UsageChart stats={mockStats} />);
|
render(<UsageChart stats={mockStats} />)
|
||||||
|
|
||||||
expect(screen.getByText('请求趋势')).toBeInTheDocument();
|
expect(screen.getByText('请求趋势')).toBeInTheDocument()
|
||||||
});
|
})
|
||||||
|
|
||||||
it('renders with data', () => {
|
it('renders with data', () => {
|
||||||
const { container } = render(<UsageChart stats={mockStats} />);
|
const { container } = render(<UsageChart stats={mockStats} />)
|
||||||
|
|
||||||
// TDesign Card component
|
// TDesign Card component
|
||||||
expect(container.querySelector('.t-card')).toBeInTheDocument();
|
expect(container.querySelector('.t-card')).toBeInTheDocument()
|
||||||
// Mocked chart container
|
// Mocked chart container
|
||||||
expect(screen.getByTestId('mock-chart-container')).toBeInTheDocument();
|
expect(screen.getByTestId('mock-chart-container')).toBeInTheDocument()
|
||||||
});
|
})
|
||||||
|
|
||||||
it('renders empty state when no data', () => {
|
it('renders empty state when no data', () => {
|
||||||
render(<UsageChart stats={[]} />);
|
render(<UsageChart stats={[]} />)
|
||||||
|
|
||||||
expect(screen.getByText('暂无数据')).toBeInTheDocument();
|
expect(screen.getByText('暂无数据')).toBeInTheDocument()
|
||||||
});
|
})
|
||||||
|
|
||||||
it('aggregates data by date correctly', () => {
|
it('aggregates data by date correctly', () => {
|
||||||
const { container } = render(<UsageChart stats={mockStats} />);
|
const { container } = render(<UsageChart stats={mockStats} />)
|
||||||
|
|
||||||
// TDesign Card component
|
// TDesign Card component
|
||||||
expect(container.querySelector('.t-card')).toBeInTheDocument();
|
expect(container.querySelector('.t-card')).toBeInTheDocument()
|
||||||
// Mocked chart should render
|
// Mocked chart should render
|
||||||
expect(screen.getByTestId('mock-chart-container')).toBeInTheDocument();
|
expect(screen.getByTestId('mock-chart-container')).toBeInTheDocument()
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { RuleTester } from '@typescript-eslint/rule-tester'
|
import { RuleTester } from '@typescript-eslint/rule-tester'
|
||||||
import { describe, it, afterAll } from 'vitest'
|
import { describe, it, afterAll } from 'vitest'
|
||||||
import rule, {
|
import rule, { RULE_NAME } from '../../../eslint-rules/rules/no-hardcoded-color-in-style.js'
|
||||||
RULE_NAME,
|
|
||||||
} from '../../../eslint-rules/rules/no-hardcoded-color-in-style.js'
|
|
||||||
|
|
||||||
RuleTester.it = it
|
RuleTester.it = it
|
||||||
RuleTester.describe = describe
|
RuleTester.describe = describe
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { renderHook, waitFor } from '@testing-library/react';
|
import { renderHook, waitFor } from '@testing-library/react'
|
||||||
import { http, HttpResponse } from 'msw';
|
import { http, HttpResponse } from 'msw'
|
||||||
import { setupServer } from 'msw/node';
|
import { setupServer } from 'msw/node'
|
||||||
import React from 'react';
|
import React from 'react'
|
||||||
import { MessagePlugin } from 'tdesign-react';
|
import { MessagePlugin } from 'tdesign-react'
|
||||||
import { useModels, useCreateModel, useUpdateModel, useDeleteModel } from '@/hooks/useModels';
|
import { useModels, useCreateModel, useUpdateModel, useDeleteModel } from '@/hooks/useModels'
|
||||||
import type { Model, CreateModelInput, UpdateModelInput } from '@/types';
|
import type { Model, CreateModelInput, UpdateModelInput } from '@/types'
|
||||||
|
|
||||||
// Mock MessagePlugin
|
// Mock MessagePlugin
|
||||||
vi.mock('tdesign-react', () => ({
|
vi.mock('tdesign-react', () => ({
|
||||||
@@ -13,7 +13,7 @@ vi.mock('tdesign-react', () => ({
|
|||||||
success: vi.fn(),
|
success: vi.fn(),
|
||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
},
|
},
|
||||||
}));
|
}))
|
||||||
|
|
||||||
// Test data
|
// Test data
|
||||||
const mockModels: Model[] = [
|
const mockModels: Model[] = [
|
||||||
@@ -33,7 +33,7 @@ const mockModels: Model[] = [
|
|||||||
createdAt: '2026-01-02T00:00:00Z',
|
createdAt: '2026-01-02T00:00:00Z',
|
||||||
unifiedId: 'gpt-4o-mini',
|
unifiedId: 'gpt-4o-mini',
|
||||||
},
|
},
|
||||||
];
|
]
|
||||||
|
|
||||||
const mockFilteredModels: Model[] = [
|
const mockFilteredModels: Model[] = [
|
||||||
{
|
{
|
||||||
@@ -44,7 +44,7 @@ const mockFilteredModels: Model[] = [
|
|||||||
createdAt: '2026-02-01T00:00:00Z',
|
createdAt: '2026-02-01T00:00:00Z',
|
||||||
unifiedId: 'claude-sonnet-4-5',
|
unifiedId: 'claude-sonnet-4-5',
|
||||||
},
|
},
|
||||||
];
|
]
|
||||||
|
|
||||||
const mockCreatedModel: Model = {
|
const mockCreatedModel: Model = {
|
||||||
id: 'model-4',
|
id: 'model-4',
|
||||||
@@ -53,36 +53,36 @@ const mockCreatedModel: Model = {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
createdAt: '2026-03-01T00:00:00Z',
|
createdAt: '2026-03-01T00:00:00Z',
|
||||||
unifiedId: 'gpt-4.1',
|
unifiedId: 'gpt-4.1',
|
||||||
};
|
}
|
||||||
|
|
||||||
// MSW handlers
|
// MSW handlers
|
||||||
const handlers = [
|
const handlers = [
|
||||||
http.get('/api/models', ({ request }) => {
|
http.get('/api/models', ({ request }) => {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url)
|
||||||
const providerId = url.searchParams.get('provider_id');
|
const providerId = url.searchParams.get('provider_id')
|
||||||
if (providerId === 'provider-2') {
|
if (providerId === 'provider-2') {
|
||||||
return HttpResponse.json(mockFilteredModels);
|
return HttpResponse.json(mockFilteredModels)
|
||||||
}
|
}
|
||||||
return HttpResponse.json(mockModels);
|
return HttpResponse.json(mockModels)
|
||||||
}),
|
}),
|
||||||
http.post('/api/models', async ({ request }) => {
|
http.post('/api/models', async ({ request }) => {
|
||||||
const body = await request.json() as Record<string, unknown>;
|
const body = (await request.json()) as Record<string, unknown>
|
||||||
return HttpResponse.json({
|
return HttpResponse.json({
|
||||||
...mockCreatedModel,
|
...mockCreatedModel,
|
||||||
...body,
|
...body,
|
||||||
});
|
})
|
||||||
}),
|
}),
|
||||||
http.put('/api/models/:id', async ({ request, params }) => {
|
http.put('/api/models/:id', async ({ request, params }) => {
|
||||||
const body = await request.json() as Record<string, unknown>;
|
const body = (await request.json()) as Record<string, unknown>
|
||||||
const existing = mockModels.find((m) => m.id === params['id']);
|
const existing = mockModels.find((m) => m.id === params['id'])
|
||||||
return HttpResponse.json({ ...existing, ...body });
|
return HttpResponse.json({ ...existing, ...body })
|
||||||
}),
|
}),
|
||||||
http.delete('/api/models/:id', () => {
|
http.delete('/api/models/:id', () => {
|
||||||
return new HttpResponse(null, { status: 204 });
|
return new HttpResponse(null, { status: 204 })
|
||||||
}),
|
}),
|
||||||
];
|
]
|
||||||
|
|
||||||
const server = setupServer(...handlers);
|
const server = setupServer(...handlers)
|
||||||
|
|
||||||
function createTestQueryClient() {
|
function createTestQueryClient() {
|
||||||
return new QueryClient({
|
return new QueryClient({
|
||||||
@@ -90,201 +90,185 @@ function createTestQueryClient() {
|
|||||||
queries: { retry: false },
|
queries: { retry: false },
|
||||||
mutations: { retry: false },
|
mutations: { retry: false },
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function createWrapper() {
|
function createWrapper() {
|
||||||
const testQueryClient = createTestQueryClient();
|
const testQueryClient = createTestQueryClient()
|
||||||
return function Wrapper({ children }: { children: React.ReactNode }) {
|
return function Wrapper({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return <QueryClientProvider client={testQueryClient}>{children}</QueryClientProvider>
|
||||||
<QueryClientProvider client={testQueryClient}>
|
}
|
||||||
{children}
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeAll(() => server.listen());
|
beforeAll(() => server.listen())
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
server.resetHandlers();
|
server.resetHandlers()
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks()
|
||||||
});
|
})
|
||||||
afterAll(() => server.close());
|
afterAll(() => server.close())
|
||||||
|
|
||||||
describe('useModels', () => {
|
describe('useModels', () => {
|
||||||
it('fetches model list', async () => {
|
it('fetches model list', async () => {
|
||||||
const { result } = renderHook(() => useModels(), {
|
const { result } = renderHook(() => useModels(), {
|
||||||
wrapper: createWrapper(),
|
wrapper: createWrapper(),
|
||||||
});
|
})
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
|
|
||||||
expect(result.current.data).toEqual(mockModels);
|
expect(result.current.data).toEqual(mockModels)
|
||||||
expect(result.current.data).toHaveLength(2);
|
expect(result.current.data).toHaveLength(2)
|
||||||
expect(result.current.data![0]!.modelName).toBe('gpt-4o');
|
expect(result.current.data![0]!.modelName).toBe('gpt-4o')
|
||||||
});
|
})
|
||||||
|
|
||||||
it('with providerId passes it to API and returns filtered models', async () => {
|
it('with providerId passes it to API and returns filtered models', async () => {
|
||||||
const { result } = renderHook(() => useModels('provider-2'), {
|
const { result } = renderHook(() => useModels('provider-2'), {
|
||||||
wrapper: createWrapper(),
|
wrapper: createWrapper(),
|
||||||
});
|
})
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
|
|
||||||
expect(result.current.data).toEqual(mockFilteredModels);
|
expect(result.current.data).toEqual(mockFilteredModels)
|
||||||
expect(result.current.data).toHaveLength(1);
|
expect(result.current.data).toHaveLength(1)
|
||||||
expect(result.current.data![0]!.modelName).toBe('claude-sonnet-4-5');
|
expect(result.current.data![0]!.modelName).toBe('claude-sonnet-4-5')
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|
||||||
describe('useCreateModel', () => {
|
describe('useCreateModel', () => {
|
||||||
it('calls API and invalidates model queries', async () => {
|
it('calls API and invalidates model queries', async () => {
|
||||||
const queryClient = createTestQueryClient();
|
const queryClient = createTestQueryClient()
|
||||||
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
|
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries')
|
||||||
|
|
||||||
function Wrapper({ children }: { children: React.ReactNode }) {
|
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
{children}
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { result } = renderHook(() => useCreateModel(), {
|
const { result } = renderHook(() => useCreateModel(), {
|
||||||
wrapper: Wrapper,
|
wrapper: Wrapper,
|
||||||
});
|
})
|
||||||
|
|
||||||
const input: CreateModelInput = {
|
const input: CreateModelInput = {
|
||||||
id: 'model-4',
|
id: 'model-4',
|
||||||
providerId: 'provider-1',
|
providerId: 'provider-1',
|
||||||
modelName: 'gpt-4.1',
|
modelName: 'gpt-4.1',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
};
|
}
|
||||||
|
|
||||||
result.current.mutate(input);
|
result.current.mutate(input)
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
|
|
||||||
expect(result.current.data).toMatchObject({
|
expect(result.current.data).toMatchObject({
|
||||||
id: 'model-4',
|
id: 'model-4',
|
||||||
modelName: 'gpt-4.1',
|
modelName: 'gpt-4.1',
|
||||||
});
|
})
|
||||||
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['models'] });
|
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['models'] })
|
||||||
expect(MessagePlugin.success).toHaveBeenCalledWith('模型创建成功');
|
expect(MessagePlugin.success).toHaveBeenCalledWith('模型创建成功')
|
||||||
});
|
})
|
||||||
|
|
||||||
it('calls message.error on failure', async () => {
|
it('calls message.error on failure', async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.post('/api/models', () => {
|
http.post('/api/models', () => {
|
||||||
return HttpResponse.json({ message: '创建失败' }, { status: 500 });
|
return HttpResponse.json({ message: '创建失败' }, { status: 500 })
|
||||||
}),
|
})
|
||||||
);
|
)
|
||||||
|
|
||||||
const { result } = renderHook(() => useCreateModel(), {
|
const { result } = renderHook(() => useCreateModel(), {
|
||||||
wrapper: createWrapper(),
|
wrapper: createWrapper(),
|
||||||
});
|
})
|
||||||
|
|
||||||
const input: CreateModelInput = {
|
const input: CreateModelInput = {
|
||||||
id: 'model-4',
|
id: 'model-4',
|
||||||
providerId: 'provider-1',
|
providerId: 'provider-1',
|
||||||
modelName: 'gpt-4.1',
|
modelName: 'gpt-4.1',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
};
|
}
|
||||||
|
|
||||||
result.current.mutate(input);
|
result.current.mutate(input)
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.isError).toBe(true));
|
await waitFor(() => expect(result.current.isError).toBe(true))
|
||||||
expect(MessagePlugin.error).toHaveBeenCalled();
|
expect(MessagePlugin.error).toHaveBeenCalled()
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|
||||||
describe('useUpdateModel', () => {
|
describe('useUpdateModel', () => {
|
||||||
it('calls API and invalidates model queries', async () => {
|
it('calls API and invalidates model queries', async () => {
|
||||||
const queryClient = createTestQueryClient();
|
const queryClient = createTestQueryClient()
|
||||||
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
|
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries')
|
||||||
|
|
||||||
function Wrapper({ children }: { children: React.ReactNode }) {
|
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
{children}
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { result } = renderHook(() => useUpdateModel(), {
|
const { result } = renderHook(() => useUpdateModel(), {
|
||||||
wrapper: Wrapper,
|
wrapper: Wrapper,
|
||||||
});
|
})
|
||||||
|
|
||||||
const input: UpdateModelInput = { modelName: 'gpt-4o-updated' };
|
const input: UpdateModelInput = { modelName: 'gpt-4o-updated' }
|
||||||
|
|
||||||
result.current.mutate({ id: 'model-1', input });
|
result.current.mutate({ id: 'model-1', input })
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
|
|
||||||
expect(result.current.data).toMatchObject({
|
expect(result.current.data).toMatchObject({
|
||||||
modelName: 'gpt-4o-updated',
|
modelName: 'gpt-4o-updated',
|
||||||
});
|
})
|
||||||
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['models'] });
|
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['models'] })
|
||||||
expect(MessagePlugin.success).toHaveBeenCalledWith('模型更新成功');
|
expect(MessagePlugin.success).toHaveBeenCalledWith('模型更新成功')
|
||||||
});
|
})
|
||||||
|
|
||||||
it('calls message.error on failure', async () => {
|
it('calls message.error on failure', async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.put('/api/models/:id', () => {
|
http.put('/api/models/:id', () => {
|
||||||
return HttpResponse.json({ message: '更新失败' }, { status: 500 });
|
return HttpResponse.json({ message: '更新失败' }, { status: 500 })
|
||||||
}),
|
})
|
||||||
);
|
)
|
||||||
|
|
||||||
const { result } = renderHook(() => useUpdateModel(), {
|
const { result } = renderHook(() => useUpdateModel(), {
|
||||||
wrapper: createWrapper(),
|
wrapper: createWrapper(),
|
||||||
});
|
})
|
||||||
|
|
||||||
result.current.mutate({ id: 'model-1', input: { modelName: 'Updated' } });
|
result.current.mutate({ id: 'model-1', input: { modelName: 'Updated' } })
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.isError).toBe(true));
|
await waitFor(() => expect(result.current.isError).toBe(true))
|
||||||
expect(MessagePlugin.error).toHaveBeenCalled();
|
expect(MessagePlugin.error).toHaveBeenCalled()
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|
||||||
describe('useDeleteModel', () => {
|
describe('useDeleteModel', () => {
|
||||||
it('calls API and invalidates model queries', async () => {
|
it('calls API and invalidates model queries', async () => {
|
||||||
const queryClient = createTestQueryClient();
|
const queryClient = createTestQueryClient()
|
||||||
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
|
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries')
|
||||||
|
|
||||||
function Wrapper({ children }: { children: React.ReactNode }) {
|
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
{children}
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { result } = renderHook(() => useDeleteModel(), {
|
const { result } = renderHook(() => useDeleteModel(), {
|
||||||
wrapper: Wrapper,
|
wrapper: Wrapper,
|
||||||
});
|
})
|
||||||
|
|
||||||
result.current.mutate('model-1');
|
result.current.mutate('model-1')
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
|
|
||||||
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['models'] });
|
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['models'] })
|
||||||
expect(MessagePlugin.success).toHaveBeenCalledWith('模型删除成功');
|
expect(MessagePlugin.success).toHaveBeenCalledWith('模型删除成功')
|
||||||
});
|
})
|
||||||
|
|
||||||
it('calls message.error on failure', async () => {
|
it('calls message.error on failure', async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.delete('/api/models/:id', () => {
|
http.delete('/api/models/:id', () => {
|
||||||
return HttpResponse.json({ message: '删除失败' }, { status: 500 });
|
return HttpResponse.json({ message: '删除失败' }, { status: 500 })
|
||||||
}),
|
})
|
||||||
);
|
)
|
||||||
|
|
||||||
const { result } = renderHook(() => useDeleteModel(), {
|
const { result } = renderHook(() => useDeleteModel(), {
|
||||||
wrapper: createWrapper(),
|
wrapper: createWrapper(),
|
||||||
});
|
})
|
||||||
|
|
||||||
result.current.mutate('model-1');
|
result.current.mutate('model-1')
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.isError).toBe(true));
|
await waitFor(() => expect(result.current.isError).toBe(true))
|
||||||
expect(MessagePlugin.error).toHaveBeenCalled();
|
expect(MessagePlugin.error).toHaveBeenCalled()
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { renderHook, waitFor } from '@testing-library/react';
|
import { renderHook, waitFor } from '@testing-library/react'
|
||||||
import { http, HttpResponse } from 'msw';
|
import { http, HttpResponse } from 'msw'
|
||||||
import { setupServer } from 'msw/node';
|
import { setupServer } from 'msw/node'
|
||||||
import React from 'react';
|
import React from 'react'
|
||||||
import { MessagePlugin } from 'tdesign-react';
|
import { MessagePlugin } from 'tdesign-react'
|
||||||
import { useProviders, useCreateProvider, useUpdateProvider, useDeleteProvider } from '@/hooks/useProviders';
|
import { useProviders, useCreateProvider, useUpdateProvider, useDeleteProvider } from '@/hooks/useProviders'
|
||||||
import type { Provider, CreateProviderInput, UpdateProviderInput } from '@/types';
|
import type { Provider, CreateProviderInput, UpdateProviderInput } from '@/types'
|
||||||
|
|
||||||
// Mock MessagePlugin
|
// Mock MessagePlugin
|
||||||
vi.mock('tdesign-react', () => ({
|
vi.mock('tdesign-react', () => ({
|
||||||
@@ -13,7 +13,7 @@ vi.mock('tdesign-react', () => ({
|
|||||||
success: vi.fn(),
|
success: vi.fn(),
|
||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
},
|
},
|
||||||
}));
|
}))
|
||||||
|
|
||||||
// Test data
|
// Test data
|
||||||
const mockProviders: Provider[] = [
|
const mockProviders: Provider[] = [
|
||||||
@@ -37,7 +37,7 @@ const mockProviders: Provider[] = [
|
|||||||
createdAt: '2026-02-01T00:00:00Z',
|
createdAt: '2026-02-01T00:00:00Z',
|
||||||
updatedAt: '2026-02-01T00:00:00Z',
|
updatedAt: '2026-02-01T00:00:00Z',
|
||||||
},
|
},
|
||||||
];
|
]
|
||||||
|
|
||||||
const mockCreatedProvider: Provider = {
|
const mockCreatedProvider: Provider = {
|
||||||
id: 'provider-3',
|
id: 'provider-3',
|
||||||
@@ -48,31 +48,31 @@ const mockCreatedProvider: Provider = {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
createdAt: '2026-03-01T00:00:00Z',
|
createdAt: '2026-03-01T00:00:00Z',
|
||||||
updatedAt: '2026-03-01T00:00:00Z',
|
updatedAt: '2026-03-01T00:00:00Z',
|
||||||
};
|
}
|
||||||
|
|
||||||
// MSW handlers
|
// MSW handlers
|
||||||
const handlers = [
|
const handlers = [
|
||||||
http.get('/api/providers', () => {
|
http.get('/api/providers', () => {
|
||||||
return HttpResponse.json(mockProviders);
|
return HttpResponse.json(mockProviders)
|
||||||
}),
|
}),
|
||||||
http.post('/api/providers', async ({ request }) => {
|
http.post('/api/providers', async ({ request }) => {
|
||||||
const body = await request.json() as Record<string, unknown>;
|
const body = (await request.json()) as Record<string, unknown>
|
||||||
return HttpResponse.json({
|
return HttpResponse.json({
|
||||||
...mockCreatedProvider,
|
...mockCreatedProvider,
|
||||||
...body,
|
...body,
|
||||||
});
|
})
|
||||||
}),
|
}),
|
||||||
http.put('/api/providers/:id', async ({ request, params }) => {
|
http.put('/api/providers/:id', async ({ request, params }) => {
|
||||||
const body = await request.json() as Record<string, unknown>;
|
const body = (await request.json()) as Record<string, unknown>
|
||||||
const existing = mockProviders.find((p) => p.id === params['id']);
|
const existing = mockProviders.find((p) => p.id === params['id'])
|
||||||
return HttpResponse.json({ ...existing, ...body });
|
return HttpResponse.json({ ...existing, ...body })
|
||||||
}),
|
}),
|
||||||
http.delete('/api/providers/:id', () => {
|
http.delete('/api/providers/:id', () => {
|
||||||
return new HttpResponse(null, { status: 204 });
|
return new HttpResponse(null, { status: 204 })
|
||||||
}),
|
}),
|
||||||
];
|
]
|
||||||
|
|
||||||
const server = setupServer(...handlers);
|
const server = setupServer(...handlers)
|
||||||
|
|
||||||
function createTestQueryClient() {
|
function createTestQueryClient() {
|
||||||
return new QueryClient({
|
return new QueryClient({
|
||||||
@@ -80,58 +80,50 @@ function createTestQueryClient() {
|
|||||||
queries: { retry: false },
|
queries: { retry: false },
|
||||||
mutations: { retry: false },
|
mutations: { retry: false },
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function createWrapper() {
|
function createWrapper() {
|
||||||
const testQueryClient = createTestQueryClient();
|
const testQueryClient = createTestQueryClient()
|
||||||
return function Wrapper({ children }: { children: React.ReactNode }) {
|
return function Wrapper({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return <QueryClientProvider client={testQueryClient}>{children}</QueryClientProvider>
|
||||||
<QueryClientProvider client={testQueryClient}>
|
}
|
||||||
{children}
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeAll(() => server.listen());
|
beforeAll(() => server.listen())
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
server.resetHandlers();
|
server.resetHandlers()
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks()
|
||||||
});
|
})
|
||||||
afterAll(() => server.close());
|
afterAll(() => server.close())
|
||||||
|
|
||||||
describe('useProviders', () => {
|
describe('useProviders', () => {
|
||||||
it('fetches and returns provider list', async () => {
|
it('fetches and returns provider list', async () => {
|
||||||
const { result } = renderHook(() => useProviders(), {
|
const { result } = renderHook(() => useProviders(), {
|
||||||
wrapper: createWrapper(),
|
wrapper: createWrapper(),
|
||||||
});
|
})
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
|
|
||||||
expect(result.current.data).toEqual(mockProviders);
|
expect(result.current.data).toEqual(mockProviders)
|
||||||
expect(result.current.data).toHaveLength(2);
|
expect(result.current.data).toHaveLength(2)
|
||||||
expect(result.current.data![0]!.name).toBe('OpenAI');
|
expect(result.current.data![0]!.name).toBe('OpenAI')
|
||||||
expect(result.current.data![1]!.name).toBe('Anthropic');
|
expect(result.current.data![1]!.name).toBe('Anthropic')
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|
||||||
describe('useCreateProvider', () => {
|
describe('useCreateProvider', () => {
|
||||||
it('calls API and invalidates provider queries', async () => {
|
it('calls API and invalidates provider queries', async () => {
|
||||||
const queryClient = createTestQueryClient();
|
const queryClient = createTestQueryClient()
|
||||||
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
|
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries')
|
||||||
|
|
||||||
function Wrapper({ children }: { children: React.ReactNode }) {
|
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
{children}
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { result } = renderHook(() => useCreateProvider(), {
|
const { result } = renderHook(() => useCreateProvider(), {
|
||||||
wrapper: Wrapper,
|
wrapper: Wrapper,
|
||||||
});
|
})
|
||||||
|
|
||||||
const input: CreateProviderInput = {
|
const input: CreateProviderInput = {
|
||||||
id: 'provider-3',
|
id: 'provider-3',
|
||||||
@@ -140,30 +132,30 @@ describe('useCreateProvider', () => {
|
|||||||
baseUrl: 'https://api.newprovider.com',
|
baseUrl: 'https://api.newprovider.com',
|
||||||
protocol: 'openai',
|
protocol: 'openai',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
};
|
}
|
||||||
|
|
||||||
result.current.mutate(input);
|
result.current.mutate(input)
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
|
|
||||||
expect(result.current.data).toMatchObject({
|
expect(result.current.data).toMatchObject({
|
||||||
id: 'provider-3',
|
id: 'provider-3',
|
||||||
name: 'NewProvider',
|
name: 'NewProvider',
|
||||||
});
|
})
|
||||||
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['providers'] });
|
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['providers'] })
|
||||||
expect(MessagePlugin.success).toHaveBeenCalledWith('供应商创建成功');
|
expect(MessagePlugin.success).toHaveBeenCalledWith('供应商创建成功')
|
||||||
});
|
})
|
||||||
|
|
||||||
it('calls message.error on failure', async () => {
|
it('calls message.error on failure', async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.post('/api/providers', () => {
|
http.post('/api/providers', () => {
|
||||||
return HttpResponse.json({ message: '创建失败' }, { status: 500 });
|
return HttpResponse.json({ message: '创建失败' }, { status: 500 })
|
||||||
}),
|
})
|
||||||
);
|
)
|
||||||
|
|
||||||
const { result } = renderHook(() => useCreateProvider(), {
|
const { result } = renderHook(() => useCreateProvider(), {
|
||||||
wrapper: createWrapper(),
|
wrapper: createWrapper(),
|
||||||
});
|
})
|
||||||
|
|
||||||
const input: CreateProviderInput = {
|
const input: CreateProviderInput = {
|
||||||
id: 'provider-3',
|
id: 'provider-3',
|
||||||
@@ -172,102 +164,94 @@ describe('useCreateProvider', () => {
|
|||||||
baseUrl: 'https://api.newprovider.com',
|
baseUrl: 'https://api.newprovider.com',
|
||||||
protocol: 'openai',
|
protocol: 'openai',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
};
|
}
|
||||||
|
|
||||||
result.current.mutate(input);
|
result.current.mutate(input)
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.isError).toBe(true));
|
await waitFor(() => expect(result.current.isError).toBe(true))
|
||||||
expect(MessagePlugin.error).toHaveBeenCalled();
|
expect(MessagePlugin.error).toHaveBeenCalled()
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|
||||||
describe('useUpdateProvider', () => {
|
describe('useUpdateProvider', () => {
|
||||||
it('calls API and invalidates provider queries', async () => {
|
it('calls API and invalidates provider queries', async () => {
|
||||||
const queryClient = createTestQueryClient();
|
const queryClient = createTestQueryClient()
|
||||||
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
|
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries')
|
||||||
|
|
||||||
function Wrapper({ children }: { children: React.ReactNode }) {
|
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
{children}
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { result } = renderHook(() => useUpdateProvider(), {
|
const { result } = renderHook(() => useUpdateProvider(), {
|
||||||
wrapper: Wrapper,
|
wrapper: Wrapper,
|
||||||
});
|
})
|
||||||
|
|
||||||
const input: UpdateProviderInput = { name: 'UpdatedProvider' };
|
const input: UpdateProviderInput = { name: 'UpdatedProvider' }
|
||||||
|
|
||||||
result.current.mutate({ id: 'provider-1', input });
|
result.current.mutate({ id: 'provider-1', input })
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
|
|
||||||
expect(result.current.data).toMatchObject({
|
expect(result.current.data).toMatchObject({
|
||||||
name: 'UpdatedProvider',
|
name: 'UpdatedProvider',
|
||||||
});
|
})
|
||||||
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['providers'] });
|
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['providers'] })
|
||||||
expect(MessagePlugin.success).toHaveBeenCalledWith('供应商更新成功');
|
expect(MessagePlugin.success).toHaveBeenCalledWith('供应商更新成功')
|
||||||
});
|
})
|
||||||
|
|
||||||
it('calls message.error on failure', async () => {
|
it('calls message.error on failure', async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.put('/api/providers/:id', () => {
|
http.put('/api/providers/:id', () => {
|
||||||
return HttpResponse.json({ message: '更新失败' }, { status: 500 });
|
return HttpResponse.json({ message: '更新失败' }, { status: 500 })
|
||||||
}),
|
})
|
||||||
);
|
)
|
||||||
|
|
||||||
const { result } = renderHook(() => useUpdateProvider(), {
|
const { result } = renderHook(() => useUpdateProvider(), {
|
||||||
wrapper: createWrapper(),
|
wrapper: createWrapper(),
|
||||||
});
|
})
|
||||||
|
|
||||||
result.current.mutate({ id: 'provider-1', input: { name: 'Updated' } });
|
result.current.mutate({ id: 'provider-1', input: { name: 'Updated' } })
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.isError).toBe(true));
|
await waitFor(() => expect(result.current.isError).toBe(true))
|
||||||
expect(MessagePlugin.error).toHaveBeenCalled();
|
expect(MessagePlugin.error).toHaveBeenCalled()
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|
||||||
describe('useDeleteProvider', () => {
|
describe('useDeleteProvider', () => {
|
||||||
it('calls API and invalidates provider queries', async () => {
|
it('calls API and invalidates provider queries', async () => {
|
||||||
const queryClient = createTestQueryClient();
|
const queryClient = createTestQueryClient()
|
||||||
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
|
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries')
|
||||||
|
|
||||||
function Wrapper({ children }: { children: React.ReactNode }) {
|
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
{children}
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { result } = renderHook(() => useDeleteProvider(), {
|
const { result } = renderHook(() => useDeleteProvider(), {
|
||||||
wrapper: Wrapper,
|
wrapper: Wrapper,
|
||||||
});
|
})
|
||||||
|
|
||||||
result.current.mutate('provider-1');
|
result.current.mutate('provider-1')
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
|
|
||||||
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['providers'] });
|
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['providers'] })
|
||||||
expect(MessagePlugin.success).toHaveBeenCalledWith('供应商删除成功');
|
expect(MessagePlugin.success).toHaveBeenCalledWith('供应商删除成功')
|
||||||
});
|
})
|
||||||
|
|
||||||
it('calls message.error on failure', async () => {
|
it('calls message.error on failure', async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.delete('/api/providers/:id', () => {
|
http.delete('/api/providers/:id', () => {
|
||||||
return HttpResponse.json({ message: '删除失败' }, { status: 500 });
|
return HttpResponse.json({ message: '删除失败' }, { status: 500 })
|
||||||
}),
|
})
|
||||||
);
|
)
|
||||||
|
|
||||||
const { result } = renderHook(() => useDeleteProvider(), {
|
const { result } = renderHook(() => useDeleteProvider(), {
|
||||||
wrapper: createWrapper(),
|
wrapper: createWrapper(),
|
||||||
});
|
})
|
||||||
|
|
||||||
result.current.mutate('provider-1');
|
result.current.mutate('provider-1')
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.isError).toBe(true));
|
await waitFor(() => expect(result.current.isError).toBe(true))
|
||||||
expect(MessagePlugin.error).toHaveBeenCalled();
|
expect(MessagePlugin.error).toHaveBeenCalled()
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { renderHook, waitFor } from '@testing-library/react';
|
import { renderHook, waitFor } from '@testing-library/react'
|
||||||
import { http, HttpResponse } from 'msw';
|
import { http, HttpResponse } from 'msw'
|
||||||
import { setupServer } from 'msw/node';
|
import { setupServer } from 'msw/node'
|
||||||
import React from 'react';
|
import React from 'react'
|
||||||
import { useStats } from '@/hooks/useStats';
|
import { useStats } from '@/hooks/useStats'
|
||||||
import type { UsageStats, StatsQueryParams } from '@/types';
|
import type { UsageStats, StatsQueryParams } from '@/types'
|
||||||
|
|
||||||
// Test data
|
// Test data
|
||||||
const mockStats: UsageStats[] = [
|
const mockStats: UsageStats[] = [
|
||||||
@@ -22,7 +22,7 @@ const mockStats: UsageStats[] = [
|
|||||||
requestCount: 50,
|
requestCount: 50,
|
||||||
date: '2026-04-01',
|
date: '2026-04-01',
|
||||||
},
|
},
|
||||||
];
|
]
|
||||||
|
|
||||||
const mockFilteredStats: UsageStats[] = [
|
const mockFilteredStats: UsageStats[] = [
|
||||||
{
|
{
|
||||||
@@ -32,24 +32,24 @@ const mockFilteredStats: UsageStats[] = [
|
|||||||
requestCount: 200,
|
requestCount: 200,
|
||||||
date: '2026-04-01',
|
date: '2026-04-01',
|
||||||
},
|
},
|
||||||
];
|
]
|
||||||
|
|
||||||
// Track the request URL for assertions
|
// Track the request URL for assertions
|
||||||
let capturedUrl: URL | null = null;
|
let capturedUrl: URL | null = null
|
||||||
|
|
||||||
// MSW handlers
|
// MSW handlers
|
||||||
const handlers = [
|
const handlers = [
|
||||||
http.get('/api/stats', ({ request }) => {
|
http.get('/api/stats', ({ request }) => {
|
||||||
capturedUrl = new URL(request.url);
|
capturedUrl = new URL(request.url)
|
||||||
const providerId = capturedUrl.searchParams.get('provider_id');
|
const providerId = capturedUrl.searchParams.get('provider_id')
|
||||||
if (providerId === 'provider-2') {
|
if (providerId === 'provider-2') {
|
||||||
return HttpResponse.json(mockFilteredStats);
|
return HttpResponse.json(mockFilteredStats)
|
||||||
}
|
}
|
||||||
return HttpResponse.json(mockStats);
|
return HttpResponse.json(mockStats)
|
||||||
}),
|
}),
|
||||||
];
|
]
|
||||||
|
|
||||||
const server = setupServer(...handlers);
|
const server = setupServer(...handlers)
|
||||||
|
|
||||||
function createTestQueryClient() {
|
function createTestQueryClient() {
|
||||||
return new QueryClient({
|
return new QueryClient({
|
||||||
@@ -57,43 +57,39 @@ function createTestQueryClient() {
|
|||||||
queries: { retry: false },
|
queries: { retry: false },
|
||||||
mutations: { retry: false },
|
mutations: { retry: false },
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function createWrapper() {
|
function createWrapper() {
|
||||||
const testQueryClient = createTestQueryClient();
|
const testQueryClient = createTestQueryClient()
|
||||||
return function Wrapper({ children }: { children: React.ReactNode }) {
|
return function Wrapper({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return <QueryClientProvider client={testQueryClient}>{children}</QueryClientProvider>
|
||||||
<QueryClientProvider client={testQueryClient}>
|
}
|
||||||
{children}
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeAll(() => server.listen());
|
beforeAll(() => server.listen())
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
server.resetHandlers();
|
server.resetHandlers()
|
||||||
capturedUrl = null;
|
capturedUrl = null
|
||||||
});
|
})
|
||||||
afterAll(() => server.close());
|
afterAll(() => server.close())
|
||||||
|
|
||||||
describe('useStats', () => {
|
describe('useStats', () => {
|
||||||
it('fetches stats without params', async () => {
|
it('fetches stats without params', async () => {
|
||||||
const { result } = renderHook(() => useStats(), {
|
const { result } = renderHook(() => useStats(), {
|
||||||
wrapper: createWrapper(),
|
wrapper: createWrapper(),
|
||||||
});
|
})
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
|
|
||||||
expect(result.current.data).toEqual(mockStats);
|
expect(result.current.data).toEqual(mockStats)
|
||||||
expect(result.current.data).toHaveLength(2);
|
expect(result.current.data).toHaveLength(2)
|
||||||
expect(result.current.data![0]!.modelName).toBe('gpt-4o');
|
expect(result.current.data![0]!.modelName).toBe('gpt-4o')
|
||||||
expect(result.current.data![1]!.requestCount).toBe(50);
|
expect(result.current.data![1]!.requestCount).toBe(50)
|
||||||
|
|
||||||
// Verify no query params were sent
|
// Verify no query params were sent
|
||||||
expect(capturedUrl!.search).toBe('');
|
expect(capturedUrl!.search).toBe('')
|
||||||
});
|
})
|
||||||
|
|
||||||
it('with filter params passes them correctly', async () => {
|
it('with filter params passes them correctly', async () => {
|
||||||
const params: StatsQueryParams = {
|
const params: StatsQueryParams = {
|
||||||
@@ -101,40 +97,40 @@ describe('useStats', () => {
|
|||||||
modelName: 'claude-sonnet-4-5',
|
modelName: 'claude-sonnet-4-5',
|
||||||
startDate: '2026-04-01',
|
startDate: '2026-04-01',
|
||||||
endDate: '2026-04-15',
|
endDate: '2026-04-15',
|
||||||
};
|
}
|
||||||
|
|
||||||
const { result } = renderHook(() => useStats(params), {
|
const { result } = renderHook(() => useStats(params), {
|
||||||
wrapper: createWrapper(),
|
wrapper: createWrapper(),
|
||||||
});
|
})
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
|
|
||||||
expect(result.current.data).toEqual(mockFilteredStats);
|
expect(result.current.data).toEqual(mockFilteredStats)
|
||||||
expect(result.current.data).toHaveLength(1);
|
expect(result.current.data).toHaveLength(1)
|
||||||
expect(result.current.data![0]!.modelName).toBe('claude-sonnet-4-5');
|
expect(result.current.data![0]!.modelName).toBe('claude-sonnet-4-5')
|
||||||
|
|
||||||
// Verify query params were passed correctly (snake_case)
|
// Verify query params were passed correctly (snake_case)
|
||||||
expect(capturedUrl!.searchParams.get('provider_id')).toBe('provider-2');
|
expect(capturedUrl!.searchParams.get('provider_id')).toBe('provider-2')
|
||||||
expect(capturedUrl!.searchParams.get('model_name')).toBe('claude-sonnet-4-5');
|
expect(capturedUrl!.searchParams.get('model_name')).toBe('claude-sonnet-4-5')
|
||||||
expect(capturedUrl!.searchParams.get('start_date')).toBe('2026-04-01');
|
expect(capturedUrl!.searchParams.get('start_date')).toBe('2026-04-01')
|
||||||
expect(capturedUrl!.searchParams.get('end_date')).toBe('2026-04-15');
|
expect(capturedUrl!.searchParams.get('end_date')).toBe('2026-04-15')
|
||||||
});
|
})
|
||||||
|
|
||||||
it('with partial filter params only sends provided params', async () => {
|
it('with partial filter params only sends provided params', async () => {
|
||||||
const params: StatsQueryParams = {
|
const params: StatsQueryParams = {
|
||||||
providerId: 'provider-1',
|
providerId: 'provider-1',
|
||||||
};
|
}
|
||||||
|
|
||||||
const { result } = renderHook(() => useStats(params), {
|
const { result } = renderHook(() => useStats(params), {
|
||||||
wrapper: createWrapper(),
|
wrapper: createWrapper(),
|
||||||
});
|
})
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
|
|
||||||
// Verify only provider_id was sent
|
// Verify only provider_id was sent
|
||||||
expect(capturedUrl!.searchParams.get('provider_id')).toBe('provider-1');
|
expect(capturedUrl!.searchParams.get('provider_id')).toBe('provider-1')
|
||||||
expect(capturedUrl!.searchParams.get('model_name')).toBeNull();
|
expect(capturedUrl!.searchParams.get('model_name')).toBeNull()
|
||||||
expect(capturedUrl!.searchParams.get('start_date')).toBeNull();
|
expect(capturedUrl!.searchParams.get('start_date')).toBeNull()
|
||||||
expect(capturedUrl!.searchParams.get('end_date')).toBeNull();
|
expect(capturedUrl!.searchParams.get('end_date')).toBeNull()
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import '@testing-library/jest-dom/vitest';
|
import '@testing-library/jest-dom/vitest'
|
||||||
|
|
||||||
// Ensure happy-dom environment is properly initialized
|
// Ensure happy-dom environment is properly initialized
|
||||||
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
||||||
throw new Error('happy-dom environment not initialized. Check vitest config.');
|
throw new Error('happy-dom environment not initialized. Check vitest config.')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Polyfill window.matchMedia for jsdom (required by TDesign)
|
// Polyfill window.matchMedia for jsdom (required by TDesign)
|
||||||
@@ -18,38 +18,37 @@ Object.defineProperty(window, 'matchMedia', {
|
|||||||
removeEventListener: () => {},
|
removeEventListener: () => {},
|
||||||
dispatchEvent: () => false,
|
dispatchEvent: () => false,
|
||||||
}),
|
}),
|
||||||
});
|
})
|
||||||
|
|
||||||
// Polyfill window.getComputedStyle to suppress jsdom warnings
|
// Polyfill window.getComputedStyle to suppress jsdom warnings
|
||||||
const originalGetComputedStyle = window.getComputedStyle;
|
const originalGetComputedStyle = window.getComputedStyle
|
||||||
window.getComputedStyle = (elt: Element, pseudoElt?: string | null) => {
|
window.getComputedStyle = (elt: Element, pseudoElt?: string | null) => {
|
||||||
try {
|
try {
|
||||||
return originalGetComputedStyle(elt, pseudoElt);
|
return originalGetComputedStyle(elt, pseudoElt)
|
||||||
} catch {
|
} catch {
|
||||||
return {} as CSSStyleDeclaration;
|
return {} as CSSStyleDeclaration
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
// Polyfill ResizeObserver for TDesign
|
// Polyfill ResizeObserver for TDesign
|
||||||
global.ResizeObserver = class ResizeObserver {
|
global.ResizeObserver = class ResizeObserver {
|
||||||
observe() {}
|
observe() {}
|
||||||
unobserve() {}
|
unobserve() {}
|
||||||
disconnect() {}
|
disconnect() {}
|
||||||
};
|
}
|
||||||
|
|
||||||
// Suppress TDesign Form internal act() warnings
|
// Suppress TDesign Form internal act() warnings
|
||||||
// These warnings come from TDesign's FormItem component internal async state updates
|
// These warnings come from TDesign's FormItem component internal async state updates
|
||||||
// They don't affect test reliability - all tests pass successfully
|
// They don't affect test reliability - all tests pass successfully
|
||||||
const originalError = console.error;
|
const originalError = console.error
|
||||||
console.error = (...args: unknown[]) => {
|
console.error = (...args: unknown[]) => {
|
||||||
const message = args[0];
|
const message = args[0]
|
||||||
// Filter out TDesign FormItem act() warnings
|
// Filter out TDesign FormItem act() warnings
|
||||||
if (
|
if (
|
||||||
typeof message === 'string' &&
|
typeof message === 'string' &&
|
||||||
message.includes('An update to FormItem inside a test was not wrapped in act(...)')
|
message.includes('An update to FormItem inside a test was not wrapped in act(...)')
|
||||||
) {
|
) {
|
||||||
return;
|
return
|
||||||
|
}
|
||||||
|
originalError(...args)
|
||||||
}
|
}
|
||||||
originalError(...args);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,81 +1,77 @@
|
|||||||
import { ApiError } from '@/types';
|
import { ApiError } from '@/types'
|
||||||
|
|
||||||
const API_BASE = import.meta.env.VITE_API_BASE || '';
|
const API_BASE = import.meta.env.VITE_API_BASE || ''
|
||||||
|
|
||||||
function toCamelCase(str: string): string {
|
function toCamelCase(str: string): string {
|
||||||
return str.replace(/_([a-z])/g, (_, letter: string) => letter.toUpperCase());
|
return str.replace(/_([a-z])/g, (_, letter: string) => letter.toUpperCase())
|
||||||
}
|
}
|
||||||
|
|
||||||
function toSnakeCase(str: string): string {
|
function toSnakeCase(str: string): string {
|
||||||
return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
|
return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
function transformKeys<T>(obj: unknown, transformer: (key: string) => string): T {
|
function transformKeys<T>(obj: unknown, transformer: (key: string) => string): T {
|
||||||
if (Array.isArray(obj)) {
|
if (Array.isArray(obj)) {
|
||||||
return obj.map((item) => transformKeys(item, transformer)) as T;
|
return obj.map((item) => transformKeys(item, transformer)) as T
|
||||||
}
|
}
|
||||||
if (obj !== null && typeof obj === 'object') {
|
if (obj !== null && typeof obj === 'object') {
|
||||||
const result: Record<string, unknown> = {};
|
const result: Record<string, unknown> = {}
|
||||||
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
|
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
|
||||||
result[transformer(key)] = transformKeys(value, transformer);
|
result[transformer(key)] = transformKeys(value, transformer)
|
||||||
}
|
}
|
||||||
return result as T;
|
return result as T
|
||||||
}
|
}
|
||||||
return obj as T;
|
return obj as T
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fromApi<T>(data: unknown): T {
|
export function fromApi<T>(data: unknown): T {
|
||||||
return transformKeys<T>(data, toCamelCase);
|
return transformKeys<T>(data, toCamelCase)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toApi<T>(data: unknown): T {
|
export function toApi<T>(data: unknown): T {
|
||||||
return transformKeys<T>(data, toSnakeCase);
|
return transformKeys<T>(data, toSnakeCase)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function request<T>(
|
export async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||||
method: string,
|
const url = `${API_BASE}${path}`
|
||||||
path: string,
|
|
||||||
body?: unknown,
|
|
||||||
): Promise<T> {
|
|
||||||
const url = `${API_BASE}${path}`;
|
|
||||||
const options: RequestInit = {
|
const options: RequestInit = {
|
||||||
method,
|
method,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
};
|
|
||||||
|
|
||||||
if (body !== undefined) {
|
|
||||||
options.body = JSON.stringify(toApi(body));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(url, options);
|
if (body !== undefined) {
|
||||||
|
options.body = JSON.stringify(toApi(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, options)
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
let message = `请求失败 (${response.status})`;
|
let message = `请求失败 (${response.status})`
|
||||||
let code: string | undefined;
|
let code: string | undefined
|
||||||
try {
|
try {
|
||||||
const errorData = await response.json();
|
const errorData = await response.json()
|
||||||
if (typeof errorData === 'object' && errorData !== null) {
|
if (typeof errorData === 'object' && errorData !== null) {
|
||||||
// 提取结构化错误响应
|
// 提取结构化错误响应
|
||||||
if ('error' in errorData && typeof errorData.error === 'string') {
|
if ('error' in errorData && typeof errorData.error === 'string') {
|
||||||
message = errorData.error;
|
message = errorData.error
|
||||||
} else if ('message' in errorData && typeof errorData.message === 'string') {
|
} else if ('message' in errorData && typeof errorData.message === 'string') {
|
||||||
message = errorData.message;
|
message = errorData.message
|
||||||
}
|
}
|
||||||
// 提取错误码
|
// 提取错误码
|
||||||
if ('code' in errorData && typeof errorData.code === 'string') {
|
if ('code' in errorData && typeof errorData.code === 'string') {
|
||||||
code = errorData.code;
|
code = errorData.code
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore JSON parse error
|
// ignore JSON parse error
|
||||||
}
|
}
|
||||||
throw new ApiError(response.status, message, code);
|
throw new ApiError(response.status, message, code)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status === 204) {
|
if (response.status === 204) {
|
||||||
return undefined as T;
|
return undefined as T
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json()
|
||||||
return fromApi<T>(data);
|
return fromApi<T>(data)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,19 @@
|
|||||||
import type { Model, CreateModelInput, UpdateModelInput } from '@/types';
|
import type { Model, CreateModelInput, UpdateModelInput } from '@/types'
|
||||||
import { request } from './client';
|
import { request } from './client'
|
||||||
|
|
||||||
export async function listModels(providerId?: string): Promise<Model[]> {
|
export async function listModels(providerId?: string): Promise<Model[]> {
|
||||||
const path = providerId
|
const path = providerId ? `/api/models?provider_id=${encodeURIComponent(providerId)}` : '/api/models'
|
||||||
? `/api/models?provider_id=${encodeURIComponent(providerId)}`
|
return request<Model[]>('GET', path)
|
||||||
: '/api/models';
|
|
||||||
return request<Model[]>('GET', path);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createModel(input: CreateModelInput): Promise<Model> {
|
export async function createModel(input: CreateModelInput): Promise<Model> {
|
||||||
return request<Model>('POST', '/api/models', input);
|
return request<Model>('POST', '/api/models', input)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateModel(
|
export async function updateModel(id: string, input: UpdateModelInput): Promise<Model> {
|
||||||
id: string,
|
return request<Model>('PUT', `/api/models/${id}`, input)
|
||||||
input: UpdateModelInput,
|
|
||||||
): Promise<Model> {
|
|
||||||
return request<Model>('PUT', `/api/models/${id}`, input);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteModel(id: string): Promise<void> {
|
export async function deleteModel(id: string): Promise<void> {
|
||||||
return request<void>('DELETE', `/api/models/${id}`);
|
return request<void>('DELETE', `/api/models/${id}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,18 @@
|
|||||||
import type { Provider, CreateProviderInput, UpdateProviderInput } from '@/types';
|
import type { Provider, CreateProviderInput, UpdateProviderInput } from '@/types'
|
||||||
import { request } from './client';
|
import { request } from './client'
|
||||||
|
|
||||||
export async function listProviders(): Promise<Provider[]> {
|
export async function listProviders(): Promise<Provider[]> {
|
||||||
return request<Provider[]>('GET', '/api/providers');
|
return request<Provider[]>('GET', '/api/providers')
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createProvider(input: CreateProviderInput): Promise<Provider> {
|
export async function createProvider(input: CreateProviderInput): Promise<Provider> {
|
||||||
return request<Provider>('POST', '/api/providers', input);
|
return request<Provider>('POST', '/api/providers', input)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateProvider(
|
export async function updateProvider(id: string, input: UpdateProviderInput): Promise<Provider> {
|
||||||
id: string,
|
return request<Provider>('PUT', `/api/providers/${id}`, input)
|
||||||
input: UpdateProviderInput,
|
|
||||||
): Promise<Provider> {
|
|
||||||
return request<Provider>('PUT', `/api/providers/${id}`, input);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteProvider(id: string): Promise<void> {
|
export async function deleteProvider(id: string): Promise<void> {
|
||||||
return request<void>('DELETE', `/api/providers/${id}`);
|
return request<void>('DELETE', `/api/providers/${id}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,26 @@
|
|||||||
import type { UsageStats, StatsQueryParams } from '@/types';
|
import type { UsageStats, StatsQueryParams } from '@/types'
|
||||||
import { request } from './client';
|
import { request } from './client'
|
||||||
|
|
||||||
export async function getStats(params?: StatsQueryParams): Promise<UsageStats[]> {
|
export async function getStats(params?: StatsQueryParams): Promise<UsageStats[]> {
|
||||||
if (!params) {
|
if (!params) {
|
||||||
return request<UsageStats[]>('GET', '/api/stats');
|
return request<UsageStats[]>('GET', '/api/stats')
|
||||||
}
|
}
|
||||||
|
|
||||||
const query = new URLSearchParams();
|
const query = new URLSearchParams()
|
||||||
const snakeParams: Record<string, string | undefined> = {
|
const snakeParams: Record<string, string | undefined> = {
|
||||||
provider_id: params.providerId,
|
provider_id: params.providerId,
|
||||||
model_name: params.modelName,
|
model_name: params.modelName,
|
||||||
start_date: params.startDate,
|
start_date: params.startDate,
|
||||||
end_date: params.endDate,
|
end_date: params.endDate,
|
||||||
};
|
}
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(snakeParams)) {
|
for (const [key, value] of Object.entries(snakeParams)) {
|
||||||
if (value) {
|
if (value) {
|
||||||
query.set(key, value);
|
query.set(key, value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const queryString = query.toString();
|
const queryString = query.toString()
|
||||||
const path = queryString ? `/api/stats?${queryString}` : '/api/stats';
|
const path = queryString ? `/api/stats?${queryString}` : '/api/stats'
|
||||||
return request<UsageStats[]>('GET', path);
|
return request<UsageStats[]>('GET', path)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react'
|
||||||
import { Outlet, useLocation, useNavigate } from 'react-router';
|
import { Outlet, useLocation, useNavigate } from 'react-router'
|
||||||
import { ServerIcon, ChartLineIcon, SettingIcon, ChevronLeftIcon, ChevronRightIcon } from 'tdesign-icons-react';
|
import { ServerIcon, ChartLineIcon, SettingIcon, ChevronLeftIcon, ChevronRightIcon } from 'tdesign-icons-react'
|
||||||
import { Layout, Menu, Button } from 'tdesign-react';
|
import { Layout, Menu, Button } from 'tdesign-react'
|
||||||
|
|
||||||
const { MenuItem } = Menu;
|
const { MenuItem } = Menu
|
||||||
|
|
||||||
export function AppLayout() {
|
export function AppLayout() {
|
||||||
const location = useLocation();
|
const location = useLocation()
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate()
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
|
|
||||||
const getPageTitle = () => {
|
const getPageTitle = () => {
|
||||||
if (location.pathname === '/providers') return '供应商管理';
|
if (location.pathname === '/providers') return '供应商管理'
|
||||||
if (location.pathname === '/stats') return '用量统计';
|
if (location.pathname === '/stats') return '用量统计'
|
||||||
if (location.pathname === '/settings') return '设置';
|
if (location.pathname === '/settings') return '设置'
|
||||||
return 'AI Gateway';
|
return 'AI Gateway'
|
||||||
};
|
}
|
||||||
|
|
||||||
const asideWidth = collapsed ? '64px' : '232px';
|
const asideWidth = collapsed ? '64px' : '232px'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout style={{ minHeight: '100vh' }}>
|
<Layout style={{ minHeight: '100vh' }}>
|
||||||
@@ -38,34 +38,36 @@ export function AppLayout() {
|
|||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
width={['232px', '64px']}
|
width={['232px', '64px']}
|
||||||
logo={
|
logo={
|
||||||
<div style={{
|
<div
|
||||||
|
style={{
|
||||||
height: 64,
|
height: 64,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
fontSize: '1.25rem',
|
fontSize: '1.25rem',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
{!collapsed && 'AI Gateway'}
|
{!collapsed && 'AI Gateway'}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
operations={
|
operations={
|
||||||
<Button
|
<Button
|
||||||
variant="text"
|
variant='text'
|
||||||
shape="square"
|
shape='square'
|
||||||
icon={collapsed ? <ChevronRightIcon /> : <ChevronLeftIcon />}
|
icon={collapsed ? <ChevronRightIcon /> : <ChevronLeftIcon />}
|
||||||
onClick={() => setCollapsed(!collapsed)}
|
onClick={() => setCollapsed(!collapsed)}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
style={{ height: '100%' }}
|
style={{ height: '100%' }}
|
||||||
>
|
>
|
||||||
<MenuItem value="/providers" icon={<ServerIcon />}>
|
<MenuItem value='/providers' icon={<ServerIcon />}>
|
||||||
供应商管理
|
供应商管理
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem value="/stats" icon={<ChartLineIcon />}>
|
<MenuItem value='/stats' icon={<ChartLineIcon />}>
|
||||||
用量统计
|
用量统计
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem value="/settings" icon={<SettingIcon />}>
|
<MenuItem value='/settings' icon={<SettingIcon />}>
|
||||||
设置
|
设置
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</Menu>
|
</Menu>
|
||||||
@@ -95,5 +97,5 @@ export function AppLayout() {
|
|||||||
</Layout.Content>
|
</Layout.Content>
|
||||||
</Layout>
|
</Layout>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,73 +1,72 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { MessagePlugin } from 'tdesign-react';
|
import { MessagePlugin } from 'tdesign-react'
|
||||||
import * as api from '@/api/models';
|
import * as api from '@/api/models'
|
||||||
import type { CreateModelInput, UpdateModelInput, ApiError } from '@/types';
|
import type { CreateModelInput, UpdateModelInput, ApiError } from '@/types'
|
||||||
|
|
||||||
const ERROR_MESSAGES: Record<string, string> = {
|
const ERROR_MESSAGES: Record<string, string> = {
|
||||||
duplicate_model: '同一供应商下模型名称已存在',
|
duplicate_model: '同一供应商下模型名称已存在',
|
||||||
invalid_provider_id: '供应商 ID 仅允许字母、数字、下划线,长度 1-64',
|
invalid_provider_id: '供应商 ID 仅允许字母、数字、下划线,长度 1-64',
|
||||||
immutable_field: '供应商 ID 不允许修改',
|
immutable_field: '供应商 ID 不允许修改',
|
||||||
provider_not_found: '供应商不存在',
|
provider_not_found: '供应商不存在',
|
||||||
};
|
}
|
||||||
|
|
||||||
function getErrorMessage(error: ApiError): string {
|
function getErrorMessage(error: ApiError): string {
|
||||||
return error.code ? ERROR_MESSAGES[error.code] || error.message : error.message;
|
return error.code ? ERROR_MESSAGES[error.code] || error.message : error.message
|
||||||
}
|
}
|
||||||
|
|
||||||
export const modelKeys = {
|
export const modelKeys = {
|
||||||
all: ['models'] as const,
|
all: ['models'] as const,
|
||||||
filtered: (providerId?: string) => ['models', providerId] as const,
|
filtered: (providerId?: string) => ['models', providerId] as const,
|
||||||
};
|
}
|
||||||
|
|
||||||
export function useModels(providerId?: string) {
|
export function useModels(providerId?: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: modelKeys.filtered(providerId),
|
queryKey: modelKeys.filtered(providerId),
|
||||||
queryFn: () => api.listModels(providerId),
|
queryFn: () => api.listModels(providerId),
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCreateModel() {
|
export function useCreateModel() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (input: CreateModelInput) => api.createModel(input),
|
mutationFn: (input: CreateModelInput) => api.createModel(input),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: modelKeys.all });
|
queryClient.invalidateQueries({ queryKey: modelKeys.all })
|
||||||
MessagePlugin.success('模型创建成功');
|
MessagePlugin.success('模型创建成功')
|
||||||
},
|
},
|
||||||
onError: (error: ApiError) => {
|
onError: (error: ApiError) => {
|
||||||
MessagePlugin.error(getErrorMessage(error));
|
MessagePlugin.error(getErrorMessage(error))
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useUpdateModel() {
|
export function useUpdateModel() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ id, input }: { id: string; input: UpdateModelInput }) =>
|
mutationFn: ({ id, input }: { id: string; input: UpdateModelInput }) => api.updateModel(id, input),
|
||||||
api.updateModel(id, input),
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: modelKeys.all });
|
queryClient.invalidateQueries({ queryKey: modelKeys.all })
|
||||||
MessagePlugin.success('模型更新成功');
|
MessagePlugin.success('模型更新成功')
|
||||||
},
|
},
|
||||||
onError: (error: ApiError) => {
|
onError: (error: ApiError) => {
|
||||||
MessagePlugin.error(getErrorMessage(error));
|
MessagePlugin.error(getErrorMessage(error))
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDeleteModel() {
|
export function useDeleteModel() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (id: string) => api.deleteModel(id),
|
mutationFn: (id: string) => api.deleteModel(id),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: modelKeys.all });
|
queryClient.invalidateQueries({ queryKey: modelKeys.all })
|
||||||
MessagePlugin.success('模型删除成功');
|
MessagePlugin.success('模型删除成功')
|
||||||
},
|
},
|
||||||
onError: (error: Error) => {
|
onError: (error: Error) => {
|
||||||
MessagePlugin.error(error.message);
|
MessagePlugin.error(error.message)
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,72 +1,71 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { MessagePlugin } from 'tdesign-react';
|
import { MessagePlugin } from 'tdesign-react'
|
||||||
import * as api from '@/api/providers';
|
import * as api from '@/api/providers'
|
||||||
import type { CreateProviderInput, UpdateProviderInput, ApiError } from '@/types';
|
import type { CreateProviderInput, UpdateProviderInput, ApiError } from '@/types'
|
||||||
|
|
||||||
const ERROR_MESSAGES: Record<string, string> = {
|
const ERROR_MESSAGES: Record<string, string> = {
|
||||||
duplicate_model: '同一供应商下模型名称已存在',
|
duplicate_model: '同一供应商下模型名称已存在',
|
||||||
invalid_provider_id: '供应商 ID 仅允许字母、数字、下划线,长度 1-64',
|
invalid_provider_id: '供应商 ID 仅允许字母、数字、下划线,长度 1-64',
|
||||||
immutable_field: '供应商 ID 不允许修改',
|
immutable_field: '供应商 ID 不允许修改',
|
||||||
provider_not_found: '供应商不存在',
|
provider_not_found: '供应商不存在',
|
||||||
};
|
}
|
||||||
|
|
||||||
function getErrorMessage(error: ApiError): string {
|
function getErrorMessage(error: ApiError): string {
|
||||||
return error.code ? ERROR_MESSAGES[error.code] || error.message : error.message;
|
return error.code ? ERROR_MESSAGES[error.code] || error.message : error.message
|
||||||
}
|
}
|
||||||
|
|
||||||
export const providerKeys = {
|
export const providerKeys = {
|
||||||
all: ['providers'] as const,
|
all: ['providers'] as const,
|
||||||
};
|
}
|
||||||
|
|
||||||
export function useProviders() {
|
export function useProviders() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: providerKeys.all,
|
queryKey: providerKeys.all,
|
||||||
queryFn: api.listProviders,
|
queryFn: api.listProviders,
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCreateProvider() {
|
export function useCreateProvider() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (input: CreateProviderInput) => api.createProvider(input),
|
mutationFn: (input: CreateProviderInput) => api.createProvider(input),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: providerKeys.all });
|
queryClient.invalidateQueries({ queryKey: providerKeys.all })
|
||||||
MessagePlugin.success('供应商创建成功');
|
MessagePlugin.success('供应商创建成功')
|
||||||
},
|
},
|
||||||
onError: (error: ApiError) => {
|
onError: (error: ApiError) => {
|
||||||
MessagePlugin.error(getErrorMessage(error));
|
MessagePlugin.error(getErrorMessage(error))
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useUpdateProvider() {
|
export function useUpdateProvider() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ id, input }: { id: string; input: UpdateProviderInput }) =>
|
mutationFn: ({ id, input }: { id: string; input: UpdateProviderInput }) => api.updateProvider(id, input),
|
||||||
api.updateProvider(id, input),
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: providerKeys.all });
|
queryClient.invalidateQueries({ queryKey: providerKeys.all })
|
||||||
MessagePlugin.success('供应商更新成功');
|
MessagePlugin.success('供应商更新成功')
|
||||||
},
|
},
|
||||||
onError: (error: ApiError) => {
|
onError: (error: ApiError) => {
|
||||||
MessagePlugin.error(getErrorMessage(error));
|
MessagePlugin.error(getErrorMessage(error))
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDeleteProvider() {
|
export function useDeleteProvider() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (id: string) => api.deleteProvider(id),
|
mutationFn: (id: string) => api.deleteProvider(id),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: providerKeys.all });
|
queryClient.invalidateQueries({ queryKey: providerKeys.all })
|
||||||
MessagePlugin.success('供应商删除成功');
|
MessagePlugin.success('供应商删除成功')
|
||||||
},
|
},
|
||||||
onError: (error: Error) => {
|
onError: (error: Error) => {
|
||||||
MessagePlugin.error(error.message);
|
MessagePlugin.error(error.message)
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import * as api from '@/api/stats';
|
import * as api from '@/api/stats'
|
||||||
import type { StatsQueryParams } from '@/types';
|
import type { StatsQueryParams } from '@/types'
|
||||||
|
|
||||||
export const statsKeys = {
|
export const statsKeys = {
|
||||||
filtered: (params?: StatsQueryParams) => ['stats', params] as const,
|
filtered: (params?: StatsQueryParams) => ['stats', params] as const,
|
||||||
};
|
}
|
||||||
|
|
||||||
export function useStats(params?: StatsQueryParams) {
|
export function useStats(params?: StatsQueryParams) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: statsKeys.filtered(params),
|
queryKey: statsKeys.filtered(params),
|
||||||
queryFn: () => api.getStats(params),
|
queryFn: () => api.getStats(params),
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ body,
|
|||||||
--td-radius-extraLarge: 16px;
|
--td-radius-extraLarge: 16px;
|
||||||
|
|
||||||
/* 系统字体栈 */
|
/* 系统字体栈 */
|
||||||
--td-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
--td-font-family:
|
||||||
"Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji",
|
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif,
|
||||||
"Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,5 +12,5 @@ if (!root) {
|
|||||||
createRoot(root).render(
|
createRoot(root).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<App />
|
||||||
</StrictMode>,
|
</StrictMode>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,25 +1,27 @@
|
|||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router'
|
||||||
import { Button } from 'tdesign-react';
|
import { Button } from 'tdesign-react'
|
||||||
|
|
||||||
export default function NotFound() {
|
export default function NotFound() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div
|
||||||
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
minHeight: '100vh',
|
minHeight: '100vh',
|
||||||
padding: '2rem',
|
padding: '2rem',
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
<h1 style={{ fontSize: '6rem', margin: 0, color: 'var(--td-text-color-placeholder)' }}>404</h1>
|
<h1 style={{ fontSize: '6rem', margin: 0, color: 'var(--td-text-color-placeholder)' }}>404</h1>
|
||||||
<p style={{ fontSize: '1.25rem', color: 'var(--td-text-color-secondary)', marginBottom: '2rem' }}>
|
<p style={{ fontSize: '1.25rem', color: 'var(--td-text-color-secondary)', marginBottom: '2rem' }}>
|
||||||
抱歉,您访问的页面不存在。
|
抱歉,您访问的页面不存在。
|
||||||
</p>
|
</p>
|
||||||
<Button theme="primary" onClick={() => navigate('/providers')}>
|
<Button theme='primary' onClick={() => navigate('/providers')}>
|
||||||
返回首页
|
返回首页
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,27 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react'
|
||||||
import { Dialog, Form, Input, Select, Switch } from 'tdesign-react';
|
import { Dialog, Form, Input, Select, Switch } from 'tdesign-react'
|
||||||
import type { Provider, Model } from '@/types';
|
import type { Provider, Model } from '@/types'
|
||||||
import type { SubmitContext } from 'tdesign-react/es/form/type';
|
import type { SubmitContext } from 'tdesign-react/es/form/type'
|
||||||
|
|
||||||
interface ModelFormValues {
|
interface ModelFormValues {
|
||||||
providerId: string;
|
providerId: string
|
||||||
modelName: string;
|
modelName: string
|
||||||
enabled: boolean;
|
enabled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ModelFormProps {
|
interface ModelFormProps {
|
||||||
open: boolean;
|
open: boolean
|
||||||
model?: Model;
|
model?: Model
|
||||||
providerId: string;
|
providerId: string
|
||||||
providers: Provider[];
|
providers: Provider[]
|
||||||
onSave: (values: ModelFormValues) => Promise<void> | void;
|
onSave: (values: ModelFormValues) => Promise<void> | void
|
||||||
onCancel: () => void;
|
onCancel: () => void
|
||||||
loading: boolean;
|
loading: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ModelForm({
|
export function ModelForm({ open, model, providerId, providers, onSave, onCancel, loading }: ModelFormProps) {
|
||||||
open,
|
const [form] = Form.useForm()
|
||||||
model,
|
const isEdit = !!model
|
||||||
providerId,
|
|
||||||
providers,
|
|
||||||
onSave,
|
|
||||||
onCancel,
|
|
||||||
loading,
|
|
||||||
}: ModelFormProps) {
|
|
||||||
const [form] = Form.useForm();
|
|
||||||
const isEdit = !!model;
|
|
||||||
|
|
||||||
// 当弹窗打开或model变化时,设置表单值
|
// 当弹窗打开或model变化时,设置表单值
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -40,63 +32,56 @@ export function ModelForm({
|
|||||||
providerId: model.providerId,
|
providerId: model.providerId,
|
||||||
modelName: model.modelName,
|
modelName: model.modelName,
|
||||||
enabled: model.enabled,
|
enabled: model.enabled,
|
||||||
});
|
})
|
||||||
} else {
|
} else {
|
||||||
// 新增模式:重置表单并设置默认providerId
|
// 新增模式:重置表单并设置默认providerId
|
||||||
form.reset();
|
form.reset()
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
providerId,
|
providerId,
|
||||||
enabled: true
|
enabled: true,
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [open, model, providerId]); // 移除form依赖,避免循环
|
}, [open, model, providerId]) // 移除form依赖,避免循环
|
||||||
|
|
||||||
const handleSubmit = (context: SubmitContext) => {
|
const handleSubmit = (context: SubmitContext) => {
|
||||||
if (context.validateResult === true && form) {
|
if (context.validateResult === true && form) {
|
||||||
const values = form.getFieldsValue(true) as ModelFormValues;
|
const values = form.getFieldsValue(true) as ModelFormValues
|
||||||
onSave(values);
|
onSave(values)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
header={isEdit ? '编辑模型' : '添加模型'}
|
header={isEdit ? '编辑模型' : '添加模型'}
|
||||||
visible={open}
|
visible={open}
|
||||||
placement="center"
|
placement='center'
|
||||||
width="520px"
|
width='520px'
|
||||||
closeOnOverlayClick={false}
|
closeOnOverlayClick={false}
|
||||||
closeOnEscKeydown={false}
|
closeOnEscKeydown={false}
|
||||||
lazy={false}
|
lazy={false}
|
||||||
onConfirm={() => { form?.submit(); return false; }}
|
onConfirm={() => {
|
||||||
|
form?.submit()
|
||||||
|
return false
|
||||||
|
}}
|
||||||
onClose={onCancel}
|
onClose={onCancel}
|
||||||
confirmLoading={loading}
|
confirmLoading={loading}
|
||||||
confirmBtn="保存"
|
confirmBtn='保存'
|
||||||
cancelBtn="取消"
|
cancelBtn='取消'
|
||||||
>
|
>
|
||||||
<Form form={form} layout="vertical" onSubmit={handleSubmit}>
|
<Form form={form} layout='vertical' onSubmit={handleSubmit}>
|
||||||
<Form.FormItem
|
<Form.FormItem label='供应商' name='providerId' rules={[{ required: true, message: '请选择供应商' }]}>
|
||||||
label="供应商"
|
<Select options={providers.map((p) => ({ label: p.name, value: p.id }))} />
|
||||||
name="providerId"
|
|
||||||
rules={[{ required: true, message: '请选择供应商' }]}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
options={providers.map((p) => ({ label: p.name, value: p.id }))}
|
|
||||||
/>
|
|
||||||
</Form.FormItem>
|
</Form.FormItem>
|
||||||
|
|
||||||
<Form.FormItem
|
<Form.FormItem label='模型名称' name='modelName' rules={[{ required: true, message: '请输入模型名称' }]}>
|
||||||
label="模型名称"
|
<Input placeholder='例如: gpt-4o' />
|
||||||
name="modelName"
|
|
||||||
rules={[{ required: true, message: '请输入模型名称' }]}
|
|
||||||
>
|
|
||||||
<Input placeholder="例如: gpt-4o" />
|
|
||||||
</Form.FormItem>
|
</Form.FormItem>
|
||||||
|
|
||||||
<Form.FormItem label="启用" name="enabled">
|
<Form.FormItem label='启用' name='enabled'>
|
||||||
<Switch />
|
<Switch />
|
||||||
</Form.FormItem>
|
</Form.FormItem>
|
||||||
</Form>
|
</Form>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import { Button, Table, Tag, Popconfirm, Space } from 'tdesign-react';
|
import { Button, Table, Tag, Popconfirm, Space } from 'tdesign-react'
|
||||||
import { useModels, useDeleteModel } from '@/hooks/useModels';
|
import { useModels, useDeleteModel } from '@/hooks/useModels'
|
||||||
import type { Model } from '@/types';
|
import type { Model } from '@/types'
|
||||||
import type { PrimaryTableCol } from 'tdesign-react/es/table/type';
|
import type { PrimaryTableCol } from 'tdesign-react/es/table/type'
|
||||||
|
|
||||||
interface ModelTableProps {
|
interface ModelTableProps {
|
||||||
providerId: string;
|
providerId: string
|
||||||
onAdd?: () => void;
|
onAdd?: () => void
|
||||||
onEdit?: (model: Model) => void;
|
onEdit?: (model: Model) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ModelTable({ providerId, onAdd, onEdit }: ModelTableProps) {
|
export function ModelTable({ providerId, onAdd, onEdit }: ModelTableProps) {
|
||||||
const { data: models = [], isLoading } = useModels(providerId);
|
const { data: models = [], isLoading } = useModels(providerId)
|
||||||
const deleteModel = useDeleteModel();
|
const deleteModel = useDeleteModel()
|
||||||
|
|
||||||
const columns: PrimaryTableCol<Model>[] = [
|
const columns: PrimaryTableCol<Model>[] = [
|
||||||
{
|
{
|
||||||
@@ -32,9 +32,13 @@ export function ModelTable({ providerId, onAdd, onEdit }: ModelTableProps) {
|
|||||||
width: 80,
|
width: 80,
|
||||||
cell: ({ row }) =>
|
cell: ({ row }) =>
|
||||||
row.enabled ? (
|
row.enabled ? (
|
||||||
<Tag theme="success" variant="light" shape="round">启用</Tag>
|
<Tag theme='success' variant='light' shape='round'>
|
||||||
|
启用
|
||||||
|
</Tag>
|
||||||
) : (
|
) : (
|
||||||
<Tag theme="danger" variant="light" shape="round">禁用</Tag>
|
<Tag theme='danger' variant='light' shape='round'>
|
||||||
|
禁用
|
||||||
|
</Tag>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -44,29 +48,26 @@ export function ModelTable({ providerId, onAdd, onEdit }: ModelTableProps) {
|
|||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Space>
|
<Space>
|
||||||
{onEdit && (
|
{onEdit && (
|
||||||
<Button variant="text" size="small" onClick={() => onEdit(row)}>
|
<Button variant='text' size='small' onClick={() => onEdit(row)}>
|
||||||
编辑
|
编辑
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Popconfirm
|
<Popconfirm content='确定要删除这个模型吗?' onConfirm={() => deleteModel.mutate(row.id)}>
|
||||||
content="确定要删除这个模型吗?"
|
<Button variant='text' theme='danger' size='small'>
|
||||||
onConfirm={() => deleteModel.mutate(row.id)}
|
|
||||||
>
|
|
||||||
<Button variant="text" theme="danger" size="small">
|
|
||||||
删除
|
删除
|
||||||
</Button>
|
</Button>
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '8px 16px' }}>
|
<div style={{ padding: '8px 16px' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||||
<span style={{ fontWeight: 500 }}>关联模型 ({models.length})</span>
|
<span style={{ fontWeight: 500 }}>关联模型 ({models.length})</span>
|
||||||
{onAdd && (
|
{onAdd && (
|
||||||
<Button variant="text" size="small" onClick={onAdd}>
|
<Button variant='text' size='small' onClick={onAdd}>
|
||||||
添加模型
|
添加模型
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -74,13 +75,13 @@ export function ModelTable({ providerId, onAdd, onEdit }: ModelTableProps) {
|
|||||||
<Table<Model>
|
<Table<Model>
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={models}
|
data={models}
|
||||||
rowKey="id"
|
rowKey='id'
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
stripe
|
stripe
|
||||||
pagination={undefined}
|
pagination={undefined}
|
||||||
size="small"
|
size='small'
|
||||||
empty="暂无模型,点击上方按钮添加"
|
empty='暂无模型,点击上方按钮添加'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,34 +1,28 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react'
|
||||||
import { Dialog, Form, Input, Switch, Select } from 'tdesign-react';
|
import { Dialog, Form, Input, Switch, Select } from 'tdesign-react'
|
||||||
import type { Provider } from '@/types';
|
import type { Provider } from '@/types'
|
||||||
import type { SubmitContext } from 'tdesign-react/es/form/type';
|
import type { SubmitContext } from 'tdesign-react/es/form/type'
|
||||||
|
|
||||||
interface ProviderFormValues {
|
interface ProviderFormValues {
|
||||||
id: string;
|
id: string
|
||||||
name: string;
|
name: string
|
||||||
apiKey: string;
|
apiKey: string
|
||||||
baseUrl: string;
|
baseUrl: string
|
||||||
protocol: 'openai' | 'anthropic';
|
protocol: 'openai' | 'anthropic'
|
||||||
enabled: boolean;
|
enabled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProviderFormProps {
|
interface ProviderFormProps {
|
||||||
open: boolean;
|
open: boolean
|
||||||
provider?: Provider;
|
provider?: Provider
|
||||||
onSave: (values: ProviderFormValues) => Promise<void> | void;
|
onSave: (values: ProviderFormValues) => Promise<void> | void
|
||||||
onCancel: () => void;
|
onCancel: () => void
|
||||||
loading: boolean;
|
loading: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProviderForm({
|
export function ProviderForm({ open, provider, onSave, onCancel, loading }: ProviderFormProps) {
|
||||||
open,
|
const [form] = Form.useForm()
|
||||||
provider,
|
const isEdit = !!provider
|
||||||
onSave,
|
|
||||||
onCancel,
|
|
||||||
loading,
|
|
||||||
}: ProviderFormProps) {
|
|
||||||
const [form] = Form.useForm();
|
|
||||||
const isEdit = !!provider;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open && form) {
|
if (open && form) {
|
||||||
@@ -40,75 +34,74 @@ export function ProviderForm({
|
|||||||
baseUrl: provider.baseUrl,
|
baseUrl: provider.baseUrl,
|
||||||
protocol: provider.protocol,
|
protocol: provider.protocol,
|
||||||
enabled: provider.enabled,
|
enabled: provider.enabled,
|
||||||
});
|
})
|
||||||
} else {
|
} else {
|
||||||
form.reset();
|
form.reset()
|
||||||
form.setFieldsValue({ enabled: true, protocol: 'openai' });
|
form.setFieldsValue({ enabled: true, protocol: 'openai' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [open, provider]);
|
}, [open, provider])
|
||||||
|
|
||||||
const handleSubmit = (context: SubmitContext) => {
|
const handleSubmit = (context: SubmitContext) => {
|
||||||
if (context.validateResult === true && form) {
|
if (context.validateResult === true && form) {
|
||||||
const values = form.getFieldsValue(true) as ProviderFormValues;
|
const values = form.getFieldsValue(true) as ProviderFormValues
|
||||||
onSave(values);
|
onSave(values)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
header={isEdit ? '编辑供应商' : '添加供应商'}
|
header={isEdit ? '编辑供应商' : '添加供应商'}
|
||||||
visible={open}
|
visible={open}
|
||||||
placement="center"
|
placement='center'
|
||||||
width="520px"
|
width='520px'
|
||||||
closeOnOverlayClick={false}
|
closeOnOverlayClick={false}
|
||||||
closeOnEscKeydown={false}
|
closeOnEscKeydown={false}
|
||||||
lazy={false}
|
lazy={false}
|
||||||
onConfirm={() => { form?.submit(); return false; }}
|
onConfirm={() => {
|
||||||
|
form?.submit()
|
||||||
|
return false
|
||||||
|
}}
|
||||||
onClose={onCancel}
|
onClose={onCancel}
|
||||||
confirmLoading={loading}
|
confirmLoading={loading}
|
||||||
confirmBtn="保存"
|
confirmBtn='保存'
|
||||||
cancelBtn="取消"
|
cancelBtn='取消'
|
||||||
>
|
>
|
||||||
<Form form={form} layout="vertical" onSubmit={handleSubmit}>
|
<Form form={form} layout='vertical' onSubmit={handleSubmit}>
|
||||||
<Form.FormItem label="ID" name="id" rules={[{ required: true, message: '请输入供应商 ID' }]}>
|
<Form.FormItem label='ID' name='id' rules={[{ required: true, message: '请输入供应商 ID' }]}>
|
||||||
<Input disabled={isEdit} placeholder="例如: openai" />
|
<Input disabled={isEdit} placeholder='例如: openai' />
|
||||||
</Form.FormItem>
|
</Form.FormItem>
|
||||||
|
|
||||||
<Form.FormItem label="名称" name="name" rules={[{ required: true, message: '请输入名称' }]}>
|
<Form.FormItem label='名称' name='name' rules={[{ required: true, message: '请输入名称' }]}>
|
||||||
<Input placeholder="例如: OpenAI" />
|
<Input placeholder='例如: OpenAI' />
|
||||||
|
</Form.FormItem>
|
||||||
|
|
||||||
|
<Form.FormItem label='API Key' name='apiKey' rules={[{ required: true, message: '请输入 API Key' }]}>
|
||||||
|
<Input placeholder='sk-...' />
|
||||||
</Form.FormItem>
|
</Form.FormItem>
|
||||||
|
|
||||||
<Form.FormItem
|
<Form.FormItem
|
||||||
label="API Key"
|
label='Base URL'
|
||||||
name="apiKey"
|
name='baseUrl'
|
||||||
rules={[{ required: true, message: '请输入 API Key' }]}
|
|
||||||
>
|
|
||||||
<Input placeholder="sk-..." />
|
|
||||||
</Form.FormItem>
|
|
||||||
|
|
||||||
<Form.FormItem
|
|
||||||
label="Base URL"
|
|
||||||
name="baseUrl"
|
|
||||||
rules={[
|
rules={[
|
||||||
{ required: true, message: '请输入 Base URL' },
|
{ required: true, message: '请输入 Base URL' },
|
||||||
{ url: true, message: '请输入有效的 URL' },
|
{ url: true, message: '请输入有效的 URL' },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Input placeholder="例如: https://api.openai.com/v1" />
|
<Input placeholder='例如: https://api.openai.com/v1' />
|
||||||
</Form.FormItem>
|
</Form.FormItem>
|
||||||
|
|
||||||
<Form.FormItem label="协议" name="protocol" rules={[{ required: true, message: '请选择协议' }]}>
|
<Form.FormItem label='协议' name='protocol' rules={[{ required: true, message: '请选择协议' }]}>
|
||||||
<Select>
|
<Select>
|
||||||
<Select.Option value="openai">OpenAI</Select.Option>
|
<Select.Option value='openai'>OpenAI</Select.Option>
|
||||||
<Select.Option value="anthropic">Anthropic</Select.Option>
|
<Select.Option value='anthropic'>Anthropic</Select.Option>
|
||||||
</Select>
|
</Select>
|
||||||
</Form.FormItem>
|
</Form.FormItem>
|
||||||
|
|
||||||
<Form.FormItem label="启用" name="enabled">
|
<Form.FormItem label='启用' name='enabled'>
|
||||||
<Switch />
|
<Switch />
|
||||||
</Form.FormItem>
|
</Form.FormItem>
|
||||||
</Form>
|
</Form>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import { Button, Table, Tag, Popconfirm, Space, Card } from 'tdesign-react';
|
import { Button, Table, Tag, Popconfirm, Space, Card } from 'tdesign-react'
|
||||||
import type { Provider, Model } from '@/types';
|
import type { Provider, Model } from '@/types'
|
||||||
import { ModelTable } from './ModelTable';
|
import { ModelTable } from './ModelTable'
|
||||||
import type { PrimaryTableCol } from 'tdesign-react/es/table/type';
|
import type { PrimaryTableCol } from 'tdesign-react/es/table/type'
|
||||||
|
|
||||||
interface ProviderTableProps {
|
interface ProviderTableProps {
|
||||||
providers: Provider[];
|
providers: Provider[]
|
||||||
loading: boolean;
|
loading: boolean
|
||||||
onAdd: () => void;
|
onAdd: () => void
|
||||||
onEdit: (provider: Provider) => void;
|
onEdit: (provider: Provider) => void
|
||||||
onDelete: (id: string) => void;
|
onDelete: (id: string) => void
|
||||||
onAddModel: (providerId: string) => void;
|
onAddModel: (providerId: string) => void
|
||||||
onEditModel: (model: Model) => void;
|
onEditModel: (model: Model) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProviderTable({
|
export function ProviderTable({
|
||||||
@@ -39,7 +39,7 @@ export function ProviderTable({
|
|||||||
colKey: 'protocol',
|
colKey: 'protocol',
|
||||||
width: 100,
|
width: 100,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Tag theme={row.protocol === 'openai' ? 'primary' : 'success'} variant="light" shape="round">
|
<Tag theme={row.protocol === 'openai' ? 'primary' : 'success'} variant='light' shape='round'>
|
||||||
{row.protocol === 'openai' ? 'OpenAI' : 'Anthropic'}
|
{row.protocol === 'openai' ? 'OpenAI' : 'Anthropic'}
|
||||||
</Tag>
|
</Tag>
|
||||||
),
|
),
|
||||||
@@ -55,9 +55,13 @@ export function ProviderTable({
|
|||||||
width: 80,
|
width: 80,
|
||||||
cell: ({ row }) =>
|
cell: ({ row }) =>
|
||||||
row.enabled ? (
|
row.enabled ? (
|
||||||
<Tag theme="success" variant="light" shape="round">启用</Tag>
|
<Tag theme='success' variant='light' shape='round'>
|
||||||
|
启用
|
||||||
|
</Tag>
|
||||||
) : (
|
) : (
|
||||||
<Tag theme="danger" variant="light" shape="round">禁用</Tag>
|
<Tag theme='danger' variant='light' shape='round'>
|
||||||
|
禁用
|
||||||
|
</Tag>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -66,29 +70,26 @@ export function ProviderTable({
|
|||||||
width: 160,
|
width: 160,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Space>
|
<Space>
|
||||||
<Button variant="text" size="small" onClick={() => onEdit(row)}>
|
<Button variant='text' size='small' onClick={() => onEdit(row)}>
|
||||||
编辑
|
编辑
|
||||||
</Button>
|
</Button>
|
||||||
<Popconfirm
|
<Popconfirm content='确定要删除这个供应商吗?关联的模型也会被删除。' onConfirm={() => onDelete(row.id)}>
|
||||||
content="确定要删除这个供应商吗?关联的模型也会被删除。"
|
<Button variant='text' theme='danger' size='small'>
|
||||||
onConfirm={() => onDelete(row.id)}
|
|
||||||
>
|
|
||||||
<Button variant="text" theme="danger" size="small">
|
|
||||||
删除
|
删除
|
||||||
</Button>
|
</Button>
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
title="供应商列表"
|
title='供应商列表'
|
||||||
headerBordered
|
headerBordered
|
||||||
hoverShadow
|
hoverShadow
|
||||||
actions={
|
actions={
|
||||||
<Button theme="primary" onClick={onAdd}>
|
<Button theme='primary' onClick={onAdd}>
|
||||||
添加供应商
|
添加供应商
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
@@ -96,19 +97,15 @@ export function ProviderTable({
|
|||||||
<Table<Provider>
|
<Table<Provider>
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={providers}
|
data={providers}
|
||||||
rowKey="id"
|
rowKey='id'
|
||||||
loading={loading}
|
loading={loading}
|
||||||
stripe
|
stripe
|
||||||
expandedRow={({ row }) => (
|
expandedRow={({ row }) => (
|
||||||
<ModelTable
|
<ModelTable providerId={row.id} onAdd={() => onAddModel(row.id)} onEdit={onEditModel} />
|
||||||
providerId={row.id}
|
|
||||||
onAdd={() => onAddModel(row.id)}
|
|
||||||
onEdit={onEditModel}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
pagination={undefined}
|
pagination={undefined}
|
||||||
empty="暂无供应商,点击上方按钮添加"
|
empty='暂无供应商,点击上方按钮添加'
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react'
|
||||||
import { useCreateModel, useUpdateModel } from '@/hooks/useModels';
|
import { useCreateModel, useUpdateModel } from '@/hooks/useModels'
|
||||||
import { useProviders, useCreateProvider, useUpdateProvider, useDeleteProvider } from '@/hooks/useProviders';
|
import { useProviders, useCreateProvider, useUpdateProvider, useDeleteProvider } from '@/hooks/useProviders'
|
||||||
import type { Provider, Model, UpdateProviderInput, UpdateModelInput } from '@/types';
|
import type { Provider, Model, UpdateProviderInput, UpdateModelInput } from '@/types'
|
||||||
import { ModelForm } from './ModelForm';
|
import { ModelForm } from './ModelForm'
|
||||||
import { ProviderForm } from './ProviderForm';
|
import { ProviderForm } from './ProviderForm'
|
||||||
import { ProviderTable } from './ProviderTable';
|
import { ProviderTable } from './ProviderTable'
|
||||||
|
|
||||||
export default function ProvidersPage() {
|
export default function ProvidersPage() {
|
||||||
const { data: providers = [], isLoading } = useProviders();
|
const { data: providers = [], isLoading } = useProviders()
|
||||||
const createProvider = useCreateProvider();
|
const createProvider = useCreateProvider()
|
||||||
const updateProvider = useUpdateProvider();
|
const updateProvider = useUpdateProvider()
|
||||||
const deleteProvider = useDeleteProvider();
|
const deleteProvider = useDeleteProvider()
|
||||||
const createModel = useCreateModel();
|
const createModel = useCreateModel()
|
||||||
const updateModel = useUpdateModel();
|
const updateModel = useUpdateModel()
|
||||||
|
|
||||||
const [providerFormOpen, setProviderFormOpen] = useState(false);
|
const [providerFormOpen, setProviderFormOpen] = useState(false)
|
||||||
const [editingProvider, setEditingProvider] = useState<Provider | undefined>();
|
const [editingProvider, setEditingProvider] = useState<Provider | undefined>()
|
||||||
const [modelFormOpen, setModelFormOpen] = useState(false);
|
const [modelFormOpen, setModelFormOpen] = useState(false)
|
||||||
const [editingModel, setEditingModel] = useState<Model | undefined>();
|
const [editingModel, setEditingModel] = useState<Model | undefined>()
|
||||||
const [modelFormProviderId, setModelFormProviderId] = useState('');
|
const [modelFormProviderId, setModelFormProviderId] = useState('')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -26,23 +26,23 @@ export default function ProvidersPage() {
|
|||||||
providers={providers}
|
providers={providers}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
onAdd={() => {
|
onAdd={() => {
|
||||||
setEditingProvider(undefined);
|
setEditingProvider(undefined)
|
||||||
setProviderFormOpen(true);
|
setProviderFormOpen(true)
|
||||||
}}
|
}}
|
||||||
onEdit={(provider) => {
|
onEdit={(provider) => {
|
||||||
setEditingProvider(provider);
|
setEditingProvider(provider)
|
||||||
setProviderFormOpen(true);
|
setProviderFormOpen(true)
|
||||||
}}
|
}}
|
||||||
onDelete={(id) => deleteProvider.mutate(id)}
|
onDelete={(id) => deleteProvider.mutate(id)}
|
||||||
onAddModel={(providerId) => {
|
onAddModel={(providerId) => {
|
||||||
setEditingModel(undefined);
|
setEditingModel(undefined)
|
||||||
setModelFormProviderId(providerId);
|
setModelFormProviderId(providerId)
|
||||||
setModelFormOpen(true);
|
setModelFormOpen(true)
|
||||||
}}
|
}}
|
||||||
onEditModel={(model) => {
|
onEditModel={(model) => {
|
||||||
setEditingModel(model);
|
setEditingModel(model)
|
||||||
setModelFormProviderId(model.providerId);
|
setModelFormProviderId(model.providerId)
|
||||||
setModelFormOpen(true);
|
setModelFormOpen(true)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -53,16 +53,16 @@ export default function ProvidersPage() {
|
|||||||
onSave={async (values) => {
|
onSave={async (values) => {
|
||||||
try {
|
try {
|
||||||
if (editingProvider) {
|
if (editingProvider) {
|
||||||
const input: Partial<UpdateProviderInput> = {};
|
const input: Partial<UpdateProviderInput> = {}
|
||||||
if (values.name !== editingProvider.name) input.name = values.name;
|
if (values.name !== editingProvider.name) input.name = values.name
|
||||||
if (values.apiKey !== editingProvider.apiKey) input.apiKey = values.apiKey;
|
if (values.apiKey !== editingProvider.apiKey) input.apiKey = values.apiKey
|
||||||
if (values.baseUrl !== editingProvider.baseUrl) input.baseUrl = values.baseUrl;
|
if (values.baseUrl !== editingProvider.baseUrl) input.baseUrl = values.baseUrl
|
||||||
if (values.enabled !== editingProvider.enabled) input.enabled = values.enabled;
|
if (values.enabled !== editingProvider.enabled) input.enabled = values.enabled
|
||||||
await updateProvider.mutateAsync({ id: editingProvider.id, input });
|
await updateProvider.mutateAsync({ id: editingProvider.id, input })
|
||||||
} else {
|
} else {
|
||||||
await createProvider.mutateAsync(values);
|
await createProvider.mutateAsync(values)
|
||||||
}
|
}
|
||||||
setProviderFormOpen(false);
|
setProviderFormOpen(false)
|
||||||
} catch {
|
} catch {
|
||||||
// 错误已由 hooks 的 onError 处理
|
// 错误已由 hooks 的 onError 处理
|
||||||
}
|
}
|
||||||
@@ -79,15 +79,15 @@ export default function ProvidersPage() {
|
|||||||
onSave={async (values) => {
|
onSave={async (values) => {
|
||||||
try {
|
try {
|
||||||
if (editingModel) {
|
if (editingModel) {
|
||||||
const input: Partial<UpdateModelInput> = {};
|
const input: Partial<UpdateModelInput> = {}
|
||||||
if (values.providerId !== editingModel.providerId) input.providerId = values.providerId;
|
if (values.providerId !== editingModel.providerId) input.providerId = values.providerId
|
||||||
if (values.modelName !== editingModel.modelName) input.modelName = values.modelName;
|
if (values.modelName !== editingModel.modelName) input.modelName = values.modelName
|
||||||
if (values.enabled !== editingModel.enabled) input.enabled = values.enabled;
|
if (values.enabled !== editingModel.enabled) input.enabled = values.enabled
|
||||||
await updateModel.mutateAsync({ id: editingModel.id, input });
|
await updateModel.mutateAsync({ id: editingModel.id, input })
|
||||||
} else {
|
} else {
|
||||||
await createModel.mutateAsync(values);
|
await createModel.mutateAsync(values)
|
||||||
}
|
}
|
||||||
setModelFormOpen(false);
|
setModelFormOpen(false)
|
||||||
} catch {
|
} catch {
|
||||||
// 错误已由 hooks 的 onError 处理
|
// 错误已由 hooks 的 onError 处理
|
||||||
}
|
}
|
||||||
@@ -95,5 +95,5 @@ export default function ProvidersPage() {
|
|||||||
onCancel={() => setModelFormOpen(false)}
|
onCancel={() => setModelFormOpen(false)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Card } from 'tdesign-react';
|
import { Card } from 'tdesign-react'
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
return (
|
return (
|
||||||
<Card title="设置">
|
<Card title='设置'>
|
||||||
<div style={{ textAlign: 'center', padding: '40px 0', color: 'var(--td-text-color-placeholder)' }}>
|
<div style={{ textAlign: 'center', padding: '40px 0', color: 'var(--td-text-color-placeholder)' }}>
|
||||||
设置功能开发中...
|
设置功能开发中...
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,29 @@
|
|||||||
import { ChartBarIcon, ChartLineIcon, ServerIcon, Calendar1Icon } from 'tdesign-icons-react';
|
import { ChartBarIcon, ChartLineIcon, ServerIcon, Calendar1Icon } from 'tdesign-icons-react'
|
||||||
import { Row, Col, Card, Statistic } from 'tdesign-react';
|
import { Row, Col, Card, Statistic } from 'tdesign-react'
|
||||||
import type { UsageStats } from '@/types';
|
import type { UsageStats } from '@/types'
|
||||||
|
|
||||||
interface StatCardsProps {
|
interface StatCardsProps {
|
||||||
stats: UsageStats[];
|
stats: UsageStats[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StatCards({ stats }: StatCardsProps) {
|
export function StatCards({ stats }: StatCardsProps) {
|
||||||
const totalRequests = stats.reduce((sum, s) => sum + s.requestCount, 0);
|
const totalRequests = stats.reduce((sum, s) => sum + s.requestCount, 0)
|
||||||
const activeModels = new Set(stats.map((s) => s.modelName)).size;
|
const activeModels = new Set(stats.map((s) => s.modelName)).size
|
||||||
const activeProviders = new Set(stats.map((s) => s.providerId)).size;
|
const activeProviders = new Set(stats.map((s) => s.providerId)).size
|
||||||
|
|
||||||
const today = new Date().toISOString().split('T')[0];
|
const today = new Date().toISOString().split('T')[0]
|
||||||
const todayRequests = stats
|
const todayRequests = stats.filter((s) => s.date === today).reduce((sum, s) => sum + s.requestCount, 0)
|
||||||
.filter((s) => s.date === today)
|
|
||||||
.reduce((sum, s) => sum + s.requestCount, 0);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||||||
<Col xs={12} md={6}>
|
<Col xs={12} md={6}>
|
||||||
<Card bordered={false} hoverShadow>
|
<Card bordered={false} hoverShadow>
|
||||||
<Statistic
|
<Statistic
|
||||||
title="总请求量"
|
title='总请求量'
|
||||||
value={totalRequests}
|
value={totalRequests}
|
||||||
color="blue"
|
color='blue'
|
||||||
prefix={<ChartBarIcon />}
|
prefix={<ChartBarIcon />}
|
||||||
suffix="次"
|
suffix='次'
|
||||||
animation={{ duration: 800, valueFrom: 0 }}
|
animation={{ duration: 800, valueFrom: 0 }}
|
||||||
animationStart
|
animationStart
|
||||||
/>
|
/>
|
||||||
@@ -34,11 +32,11 @@ export function StatCards({ stats }: StatCardsProps) {
|
|||||||
<Col xs={12} md={6}>
|
<Col xs={12} md={6}>
|
||||||
<Card bordered={false} hoverShadow>
|
<Card bordered={false} hoverShadow>
|
||||||
<Statistic
|
<Statistic
|
||||||
title="活跃模型数"
|
title='活跃模型数'
|
||||||
value={activeModels}
|
value={activeModels}
|
||||||
color="green"
|
color='green'
|
||||||
prefix={<ChartLineIcon />}
|
prefix={<ChartLineIcon />}
|
||||||
suffix="个"
|
suffix='个'
|
||||||
animation={{ duration: 800, valueFrom: 0 }}
|
animation={{ duration: 800, valueFrom: 0 }}
|
||||||
animationStart
|
animationStart
|
||||||
/>
|
/>
|
||||||
@@ -47,11 +45,11 @@ export function StatCards({ stats }: StatCardsProps) {
|
|||||||
<Col xs={12} md={6}>
|
<Col xs={12} md={6}>
|
||||||
<Card bordered={false} hoverShadow>
|
<Card bordered={false} hoverShadow>
|
||||||
<Statistic
|
<Statistic
|
||||||
title="活跃供应商数"
|
title='活跃供应商数'
|
||||||
value={activeProviders}
|
value={activeProviders}
|
||||||
color="orange"
|
color='orange'
|
||||||
prefix={<ServerIcon />}
|
prefix={<ServerIcon />}
|
||||||
suffix="个"
|
suffix='个'
|
||||||
animation={{ duration: 800, valueFrom: 0 }}
|
animation={{ duration: 800, valueFrom: 0 }}
|
||||||
animationStart
|
animationStart
|
||||||
/>
|
/>
|
||||||
@@ -60,16 +58,16 @@ export function StatCards({ stats }: StatCardsProps) {
|
|||||||
<Col xs={12} md={6}>
|
<Col xs={12} md={6}>
|
||||||
<Card bordered={false} hoverShadow>
|
<Card bordered={false} hoverShadow>
|
||||||
<Statistic
|
<Statistic
|
||||||
title="今日请求量"
|
title='今日请求量'
|
||||||
value={todayRequests}
|
value={todayRequests}
|
||||||
color="red"
|
color='red'
|
||||||
prefix={<Calendar1Icon />}
|
prefix={<Calendar1Icon />}
|
||||||
suffix="次"
|
suffix='次'
|
||||||
animation={{ duration: 800, valueFrom: 0 }}
|
animation={{ duration: 800, valueFrom: 0 }}
|
||||||
animationStart
|
animationStart
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react'
|
||||||
import { Table, Select, Input, DateRangePicker, Space, Card } from 'tdesign-react';
|
import { Table, Select, Input, DateRangePicker, Space, Card } from 'tdesign-react'
|
||||||
import type { UsageStats, Provider } from '@/types';
|
import type { UsageStats, Provider } from '@/types'
|
||||||
import type { PrimaryTableCol } from 'tdesign-react/es/table/type';
|
import type { PrimaryTableCol } from 'tdesign-react/es/table/type'
|
||||||
|
|
||||||
interface StatsTableProps {
|
interface StatsTableProps {
|
||||||
providers: Provider[];
|
providers: Provider[]
|
||||||
stats: UsageStats[];
|
stats: UsageStats[]
|
||||||
loading: boolean;
|
loading: boolean
|
||||||
providerId?: string;
|
providerId?: string
|
||||||
modelName?: string;
|
modelName?: string
|
||||||
dateRange: [Date | null, Date | null] | null;
|
dateRange: [Date | null, Date | null] | null
|
||||||
onProviderIdChange: (value: string | undefined) => void;
|
onProviderIdChange: (value: string | undefined) => void
|
||||||
onModelNameChange: (value: string | undefined) => void;
|
onModelNameChange: (value: string | undefined) => void
|
||||||
onDateRangeChange: (dates: [Date | null, Date | null] | null) => void;
|
onDateRangeChange: (dates: [Date | null, Date | null] | null) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StatsTable({
|
export function StatsTable({
|
||||||
@@ -27,12 +27,12 @@ export function StatsTable({
|
|||||||
onDateRangeChange,
|
onDateRangeChange,
|
||||||
}: StatsTableProps) {
|
}: StatsTableProps) {
|
||||||
const providerMap = useMemo(() => {
|
const providerMap = useMemo(() => {
|
||||||
const map = new Map<string, string>();
|
const map = new Map<string, string>()
|
||||||
for (const p of providers) {
|
for (const p of providers) {
|
||||||
map.set(p.id, p.name);
|
map.set(p.id, p.name)
|
||||||
}
|
}
|
||||||
return map;
|
return map
|
||||||
}, [providers]);
|
}, [providers])
|
||||||
|
|
||||||
const columns: PrimaryTableCol<UsageStats>[] = [
|
const columns: PrimaryTableCol<UsageStats>[] = [
|
||||||
{
|
{
|
||||||
@@ -50,7 +50,7 @@ export function StatsTable({
|
|||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
// 如果后端返回统一 ID 格式(包含 /),直接显示
|
// 如果后端返回统一 ID 格式(包含 /),直接显示
|
||||||
// 否则显示原始 model_name
|
// 否则显示原始 model_name
|
||||||
return row.modelName;
|
return row.modelName
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -64,25 +64,25 @@ export function StatsTable({
|
|||||||
width: 100,
|
width: 100,
|
||||||
align: 'right',
|
align: 'right',
|
||||||
},
|
},
|
||||||
];
|
]
|
||||||
|
|
||||||
const handleDateChange = (value: unknown) => {
|
const handleDateChange = (value: unknown) => {
|
||||||
if (Array.isArray(value) && value.length === 2) {
|
if (Array.isArray(value) && value.length === 2) {
|
||||||
// 将值转换为Date对象
|
// 将值转换为Date对象
|
||||||
const startDate = value[0] ? new Date(value[0] as string | number | Date) : null;
|
const startDate = value[0] ? new Date(value[0] as string | number | Date) : null
|
||||||
const endDate = value[1] ? new Date(value[1] as string | number | Date) : null;
|
const endDate = value[1] ? new Date(value[1] as string | number | Date) : null
|
||||||
onDateRangeChange([startDate, endDate]);
|
onDateRangeChange([startDate, endDate])
|
||||||
} else {
|
} else {
|
||||||
onDateRangeChange(null);
|
onDateRangeChange(null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card title="统计数据" headerBordered hoverShadow>
|
<Card title='统计数据' headerBordered hoverShadow>
|
||||||
<Space style={{ marginBottom: 16 }} size="medium" breakLine>
|
<Space style={{ marginBottom: 16 }} size='medium' breakLine>
|
||||||
<Select
|
<Select
|
||||||
clearable
|
clearable
|
||||||
placeholder="所有供应商"
|
placeholder='所有供应商'
|
||||||
style={{ width: 200 }}
|
style={{ width: 200 }}
|
||||||
value={providerId}
|
value={providerId}
|
||||||
onChange={(value) => onProviderIdChange(value as string | undefined)}
|
onChange={(value) => onProviderIdChange(value as string | undefined)}
|
||||||
@@ -90,13 +90,13 @@ export function StatsTable({
|
|||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
clearable
|
clearable
|
||||||
placeholder="模型名称"
|
placeholder='模型名称'
|
||||||
style={{ width: 200 }}
|
style={{ width: 200 }}
|
||||||
value={modelName ?? ''}
|
value={modelName ?? ''}
|
||||||
onChange={(value) => onModelNameChange((value as string) || undefined)}
|
onChange={(value) => onModelNameChange((value as string) || undefined)}
|
||||||
/>
|
/>
|
||||||
<DateRangePicker
|
<DateRangePicker
|
||||||
mode="date"
|
mode='date'
|
||||||
value={dateRange && dateRange[0] && dateRange[1] ? [dateRange[0], dateRange[1]] : []}
|
value={dateRange && dateRange[0] && dateRange[1] ? [dateRange[0], dateRange[1]] : []}
|
||||||
onChange={handleDateChange}
|
onChange={handleDateChange}
|
||||||
/>
|
/>
|
||||||
@@ -105,12 +105,12 @@ export function StatsTable({
|
|||||||
<Table<UsageStats>
|
<Table<UsageStats>
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={stats}
|
data={stats}
|
||||||
rowKey="id"
|
rowKey='id'
|
||||||
loading={loading}
|
loading={loading}
|
||||||
stripe
|
stripe
|
||||||
pagination={{ pageSize: 20 }}
|
pagination={{ pageSize: 20 }}
|
||||||
empty="暂无统计数据"
|
empty='暂无统计数据'
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,43 +1,43 @@
|
|||||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, ResponsiveContainer, Tooltip } from 'recharts';
|
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, ResponsiveContainer, Tooltip } from 'recharts'
|
||||||
import { Card } from 'tdesign-react';
|
import { Card } from 'tdesign-react'
|
||||||
import type { UsageStats } from '@/types';
|
import type { UsageStats } from '@/types'
|
||||||
|
|
||||||
interface UsageChartProps {
|
interface UsageChartProps {
|
||||||
stats: UsageStats[];
|
stats: UsageStats[]
|
||||||
isLoading?: boolean;
|
isLoading?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UsageChart({ stats, isLoading }: UsageChartProps) {
|
export function UsageChart({ stats, isLoading }: UsageChartProps) {
|
||||||
const chartData = Object.entries(
|
const chartData = Object.entries(
|
||||||
stats.reduce<Record<string, number>>((acc, s) => {
|
stats.reduce<Record<string, number>>((acc, s) => {
|
||||||
acc[s.date] = (acc[s.date] || 0) + s.requestCount;
|
acc[s.date] = (acc[s.date] || 0) + s.requestCount
|
||||||
return acc;
|
return acc
|
||||||
}, {})
|
}, {})
|
||||||
)
|
)
|
||||||
.map(([date, requestCount]) => ({ date, requestCount }))
|
.map(([date, requestCount]) => ({ date, requestCount }))
|
||||||
.sort((a, b) => a.date.localeCompare(b.date));
|
.sort((a, b) => a.date.localeCompare(b.date))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card title="请求趋势" headerBordered hoverShadow loading={isLoading} style={{ marginBottom: 16 }}>
|
<Card title='请求趋势' headerBordered hoverShadow loading={isLoading} style={{ marginBottom: 16 }}>
|
||||||
{chartData.length > 0 ? (
|
{chartData.length > 0 ? (
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width='100%' height={300}>
|
||||||
<AreaChart data={chartData}>
|
<AreaChart data={chartData}>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="requestGradient" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id='requestGradient' x1='0' y1='0' x2='0' y2='1'>
|
||||||
<stop offset="0%" stopColor="#0052D9" stopOpacity={0.4} />
|
<stop offset='0%' stopColor='#0052D9' stopOpacity={0.4} />
|
||||||
<stop offset="100%" stopColor="#0052D9" stopOpacity={0} />
|
<stop offset='100%' stopColor='#0052D9' stopOpacity={0} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#e8e8e8" />
|
<CartesianGrid strokeDasharray='3 3' stroke='#e8e8e8' />
|
||||||
<XAxis dataKey="date" />
|
<XAxis dataKey='date' />
|
||||||
<YAxis />
|
<YAxis />
|
||||||
<Tooltip />
|
<Tooltip />
|
||||||
<Area
|
<Area
|
||||||
type="monotone"
|
type='monotone'
|
||||||
dataKey="requestCount"
|
dataKey='requestCount'
|
||||||
stroke="#0052D9"
|
stroke='#0052D9'
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
fill="url(#requestGradient)"
|
fill='url(#requestGradient)'
|
||||||
/>
|
/>
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
@@ -47,5 +47,5 @@ export function UsageChart({ stats, isLoading }: UsageChartProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import { useState, useMemo } from 'react';
|
import { useState, useMemo } from 'react'
|
||||||
import { useProviders } from '@/hooks/useProviders';
|
import { useProviders } from '@/hooks/useProviders'
|
||||||
import { useStats } from '@/hooks/useStats';
|
import { useStats } from '@/hooks/useStats'
|
||||||
import { StatCards } from './StatCards';
|
import { StatCards } from './StatCards'
|
||||||
import { StatsTable } from './StatsTable';
|
import { StatsTable } from './StatsTable'
|
||||||
import { UsageChart } from './UsageChart';
|
import { UsageChart } from './UsageChart'
|
||||||
|
|
||||||
export default function StatsPage() {
|
export default function StatsPage() {
|
||||||
const { data: providers = [] } = useProviders();
|
const { data: providers = [] } = useProviders()
|
||||||
|
|
||||||
const [providerId, setProviderId] = useState<string | undefined>();
|
const [providerId, setProviderId] = useState<string | undefined>()
|
||||||
const [modelName, setModelName] = useState<string | undefined>();
|
const [modelName, setModelName] = useState<string | undefined>()
|
||||||
const [dateRange, setDateRange] = useState<[Date | null, Date | null] | null>(null);
|
const [dateRange, setDateRange] = useState<[Date | null, Date | null] | null>(null)
|
||||||
|
|
||||||
const params = useMemo(
|
const params = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -19,10 +19,10 @@ export default function StatsPage() {
|
|||||||
startDate: dateRange?.[0]?.toISOString().split('T')[0],
|
startDate: dateRange?.[0]?.toISOString().split('T')[0],
|
||||||
endDate: dateRange?.[1]?.toISOString().split('T')[0],
|
endDate: dateRange?.[1]?.toISOString().split('T')[0],
|
||||||
}),
|
}),
|
||||||
[providerId, modelName, dateRange],
|
[providerId, modelName, dateRange]
|
||||||
);
|
)
|
||||||
|
|
||||||
const { data: stats = [], isLoading } = useStats(params);
|
const { data: stats = [], isLoading } = useStats(params)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -40,5 +40,5 @@ export default function StatsPage() {
|
|||||||
onDateRangeChange={setDateRange}
|
onDateRangeChange={setDateRange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
import { lazy, Suspense } from 'react';
|
import { lazy, Suspense } from 'react'
|
||||||
import { Routes, Route, Navigate } from 'react-router';
|
import { Routes, Route, Navigate } from 'react-router'
|
||||||
import { Loading } from 'tdesign-react';
|
import { Loading } from 'tdesign-react'
|
||||||
import { AppLayout } from '@/components/AppLayout';
|
import { AppLayout } from '@/components/AppLayout'
|
||||||
|
|
||||||
const ProvidersPage = lazy(() => import('@/pages/Providers'));
|
const ProvidersPage = lazy(() => import('@/pages/Providers'))
|
||||||
const StatsPage = lazy(() => import('@/pages/Stats'));
|
const StatsPage = lazy(() => import('@/pages/Stats'))
|
||||||
const SettingsPage = lazy(() => import('@/pages/Settings'));
|
const SettingsPage = lazy(() => import('@/pages/Settings'))
|
||||||
const NotFound = lazy(() => import('@/pages/NotFound'));
|
const NotFound = lazy(() => import('@/pages/NotFound'))
|
||||||
|
|
||||||
export function AppRoutes() {
|
export function AppRoutes() {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<Loading />}>
|
<Suspense fallback={<Loading />}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<AppLayout />}>
|
<Route element={<AppLayout />}>
|
||||||
<Route index element={<Navigate to="/providers" replace />} />
|
<Route index element={<Navigate to='/providers' replace />} />
|
||||||
<Route path="providers" element={<ProvidersPage />} />
|
<Route path='providers' element={<ProvidersPage />} />
|
||||||
<Route path="stats" element={<StatsPage />} />
|
<Route path='stats' element={<StatsPage />} />
|
||||||
<Route path="settings" element={<SettingsPage />} />
|
<Route path='settings' element={<SettingsPage />} />
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path='*' element={<NotFound />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,84 +1,80 @@
|
|||||||
export interface Provider {
|
export interface Provider {
|
||||||
id: string;
|
id: string
|
||||||
name: string;
|
name: string
|
||||||
apiKey: string;
|
apiKey: string
|
||||||
baseUrl: string;
|
baseUrl: string
|
||||||
protocol: 'openai' | 'anthropic';
|
protocol: 'openai' | 'anthropic'
|
||||||
enabled: boolean;
|
enabled: boolean
|
||||||
createdAt: string;
|
createdAt: string
|
||||||
updatedAt: string;
|
updatedAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Model {
|
export interface Model {
|
||||||
id: string;
|
id: string
|
||||||
providerId: string;
|
providerId: string
|
||||||
modelName: string;
|
modelName: string
|
||||||
enabled: boolean;
|
enabled: boolean
|
||||||
createdAt: string;
|
createdAt: string
|
||||||
unifiedId?: string;
|
unifiedId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UsageStats {
|
export interface UsageStats {
|
||||||
id: number;
|
id: number
|
||||||
providerId: string;
|
providerId: string
|
||||||
modelName: string;
|
modelName: string
|
||||||
requestCount: number;
|
requestCount: number
|
||||||
date: string;
|
date: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateProviderInput {
|
export interface CreateProviderInput {
|
||||||
id: string;
|
id: string
|
||||||
name: string;
|
name: string
|
||||||
apiKey: string;
|
apiKey: string
|
||||||
baseUrl: string;
|
baseUrl: string
|
||||||
protocol: 'openai' | 'anthropic';
|
protocol: 'openai' | 'anthropic'
|
||||||
enabled: boolean;
|
enabled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateProviderInput {
|
export interface UpdateProviderInput {
|
||||||
name?: string;
|
name?: string
|
||||||
apiKey?: string;
|
apiKey?: string
|
||||||
baseUrl?: string;
|
baseUrl?: string
|
||||||
protocol?: 'openai' | 'anthropic';
|
protocol?: 'openai' | 'anthropic'
|
||||||
enabled?: boolean;
|
enabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateModelInput {
|
export interface CreateModelInput {
|
||||||
providerId: string;
|
providerId: string
|
||||||
modelName: string;
|
modelName: string
|
||||||
enabled: boolean;
|
enabled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateModelInput {
|
export interface UpdateModelInput {
|
||||||
providerId?: string;
|
providerId?: string
|
||||||
modelName?: string;
|
modelName?: string
|
||||||
enabled?: boolean;
|
enabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StatsQueryParams {
|
export interface StatsQueryParams {
|
||||||
providerId?: string;
|
providerId?: string
|
||||||
modelName?: string;
|
modelName?: string
|
||||||
startDate?: string;
|
startDate?: string
|
||||||
endDate?: string;
|
endDate?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
status: number;
|
status: number
|
||||||
code?: string;
|
code?: string
|
||||||
|
|
||||||
constructor(
|
constructor(status: number, message: string, code?: string) {
|
||||||
status: number,
|
super(message)
|
||||||
message: string,
|
this.name = 'ApiError'
|
||||||
code?: string,
|
this.status = status
|
||||||
) {
|
this.code = code
|
||||||
super(message);
|
|
||||||
this.name = 'ApiError';
|
|
||||||
this.status = status;
|
|
||||||
this.code = code;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiErrorResponse {
|
export interface ApiErrorResponse {
|
||||||
error: string;
|
error: string
|
||||||
code?: string;
|
code?: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,12 +17,7 @@ export default defineConfig({
|
|||||||
coverage: {
|
coverage: {
|
||||||
provider: 'v8',
|
provider: 'v8',
|
||||||
include: ['src/**/*.{ts,tsx}'],
|
include: ['src/**/*.{ts,tsx}'],
|
||||||
exclude: [
|
exclude: ['src/__tests__/**', 'src/main.tsx', 'src/**/*.module.scss', 'src/types/**'],
|
||||||
'src/__tests__/**',
|
|
||||||
'src/main.tsx',
|
|
||||||
'src/**/*.module.scss',
|
|
||||||
'src/types/**',
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -49,18 +49,20 @@ TBD - 定义前端 ESLint 规则配置、构建集成 lint 检查、以及自定
|
|||||||
|
|
||||||
### Requirement: 构建集成 lint 检查
|
### Requirement: 构建集成 lint 检查
|
||||||
|
|
||||||
前端 SHALL 在 `build` 命令中集成 ESLint 检查。
|
前端 SHALL 在 `build` 命令中集成 ESLint 检查和 Prettier 格式检查。
|
||||||
|
|
||||||
#### Scenario: 构建时执行 lint
|
#### Scenario: 构建时执行 lint 和格式检查
|
||||||
|
|
||||||
- **WHEN** 执行 `bun run build`
|
- **WHEN** 执行 `bun run build`
|
||||||
- **THEN** 构建 SHALL 依次执行 `tsc -b`、`eslint .`、`vite build`
|
- **THEN** 构建 SHALL 依次执行 `tsc -b`、`bun run check`、`vite build`
|
||||||
|
- **THEN** `bun run check` SHALL 执行 `bun run lint && bun run format:check`
|
||||||
- **THEN** 若 `eslint .` 报告任何错误,构建 SHALL 中断
|
- **THEN** 若 `eslint .` 报告任何错误,构建 SHALL 中断
|
||||||
|
- **THEN** 若 `prettier --check .` 报告任何格式问题,构建 SHALL 中断
|
||||||
|
|
||||||
#### Scenario: lint 警告不中断构建
|
#### Scenario: lint 警告不中断构建
|
||||||
|
|
||||||
- **WHEN** `eslint .` 仅报告警告(无错误)
|
- **WHEN** `eslint .` 仅报告警告(无错误)
|
||||||
- **THEN** 构建 SHALL 继续执行 `vite build`
|
- **THEN** 构建 SHALL 继续执行格式检查和 `vite build`
|
||||||
|
|
||||||
#### Scenario: 单独执行 lint
|
#### Scenario: 单独执行 lint
|
||||||
|
|
||||||
@@ -72,6 +74,19 @@ TBD - 定义前端 ESLint 规则配置、构建集成 lint 检查、以及自定
|
|||||||
- **WHEN** 执行 `bun run lint:fix`
|
- **WHEN** 执行 `bun run lint:fix`
|
||||||
- **THEN** SHALL 运行 `eslint . --fix`
|
- **THEN** SHALL 运行 `eslint . --fix`
|
||||||
|
|
||||||
|
#### Scenario: 统一检查命令
|
||||||
|
|
||||||
|
- **WHEN** 执行 `bun run check`
|
||||||
|
- **THEN** SHALL 运行 `bun run lint && bun run format:check`
|
||||||
|
- **THEN** lint 错误和格式问题 SHALL 都被检查
|
||||||
|
|
||||||
|
#### Scenario: 统一修复命令
|
||||||
|
|
||||||
|
- **WHEN** 执行 `bun run fix`
|
||||||
|
- **THEN** SHALL 运行 `bun run lint:fix && bun run format`
|
||||||
|
- **THEN** lint 问题 SHALL 被修复
|
||||||
|
- **THEN** 文件 SHALL 被格式化
|
||||||
|
|
||||||
### Requirement: 自定义规则禁止硬编码颜色
|
### Requirement: 自定义规则禁止硬编码颜色
|
||||||
|
|
||||||
前端 SHALL 提供自定义 ESLint 规则 `no-hardcoded-color-in-style`,检测 JSX style 属性中的硬编码颜色值。
|
前端 SHALL 提供自定义 ESLint 规则 `no-hardcoded-color-in-style`,检测 JSX style 属性中的硬编码颜色值。
|
||||||
@@ -112,3 +127,14 @@ TBD - 定义前端 ESLint 规则配置、构建集成 lint 检查、以及自定
|
|||||||
- **THEN** 规则文件 SHALL 放置在 `frontend/eslint-rules/` 目录下
|
- **THEN** 规则文件 SHALL 放置在 `frontend/eslint-rules/` 目录下
|
||||||
- **THEN** `eslint.config.js` SHALL 通过相对路径引用本地插件
|
- **THEN** `eslint.config.js` SHALL 通过相对路径引用本地插件
|
||||||
- **THEN** 自定义规则 SHALL NOT 作为 npm 包发布
|
- **THEN** 自定义规则 SHALL NOT 作为 npm 包发布
|
||||||
|
|
||||||
|
### Requirement: ESLint 与 Prettier 集成配置
|
||||||
|
|
||||||
|
前端 SHALL 在 `eslint.config.js` 中集成 `eslint-config-prettier`,确保 ESLint 和 Prettier 职责分离且不冲突。
|
||||||
|
|
||||||
|
#### Scenario: 职责分离
|
||||||
|
|
||||||
|
- **WHEN** 检查代码
|
||||||
|
- **THEN** ESLint SHALL 负责代码质量检查(如未使用变量、语法错误)
|
||||||
|
- **THEN** Prettier SHALL 负责代码格式化(如缩进、引号、分号)
|
||||||
|
- **THEN** 两者 SHALL NOT 重复检查同一规则
|
||||||
|
|||||||
@@ -508,8 +508,30 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
|
|||||||
- **THEN** Vite SHALL 对业务代码执行混淆处理
|
- **THEN** Vite SHALL 对业务代码执行混淆处理
|
||||||
- **THEN** 混淆 SHALL 仅应用于 src 目录下的业务代码
|
- **THEN** 混淆 SHALL 仅应用于 src 目录下的业务代码
|
||||||
- **THEN** 混淆 SHALL NOT 应用于 node_modules 中的第三方库
|
- **THEN** 混淆 SHALL NOT 应用于 node_modules 中的第三方库
|
||||||
- **THEN** 构建流程 SHALL 在 vite build 之前执行 ESLint 检查
|
- **THEN** 构建流程 SHALL 在 vite build 之前执行 ESLint 检查和 Prettier 格式检查
|
||||||
- **THEN** ESLint 检查失败 SHALL 中断构建
|
- **THEN** ESLint 检查失败 SHALL 中断构建
|
||||||
|
- **THEN** Prettier 格式检查失败 SHALL 中断构建
|
||||||
|
|
||||||
|
### Requirement: 开发环境格式化工具
|
||||||
|
|
||||||
|
前端 SHALL 配置开发环境格式化工具,确保开发者保存时自动格式化代码。
|
||||||
|
|
||||||
|
#### Scenario: VS Code 保存时自动格式化
|
||||||
|
|
||||||
|
- **WHEN** 开发者在 VS Code 中保存文件
|
||||||
|
- **THEN** 文件 SHALL 自动使用 Prettier 格式化
|
||||||
|
- **THEN** ESLint 可修复的问题 SHALL 自动修复
|
||||||
|
|
||||||
|
#### Scenario: 编辑器统一配置
|
||||||
|
|
||||||
|
- **WHEN** 开发者在编辑器中打开项目
|
||||||
|
- **THEN** 编辑器 SHALL 自动应用 `.editorconfig` 配置
|
||||||
|
- **THEN** 编辑器 SHALL 使用 2 空格缩进、UTF-8 编码、Unix 换行符
|
||||||
|
|
||||||
|
#### Scenario: VS Code 推荐安装扩展
|
||||||
|
|
||||||
|
- **WHEN** 开发者使用 VS Code 打开项目
|
||||||
|
- **THEN** VS Code SHALL 提示安装 Prettier 和 ESLint 扩展
|
||||||
|
|
||||||
### Requirement: 与后端 API 通信
|
### Requirement: 与后端 API 通信
|
||||||
|
|
||||||
|
|||||||
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