From 52007c94611628a3646fab4773d0562dc54ea932 Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Thu, 23 Apr 2026 22:47:32 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=E5=89=8D=E7=AB=AF=20ESLint=20?= =?UTF-8?q?=E8=A7=84=E5=88=99=E5=A2=9E=E5=BC=BA=EF=BC=8C=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E6=A3=80=E6=B5=8B=20LLM=20=E7=BC=96=E7=A0=81=E8=BF=9D=E8=A7=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 启用 TanStack Query flat/recommended(7 条规则) - 新增 no-console(允许 warn/error)、consistent-type-imports(inline 风格)、no-non-null-assertion 规则 - 新增自定义规则 no-hardcoded-color-in-style,检测 JSX style 中硬编码颜色值 - 将 ESLint 检查集成到 build 命令(tsc -b && eslint . && vite build) - 修复现有代码中的 lint 违规(import 顺序、type import 风格、unused vars) - 使用 @typescript-eslint/rule-tester 编写自定义规则集成测试 --- frontend/bun.lock | 105 +++++++++++++-- frontend/eslint-rules/index.js | 13 ++ .../rules/no-hardcoded-color-in-style.js | 112 ++++++++++++++++ frontend/eslint.config.js | 19 +++ frontend/package.json | 4 +- frontend/playwright.config.ts | 2 +- frontend/src/__tests__/api/client.test.ts | 4 +- frontend/src/__tests__/api/models.test.ts | 4 +- frontend/src/__tests__/api/providers.test.ts | 8 +- frontend/src/__tests__/api/stats.test.ts | 4 +- .../__tests__/components/AppLayout.test.tsx | 2 +- .../eslint-rules/no-hardcoded-color.test.ts | 124 ++++++++++++++++++ .../src/__tests__/hooks/useModels.test.tsx | 6 +- .../src/__tests__/hooks/useProviders.test.tsx | 6 +- .../src/__tests__/hooks/useStats.test.tsx | 4 +- frontend/src/components/AppLayout/index.tsx | 4 +- frontend/src/hooks/useModels.ts | 2 +- frontend/src/hooks/useProviders.ts | 2 +- frontend/src/hooks/useStats.ts | 2 +- frontend/src/main.tsx | 6 +- frontend/src/pages/NotFound.tsx | 2 +- frontend/src/pages/Providers/ModelTable.tsx | 4 +- .../src/pages/Providers/ProviderTable.tsx | 2 +- frontend/src/pages/Providers/index.tsx | 8 +- frontend/src/pages/Stats/StatCards.tsx | 2 +- frontend/src/pages/Stats/StatsTable.tsx | 2 +- frontend/src/pages/Stats/UsageChart.tsx | 2 +- frontend/src/pages/Stats/index.tsx | 2 +- frontend/vite.config.ts | 6 +- frontend/vitest.config.ts | 4 +- openspec/specs/frontend-lint-rules/spec.md | 114 ++++++++++++++++ openspec/specs/frontend/spec.md | 5 + 32 files changed, 531 insertions(+), 55 deletions(-) create mode 100644 frontend/eslint-rules/index.js create mode 100644 frontend/eslint-rules/rules/no-hardcoded-color-in-style.js create mode 100644 frontend/src/__tests__/eslint-rules/no-hardcoded-color.test.ts create mode 100644 openspec/specs/frontend-lint-rules/spec.md diff --git a/frontend/bun.lock b/frontend/bun.lock index 5a2c2c2..183cbf3 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -25,6 +25,7 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@types/sql.js": "^1.4.11", + "@typescript-eslint/rule-tester": "^8.59.0", "@vitejs/plugin-react": "^6.0.1", "@vitest/coverage-v8": "^3.2.1", "eslint": "^9.39.4", @@ -425,23 +426,25 @@ "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.58.2", "https://registry.npmmirror.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.2.tgz", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.58.2", "@typescript-eslint/type-utils": "8.58.2", "@typescript-eslint/utils": "8.58.2", "@typescript-eslint/visitor-keys": "8.58.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.58.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw=="], - "@typescript-eslint/parser": ["@typescript-eslint/parser@8.58.2", "https://registry.npmmirror.com/@typescript-eslint/parser/-/parser-8.58.2.tgz", { "dependencies": { "@typescript-eslint/scope-manager": "8.58.2", "@typescript-eslint/types": "8.58.2", "@typescript-eslint/typescript-estree": "8.58.2", "@typescript-eslint/visitor-keys": "8.58.2", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg=="], + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.59.0", "https://registry.npmmirror.com/@typescript-eslint/parser/-/parser-8.59.0.tgz", { "dependencies": { "@typescript-eslint/scope-manager": "8.59.0", "@typescript-eslint/types": "8.59.0", "@typescript-eslint/typescript-estree": "8.59.0", "@typescript-eslint/visitor-keys": "8.59.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg=="], - "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.58.2", "https://registry.npmmirror.com/@typescript-eslint/project-service/-/project-service-8.58.2.tgz", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.58.2", "@typescript-eslint/types": "^8.58.2", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg=="], + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.59.0", "https://registry.npmmirror.com/@typescript-eslint/project-service/-/project-service-8.59.0.tgz", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.59.0", "@typescript-eslint/types": "^8.59.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Lw5ITrR5s5TbC19YSvlr63ZfLaJoU6vtKTHyB0GQOpX0W7d5/Ir6vUahWi/8Sps/nOukZQ0IB3SmlxZnjaKVnw=="], + + "@typescript-eslint/rule-tester": ["@typescript-eslint/rule-tester@8.59.0", "https://registry.npmmirror.com/@typescript-eslint/rule-tester/-/rule-tester-8.59.0.tgz", { "dependencies": { "@typescript-eslint/parser": "8.59.0", "@typescript-eslint/typescript-estree": "8.59.0", "@typescript-eslint/utils": "8.59.0", "ajv": "^6.12.6", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "4.6.2", "semver": "^7.7.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0" } }, "sha512-2Ej6W28DqObFuEUQ+puEpDZFWFXAW7jIZ4TsgfLUCTNz1FID0NMfp1sXc+fQq8m5ysfPdhXAPjti6jYEu1oRcg=="], "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.58.2", "https://registry.npmmirror.com/@typescript-eslint/scope-manager/-/scope-manager-8.58.2.tgz", { "dependencies": { "@typescript-eslint/types": "8.58.2", "@typescript-eslint/visitor-keys": "8.58.2" } }, "sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q=="], - "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.58.2", "https://registry.npmmirror.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.2.tgz", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A=="], + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.59.0", "https://registry.npmmirror.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.0.tgz", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-91Sbl3s4Kb3SybliIY6muFBmHVv+pYXfybC4Oolp3dvk8BvIE3wOPc+403CWIT7mJNkfQRGtdqghzs2+Z91Tqg=="], "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.58.2", "https://registry.npmmirror.com/@typescript-eslint/type-utils/-/type-utils-8.58.2.tgz", { "dependencies": { "@typescript-eslint/types": "8.58.2", "@typescript-eslint/typescript-estree": "8.58.2", "@typescript-eslint/utils": "8.58.2", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Z7EloNR/B389FvabdGeTo2XMs4W9TjtPiO9DAsmT0yom0bwlPyRjkJ1uCdW1DvrrrYP50AJZ9Xc3sByZA9+dcg=="], "@typescript-eslint/types": ["@typescript-eslint/types@8.58.2", "https://registry.npmmirror.com/@typescript-eslint/types/-/types-8.58.2.tgz", {}, "sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ=="], - "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.58.2", "https://registry.npmmirror.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.2.tgz", { "dependencies": { "@typescript-eslint/project-service": "8.58.2", "@typescript-eslint/tsconfig-utils": "8.58.2", "@typescript-eslint/types": "8.58.2", "@typescript-eslint/visitor-keys": "8.58.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw=="], + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.59.0", "https://registry.npmmirror.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.0.tgz", { "dependencies": { "@typescript-eslint/project-service": "8.59.0", "@typescript-eslint/tsconfig-utils": "8.59.0", "@typescript-eslint/types": "8.59.0", "@typescript-eslint/visitor-keys": "8.59.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-O9Re9P1BmBLFJyikRbQpLku/QA3/AueZNO9WePLBwQrvkixTmDe8u76B6CYUAITRl/rHawggEqUGn5QIkVRLMw=="], "@typescript-eslint/utils": ["@typescript-eslint/utils@8.58.2", "https://registry.npmmirror.com/@typescript-eslint/utils/-/utils-8.58.2.tgz", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.58.2", "@typescript-eslint/types": "8.58.2", "@typescript-eslint/typescript-estree": "8.58.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA=="], - "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.58.2", "https://registry.npmmirror.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.2.tgz", { "dependencies": { "@typescript-eslint/types": "8.58.2", "eslint-visitor-keys": "^5.0.0" } }, "sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA=="], + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.0", "https://registry.npmmirror.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.0.tgz", { "dependencies": { "@typescript-eslint/types": "8.59.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q=="], "@vercel/blob": ["@vercel/blob@2.3.3", "https://registry.npmmirror.com/@vercel/blob/-/blob-2.3.3.tgz", { "dependencies": { "async-retry": "^1.3.3", "is-buffer": "^2.0.5", "is-node-process": "^1.2.0", "throttleit": "^2.1.0", "undici": "^6.23.0" } }, "sha512-MtD7VLo6hU07eHR7bmk5SIMD290q574UaNYTe46qeyRT+hWrCy26CoAqfd7PnIefVXvRehRZBzukxuTO9iGTVg=="], @@ -1153,7 +1156,7 @@ "scheduler": ["scheduler@0.27.0", "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], - "semver": ["semver@6.3.1", "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "semver": ["semver@7.7.4", "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "set-cookie-parser": ["set-cookie-parser@2.7.2", "https://registry.npmmirror.com/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], @@ -1379,6 +1382,10 @@ "@babel/core/json5": ["json5@2.2.3", "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + "@babel/core/semver": ["semver@6.3.1", "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@eslint/eslintrc/globals": ["globals@14.0.0", "https://registry.npmmirror.com/globals/-/globals-14.0.0.tgz", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], @@ -1397,11 +1404,29 @@ "@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "https://registry.npmmirror.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], + "@typescript-eslint/eslint-plugin/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.58.2", "https://registry.npmmirror.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.2.tgz", { "dependencies": { "@typescript-eslint/types": "8.58.2", "eslint-visitor-keys": "^5.0.0" } }, "sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA=="], + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "https://registry.npmmirror.com/ignore/-/ignore-7.0.5.tgz", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + "@typescript-eslint/parser/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.0", "https://registry.npmmirror.com/@typescript-eslint/scope-manager/-/scope-manager-8.59.0.tgz", { "dependencies": { "@typescript-eslint/types": "8.59.0", "@typescript-eslint/visitor-keys": "8.59.0" } }, "sha512-UzR16Ut8IpA3Mc4DbgAShlPPkVm8xXMWafXxB0BocaVRHs8ZGakAxGRskF7FId3sdk9lgGD73GSFaWmWFDE4dg=="], + + "@typescript-eslint/parser/@typescript-eslint/types": ["@typescript-eslint/types@8.59.0", "https://registry.npmmirror.com/@typescript-eslint/types/-/types-8.59.0.tgz", {}, "sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A=="], + + "@typescript-eslint/project-service/@typescript-eslint/types": ["@typescript-eslint/types@8.59.0", "https://registry.npmmirror.com/@typescript-eslint/types/-/types-8.59.0.tgz", {}, "sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A=="], + + "@typescript-eslint/rule-tester/@typescript-eslint/utils": ["@typescript-eslint/utils@8.59.0", "https://registry.npmmirror.com/@typescript-eslint/utils/-/utils-8.59.0.tgz", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.59.0", "@typescript-eslint/types": "8.59.0", "@typescript-eslint/typescript-estree": "8.59.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-I1R/K7V07XsMJ12Oaxg/O9GfrysGTmCRhvZJBv0RE0NcULMzjqVpR5kRRQjHsz3J/bElU7HwCO7zkqL+MSUz+g=="], + + "@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.58.2", "https://registry.npmmirror.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.2.tgz", { "dependencies": { "@typescript-eslint/types": "8.58.2", "eslint-visitor-keys": "^5.0.0" } }, "sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA=="], + + "@typescript-eslint/type-utils/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.58.2", "https://registry.npmmirror.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.2.tgz", { "dependencies": { "@typescript-eslint/project-service": "8.58.2", "@typescript-eslint/tsconfig-utils": "8.58.2", "@typescript-eslint/types": "8.58.2", "@typescript-eslint/visitor-keys": "8.58.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw=="], + + "@typescript-eslint/typescript-estree/@typescript-eslint/types": ["@typescript-eslint/types@8.59.0", "https://registry.npmmirror.com/@typescript-eslint/types/-/types-8.59.0.tgz", {}, "sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A=="], + "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.5.tgz", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], - "@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "@typescript-eslint/utils/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.58.2", "https://registry.npmmirror.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.2.tgz", { "dependencies": { "@typescript-eslint/project-service": "8.58.2", "@typescript-eslint/tsconfig-utils": "8.58.2", "@typescript-eslint/types": "8.58.2", "@typescript-eslint/visitor-keys": "8.58.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw=="], + + "@typescript-eslint/visitor-keys/@typescript-eslint/types": ["@typescript-eslint/types@8.59.0", "https://registry.npmmirror.com/@typescript-eslint/types/-/types-8.59.0.tgz", {}, "sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A=="], "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], @@ -1415,8 +1440,6 @@ "conf/env-paths": ["env-paths@3.0.0", "https://registry.npmmirror.com/env-paths/-/env-paths-3.0.0.tgz", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="], - "conf/semver": ["semver@7.7.4", "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - "data-urls/whatwg-mimetype": ["whatwg-mimetype@4.0.0", "https://registry.npmmirror.com/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], "eslint-import-resolver-node/debug": ["debug@3.2.7", "https://registry.npmmirror.com/debug/-/debug-3.2.7.tgz", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], @@ -1425,6 +1448,8 @@ "eslint-plugin-import/debug": ["debug@3.2.7", "https://registry.npmmirror.com/debug/-/debug-3.2.7.tgz", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], + "eslint-plugin-import/semver": ["semver@6.3.1", "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "espree/acorn": ["acorn@8.16.0", "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], "glob/minimatch": ["minimatch@9.0.9", "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], @@ -1435,12 +1460,12 @@ "loose-envify/js-tokens": ["js-tokens@4.0.0", "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - "make-dir/semver": ["semver@7.7.4", "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - "md5/is-buffer": ["is-buffer@1.1.6", "https://registry.npmmirror.com/is-buffer/-/is-buffer-1.1.6.tgz", {}, "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w=="], "msw/tough-cookie": ["tough-cookie@6.0.1", "https://registry.npmmirror.com/tough-cookie/-/tough-cookie-6.0.1.tgz", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw=="], + "node-exports-info/semver": ["semver@6.3.1", "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "parse5/entities": ["entities@6.0.1", "https://registry.npmmirror.com/entities/-/entities-6.0.1.tgz", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], "path-scurry/lru-cache": ["lru-cache@10.4.3", "https://registry.npmmirror.com/lru-cache/-/lru-cache-10.4.3.tgz", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], @@ -1463,6 +1488,10 @@ "test-exclude/minimatch": ["minimatch@10.2.5", "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.5.tgz", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + "typescript-eslint/@typescript-eslint/parser": ["@typescript-eslint/parser@8.58.2", "https://registry.npmmirror.com/@typescript-eslint/parser/-/parser-8.58.2.tgz", { "dependencies": { "@typescript-eslint/scope-manager": "8.58.2", "@typescript-eslint/types": "8.58.2", "@typescript-eslint/typescript-estree": "8.58.2", "@typescript-eslint/visitor-keys": "8.58.2", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg=="], + + "typescript-eslint/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.58.2", "https://registry.npmmirror.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.2.tgz", { "dependencies": { "@typescript-eslint/project-service": "8.58.2", "@typescript-eslint/tsconfig-utils": "8.58.2", "@typescript-eslint/types": "8.58.2", "@typescript-eslint/visitor-keys": "8.58.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw=="], + "vite-node/vite": ["vite@7.3.2", "https://registry.npmmirror.com/vite/-/vite-7.3.2.tgz", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg=="], "vite-plugin-javascript-obfuscator/javascript-obfuscator": ["javascript-obfuscator@4.2.2", "https://registry.npmmirror.com/javascript-obfuscator/-/javascript-obfuscator-4.2.2.tgz", { "dependencies": { "@javascript-obfuscator/escodegen": "2.3.1", "@javascript-obfuscator/estraverse": "5.4.0", "acorn": "8.15.0", "assert": "2.1.0", "chalk": "4.1.2", "chance": "1.1.13", "class-validator": "0.14.3", "commander": "12.1.0", "conf": "15.0.2", "eslint-scope": "8.4.0", "eslint-visitor-keys": "4.2.1", "fast-deep-equal": "3.1.3", "inversify": "6.1.4", "js-string-escape": "1.0.1", "md5": "2.3.0", "mkdirp": "3.0.1", "multimatch": "5.0.0", "process": "0.11.10", "reflect-metadata": "0.2.2", "source-map-support": "0.5.21", "string-template": "1.0.0", "stringz": "2.1.0", "tslib": "2.8.1" }, "bin": { "javascript-obfuscator": "bin/javascript-obfuscator" } }, "sha512-+7oXAUnFCA6vS0omIGHcWpSr67dUBIF7FKGYSXyzxShSLqM6LBgdugWKFl0XrYtGWyJMGfQR5F4LL85iCefkRA=="], @@ -1481,8 +1510,32 @@ "@javascript-obfuscator/escodegen/optionator/type-check": ["type-check@0.3.2", "https://registry.npmmirror.com/type-check/-/type-check-0.3.2.tgz", { "dependencies": { "prelude-ls": "~1.1.2" } }, "sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg=="], + "@typescript-eslint/eslint-plugin/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + + "@typescript-eslint/rule-tester/@typescript-eslint/utils/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.0", "https://registry.npmmirror.com/@typescript-eslint/scope-manager/-/scope-manager-8.59.0.tgz", { "dependencies": { "@typescript-eslint/types": "8.59.0", "@typescript-eslint/visitor-keys": "8.59.0" } }, "sha512-UzR16Ut8IpA3Mc4DbgAShlPPkVm8xXMWafXxB0BocaVRHs8ZGakAxGRskF7FId3sdk9lgGD73GSFaWmWFDE4dg=="], + + "@typescript-eslint/rule-tester/@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.59.0", "https://registry.npmmirror.com/@typescript-eslint/types/-/types-8.59.0.tgz", {}, "sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A=="], + + "@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + + "@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.58.2", "https://registry.npmmirror.com/@typescript-eslint/project-service/-/project-service-8.58.2.tgz", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.58.2", "@typescript-eslint/types": "^8.58.2", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg=="], + + "@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.58.2", "https://registry.npmmirror.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.2.tgz", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A=="], + + "@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.58.2", "https://registry.npmmirror.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.2.tgz", { "dependencies": { "@typescript-eslint/types": "8.58.2", "eslint-visitor-keys": "^5.0.0" } }, "sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA=="], + + "@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.5.tgz", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.5", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.5.tgz", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], + "@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.58.2", "https://registry.npmmirror.com/@typescript-eslint/project-service/-/project-service-8.58.2.tgz", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.58.2", "@typescript-eslint/types": "^8.58.2", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg=="], + + "@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.58.2", "https://registry.npmmirror.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.2.tgz", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A=="], + + "@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.58.2", "https://registry.npmmirror.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.2.tgz", { "dependencies": { "@typescript-eslint/types": "8.58.2", "eslint-visitor-keys": "^5.0.0" } }, "sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA=="], + + "@typescript-eslint/utils/@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.5.tgz", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + "ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "conf/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], @@ -1493,16 +1546,46 @@ "test-exclude/minimatch/brace-expansion": ["brace-expansion@5.0.5", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.5.tgz", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], + "typescript-eslint/@typescript-eslint/parser/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.58.2", "https://registry.npmmirror.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.2.tgz", { "dependencies": { "@typescript-eslint/types": "8.58.2", "eslint-visitor-keys": "^5.0.0" } }, "sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA=="], + + "typescript-eslint/@typescript-eslint/typescript-estree/@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.58.2", "https://registry.npmmirror.com/@typescript-eslint/project-service/-/project-service-8.58.2.tgz", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.58.2", "@typescript-eslint/types": "^8.58.2", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg=="], + + "typescript-eslint/@typescript-eslint/typescript-estree/@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.58.2", "https://registry.npmmirror.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.2.tgz", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A=="], + + "typescript-eslint/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.58.2", "https://registry.npmmirror.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.2.tgz", { "dependencies": { "@typescript-eslint/types": "8.58.2", "eslint-visitor-keys": "^5.0.0" } }, "sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA=="], + + "typescript-eslint/@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.5.tgz", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + "vite-plugin-javascript-obfuscator/javascript-obfuscator/@javascript-obfuscator/escodegen": ["@javascript-obfuscator/escodegen@2.3.1", "https://registry.npmmirror.com/@javascript-obfuscator/escodegen/-/escodegen-2.3.1.tgz", { "dependencies": { "@javascript-obfuscator/estraverse": "^5.3.0", "esprima": "^4.0.1", "esutils": "^2.0.2", "optionator": "^0.8.1" }, "optionalDependencies": { "source-map": "~0.6.1" } }, "sha512-Z0HEAVwwafOume+6LFXirAVZeuEMKWuPzpFbQhCEU9++BMz0IwEa9bmedJ+rMn/IlXRBID9j3gQ0XYAa6jM10g=="], + "@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + + "@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.5", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.5.tgz", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + + "@typescript-eslint/utils/@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.5", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.5.tgz", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], + "msw/tough-cookie/tldts/tldts-core": ["tldts-core@7.0.28", "https://registry.npmmirror.com/tldts-core/-/tldts-core-7.0.28.tgz", {}, "sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ=="], "test-exclude/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "typescript-eslint/@typescript-eslint/parser/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + + "typescript-eslint/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + + "typescript-eslint/@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.5", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.5.tgz", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], + "vite-plugin-javascript-obfuscator/javascript-obfuscator/@javascript-obfuscator/escodegen/optionator": ["optionator@0.8.3", "https://registry.npmmirror.com/optionator/-/optionator-0.8.3.tgz", { "dependencies": { "deep-is": "~0.1.3", "fast-levenshtein": "~2.0.6", "levn": "~0.3.0", "prelude-ls": "~1.1.2", "type-check": "~0.3.2", "word-wrap": "~1.2.3" } }, "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA=="], + "@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "@typescript-eslint/utils/@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "typescript-eslint/@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "vite-plugin-javascript-obfuscator/javascript-obfuscator/@javascript-obfuscator/escodegen/optionator/levn": ["levn@0.3.0", "https://registry.npmmirror.com/levn/-/levn-0.3.0.tgz", { "dependencies": { "prelude-ls": "~1.1.2", "type-check": "~0.3.2" } }, "sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA=="], "vite-plugin-javascript-obfuscator/javascript-obfuscator/@javascript-obfuscator/escodegen/optionator/prelude-ls": ["prelude-ls@1.1.2", "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.1.2.tgz", {}, "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w=="], diff --git a/frontend/eslint-rules/index.js b/frontend/eslint-rules/index.js new file mode 100644 index 0000000..317b657 --- /dev/null +++ b/frontend/eslint-rules/index.js @@ -0,0 +1,13 @@ +import noHardcodedColorInStyle from './rules/no-hardcoded-color-in-style.js' + +const plugin = { + rules: { + 'no-hardcoded-color-in-style': noHardcodedColorInStyle, + }, + configs: {}, + meta: { + name: 'eslint-plugin-local', + }, +} + +export default plugin \ No newline at end of file diff --git a/frontend/eslint-rules/rules/no-hardcoded-color-in-style.js b/frontend/eslint-rules/rules/no-hardcoded-color-in-style.js new file mode 100644 index 0000000..f52e96c --- /dev/null +++ b/frontend/eslint-rules/rules/no-hardcoded-color-in-style.js @@ -0,0 +1,112 @@ +import { ESLintUtils } from '@typescript-eslint/utils' + +const RE_HEX3 = /^#[0-9a-fA-F]{3}$/ +const RE_HEX6 = /^#[0-9a-fA-F]{6}$/ +const RE_HEX8 = /^#[0-9a-fA-F]{8}$/ +const RE_RGB = /^rgb\s*\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*\)$/ +const RE_RGBA = /^rgba\s*\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*[\d.]+\s*\)$/ +const RE_HSL = /^hsl\s*\(\s*\d+\s*,\s*[\d.]+%?\s*,\s*[\d.]+%?\s*\)$/ + +const ALLOWED_KEYWORDS = new Set([ + 'inherit', + 'transparent', + 'currentColor', + 'none', + 'unset', + 'initial', + 'auto', + 'contain', + 'cover', +]) + +function isHardcodedColor(value) { + if (typeof value !== 'string') return false + + const trimmed = value.trim() + + if (ALLOWED_KEYWORDS.has(trimmed.toLowerCase())) return false + if (trimmed.startsWith('var(')) return false + if (/^\d+(\.\d+)?px?$/.test(trimmed)) return false + if (/^\d+(\.\d+)?\%$/.test(trimmed)) return false + + return ( + RE_HEX3.test(trimmed) || + RE_HEX6.test(trimmed) || + RE_HEX8.test(trimmed) || + RE_RGB.test(trimmed) || + RE_RGBA.test(trimmed) || + RE_HSL.test(trimmed) + ) +} + +function extractStyleProperties(expression) { + const properties = [] + + if ( + expression.type === 'ObjectExpression' && + expression.properties + ) { + for (const styleProp of expression.properties) { + if ( + styleProp.type === 'Property' && + styleProp.key?.type === 'Identifier' && + styleProp.value?.type === 'Literal' && + typeof styleProp.value.value === 'string' + ) { + properties.push({ + key: styleProp.key.name, + value: styleProp.value.value, + loc: styleProp.value.loc, + }) + } + } + } + + return properties +} + +export const RULE_NAME = 'no-hardcoded-color-in-style' + +export default ESLintUtils.RuleCreator((name) => { + return `https://eslint.dev/rules/#${name}` +})({ + name: 'no-hardcoded-color-in-style', + meta: { + type: 'problem', + docs: { + description: 'Disallow hardcoded color values in JSX style properties', + recommended: false, + }, + messages: { + hardcodedColor: + '硬编码的颜色值 "{{value}}" 不允许使用。请使用 TDesign CSS Token(如 var(--td-text-color-placeholder))代替。', + }, + schema: [], + }, + create(context) { + return { + JSXAttribute(node) { + if ( + node.name?.type === 'JSXIdentifier' && + node.name.name === 'style' && + node.value?.type === 'JSXExpressionContainer' && + node.value.expression + ) { + const styleProps = extractStyleProperties( + node.value.expression, + ) + + for (const prop of styleProps) { + if (isHardcodedColor(prop.value)) { + context.report({ + node: context.sourceCode.getLastToken(node), + messageId: 'hardcodedColor', + data: { value: prop.value }, + }) + } + } + } + }, + } + }, +}) \ No newline at end of file diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 32e3be5..f0bf189 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -5,9 +5,11 @@ import reactRefresh from 'eslint-plugin-react-refresh' import tseslint from 'typescript-eslint' import importPlugin from 'eslint-plugin-import' import tanstackQuery from '@tanstack/eslint-plugin-query' +import localRules from './eslint-rules/index.js' export default tseslint.config( { ignores: ['dist'] }, + ...tanstackQuery.configs['flat/recommended'], { extends: [js.configs.recommended, ...tseslint.configs.recommended], files: ['**/*.{ts,tsx}'], @@ -20,6 +22,7 @@ export default tseslint.config( 'react-refresh': reactRefresh, import: importPlugin, '@tanstack/query': tanstackQuery, + local: localRules, }, rules: { ...reactHooks.configs.recommended.rules, @@ -27,6 +30,13 @@ export default tseslint.config( 'warn', { allowConstantExport: true }, ], + 'no-console': ['error', { allow: ['warn', 'error'] }], + '@typescript-eslint/consistent-type-imports': [ + 'error', + { prefer: 'type-imports', fixStyle: 'inline-type-imports' }, + ], + '@typescript-eslint/no-non-null-assertion': 'error', + 'local/no-hardcoded-color-in-style': 'warn', 'import/order': [ 'warn', { @@ -48,4 +58,13 @@ export default tseslint.config( ], }, }, + { + files: ['src/__tests__/**', 'e2e/**'], + rules: { + '@typescript-eslint/no-non-null-assertion': 'off', + 'react-hooks/exhaustive-deps': 'off', + '@typescript-eslint/consistent-type-imports': 'off', + 'no-console': 'off', + }, + }, ) diff --git a/frontend/package.json b/frontend/package.json index f090dd9..672d2fd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,8 +5,9 @@ "type": "module", "scripts": { "dev": "vite", - "build": "tsc -b && vite build", + "build": "tsc -b && eslint . && vite build", "lint": "eslint .", + "lint:fix": "eslint . --fix", "preview": "vite preview", "test": "vitest run", "test:watch": "vitest", @@ -34,6 +35,7 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@types/sql.js": "^1.4.11", + "@typescript-eslint/rule-tester": "^8.59.0", "@vitejs/plugin-react": "^6.0.1", "@vitest/coverage-v8": "^3.2.1", "eslint": "^9.39.4", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 118cf03..44d7677 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -1,8 +1,8 @@ -import { defineConfig, devices } from '@playwright/test' import fs from 'node:fs' import os from 'node:os' import path from 'node:path' import { fileURLToPath } from 'node:url' +import { defineConfig, devices } from '@playwright/test' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) diff --git a/frontend/src/__tests__/api/client.test.ts b/frontend/src/__tests__/api/client.test.ts index 0fb33a2..47c29bd 100644 --- a/frontend/src/__tests__/api/client.test.ts +++ b/frontend/src/__tests__/api/client.test.ts @@ -1,6 +1,6 @@ -import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest'; -import { setupServer } from 'msw/node'; import { http, HttpResponse } from 'msw'; +import { setupServer } from 'msw/node'; +import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest'; import { request, fromApi, toApi } from '@/api/client'; import { ApiError } from '@/types'; diff --git a/frontend/src/__tests__/api/models.test.ts b/frontend/src/__tests__/api/models.test.ts index 518d45a..264a47c 100644 --- a/frontend/src/__tests__/api/models.test.ts +++ b/frontend/src/__tests__/api/models.test.ts @@ -1,6 +1,6 @@ -import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest'; -import { setupServer } from 'msw/node'; import { http, HttpResponse } from 'msw'; +import { setupServer } from 'msw/node'; +import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest'; import { listModels, createModel, updateModel, deleteModel } from '@/api/models'; const mockModels = [ diff --git a/frontend/src/__tests__/api/providers.test.ts b/frontend/src/__tests__/api/providers.test.ts index 7af422e..b75bde5 100644 --- a/frontend/src/__tests__/api/providers.test.ts +++ b/frontend/src/__tests__/api/providers.test.ts @@ -1,6 +1,6 @@ -import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest'; -import { setupServer } from 'msw/node'; import { http, HttpResponse } from 'msw'; +import { setupServer } from 'msw/node'; +import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest'; import { listProviders, createProvider, updateProvider, deleteProvider } from '@/api/providers'; const mockProviders = [ @@ -119,7 +119,7 @@ describe('providers API', () => { let receivedBody: Record | null = null; server.use( - http.put('http://localhost:3000/api/providers/:id', async ({ request, params }) => { + http.put('http://localhost:3000/api/providers/:id', async ({ request }) => { receivedMethod = request.method; receivedUrl = new URL(request.url).pathname; receivedBody = (await request.json()) as Record; @@ -153,7 +153,7 @@ describe('providers API', () => { let receivedUrl: string | null = null; server.use( - http.delete('http://localhost:3000/api/providers/:id', ({ request, params }) => { + http.delete('http://localhost:3000/api/providers/:id', ({ request }) => { receivedMethod = request.method; receivedUrl = new URL(request.url).pathname; return new HttpResponse(null, { status: 204 }); diff --git a/frontend/src/__tests__/api/stats.test.ts b/frontend/src/__tests__/api/stats.test.ts index dc00c29..32dd943 100644 --- a/frontend/src/__tests__/api/stats.test.ts +++ b/frontend/src/__tests__/api/stats.test.ts @@ -1,6 +1,6 @@ -import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest'; -import { setupServer } from 'msw/node'; import { http, HttpResponse } from 'msw'; +import { setupServer } from 'msw/node'; +import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest'; import { getStats } from '@/api/stats'; const mockStats = [ diff --git a/frontend/src/__tests__/components/AppLayout.test.tsx b/frontend/src/__tests__/components/AppLayout.test.tsx index 414c990..c5ac659 100644 --- a/frontend/src/__tests__/components/AppLayout.test.tsx +++ b/frontend/src/__tests__/components/AppLayout.test.tsx @@ -1,6 +1,6 @@ import { render, screen } from '@testing-library/react'; -import { describe, it, expect } from 'vitest'; import { BrowserRouter } from 'react-router'; +import { describe, it, expect } from 'vitest'; import { AppLayout } from '@/components/AppLayout'; const renderWithRouter = (component: React.ReactNode) => { diff --git a/frontend/src/__tests__/eslint-rules/no-hardcoded-color.test.ts b/frontend/src/__tests__/eslint-rules/no-hardcoded-color.test.ts new file mode 100644 index 0000000..7a467aa --- /dev/null +++ b/frontend/src/__tests__/eslint-rules/no-hardcoded-color.test.ts @@ -0,0 +1,124 @@ +import { RuleTester } from '@typescript-eslint/rule-tester' +import { describe, it, afterAll } from 'vitest' +import rule, { + RULE_NAME, +} from '../../../eslint-rules/rules/no-hardcoded-color-in-style.js' + +RuleTester.it = it +RuleTester.describe = describe +RuleTester.afterAll = afterAll + +const ruleTester = new RuleTester({ + languageOptions: { + parserOptions: { + ecmaFeatures: { jsx: true }, + ecmaVersion: 2023, + sourceType: 'module', + }, + }, +}) + +describe('no-hardcoded-color-in-style (ESLint rule)', () => { + ruleTester.run(RULE_NAME, rule, { + valid: [ + { + name: 'CSS var token', + code: `
`, + }, + { + name: 'numeric value 0', + code: `
`, + }, + { + name: 'numeric value 16', + code: `
`, + }, + { + name: 'inherit keyword', + code: `
`, + }, + { + name: 'transparent keyword', + code: `
`, + }, + { + name: 'currentColor keyword', + code: `
`, + }, + { + name: 'none keyword', + code: `
`, + }, + { + name: 'unset keyword', + code: `
`, + }, + { + name: 'initial keyword', + code: `
`, + }, + { + name: 'pixel string value', + code: `
`, + }, + { + name: 'percentage string value', + code: `
`, + }, + { + name: 'plain numeric string value', + code: `
`, + }, + { + name: 'auto keyword', + code: `
`, + }, + { + name: 'contain keyword', + code: `
`, + }, + { + name: 'cover keyword', + code: `
`, + }, + ], + + invalid: [ + { + name: 'hex3 color #fff', + code: `
`, + errors: [{ messageId: 'hardcodedColor', data: { value: '#fff' } }], + }, + { + name: 'hex6 color #ffffff', + code: `
`, + errors: [{ messageId: 'hardcodedColor', data: { value: '#ffffff' } }], + }, + { + name: 'hex8 color #ffffffff', + code: `
`, + errors: [{ messageId: 'hardcodedColor', data: { value: '#ffffffff' } }], + }, + { + name: 'rgb color', + code: `
`, + errors: [{ messageId: 'hardcodedColor', data: { value: 'rgb(255, 255, 255)' } }], + }, + { + name: 'rgba color', + code: `
`, + errors: [{ messageId: 'hardcodedColor', data: { value: 'rgba(255, 255, 255, 0.5)' } }], + }, + { + name: 'hsl color', + code: `
`, + errors: [{ messageId: 'hardcodedColor', data: { value: 'hsl(120, 50%, 50%)' } }], + }, + { + name: 'multiple style properties with one hardcoded', + code: `
`, + errors: [{ messageId: 'hardcodedColor', data: { value: '#999' } }], + }, + ], + }) +}) \ No newline at end of file diff --git a/frontend/src/__tests__/hooks/useModels.test.tsx b/frontend/src/__tests__/hooks/useModels.test.tsx index 47a0eec..2b15088 100644 --- a/frontend/src/__tests__/hooks/useModels.test.tsx +++ b/frontend/src/__tests__/hooks/useModels.test.tsx @@ -1,11 +1,11 @@ -import { renderHook, waitFor } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import React from 'react'; +import { renderHook, waitFor } from '@testing-library/react'; import { http, HttpResponse } from 'msw'; import { setupServer } from 'msw/node'; +import React from 'react'; +import { MessagePlugin } from 'tdesign-react'; import { useModels, useCreateModel, useUpdateModel, useDeleteModel } from '@/hooks/useModels'; import type { Model, CreateModelInput, UpdateModelInput } from '@/types'; -import { MessagePlugin } from 'tdesign-react'; // Mock MessagePlugin vi.mock('tdesign-react', () => ({ diff --git a/frontend/src/__tests__/hooks/useProviders.test.tsx b/frontend/src/__tests__/hooks/useProviders.test.tsx index 1d0436a..e2d289c 100644 --- a/frontend/src/__tests__/hooks/useProviders.test.tsx +++ b/frontend/src/__tests__/hooks/useProviders.test.tsx @@ -1,11 +1,11 @@ -import { renderHook, waitFor } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import React from 'react'; +import { renderHook, waitFor } from '@testing-library/react'; import { http, HttpResponse } from 'msw'; import { setupServer } from 'msw/node'; +import React from 'react'; +import { MessagePlugin } from 'tdesign-react'; import { useProviders, useCreateProvider, useUpdateProvider, useDeleteProvider } from '@/hooks/useProviders'; import type { Provider, CreateProviderInput, UpdateProviderInput } from '@/types'; -import { MessagePlugin } from 'tdesign-react'; // Mock MessagePlugin vi.mock('tdesign-react', () => ({ diff --git a/frontend/src/__tests__/hooks/useStats.test.tsx b/frontend/src/__tests__/hooks/useStats.test.tsx index daa97bb..e4bf99e 100644 --- a/frontend/src/__tests__/hooks/useStats.test.tsx +++ b/frontend/src/__tests__/hooks/useStats.test.tsx @@ -1,8 +1,8 @@ -import { renderHook, waitFor } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import React from 'react'; +import { renderHook, waitFor } from '@testing-library/react'; import { http, HttpResponse } from 'msw'; import { setupServer } from 'msw/node'; +import React from 'react'; import { useStats } from '@/hooks/useStats'; import type { UsageStats, StatsQueryParams } from '@/types'; diff --git a/frontend/src/components/AppLayout/index.tsx b/frontend/src/components/AppLayout/index.tsx index 88d69c2..e6429c0 100644 --- a/frontend/src/components/AppLayout/index.tsx +++ b/frontend/src/components/AppLayout/index.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; -import { Layout, Menu, Button } from 'tdesign-react'; -import { ServerIcon, ChartLineIcon, SettingIcon, ChevronLeftIcon, ChevronRightIcon } from 'tdesign-icons-react'; import { Outlet, useLocation, useNavigate } from 'react-router'; +import { ServerIcon, ChartLineIcon, SettingIcon, ChevronLeftIcon, ChevronRightIcon } from 'tdesign-icons-react'; +import { Layout, Menu, Button } from 'tdesign-react'; const { MenuItem } = Menu; diff --git a/frontend/src/hooks/useModels.ts b/frontend/src/hooks/useModels.ts index dbf7e58..356c984 100644 --- a/frontend/src/hooks/useModels.ts +++ b/frontend/src/hooks/useModels.ts @@ -1,7 +1,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { MessagePlugin } from 'tdesign-react'; -import type { CreateModelInput, UpdateModelInput, ApiError } from '@/types'; import * as api from '@/api/models'; +import type { CreateModelInput, UpdateModelInput, ApiError } from '@/types'; const ERROR_MESSAGES: Record = { duplicate_model: '同一供应商下模型名称已存在', diff --git a/frontend/src/hooks/useProviders.ts b/frontend/src/hooks/useProviders.ts index fb9836b..1e2e989 100644 --- a/frontend/src/hooks/useProviders.ts +++ b/frontend/src/hooks/useProviders.ts @@ -1,7 +1,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { MessagePlugin } from 'tdesign-react'; -import type { CreateProviderInput, UpdateProviderInput, ApiError } from '@/types'; import * as api from '@/api/providers'; +import type { CreateProviderInput, UpdateProviderInput, ApiError } from '@/types'; const ERROR_MESSAGES: Record = { duplicate_model: '同一供应商下模型名称已存在', diff --git a/frontend/src/hooks/useStats.ts b/frontend/src/hooks/useStats.ts index 8d0867e..c673667 100644 --- a/frontend/src/hooks/useStats.ts +++ b/frontend/src/hooks/useStats.ts @@ -1,6 +1,6 @@ import { useQuery } from '@tanstack/react-query'; -import type { StatsQueryParams } from '@/types'; import * as api from '@/api/stats'; +import type { StatsQueryParams } from '@/types'; export const statsKeys = { filtered: (params?: StatsQueryParams) => ['stats', params] as const, diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index dc9aed9..3dc8d1f 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -5,7 +5,11 @@ import 'tdesign-react/es/_util/react-19-adapter' import './index.scss' import App from './App' -createRoot(document.getElementById('root')!).render( +const root = document.getElementById('root') +if (!root) { + throw new Error('Root element not found') +} +createRoot(root).render( , diff --git a/frontend/src/pages/NotFound.tsx b/frontend/src/pages/NotFound.tsx index d8ee42c..4d91070 100644 --- a/frontend/src/pages/NotFound.tsx +++ b/frontend/src/pages/NotFound.tsx @@ -1,5 +1,5 @@ -import { Button } from 'tdesign-react'; import { useNavigate } from 'react-router'; +import { Button } from 'tdesign-react'; export default function NotFound() { const navigate = useNavigate(); diff --git a/frontend/src/pages/Providers/ModelTable.tsx b/frontend/src/pages/Providers/ModelTable.tsx index afb8a14..6b41eeb 100644 --- a/frontend/src/pages/Providers/ModelTable.tsx +++ b/frontend/src/pages/Providers/ModelTable.tsx @@ -1,7 +1,7 @@ import { Button, Table, Tag, Popconfirm, Space } from 'tdesign-react'; -import type { PrimaryTableCol } from 'tdesign-react/es/table/type'; -import type { Model } from '@/types'; import { useModels, useDeleteModel } from '@/hooks/useModels'; +import type { Model } from '@/types'; +import type { PrimaryTableCol } from 'tdesign-react/es/table/type'; interface ModelTableProps { providerId: string; diff --git a/frontend/src/pages/Providers/ProviderTable.tsx b/frontend/src/pages/Providers/ProviderTable.tsx index 6219bd5..1df29f4 100644 --- a/frontend/src/pages/Providers/ProviderTable.tsx +++ b/frontend/src/pages/Providers/ProviderTable.tsx @@ -1,7 +1,7 @@ import { Button, Table, Tag, Popconfirm, Space, Card } from 'tdesign-react'; -import type { PrimaryTableCol } from 'tdesign-react/es/table/type'; import type { Provider, Model } from '@/types'; import { ModelTable } from './ModelTable'; +import type { PrimaryTableCol } from 'tdesign-react/es/table/type'; interface ProviderTableProps { providers: Provider[]; diff --git a/frontend/src/pages/Providers/index.tsx b/frontend/src/pages/Providers/index.tsx index e35ed7e..b877e4e 100644 --- a/frontend/src/pages/Providers/index.tsx +++ b/frontend/src/pages/Providers/index.tsx @@ -1,10 +1,10 @@ import { useState } from 'react'; -import type { Provider, Model, UpdateProviderInput, UpdateModelInput } from '@/types'; -import { useProviders, useCreateProvider, useUpdateProvider, useDeleteProvider } from '@/hooks/useProviders'; import { useCreateModel, useUpdateModel } from '@/hooks/useModels'; -import { ProviderTable } from './ProviderTable'; -import { ProviderForm } from './ProviderForm'; +import { useProviders, useCreateProvider, useUpdateProvider, useDeleteProvider } from '@/hooks/useProviders'; +import type { Provider, Model, UpdateProviderInput, UpdateModelInput } from '@/types'; import { ModelForm } from './ModelForm'; +import { ProviderForm } from './ProviderForm'; +import { ProviderTable } from './ProviderTable'; export default function ProvidersPage() { const { data: providers = [], isLoading } = useProviders(); diff --git a/frontend/src/pages/Stats/StatCards.tsx b/frontend/src/pages/Stats/StatCards.tsx index 2275eea..7fc8340 100644 --- a/frontend/src/pages/Stats/StatCards.tsx +++ b/frontend/src/pages/Stats/StatCards.tsx @@ -1,5 +1,5 @@ -import { Row, Col, Card, Statistic } from 'tdesign-react'; import { ChartBarIcon, ChartLineIcon, ServerIcon, Calendar1Icon } from 'tdesign-icons-react'; +import { Row, Col, Card, Statistic } from 'tdesign-react'; import type { UsageStats } from '@/types'; interface StatCardsProps { diff --git a/frontend/src/pages/Stats/StatsTable.tsx b/frontend/src/pages/Stats/StatsTable.tsx index 81e8223..b6cd145 100644 --- a/frontend/src/pages/Stats/StatsTable.tsx +++ b/frontend/src/pages/Stats/StatsTable.tsx @@ -1,7 +1,7 @@ import { useMemo } from 'react'; import { Table, Select, Input, DateRangePicker, Space, Card } from 'tdesign-react'; -import type { PrimaryTableCol } from 'tdesign-react/es/table/type'; import type { UsageStats, Provider } from '@/types'; +import type { PrimaryTableCol } from 'tdesign-react/es/table/type'; interface StatsTableProps { providers: Provider[]; diff --git a/frontend/src/pages/Stats/UsageChart.tsx b/frontend/src/pages/Stats/UsageChart.tsx index 2eee761..f03ff3c 100644 --- a/frontend/src/pages/Stats/UsageChart.tsx +++ b/frontend/src/pages/Stats/UsageChart.tsx @@ -1,5 +1,5 @@ -import { Card } from 'tdesign-react'; import { AreaChart, Area, XAxis, YAxis, CartesianGrid, ResponsiveContainer, Tooltip } from 'recharts'; +import { Card } from 'tdesign-react'; import type { UsageStats } from '@/types'; interface UsageChartProps { diff --git a/frontend/src/pages/Stats/index.tsx b/frontend/src/pages/Stats/index.tsx index 7c9c9e9..57f6449 100644 --- a/frontend/src/pages/Stats/index.tsx +++ b/frontend/src/pages/Stats/index.tsx @@ -2,8 +2,8 @@ import { useState, useMemo } from 'react'; import { useProviders } from '@/hooks/useProviders'; import { useStats } from '@/hooks/useStats'; import { StatCards } from './StatCards'; -import { UsageChart } from './UsageChart'; import { StatsTable } from './StatsTable'; +import { UsageChart } from './UsageChart'; export default function StatsPage() { const { data: providers = [] } = useProviders(); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 95dfed2..f2f30df 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,7 +1,7 @@ -import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react' -import obfuscatorPlugin from 'vite-plugin-javascript-obfuscator' import path from 'node:path' +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vite' +import obfuscatorPlugin from 'vite-plugin-javascript-obfuscator' const vendorChunks: Record = { 'vendor-react': ['react', 'react-dom', 'react-router'], diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index b4fa797..7756efe 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -1,6 +1,6 @@ -import { defineConfig } from 'vitest/config' -import react from '@vitejs/plugin-react' import path from 'node:path' +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vitest/config' export default defineConfig({ plugins: [react()], diff --git a/openspec/specs/frontend-lint-rules/spec.md b/openspec/specs/frontend-lint-rules/spec.md new file mode 100644 index 0000000..1329830 --- /dev/null +++ b/openspec/specs/frontend-lint-rules/spec.md @@ -0,0 +1,114 @@ +# 前端 Lint 规则 + +## Purpose + +TBD - 定义前端 ESLint 规则配置、构建集成 lint 检查、以及自定义规则 + +## Requirements + +### Requirement: ESLint 规则配置 + +前端 SHALL 在 `eslint.config.js` 中配置以下规则: + +- `@tanstack/query/exhaustive-deps`: `error` — queryFn 中使用的变量 SHALL 出现在 queryKey 中 +- `@tanstack/query/no-void-query-fn`: `error` — queryFn SHALL 有返回值 +- `@tanstack/query/stable-query-client`: `error` — QueryClient SHALL NOT 在组件渲染中创建 +- `@tanstack/query/no-unstable-deps`: `error` — query 选项中 SHALL NOT 有不稳定引用 +- `@tanstack/query/infinite-query-property-order`: `error` — infinite query 属性顺序 SHALL 规范 +- `@tanstack/query/mutation-property-order`: `error` — mutation 回调顺序 SHALL 规范 +- `@tanstack/query/no-rest-destructuring`: `warn` — query 结果 SHALL NOT 使用 rest 解构 +- `no-console`: `['error', { allow: ['warn', 'error'] }]` — 代码中 SHALL NOT 使用 `console.log`、`console.info`、`console.debug` 等,仅允许 `console.warn` 和 `console.error` +- `@typescript-eslint/consistent-type-imports`: `['error', { prefer: 'type-imports', fixStyle: 'inline-type-imports' }]` — type import SHALL 使用内联风格 `import { type Foo }` +- `@typescript-eslint/no-non-null-assertion`: `error` — 代码中 SHALL NOT 使用 `foo!` 非空断言 + +#### Scenario: TanStack Query 规则未启用时构建失败 + +- **WHEN** `eslint.config.js` 中未配置 TanStack Query 的 `flat/recommended` 规则 +- **THEN** 前端构建 SHALL 失败 + +#### Scenario: 使用 console.log 时构建失败 + +- **WHEN** 源代码中出现 `console.log(...)` 调用 +- **THEN** ESLint SHALL 报告错误 +- **THEN** 前端构建 SHALL 失败 + +#### Scenario: 使用 console.warn 时不报错 + +- **WHEN** 源代码中出现 `console.warn(...)` 调用 +- **THEN** ESLint SHALL NOT 报告错误 + +#### Scenario: 使用独立 type import 时自动修复 + +- **WHEN** 源代码中出现 `import type { Foo } from 'module'` +- **THEN** `eslint --fix` SHALL 自动修复为 `import { type Foo } from 'module'` + +#### Scenario: 使用非空断言时构建失败 + +- **WHEN** 源代码中出现 `foo!.bar` 非空断言 +- **THEN** ESLint SHALL 报告错误 + +### Requirement: 构建集成 lint 检查 + +前端 SHALL 在 `build` 命令中集成 ESLint 检查。 + +#### Scenario: 构建时执行 lint + +- **WHEN** 执行 `bun run build` +- **THEN** 构建 SHALL 依次执行 `tsc -b`、`eslint .`、`vite build` +- **THEN** 若 `eslint .` 报告任何错误,构建 SHALL 中断 + +#### Scenario: lint 警告不中断构建 + +- **WHEN** `eslint .` 仅报告警告(无错误) +- **THEN** 构建 SHALL 继续执行 `vite build` + +#### Scenario: 单独执行 lint + +- **WHEN** 执行 `bun run lint` +- **THEN** SHALL 运行 `eslint .` + +#### Scenario: 自动修复 lint 问题 + +- **WHEN** 执行 `bun run lint:fix` +- **THEN** SHALL 运行 `eslint . --fix` + +### Requirement: 自定义规则禁止硬编码颜色 + +前端 SHALL 提供自定义 ESLint 规则 `no-hardcoded-color-in-style`,检测 JSX style 属性中的硬编码颜色值。 + +#### Scenario: 检测十六进制颜色 + +- **WHEN** JSX style 属性值匹配 `#xxx` 或 `#xxxxxx` 格式 +- **THEN** 规则 SHALL 报告警告 +- **THEN** 警告消息 SHALL 提示使用 `var(--td-*)` CSS Token + +#### Scenario: 检测 rgb/rgba/hsl 颜色函数 + +- **WHEN** JSX style 属性值匹配 `rgb()`、`rgba()`、`hsl()` 格式 +- **THEN** 规则 SHALL 报告警告 + +#### Scenario: 允许 CSS Token 引用 + +- **WHEN** JSX style 属性值为 `var(--td-*)` 格式 +- **THEN** 规则 SHALL NOT 报告 + +#### Scenario: 允许特殊颜色关键字 + +- **WHEN** JSX style 属性值为 `inherit`、`transparent`、`currentColor`、`none`、`unset`、`initial` +- **THEN** 规则 SHALL NOT 报告 + +#### Scenario: 允许数字值 + +- **WHEN** JSX style 属性值为数字(如 `0`、`16`) +- **THEN** 规则 SHALL NOT 报告 + +### Requirement: 自定义规则存放位置 + +自定义 ESLint 规则 SHALL 存放在 `frontend/eslint-rules/` 目录中。 + +#### Scenario: 自定义规则目录结构 + +- **WHEN** 添加自定义 ESLint 规则 +- **THEN** 规则文件 SHALL 放置在 `frontend/eslint-rules/` 目录下 +- **THEN** `eslint.config.js` SHALL 通过相对路径引用本地插件 +- **THEN** 自定义规则 SHALL NOT 作为 npm 包发布 diff --git a/openspec/specs/frontend/spec.md b/openspec/specs/frontend/spec.md index 0aa0403..a77e6e6 100644 --- a/openspec/specs/frontend/spec.md +++ b/openspec/specs/frontend/spec.md @@ -482,6 +482,9 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面 - **THEN** TypeScript 配置 SHALL 开启 noUncheckedIndexedAccess - **THEN** 所有代码 SHALL NOT 使用 any 类型 - **THEN** tsconfig SHALL 合并为单文件(不使用 project references) +- **THEN** type import SHALL 使用内联风格 `import { type Foo }` +- **THEN** 代码 SHALL NOT 使用非空断言 `foo!` +- **THEN** 代码 SHALL NOT 使用 `console.log`、`console.info`、`console.debug`(仅允许 `console.warn` 和 `console.error`) #### Scenario: React 函数组件 @@ -505,6 +508,8 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面 - **THEN** Vite SHALL 对业务代码执行混淆处理 - **THEN** 混淆 SHALL 仅应用于 src 目录下的业务代码 - **THEN** 混淆 SHALL NOT 应用于 node_modules 中的第三方库 +- **THEN** 构建流程 SHALL 在 vite build 之前执行 ESLint 检查 +- **THEN** ESLint 检查失败 SHALL 中断构建 ### Requirement: 与后端 API 通信 From 365943e4c45ce5d71ebd68928c007747367eb672 Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Fri, 24 Apr 2026 13:40:53 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=E5=89=8D=E7=AB=AF=E9=9B=86?= =?UTF-8?q?=E6=88=90=20Prettier=20=E4=BB=A3=E7=A0=81=E6=A0=BC=E5=BC=8F?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/.editorconfig | 12 + frontend/.gitignore | 1 + frontend/.prettierignore | 16 ++ frontend/.prettierrc | 16 ++ frontend/.vscode/extensions.json | 3 + frontend/.vscode/settings.json | 7 + frontend/README.md | 44 +++- frontend/bun.lock | 6 + frontend/e2e/fixtures.ts | 28 +- frontend/e2e/models.spec.ts | 31 ++- frontend/e2e/providers.spec.ts | 14 +- frontend/eslint-rules/index.js | 2 +- .../rules/no-hardcoded-color-in-style.js | 11 +- frontend/eslint.config.js | 21 +- frontend/index.html | 2 +- frontend/package.json | 8 +- frontend/src/App.tsx | 24 +- frontend/src/__tests__/api/client.test.ts | 238 +++++++++-------- frontend/src/__tests__/api/models.test.ts | 148 +++++------ frontend/src/__tests__/api/providers.test.ts | 122 ++++----- frontend/src/__tests__/api/stats.test.ts | 100 ++++---- .../__tests__/components/AppLayout.test.tsx | 54 ++-- .../__tests__/components/ModelForm.test.tsx | 122 +++++---- .../__tests__/components/ModelTable.test.tsx | 118 ++++----- .../components/ProviderForm.test.tsx | 240 +++++++++--------- .../components/ProviderTable.test.tsx | 180 ++++++------- .../__tests__/components/StatCards.test.tsx | 44 ++-- .../__tests__/components/StatsTable.test.tsx | 106 ++++---- .../__tests__/components/UsageChart.test.tsx | 46 ++-- .../eslint-rules/no-hardcoded-color.test.ts | 6 +- .../src/__tests__/hooks/useModels.test.tsx | 226 ++++++++--------- .../src/__tests__/hooks/useProviders.test.tsx | 208 +++++++-------- .../src/__tests__/hooks/useStats.test.tsx | 110 ++++---- frontend/src/__tests__/setup.ts | 27 +- frontend/src/api/client.ts | 60 ++--- frontend/src/api/models.ts | 21 +- frontend/src/api/providers.ts | 17 +- frontend/src/api/stats.ts | 18 +- frontend/src/components/AppLayout/index.tsx | 58 +++-- frontend/src/hooks/useModels.ts | 49 ++-- frontend/src/hooks/useProviders.ts | 49 ++-- frontend/src/hooks/useStats.ts | 10 +- frontend/src/index.scss | 6 +- frontend/src/main.tsx | 2 +- frontend/src/pages/NotFound.tsx | 28 +- frontend/src/pages/Providers/ModelForm.tsx | 95 +++---- frontend/src/pages/Providers/ModelTable.tsx | 47 ++-- frontend/src/pages/Providers/ProviderForm.tsx | 107 ++++---- .../src/pages/Providers/ProviderTable.tsx | 59 ++--- frontend/src/pages/Providers/index.tsx | 88 +++---- frontend/src/pages/Settings/index.tsx | 6 +- frontend/src/pages/Stats/StatCards.tsx | 46 ++-- frontend/src/pages/Stats/StatsTable.tsx | 64 ++--- frontend/src/pages/Stats/UsageChart.tsx | 40 +-- frontend/src/pages/Stats/index.tsx | 28 +- frontend/src/routes/index.tsx | 28 +- frontend/src/types/index.ts | 102 ++++---- frontend/vitest.config.ts | 7 +- openspec/specs/frontend-lint-rules/spec.md | 34 ++- openspec/specs/frontend/spec.md | 24 +- openspec/specs/prettier-formatting/spec.md | 232 +++++++++++++++++ 61 files changed, 1968 insertions(+), 1698 deletions(-) create mode 100644 frontend/.editorconfig create mode 100644 frontend/.prettierignore create mode 100644 frontend/.prettierrc create mode 100644 frontend/.vscode/extensions.json create mode 100644 frontend/.vscode/settings.json create mode 100644 openspec/specs/prettier-formatting/spec.md diff --git a/frontend/.editorconfig b/frontend/.editorconfig new file mode 100644 index 0000000..4bd3bd8 --- /dev/null +++ b/frontend/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false diff --git a/frontend/.gitignore b/frontend/.gitignore index a547bf3..49ef0bd 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -15,6 +15,7 @@ dist-ssr # Editor directories and files .vscode/* !.vscode/extensions.json +!.vscode/settings.json .idea .DS_Store *.suo diff --git a/frontend/.prettierignore b/frontend/.prettierignore new file mode 100644 index 0000000..26ac5c2 --- /dev/null +++ b/frontend/.prettierignore @@ -0,0 +1,16 @@ +node_modules +dist +dist-ssr +bun.lock +package-lock.json +yarn.lock +pnpm-lock.yaml +.env.* +*.local +coverage +**/*.snap +**/__snapshots__/** +*.svg +*.min.js +*.min.css +openspec/changes/archive/ diff --git a/frontend/.prettierrc b/frontend/.prettierrc new file mode 100644 index 0000000..b6a2caa --- /dev/null +++ b/frontend/.prettierrc @@ -0,0 +1,16 @@ +{ + "semi": false, + "singleQuote": true, + "jsxSingleQuote": true, + "tabWidth": 2, + "useTabs": false, + "trailingComma": "es5", + "printWidth": 120, + "bracketSpacing": true, + "arrowParens": "always", + "endOfLine": "lf", + "proseWrap": "preserve", + "htmlWhitespaceSensitivity": "css", + "embeddedLanguageFormatting": "auto", + "singleAttributePerLine": false +} diff --git a/frontend/.vscode/extensions.json b/frontend/.vscode/extensions.json new file mode 100644 index 0000000..d7df89c --- /dev/null +++ b/frontend/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["esbenp.prettier-vscode", "dbaeumer.vscode-eslint"] +} diff --git a/frontend/.vscode/settings.json b/frontend/.vscode/settings.json new file mode 100644 index 0000000..02b5dc1 --- /dev/null +++ b/frontend/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit" + } +} diff --git a/frontend/README.md b/frontend/README.md index 4f2b6ee..a89fb8d 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -13,6 +13,7 @@ AI 网关管理前端,提供供应商配置和用量统计界面。 - **数据获取**: TanStack Query v5 - **样式**: SCSS Modules(禁止使用纯 CSS) - **测试**: Vitest + React Testing Library + Playwright +- **代码格式化**: Prettier ## API 层 @@ -22,10 +23,10 @@ AI 网关管理前端,提供供应商配置和用量统计界面。 ```typescript // 发送请求时:camelCase → snake_case -toApi({ providerId: "openai" }) // → { provider_id: "openai" } +toApi({ providerId: 'openai' }) // → { provider_id: "openai" } // 接收响应时:snake_case → camelCase -fromApi({ provider_id: "openai" }) // → { providerId: "openai" } +fromApi({ provider_id: 'openai' }) // → { providerId: "openai" } ``` ### 统一请求函数 @@ -42,9 +43,9 @@ export async function request(method: string, path: string, body?: unknown): ```typescript class ApiError extends Error { - status: number; // HTTP 状态码 - code?: string; // 业务错误码 - message: string; // 错误消息 + status: number // HTTP 状态码 + code?: string // 业务错误码 + message: string // 错误消息 } ``` @@ -56,13 +57,13 @@ class ApiError extends Error { // src/hooks/useProviders.ts export const providerKeys = { all: ['providers'] as const, -}; +} // src/hooks/useModels.ts export const modelKeys = { all: ['models'] as const, byProvider: (providerId: string) => [...modelKeys.all, { providerId }] as const, -}; +} ``` ### Mutation 使用 @@ -71,9 +72,9 @@ export const modelKeys = { const mutation = useMutation({ mutationFn: createProvider, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: providerKeys.all }); + queryClient.invalidateQueries({ queryKey: providerKeys.all }) }, -}); +}) ``` ## 项目结构 @@ -142,9 +143,20 @@ bun run build ### 代码检查 ```bash -bun run lint +bun run lint # ESLint 检查 +bun run format:check # Prettier 格式检查 +bun run check # 同时检查 lint 和格式 ``` +### 代码格式化 + +```bash +bun run format # 格式化所有文件 +bun run fix # 修复 lint 问题并格式化 +``` + +VS Code 保存时自动格式化(需安装 Prettier 扩展)。 + ## 测试 ### 单元测试 + 组件测试 @@ -219,26 +231,30 @@ __tests__/ ## 环境变量 -| 变量 | 开发环境 | 生产环境 | 说明 | -|------|----------|----------|------| -| `VITE_API_BASE` | (空) | `/api` | API 基础路径,空则走 Vite proxy | +| 变量 | 开发环境 | 生产环境 | 说明 | +| --------------- | -------- | -------- | ------------------------------- | +| `VITE_API_BASE` | (空) | `/api` | API 基础路径,空则走 Vite proxy | **E2E 测试特有**: + - `NEX_BACKEND_PORT` - E2E 后端端口(默认 19026) - `NEX_E2E_TEMP_DIR` - E2E 临时目录 ## 开发规范 - 所有样式使用 SCSS,禁止使用纯 CSS 文件 -- 组件级样式使用 SCSS Modules(*.module.scss) +- 组件级样式使用 SCSS Modules(\*.module.scss) - 图标优先使用 TDesign 图标(tdesign-icons-react) - TypeScript strict 模式,禁止 any 类型 - API 层自动处理 snake_case ↔ camelCase 字段转换 - 使用路径别名 `@/` 引用 src 目录 +- 代码格式化使用 Prettier,配置见 `.prettierrc` +- 编辑器配置见 `.editorconfig`(统一缩进、换行符、编码) ### 环境要求 - Bun 1.0 或更高版本 +- VS Code 推荐安装 Prettier 和 ESLint 扩展(见 `.vscode/extensions.json`) ### 添加新页面 diff --git a/frontend/bun.lock b/frontend/bun.lock index 183cbf3..a07089e 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -29,6 +29,7 @@ "@vitejs/plugin-react": "^6.0.1", "@vitest/coverage-v8": "^3.2.1", "eslint": "^9.39.4", + "eslint-config-prettier": "^10.1.8", "eslint-plugin-import": "^2.31.0", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", @@ -37,6 +38,7 @@ "javascript-obfuscator": "^5.4.1", "jsdom": "^26.1.0", "msw": "^2.8.2", + "prettier": "^3.8.3", "sass": "^1.99.0", "sql.js": "^1.14.1", "typescript": "~6.0.2", @@ -688,6 +690,8 @@ "eslint": ["eslint@9.39.4", "https://registry.npmmirror.com/eslint/-/eslint-9.39.4.tgz", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.5", "@eslint/js": "9.39.4", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ=="], + "eslint-config-prettier": ["eslint-config-prettier@10.1.8", "https://registry.npmmirror.com/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", { "peerDependencies": { "eslint": ">=7.0.0" }, "bin": { "eslint-config-prettier": "bin/cli.js" } }, "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w=="], + "eslint-import-resolver-node": ["eslint-import-resolver-node@0.3.10", "https://registry.npmmirror.com/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz", { "dependencies": { "debug": "^3.2.7", "is-core-module": "^2.16.1", "resolve": "^2.0.0-next.6" } }, "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ=="], "eslint-module-utils": ["eslint-module-utils@2.12.1", "https://registry.npmmirror.com/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", { "dependencies": { "debug": "^3.2.7" } }, "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw=="], @@ -1080,6 +1084,8 @@ "prelude-ls": ["prelude-ls@1.2.1", "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.2.1.tgz", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + "prettier": ["prettier@3.8.3", "https://registry.npmmirror.com/prettier/-/prettier-3.8.3.tgz", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="], + "pretty-format": ["pretty-format@27.5.1", "https://registry.npmmirror.com/pretty-format/-/pretty-format-27.5.1.tgz", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], "process": ["process@0.11.10", "https://registry.npmmirror.com/process/-/process-0.11.10.tgz", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], diff --git a/frontend/e2e/fixtures.ts b/frontend/e2e/fixtures.ts index 104f8c7..968aaea 100644 --- a/frontend/e2e/fixtures.ts +++ b/frontend/e2e/fixtures.ts @@ -27,9 +27,7 @@ export interface SeedStatsInput { date: string } -export async function clearDatabase( - request: import('@playwright/test').APIRequestContext, -) { +export async function clearDatabase(request: import('@playwright/test').APIRequestContext) { const providers = await request.get(`${API_BASE}/api/providers`) if (providers.ok()) { const data = await providers.json() @@ -39,10 +37,7 @@ export async function clearDatabase( } } -export async function seedProvider( - request: import('@playwright/test').APIRequestContext, - data: SeedProviderInput, -) { +export async function seedProvider(request: import('@playwright/test').APIRequestContext, data: SeedProviderInput) { const resp = await request.post(`${API_BASE}/api/providers`, { data: { id: data.id, @@ -59,10 +54,7 @@ export async function seedProvider( return resp.json() } -export async function seedModel( - request: import('@playwright/test').APIRequestContext, - data: SeedModelInput, -) { +export async function seedModel(request: import('@playwright/test').APIRequestContext, data: SeedModelInput) { const resp = await request.post(`${API_BASE}/api/models`, { data: { provider_id: data.providerId, @@ -80,20 +72,22 @@ export async function seedUsageStats(statsData: SeedStatsInput[]) { const tempDir = path.join(os.tmpdir(), 'nex-e2e') const dbPath = path.join(tempDir, 'test.db') - + if (!fs.existsSync(dbPath)) { throw new Error(`Database file not found at ${dbPath}. Backend may not have created it yet.`) } - + const SQL = await initSqlite() const buf = fs.readFileSync(dbPath) const db = new SQL.Database(buf) for (const row of statsData) { - db.run( - 'INSERT OR REPLACE INTO usage_stats (provider_id, model_name, request_count, date) VALUES (?, ?, ?, ?)', - [row.providerId, row.modelName, row.requestCount, row.date], - ) + db.run('INSERT OR REPLACE INTO usage_stats (provider_id, model_name, request_count, date) VALUES (?, ?, ?, ?)', [ + row.providerId, + row.modelName, + row.requestCount, + row.date, + ]) } const data = db.export() diff --git a/frontend/e2e/models.spec.ts b/frontend/e2e/models.spec.ts index 5fa07e6..1d17e57 100644 --- a/frontend/e2e/models.spec.ts +++ b/frontend/e2e/models.spec.ts @@ -47,18 +47,25 @@ test.describe('模型管理', () => { await page.locator('.t-table__expand-box').first().click() await expect(page.locator('.t-table__expanded-row').first()).toBeVisible() - await page.locator('.t-dialog:visible').waitFor({ state: 'hidden', timeout: 3000 }).catch(() => {}) - + await page + .locator('.t-dialog:visible') + .waitFor({ state: 'hidden', timeout: 3000 }) + .catch(() => {}) + await page.locator('.t-table__expanded-row button:has-text("添加模型")').first().click() await expect(page.locator('.t-dialog:visible')).toBeVisible() const inputs = modelFormInputs(page) await inputs.modelName.fill('gpt_4_turbo') - - const responsePromise = page.waitForResponse(resp => resp.url().includes('/api/models') && resp.request().method() === 'POST') + + const responsePromise = page.waitForResponse( + (resp) => resp.url().includes('/api/models') && resp.request().method() === 'POST' + ) await inputs.saveBtn.click() await responsePromise - await expect(page.locator('.t-table__expanded-row').getByText('gpt_4_turbo', { exact: true })).toBeVisible({ timeout: 5000 }) + await expect(page.locator('.t-table__expanded-row').getByText('gpt_4_turbo', { exact: true })).toBeVisible({ + timeout: 5000, + }) }) test('应显示统一模型 ID', async ({ page, request }) => { @@ -100,11 +107,15 @@ test.describe('模型管理', () => { const inputs = modelFormInputs(page) await inputs.modelName.clear() await inputs.modelName.fill('gpt_4o') - - const responsePromise = page.waitForResponse(resp => resp.url().includes('/api/models') && resp.request().method() === 'PUT') + + const responsePromise = page.waitForResponse( + (resp) => resp.url().includes('/api/models') && resp.request().method() === 'PUT' + ) await inputs.saveBtn.click() await responsePromise - await expect(page.locator('.t-table__expanded-row').getByText('gpt_4o', { exact: true })).toBeVisible({ timeout: 5000 }) + await expect(page.locator('.t-table__expanded-row').getByText('gpt_4o', { exact: true })).toBeVisible({ + timeout: 5000, + }) }) test('应能删除模型', async ({ page, request }) => { @@ -126,6 +137,8 @@ test.describe('模型管理', () => { await page.locator('.t-table__expanded-row button:has-text("删除")').first().click() await expect(page.getByText(/确定要删除/)).toBeVisible() await page.locator('.t-popconfirm').getByRole('button', { name: '确定' }).click() - await expect(page.locator('.t-table__expanded-row').getByText('to_delete_model', { exact: true })).not.toBeVisible({ timeout: 5000 }) + await expect(page.locator('.t-table__expanded-row').getByText('to_delete_model', { exact: true })).not.toBeVisible({ + timeout: 5000, + }) }) }) diff --git a/frontend/e2e/providers.spec.ts b/frontend/e2e/providers.spec.ts index 4b03d8b..b71d704 100644 --- a/frontend/e2e/providers.spec.ts +++ b/frontend/e2e/providers.spec.ts @@ -43,7 +43,7 @@ test.describe('供应商管理', () => { await page.waitForSelector('.t-select__dropdown', { state: 'hidden', timeout: 3000 }) await inputs.saveBtn.click() - + await expect(page.locator('.t-table__body').getByText('Test Provider')).toBeVisible({ timeout: 10000 }) }) @@ -60,8 +60,10 @@ test.describe('供应商管理', () => { await page.waitForSelector('.t-select__dropdown', { timeout: 3000 }) await page.locator('.t-select__dropdown .t-select-option').first().click() await page.waitForSelector('.t-select__dropdown', { state: 'hidden', timeout: 3000 }) - - const responsePromise = page.waitForResponse(resp => resp.url().includes('/api/providers') && resp.request().method() === 'POST') + + const responsePromise = page.waitForResponse( + (resp) => resp.url().includes('/api/providers') && resp.request().method() === 'POST' + ) await inputs.saveBtn.click() await responsePromise await expect(page.locator('.t-table__body').getByText('Before Edit')).toBeVisible({ timeout: 5000 }) @@ -72,8 +74,10 @@ test.describe('供应商管理', () => { const editInputs = formInputs(page) await editInputs.name.clear() await editInputs.name.fill('After Edit') - - const updateResponsePromise = page.waitForResponse(resp => resp.url().includes('/api/providers') && resp.request().method() === 'PUT') + + const updateResponsePromise = page.waitForResponse( + (resp) => resp.url().includes('/api/providers') && resp.request().method() === 'PUT' + ) await editInputs.saveBtn.click() await updateResponsePromise await expect(page.locator('.t-table__body').getByText('After Edit')).toBeVisible({ timeout: 5000 }) diff --git a/frontend/eslint-rules/index.js b/frontend/eslint-rules/index.js index 317b657..42583b6 100644 --- a/frontend/eslint-rules/index.js +++ b/frontend/eslint-rules/index.js @@ -10,4 +10,4 @@ const plugin = { }, } -export default plugin \ No newline at end of file +export default plugin diff --git a/frontend/eslint-rules/rules/no-hardcoded-color-in-style.js b/frontend/eslint-rules/rules/no-hardcoded-color-in-style.js index f52e96c..f815d9b 100644 --- a/frontend/eslint-rules/rules/no-hardcoded-color-in-style.js +++ b/frontend/eslint-rules/rules/no-hardcoded-color-in-style.js @@ -42,10 +42,7 @@ function isHardcodedColor(value) { function extractStyleProperties(expression) { const properties = [] - if ( - expression.type === 'ObjectExpression' && - expression.properties - ) { + if (expression.type === 'ObjectExpression' && expression.properties) { for (const styleProp of expression.properties) { if ( styleProp.type === 'Property' && @@ -92,9 +89,7 @@ export default ESLintUtils.RuleCreator((name) => { node.value?.type === 'JSXExpressionContainer' && node.value.expression ) { - const styleProps = extractStyleProperties( - node.value.expression, - ) + const styleProps = extractStyleProperties(node.value.expression) for (const prop of styleProps) { if (isHardcodedColor(prop.value)) { @@ -109,4 +104,4 @@ export default ESLintUtils.RuleCreator((name) => { }, } }, -}) \ No newline at end of file +}) diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index f0bf189..d37de8b 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -6,6 +6,7 @@ import tseslint from 'typescript-eslint' import importPlugin from 'eslint-plugin-import' import tanstackQuery from '@tanstack/eslint-plugin-query' import localRules from './eslint-rules/index.js' +import eslintConfigPrettier from 'eslint-config-prettier' export default tseslint.config( { ignores: ['dist'] }, @@ -26,10 +27,7 @@ export default tseslint.config( }, rules: { ...reactHooks.configs.recommended.rules, - 'react-refresh/only-export-components': [ - 'warn', - { allowConstantExport: true }, - ], + 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], 'no-console': ['error', { allow: ['warn', 'error'] }], '@typescript-eslint/consistent-type-imports': [ 'error', @@ -40,20 +38,10 @@ export default tseslint.config( 'import/order': [ 'warn', { - groups: [ - 'builtin', - 'external', - 'internal', - 'parent', - 'sibling', - 'index', - 'type', - ], + groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index', 'type'], 'newlines-between': 'never', alphabetize: { order: 'asc', caseInsensitive: true }, - pathGroups: [ - { pattern: '@/**', group: 'internal', position: 'before' }, - ], + pathGroups: [{ pattern: '@/**', group: 'internal', position: 'before' }], }, ], }, @@ -67,4 +55,5 @@ export default tseslint.config( 'no-console': 'off', }, }, + eslintConfigPrettier ) diff --git a/frontend/index.html b/frontend/index.html index 6e70a00..d2f2908 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,4 +1,4 @@ - + diff --git a/frontend/package.json b/frontend/package.json index 672d2fd..5c88e20 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,9 +5,13 @@ "type": "module", "scripts": { "dev": "vite", - "build": "tsc -b && eslint . && vite build", + "build": "tsc -b && bun run check && vite build", "lint": "eslint .", "lint:fix": "eslint . --fix", + "format": "prettier --write .", + "format:check": "prettier --check .", + "check": "bun run lint && bun run format:check", + "fix": "bun run lint:fix && bun run format", "preview": "vite preview", "test": "vitest run", "test:watch": "vitest", @@ -39,6 +43,7 @@ "@vitejs/plugin-react": "^6.0.1", "@vitest/coverage-v8": "^3.2.1", "eslint": "^9.39.4", + "eslint-config-prettier": "^10.1.8", "eslint-plugin-import": "^2.31.0", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", @@ -47,6 +52,7 @@ "javascript-obfuscator": "^5.4.1", "jsdom": "^26.1.0", "msw": "^2.8.2", + "prettier": "^3.8.3", "sass": "^1.99.0", "sql.js": "^1.14.1", "typescript": "~6.0.2", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9f1abb7..f964a89 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,7 +1,7 @@ -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { BrowserRouter } from 'react-router'; -import { ConfigProvider } from 'tdesign-react'; -import { AppRoutes } from '@/routes'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { BrowserRouter } from 'react-router' +import { ConfigProvider } from 'tdesign-react' +import { AppRoutes } from '@/routes' const queryClient = new QueryClient({ defaultOptions: { @@ -11,21 +11,23 @@ const queryClient = new QueryClient({ refetchOnWindowFocus: false, }, }, -}); +}) function App() { return ( - + - ); + ) } -export default App; +export default App diff --git a/frontend/src/__tests__/api/client.test.ts b/frontend/src/__tests__/api/client.test.ts index 47c29bd..52bf864 100644 --- a/frontend/src/__tests__/api/client.test.ts +++ b/frontend/src/__tests__/api/client.test.ts @@ -1,88 +1,85 @@ -import { http, HttpResponse } from 'msw'; -import { setupServer } from 'msw/node'; -import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest'; -import { request, fromApi, toApi } from '@/api/client'; -import { ApiError } from '@/types'; +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest' +import { request, fromApi, toApi } from '@/api/client' +import { ApiError } from '@/types' describe('fromApi', () => { it('converts snake_case keys to camelCase', () => { - const input = { first_name: 'John', last_name: 'Doe' }; - const result = fromApi<{ firstName: string; lastName: string }>(input); - expect(result).toEqual({ firstName: 'John', lastName: 'Doe' }); - }); + const input = { first_name: 'John', last_name: 'Doe' } + const result = fromApi<{ firstName: string; lastName: string }>(input) + expect(result).toEqual({ firstName: 'John', lastName: 'Doe' }) + }) it('converts nested objects recursively', () => { const input = { user_name: 'alice', contact_info: { email_address: 'alice@example.com' }, - }; + } const result = fromApi<{ - userName: string; - contactInfo: { emailAddress: string }; - }>(input); + userName: string + contactInfo: { emailAddress: string } + }>(input) expect(result).toEqual({ userName: 'alice', contactInfo: { emailAddress: 'alice@example.com' }, - }); - }); + }) + }) it('converts arrays recursively', () => { - const input = [ - { item_name: 'a' }, - { item_name: 'b' }, - ]; - const result = fromApi>(input); - expect(result).toEqual([{ itemName: 'a' }, { itemName: 'b' }]); - }); + const input = [{ item_name: 'a' }, { item_name: 'b' }] + const result = fromApi>(input) + expect(result).toEqual([{ itemName: 'a' }, { itemName: 'b' }]) + }) it('returns primitives unchanged', () => { - expect(fromApi('hello')).toBe('hello'); - expect(fromApi(42)).toBe(42); - expect(fromApi(null)).toBeNull(); - }); -}); + expect(fromApi('hello')).toBe('hello') + expect(fromApi(42)).toBe(42) + expect(fromApi(null)).toBeNull() + }) +}) describe('toApi', () => { it('converts camelCase keys to snake_case', () => { - const input = { firstName: 'John', lastName: 'Doe' }; - const result = toApi<{ first_name: string; last_name: string }>(input); - expect(result).toEqual({ first_name: 'John', last_name: 'Doe' }); - }); + const input = { firstName: 'John', lastName: 'Doe' } + const result = toApi<{ first_name: string; last_name: string }>(input) + expect(result).toEqual({ first_name: 'John', last_name: 'Doe' }) + }) it('converts nested objects recursively', () => { const input = { userName: 'alice', contactInfo: { emailAddress: 'alice@example.com' }, - }; + } const result = toApi<{ - user_name: string; - contact_info: { email_address: string }; - }>(input); + user_name: string + contact_info: { email_address: string } + }>(input) expect(result).toEqual({ user_name: 'alice', contact_info: { email_address: 'alice@example.com' }, - }); - }); + }) + }) it('converts arrays recursively', () => { - const input = [{ itemName: 'a' }, { itemName: 'b' }]; - const result = toApi>(input); - expect(result).toEqual([{ item_name: 'a' }, { item_name: 'b' }]); - }); + const input = [{ itemName: 'a' }, { itemName: 'b' }] + const result = toApi>(input) + expect(result).toEqual([{ item_name: 'a' }, { item_name: 'b' }]) + }) it('returns primitives unchanged', () => { - expect(toApi('hello')).toBe('hello'); - expect(toApi(42)).toBe(42); - expect(toApi(null)).toBeNull(); - }); -}); + expect(toApi('hello')).toBe('hello') + expect(toApi(42)).toBe(42) + expect(toApi(null)).toBeNull() + }) +}) describe('request', () => { - const mswServer = setupServer(); + const mswServer = setupServer() - beforeAll(() => mswServer.listen({ onUnhandledRequest: 'bypass' })); - afterEach(() => mswServer.resetHandlers()); - afterAll(() => mswServer.close()); + beforeAll(() => mswServer.listen({ onUnhandledRequest: 'bypass' })) + afterEach(() => mswServer.resetHandlers()) + afterAll(() => mswServer.close()) it('parses JSON and converts snake_case keys to camelCase on success', async () => { mswServer.use( @@ -91,139 +88,130 @@ describe('request', () => { id: '1', created_at: '2025-01-01', nested_obj: { inner_key: 'value' }, - }); - }), - ); + }) + }) + ) const result = await request<{ - id: string; - createdAt: string; - nestedObj: { innerKey: string }; - }>('GET', '/api/test'); + id: string + createdAt: string + nestedObj: { innerKey: string } + }>('GET', '/api/test') expect(result).toEqual({ id: '1', createdAt: '2025-01-01', nestedObj: { innerKey: 'value' }, - }); - }); + }) + }) it('throws ApiError with status and message on HTTP error', async () => { mswServer.use( http.get('http://localhost:3000/api/test', () => { - return HttpResponse.json( - { message: 'Not found' }, - { status: 404 }, - ); - }), - ); + return HttpResponse.json({ message: 'Not found' }, { status: 404 }) + }) + ) - await expect(request('GET', '/api/test')).rejects.toThrow(ApiError); + await expect(request('GET', '/api/test')).rejects.toThrow(ApiError) try { - await request('GET', '/api/test'); + await request('GET', '/api/test') } catch (error) { - expect(error).toBeInstanceOf(ApiError); - const apiError = error as ApiError; - expect(apiError.status).toBe(404); - expect(apiError.message).toBe('Not found'); + expect(error).toBeInstanceOf(ApiError) + const apiError = error as ApiError + expect(apiError.status).toBe(404) + expect(apiError.message).toBe('Not found') } - }); + }) it('throws ApiError with default message when error body has no message', async () => { mswServer.use( http.get('http://localhost:3000/api/test', () => { - return HttpResponse.json( - { details: 'something' }, - { status: 500 }, - ); - }), - ); + return HttpResponse.json({ details: 'something' }, { status: 500 }) + }) + ) try { - await request('GET', '/api/test'); + await request('GET', '/api/test') } catch (error) { - expect(error).toBeInstanceOf(ApiError); - const apiError = error as ApiError; - expect(apiError.status).toBe(500); - expect(apiError.message).toContain('500'); + expect(error).toBeInstanceOf(ApiError) + const apiError = error as ApiError + expect(apiError.status).toBe(500) + expect(apiError.message).toContain('500') } - }); + }) it('throws ApiError with code field when error response includes code', async () => { mswServer.use( http.get('http://localhost:3000/api/test', () => { - return HttpResponse.json( - { error: 'Model not found', code: 'MODEL_NOT_FOUND' }, - { status: 404 }, - ); - }), - ); + return HttpResponse.json({ error: 'Model not found', code: 'MODEL_NOT_FOUND' }, { status: 404 }) + }) + ) try { - await request('GET', '/api/test'); + await request('GET', '/api/test') } catch (error) { - expect(error).toBeInstanceOf(ApiError); - const apiError = error as ApiError; - expect(apiError.status).toBe(404); - expect(apiError.message).toBe('Model not found'); - expect(apiError.code).toBe('MODEL_NOT_FOUND'); + expect(error).toBeInstanceOf(ApiError) + const apiError = error as ApiError + expect(apiError.status).toBe(404) + expect(apiError.message).toBe('Model not found') + expect(apiError.code).toBe('MODEL_NOT_FOUND') } - }); + }) it('throws Error on network failure', async () => { mswServer.use( http.get('http://localhost:3000/api/test', () => { - return HttpResponse.error(); - }), - ); + return HttpResponse.error() + }) + ) - await expect(request('GET', '/api/test')).rejects.toThrow(); - }); + await expect(request('GET', '/api/test')).rejects.toThrow() + }) it('returns undefined for 204 No Content', async () => { mswServer.use( http.delete('http://localhost:3000/api/test/1', () => { - return new HttpResponse(null, { status: 204 }); - }), - ); + return new HttpResponse(null, { status: 204 }) + }) + ) - const result = await request('DELETE', '/api/test/1'); - expect(result).toBeUndefined(); - }); + const result = await request('DELETE', '/api/test/1') + expect(result).toBeUndefined() + }) it('sends body with camelCase keys converted to snake_case', async () => { - let receivedBody: Record | null = null; + let receivedBody: Record | null = null mswServer.use( http.post('http://localhost:3000/api/test', async ({ request }) => { - receivedBody = (await request.json()) as Record; - return HttpResponse.json({ id: '1' }); - }), - ); + receivedBody = (await request.json()) as Record + return HttpResponse.json({ id: '1' }) + }) + ) await request('POST', '/api/test', { providerId: 'prov-1', modelName: 'gpt-4', - }); + }) expect(receivedBody).toEqual({ provider_id: 'prov-1', model_name: 'gpt-4', - }); - }); + }) + }) it('sends Content-Type header as application/json', async () => { - let contentType: string | null = null; + let contentType: string | null = null mswServer.use( http.post('http://localhost:3000/api/test', async ({ request }) => { - contentType = request.headers.get('Content-Type'); - return HttpResponse.json({ id: '1' }); - }), - ); + contentType = request.headers.get('Content-Type') + return HttpResponse.json({ id: '1' }) + }) + ) - await request('POST', '/api/test', { name: 'test' }); + await request('POST', '/api/test', { name: 'test' }) - expect(contentType).toBe('application/json'); - }); -}); + expect(contentType).toBe('application/json') + }) +}) diff --git a/frontend/src/__tests__/api/models.test.ts b/frontend/src/__tests__/api/models.test.ts index 264a47c..2a44239 100644 --- a/frontend/src/__tests__/api/models.test.ts +++ b/frontend/src/__tests__/api/models.test.ts @@ -1,7 +1,7 @@ -import { http, HttpResponse } from 'msw'; -import { setupServer } from 'msw/node'; -import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest'; -import { listModels, createModel, updateModel, deleteModel } from '@/api/models'; +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest' +import { listModels, createModel, updateModel, deleteModel } from '@/api/models' const mockModels = [ { @@ -20,24 +20,24 @@ const mockModels = [ enabled: false, created_at: '2025-01-02T00:00:00Z', }, -]; +] describe('models API', () => { - const server = setupServer(); + const server = setupServer() - beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' })); - afterEach(() => server.resetHandlers()); - afterAll(() => server.close()); + beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' })) + afterEach(() => server.resetHandlers()) + afterAll(() => server.close()) describe('listModels', () => { it('returns array of Model objects with camelCase keys', async () => { server.use( http.get('http://localhost:3000/api/models', () => { - return HttpResponse.json(mockModels); - }), - ); + return HttpResponse.json(mockModels) + }) + ) - const result = await listModels(); + const result = await listModels() expect(result).toEqual([ { @@ -56,114 +56,114 @@ describe('models API', () => { enabled: false, createdAt: '2025-01-02T00:00:00Z', }, - ]); - }); + ]) + }) it('appends provider_id query parameter when providerId is given', async () => { - let receivedUrl: string | null = null; + let receivedUrl: string | null = null server.use( http.get('http://localhost:3000/api/models', ({ request }) => { - receivedUrl = request.url; - return HttpResponse.json([mockModels[0]]); - }), - ); + receivedUrl = request.url + return HttpResponse.json([mockModels[0]]) + }) + ) - const result = await listModels('prov-1'); + const result = await listModels('prov-1') - expect(receivedUrl).toContain('provider_id=prov-1'); - expect(result).toHaveLength(1); - expect(result[0].providerId).toBe('prov-1'); - }); - }); + expect(receivedUrl).toContain('provider_id=prov-1') + expect(result).toHaveLength(1) + expect(result[0].providerId).toBe('prov-1') + }) + }) describe('createModel', () => { it('sends POST with correct body and returns model', async () => { - let receivedMethod: string | null = null; - let receivedBody: Record | null = null; + let receivedMethod: string | null = null + let receivedBody: Record | null = null server.use( http.post('http://localhost:3000/api/models', async ({ request }) => { - receivedMethod = request.method; - receivedBody = (await request.json()) as Record; - return HttpResponse.json(mockModels[0]); - }), - ); + receivedMethod = request.method + receivedBody = (await request.json()) as Record + return HttpResponse.json(mockModels[0]) + }) + ) const input = { providerId: 'prov-1', modelName: 'gpt-4', enabled: true, - }; + } - const result = await createModel(input); + const result = await createModel(input) - expect(receivedMethod).toBe('POST'); + expect(receivedMethod).toBe('POST') expect(receivedBody).toEqual({ provider_id: 'prov-1', model_name: 'gpt-4', enabled: true, - }); - expect(result.id).toBe('gpt-4'); - expect(result.providerId).toBe('prov-1'); - expect(result.modelName).toBe('gpt-4'); - expect(result.unifiedId).toBe('prov-1/gpt-4'); - }); - }); + }) + expect(result.id).toBe('gpt-4') + expect(result.providerId).toBe('prov-1') + expect(result.modelName).toBe('gpt-4') + expect(result.unifiedId).toBe('prov-1/gpt-4') + }) + }) describe('updateModel', () => { it('sends PUT with correct body and returns model', async () => { - let receivedMethod: string | null = null; - let receivedUrl: string | null = null; - let receivedBody: Record | null = null; + let receivedMethod: string | null = null + let receivedUrl: string | null = null + let receivedBody: Record | null = null server.use( http.put('http://localhost:3000/api/models/:id', async ({ request }) => { - receivedMethod = request.method; - receivedUrl = new URL(request.url).pathname; - receivedBody = (await request.json()) as Record; + receivedMethod = request.method + receivedUrl = new URL(request.url).pathname + receivedBody = (await request.json()) as Record return HttpResponse.json({ ...mockModels[0], model_name: 'gpt-4-turbo', enabled: false, - }); - }), - ); + }) + }) + ) const result = await updateModel('gpt-4', { modelName: 'gpt-4-turbo', enabled: false, - }); + }) - expect(receivedMethod).toBe('PUT'); - expect(receivedUrl).toBe('/api/models/gpt-4'); + expect(receivedMethod).toBe('PUT') + expect(receivedUrl).toBe('/api/models/gpt-4') expect(receivedBody).toEqual({ model_name: 'gpt-4-turbo', enabled: false, - }); - expect(result.modelName).toBe('gpt-4-turbo'); - expect(result.enabled).toBe(false); - }); - }); + }) + expect(result.modelName).toBe('gpt-4-turbo') + expect(result.enabled).toBe(false) + }) + }) describe('deleteModel', () => { it('sends DELETE and returns void', async () => { - let receivedMethod: string | null = null; - let receivedUrl: string | null = null; + let receivedMethod: string | null = null + let receivedUrl: string | null = null server.use( http.delete('http://localhost:3000/api/models/:id', ({ request }) => { - receivedMethod = request.method; - receivedUrl = new URL(request.url).pathname; - return new HttpResponse(null, { status: 204 }); - }), - ); + receivedMethod = request.method + receivedUrl = new URL(request.url).pathname + return new HttpResponse(null, { status: 204 }) + }) + ) - const result = await deleteModel('gpt-4'); + const result = await deleteModel('gpt-4') - expect(receivedMethod).toBe('DELETE'); - expect(receivedUrl).toBe('/api/models/gpt-4'); - expect(result).toBeUndefined(); - }); - }); -}); + expect(receivedMethod).toBe('DELETE') + expect(receivedUrl).toBe('/api/models/gpt-4') + expect(result).toBeUndefined() + }) + }) +}) diff --git a/frontend/src/__tests__/api/providers.test.ts b/frontend/src/__tests__/api/providers.test.ts index b75bde5..665567c 100644 --- a/frontend/src/__tests__/api/providers.test.ts +++ b/frontend/src/__tests__/api/providers.test.ts @@ -1,7 +1,7 @@ -import { http, HttpResponse } from 'msw'; -import { setupServer } from 'msw/node'; -import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest'; -import { listProviders, createProvider, updateProvider, deleteProvider } from '@/api/providers'; +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest' +import { listProviders, createProvider, updateProvider, deleteProvider } from '@/api/providers' const mockProviders = [ { @@ -24,24 +24,24 @@ const mockProviders = [ created_at: '2025-01-02T00:00:00Z', updated_at: '2025-01-02T00:00:00Z', }, -]; +] describe('providers API', () => { - const server = setupServer(); + const server = setupServer() - beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' })); - afterEach(() => server.resetHandlers()); - afterAll(() => server.close()); + beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' })) + afterEach(() => server.resetHandlers()) + afterAll(() => server.close()) describe('listProviders', () => { it('returns array of Provider objects with camelCase keys', async () => { server.use( http.get('http://localhost:3000/api/providers', () => { - return HttpResponse.json(mockProviders); - }), - ); + return HttpResponse.json(mockProviders) + }) + ) - const result = await listProviders(); + const result = await listProviders() expect(result).toEqual([ { @@ -64,22 +64,22 @@ describe('providers API', () => { createdAt: '2025-01-02T00:00:00Z', updatedAt: '2025-01-02T00:00:00Z', }, - ]); - }); - }); + ]) + }) + }) describe('createProvider', () => { it('sends POST with correct body and returns provider', async () => { - let receivedMethod: string | null = null; - let receivedBody: Record | null = null; + let receivedMethod: string | null = null + let receivedBody: Record | null = null server.use( http.post('http://localhost:3000/api/providers', async ({ request }) => { - receivedMethod = request.method; - receivedBody = (await request.json()) as Record; - return HttpResponse.json(mockProviders[0]); - }), - ); + receivedMethod = request.method + receivedBody = (await request.json()) as Record + return HttpResponse.json(mockProviders[0]) + }) + ) const input = { id: 'prov-1', @@ -87,18 +87,18 @@ describe('providers API', () => { apiKey: 'sk-xxx', baseUrl: 'https://api.openai.com', enabled: true, - }; + } - const result = await createProvider(input); + const result = await createProvider(input) - expect(receivedMethod).toBe('POST'); + expect(receivedMethod).toBe('POST') expect(receivedBody).toEqual({ id: 'prov-1', name: 'OpenAI', api_key: 'sk-xxx', base_url: 'https://api.openai.com', enabled: true, - }); + }) expect(result).toEqual({ id: 'prov-1', name: 'OpenAI', @@ -108,63 +108,63 @@ describe('providers API', () => { enabled: true, createdAt: '2025-01-01T00:00:00Z', updatedAt: '2025-01-01T00:00:00Z', - }); - }); - }); + }) + }) + }) describe('updateProvider', () => { it('sends PUT with correct body and returns provider', async () => { - let receivedMethod: string | null = null; - let receivedUrl: string | null = null; - let receivedBody: Record | null = null; + let receivedMethod: string | null = null + let receivedUrl: string | null = null + let receivedBody: Record | null = null server.use( http.put('http://localhost:3000/api/providers/:id', async ({ request }) => { - receivedMethod = request.method; - receivedUrl = new URL(request.url).pathname; - receivedBody = (await request.json()) as Record; + receivedMethod = request.method + receivedUrl = new URL(request.url).pathname + receivedBody = (await request.json()) as Record return HttpResponse.json({ ...mockProviders[0], name: 'Updated', api_key: 'sk-updated', - }); - }), - ); + }) + }) + ) const result = await updateProvider('prov-1', { name: 'Updated', apiKey: 'sk-updated', - }); + }) - expect(receivedMethod).toBe('PUT'); - expect(receivedUrl).toBe('/api/providers/prov-1'); + expect(receivedMethod).toBe('PUT') + expect(receivedUrl).toBe('/api/providers/prov-1') expect(receivedBody).toEqual({ name: 'Updated', api_key: 'sk-updated', - }); - expect(result.name).toBe('Updated'); - expect(result.apiKey).toBe('sk-updated'); - }); - }); + }) + expect(result.name).toBe('Updated') + expect(result.apiKey).toBe('sk-updated') + }) + }) describe('deleteProvider', () => { it('sends DELETE and returns void', async () => { - let receivedMethod: string | null = null; - let receivedUrl: string | null = null; + let receivedMethod: string | null = null + let receivedUrl: string | null = null server.use( http.delete('http://localhost:3000/api/providers/:id', ({ request }) => { - receivedMethod = request.method; - receivedUrl = new URL(request.url).pathname; - return new HttpResponse(null, { status: 204 }); - }), - ); + receivedMethod = request.method + receivedUrl = new URL(request.url).pathname + return new HttpResponse(null, { status: 204 }) + }) + ) - const result = await deleteProvider('prov-1'); + const result = await deleteProvider('prov-1') - expect(receivedMethod).toBe('DELETE'); - expect(receivedUrl).toBe('/api/providers/prov-1'); - expect(result).toBeUndefined(); - }); - }); -}); + expect(receivedMethod).toBe('DELETE') + expect(receivedUrl).toBe('/api/providers/prov-1') + expect(result).toBeUndefined() + }) + }) +}) diff --git a/frontend/src/__tests__/api/stats.test.ts b/frontend/src/__tests__/api/stats.test.ts index 32dd943..9531015 100644 --- a/frontend/src/__tests__/api/stats.test.ts +++ b/frontend/src/__tests__/api/stats.test.ts @@ -1,7 +1,7 @@ -import { http, HttpResponse } from 'msw'; -import { setupServer } from 'msw/node'; -import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest'; -import { getStats } from '@/api/stats'; +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest' +import { getStats } from '@/api/stats' const mockStats = [ { @@ -18,29 +18,29 @@ const mockStats = [ request_count: 50, date: '2025-01-16', }, -]; +] describe('stats API', () => { - const server = setupServer(); + const server = setupServer() - beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' })); - afterEach(() => server.resetHandlers()); - afterAll(() => server.close()); + beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' })) + afterEach(() => server.resetHandlers()) + afterAll(() => server.close()) describe('getStats', () => { it('calls /api/stats without params', async () => { - let receivedUrl: string | null = null; + let receivedUrl: string | null = null server.use( http.get('http://localhost:3000/api/stats', ({ request }) => { - receivedUrl = request.url; - return HttpResponse.json(mockStats); - }), - ); + receivedUrl = request.url + return HttpResponse.json(mockStats) + }) + ) - const result = await getStats(); + const result = await getStats() - expect(receivedUrl).toMatch(/\/api\/stats$/); + expect(receivedUrl).toMatch(/\/api\/stats$/) expect(result).toEqual([ { id: 1, @@ -56,76 +56,76 @@ describe('stats API', () => { requestCount: 50, date: '2025-01-16', }, - ]); - }); + ]) + }) it('builds correct query string with snake_case keys when params are provided', async () => { - let receivedUrl: string | null = null; + let receivedUrl: string | null = null server.use( http.get('http://localhost:3000/api/stats', ({ request }) => { - receivedUrl = request.url; - return HttpResponse.json([]); - }), - ); + receivedUrl = request.url + return HttpResponse.json([]) + }) + ) await getStats({ providerId: 'prov-1', modelName: 'gpt-4', startDate: '2025-01-01', endDate: '2025-01-31', - }); + }) - expect(receivedUrl).toContain('provider_id=prov-1'); - expect(receivedUrl).toContain('model_name=gpt-4'); - expect(receivedUrl).toContain('start_date=2025-01-01'); - expect(receivedUrl).toContain('end_date=2025-01-31'); - }); + expect(receivedUrl).toContain('provider_id=prov-1') + expect(receivedUrl).toContain('model_name=gpt-4') + expect(receivedUrl).toContain('start_date=2025-01-01') + expect(receivedUrl).toContain('end_date=2025-01-31') + }) it('omits undefined params from query string', async () => { - let receivedUrl: string | null = null; + let receivedUrl: string | null = null server.use( http.get('http://localhost:3000/api/stats', ({ request }) => { - receivedUrl = request.url; - return HttpResponse.json([]); - }), - ); + receivedUrl = request.url + return HttpResponse.json([]) + }) + ) await getStats({ providerId: 'prov-1', - }); + }) - expect(receivedUrl).toContain('provider_id=prov-1'); - expect(receivedUrl).not.toContain('model_name'); - expect(receivedUrl).not.toContain('start_date'); - expect(receivedUrl).not.toContain('end_date'); - }); + expect(receivedUrl).toContain('provider_id=prov-1') + expect(receivedUrl).not.toContain('model_name') + expect(receivedUrl).not.toContain('start_date') + expect(receivedUrl).not.toContain('end_date') + }) it('returns UsageStats array with camelCase keys', async () => { server.use( http.get('http://localhost:3000/api/stats', () => { - return HttpResponse.json(mockStats); - }), - ); + return HttpResponse.json(mockStats) + }) + ) - const result = await getStats(); + const result = await getStats() - expect(result).toHaveLength(2); + expect(result).toHaveLength(2) expect(result[0]).toEqual({ id: 1, providerId: 'prov-1', modelName: 'gpt-4', requestCount: 100, date: '2025-01-15', - }); + }) expect(result[1]).toEqual({ id: 2, providerId: 'prov-2', modelName: 'claude-3', requestCount: 50, date: '2025-01-16', - }); - }); - }); -}); + }) + }) + }) +}) diff --git a/frontend/src/__tests__/components/AppLayout.test.tsx b/frontend/src/__tests__/components/AppLayout.test.tsx index c5ac659..b454842 100644 --- a/frontend/src/__tests__/components/AppLayout.test.tsx +++ b/frontend/src/__tests__/components/AppLayout.test.tsx @@ -1,52 +1,52 @@ -import { render, screen } from '@testing-library/react'; -import { BrowserRouter } from 'react-router'; -import { describe, it, expect } from 'vitest'; -import { AppLayout } from '@/components/AppLayout'; +import { render, screen } from '@testing-library/react' +import { BrowserRouter } from 'react-router' +import { describe, it, expect } from 'vitest' +import { AppLayout } from '@/components/AppLayout' const renderWithRouter = (component: React.ReactNode) => { - return render({component}); -}; + return render({component}) +} describe('AppLayout', () => { it('renders sidebar with app name', () => { - renderWithRouter(); + renderWithRouter() - const appNames = screen.getAllByText('AI Gateway'); - expect(appNames.length).toBeGreaterThan(0); - }); + const appNames = screen.getAllByText('AI Gateway') + expect(appNames.length).toBeGreaterThan(0) + }) it('renders navigation menu items', () => { - renderWithRouter(); + renderWithRouter() - expect(screen.getByText('供应商管理')).toBeInTheDocument(); - expect(screen.getByText('用量统计')).toBeInTheDocument(); - }); + expect(screen.getByText('供应商管理')).toBeInTheDocument() + expect(screen.getByText('用量统计')).toBeInTheDocument() + }) it('renders settings menu item', () => { - renderWithRouter(); + renderWithRouter() - expect(screen.getByText('设置')).toBeInTheDocument(); - }); + expect(screen.getByText('设置')).toBeInTheDocument() + }) it('renders content outlet', () => { - const { container } = renderWithRouter(); + const { container } = renderWithRouter() // TDesign Layout content - expect(container.querySelector('.t-layout__content')).toBeInTheDocument(); - }); + expect(container.querySelector('.t-layout__content')).toBeInTheDocument() + }) it('renders sidebar', () => { - const { container } = renderWithRouter(); + const { container } = renderWithRouter() // TDesign Layout.Aside might render with different class names // Check for Menu component which is in the sidebar - expect(container.querySelector('.t-menu')).toBeInTheDocument(); - }); + expect(container.querySelector('.t-menu')).toBeInTheDocument() + }) it('renders header with page title', () => { - const { container } = renderWithRouter(); + const { container } = renderWithRouter() // TDesign Layout header - expect(container.querySelector('.t-layout__header')).toBeInTheDocument(); - }); -}); + expect(container.querySelector('.t-layout__header')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/__tests__/components/ModelForm.test.tsx b/frontend/src/__tests__/components/ModelForm.test.tsx index 353874e..53a0b81 100644 --- a/frontend/src/__tests__/components/ModelForm.test.tsx +++ b/frontend/src/__tests__/components/ModelForm.test.tsx @@ -1,8 +1,8 @@ -import { render, screen, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { describe, it, expect, vi } from 'vitest'; -import { ModelForm } from '@/pages/Providers/ModelForm'; -import type { Provider, Model } from '@/types'; +import { render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, it, expect, vi } from 'vitest' +import { ModelForm } from '@/pages/Providers/ModelForm' +import type { Provider, Model } from '@/types' const mockProviders: Provider[] = [ { @@ -25,7 +25,7 @@ const mockProviders: Provider[] = [ createdAt: '2024-01-02T00:00:00Z', updatedAt: '2024-01-02T00:00:00Z', }, -]; +] const mockModel: Model = { id: 'gpt-4o', @@ -34,7 +34,7 @@ const mockModel: Model = { enabled: true, createdAt: '2024-01-01T00:00:00Z', unifiedId: 'openai/gpt-4o', -}; +} const defaultProps = { open: true, @@ -43,69 +43,63 @@ const defaultProps = { onSave: vi.fn(), onCancel: vi.fn(), loading: false, -}; +} function getDialog() { // TDesign Dialog doesn't have role="dialog", use class selector - const dialog = document.querySelector('.t-dialog'); + const dialog = document.querySelector('.t-dialog') if (!dialog) { - throw new Error('Dialog not found'); + throw new Error('Dialog not found') } - return dialog; + return dialog } describe('ModelForm', () => { it('renders form with provider select', () => { - render(); + render() - const dialog = getDialog(); - expect(within(dialog).getByText('添加模型')).toBeInTheDocument(); - expect(within(dialog).getByText('供应商')).toBeInTheDocument(); - expect(within(dialog).getByText('模型名称')).toBeInTheDocument(); - expect(within(dialog).getByText('启用')).toBeInTheDocument(); - }); + const dialog = getDialog() + expect(within(dialog).getByText('添加模型')).toBeInTheDocument() + expect(within(dialog).getByText('供应商')).toBeInTheDocument() + expect(within(dialog).getByText('模型名称')).toBeInTheDocument() + expect(within(dialog).getByText('启用')).toBeInTheDocument() + }) it('defaults providerId to the passed providerId in create mode', () => { - render(); + render() - const dialog = getDialog(); + const dialog = getDialog() // Form renders with provider select - expect(within(dialog).getByText('供应商')).toBeInTheDocument(); - }); + expect(within(dialog).getByText('供应商')).toBeInTheDocument() + }) it('shows validation error messages for required fields', async () => { - const user = userEvent.setup(); - render( - , - ); + const user = userEvent.setup() + render() - const dialog = getDialog(); - const okButton = within(dialog).getByRole('button', { name: /保/ }); - await user.click(okButton); + const dialog = getDialog() + const okButton = within(dialog).getByRole('button', { name: /保/ }) + await user.click(okButton) - expect(await screen.findByText('请选择供应商')).toBeInTheDocument(); - expect(screen.getByText('请输入模型名称')).toBeInTheDocument(); - }); + expect(await screen.findByText('请选择供应商')).toBeInTheDocument() + expect(screen.getByText('请输入模型名称')).toBeInTheDocument() + }) it('calls onSave with form values on successful submission', async () => { - const user = userEvent.setup(); - const onSave = vi.fn(); - render(); + const user = userEvent.setup() + const onSave = vi.fn() + render() - const dialog = getDialog(); + const dialog = getDialog() // Only one input with placeholder "例如: gpt-4o" for model name - const modelNameInput = within(dialog).getByPlaceholderText('例如: gpt-4o'); + const modelNameInput = within(dialog).getByPlaceholderText('例如: gpt-4o') // Type into the model name field - await user.clear(modelNameInput); - await user.type(modelNameInput, 'gpt-4o-mini'); + await user.clear(modelNameInput) + await user.type(modelNameInput, 'gpt-4o-mini') - const okButton = within(dialog).getByRole('button', { name: /保/ }); - await user.click(okButton); + const okButton = within(dialog).getByRole('button', { name: /保/ }) + await user.click(okButton) // Wait for the onSave to be called await vi.waitFor(() => { @@ -114,30 +108,30 @@ describe('ModelForm', () => { providerId: 'openai', modelName: 'gpt-4o-mini', enabled: true, - }), - ); - }); - }, 10000); + }) + ) + }) + }, 10000) it('renders pre-filled fields in edit mode', () => { - render(); + render() - const dialog = getDialog(); - expect(within(dialog).getByText('编辑模型')).toBeInTheDocument(); + const dialog = getDialog() + expect(within(dialog).getByText('编辑模型')).toBeInTheDocument() // Check model name input - const modelNameInput = within(dialog).getByPlaceholderText('例如: gpt-4o') as HTMLInputElement; - expect(modelNameInput.value).toBe('gpt-4o'); - }); + const modelNameInput = within(dialog).getByPlaceholderText('例如: gpt-4o') as HTMLInputElement + expect(modelNameInput.value).toBe('gpt-4o') + }) it('calls onCancel when clicking cancel button', async () => { - const user = userEvent.setup(); - const onCancel = vi.fn(); - render(); + const user = userEvent.setup() + const onCancel = vi.fn() + render() - const dialog = getDialog(); - const cancelButton = within(dialog).getByRole('button', { name: /取/ }); - await user.click(cancelButton); - expect(onCancel).toHaveBeenCalledTimes(1); - }); -}); + const dialog = getDialog() + const cancelButton = within(dialog).getByRole('button', { name: /取/ }) + await user.click(cancelButton) + expect(onCancel).toHaveBeenCalledTimes(1) + }) +}) diff --git a/frontend/src/__tests__/components/ModelTable.test.tsx b/frontend/src/__tests__/components/ModelTable.test.tsx index d25403d..2adf59c 100644 --- a/frontend/src/__tests__/components/ModelTable.test.tsx +++ b/frontend/src/__tests__/components/ModelTable.test.tsx @@ -1,8 +1,8 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { ModelTable } from '@/pages/Providers/ModelTable'; -import type { Model } from '@/types'; +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { ModelTable } from '@/pages/Providers/ModelTable' +import type { Model } from '@/types' const mockModels: Model[] = [ { @@ -21,103 +21,103 @@ const mockModels: Model[] = [ createdAt: '2024-01-02T00:00:00Z', unifiedId: 'openai/gpt-3.5-turbo', }, -]; +] -const mockMutate = vi.fn(); +const mockMutate = vi.fn() vi.mock('@/hooks/useModels', () => ({ useModels: vi.fn((providerId: string) => { if (providerId === 'openai') { - return { data: mockModels, isLoading: false }; + return { data: mockModels, isLoading: false } } - return { data: [], isLoading: false }; + return { data: [], isLoading: false } }), useDeleteModel: vi.fn(() => ({ mutate: mockMutate })), -})); +})) const defaultProps = { providerId: 'openai', onAdd: vi.fn(), onEdit: vi.fn(), -}; +} describe('ModelTable', () => { beforeEach(() => { - mockMutate.mockClear(); - }); + mockMutate.mockClear() + }) it('renders model list with unified ID and model name', () => { - render(); + render() - expect(screen.getByText(/关联模型/)).toBeInTheDocument(); - expect(screen.getByText('openai/gpt-4o')).toBeInTheDocument(); - expect(screen.getByText('openai/gpt-3.5-turbo')).toBeInTheDocument(); - expect(screen.getByText('gpt-4o')).toBeInTheDocument(); - expect(screen.getByText('gpt-3.5-turbo')).toBeInTheDocument(); - }); + expect(screen.getByText(/关联模型/)).toBeInTheDocument() + expect(screen.getByText('openai/gpt-4o')).toBeInTheDocument() + expect(screen.getByText('openai/gpt-3.5-turbo')).toBeInTheDocument() + expect(screen.getByText('gpt-4o')).toBeInTheDocument() + expect(screen.getByText('gpt-3.5-turbo')).toBeInTheDocument() + }) it('renders status tags correctly', () => { - render(); + render() - const enabledTags = screen.getAllByText('启用'); - const disabledTags = screen.getAllByText('禁用'); - expect(enabledTags.length).toBeGreaterThanOrEqual(1); - expect(disabledTags.length).toBeGreaterThanOrEqual(1); - }); + const enabledTags = screen.getAllByText('启用') + const disabledTags = screen.getAllByText('禁用') + expect(enabledTags.length).toBeGreaterThanOrEqual(1) + expect(disabledTags.length).toBeGreaterThanOrEqual(1) + }) it('calls onAdd when clicking "添加模型" button', async () => { - const user = userEvent.setup(); - const onAdd = vi.fn(); - render(); + const user = userEvent.setup() + const onAdd = vi.fn() + render() - await user.click(screen.getByRole('button', { name: '添加模型' })); - expect(onAdd).toHaveBeenCalledTimes(1); - }); + await user.click(screen.getByRole('button', { name: '添加模型' })) + expect(onAdd).toHaveBeenCalledTimes(1) + }) it('calls onEdit with correct model when clicking "编辑"', async () => { - const user = userEvent.setup(); - const onEdit = vi.fn(); - render(); + const user = userEvent.setup() + const onEdit = vi.fn() + render() - const editButtons = screen.getAllByRole('button', { name: /编 ?辑/ }); - await user.click(editButtons[0]); + const editButtons = screen.getAllByRole('button', { name: /编 ?辑/ }) + await user.click(editButtons[0]) - expect(onEdit).toHaveBeenCalledTimes(1); - expect(onEdit).toHaveBeenCalledWith(mockModels[0]); - }); + expect(onEdit).toHaveBeenCalledTimes(1) + expect(onEdit).toHaveBeenCalledWith(mockModels[0]) + }) it('calls deleteModel.mutate with correct model ID when delete is confirmed', async () => { - const user = userEvent.setup(); + const user = userEvent.setup() - render(); + render() // Find and click the delete button for the first row - const deleteButtons = screen.getAllByRole('button', { name: '删除' }); - await user.click(deleteButtons[0]); + const deleteButtons = screen.getAllByRole('button', { name: '删除' }) + await user.click(deleteButtons[0]) // TDesign Popconfirm renders confirmation popup with "确定" button - const confirmButton = await screen.findByRole('button', { name: '确定' }); - await user.click(confirmButton); + const confirmButton = await screen.findByRole('button', { name: '确定' }) + await user.click(confirmButton) // Assert that deleteModel.mutate was called with the correct model ID - expect(mockMutate).toHaveBeenCalledTimes(1); - expect(mockMutate).toHaveBeenCalledWith('model-1'); - }, 10000); + expect(mockMutate).toHaveBeenCalledTimes(1) + expect(mockMutate).toHaveBeenCalledWith('model-1') + }, 10000) it('shows custom empty text when models list is empty', () => { - render(); - expect(screen.getByText('暂无模型,点击上方按钮添加')).toBeInTheDocument(); - }); + render() + expect(screen.getByText('暂无模型,点击上方按钮添加')).toBeInTheDocument() + }) it('does not render add button when onAdd is not provided', () => { - render(); + render() - expect(screen.queryByRole('button', { name: '添加模型' })).not.toBeInTheDocument(); - }); + expect(screen.queryByRole('button', { name: '添加模型' })).not.toBeInTheDocument() + }) it('does not render edit button when onEdit is not provided', () => { - render(); + render() - expect(screen.queryByRole('button', { name: /编 ?辑/ })).not.toBeInTheDocument(); - }); -}); + expect(screen.queryByRole('button', { name: /编 ?辑/ })).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/__tests__/components/ProviderForm.test.tsx b/frontend/src/__tests__/components/ProviderForm.test.tsx index 6af758b..5c67857 100644 --- a/frontend/src/__tests__/components/ProviderForm.test.tsx +++ b/frontend/src/__tests__/components/ProviderForm.test.tsx @@ -1,8 +1,8 @@ -import { render, screen, within, fireEvent } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { describe, it, expect, vi } from 'vitest'; -import { ProviderForm } from '@/pages/Providers/ProviderForm'; -import type { Provider } from '@/types'; +import { render, screen, within, fireEvent } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, it, expect, vi } from 'vitest' +import { ProviderForm } from '@/pages/Providers/ProviderForm' +import type { Provider } from '@/types' const mockProvider: Provider = { id: 'openai', @@ -13,187 +13,193 @@ const mockProvider: Provider = { enabled: true, createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z', -}; +} const defaultProps = { open: true, onSave: vi.fn(), onCancel: vi.fn(), loading: false, -}; +} function getDialog() { // TDesign Dialog doesn't have role="dialog", use class selector - const dialog = document.querySelector('.t-dialog'); + const dialog = document.querySelector('.t-dialog') if (!dialog) { - throw new Error('Dialog not found'); + throw new Error('Dialog not found') } - return dialog; + return dialog } describe('ProviderForm', () => { it('renders form fields in create mode', () => { - render(); + render() - const dialog = getDialog(); - expect(within(dialog).getByText('添加供应商')).toBeInTheDocument(); - expect(within(dialog).getByText('ID')).toBeInTheDocument(); - expect(within(dialog).getByText('名称')).toBeInTheDocument(); - expect(within(dialog).getByText('API Key')).toBeInTheDocument(); - expect(within(dialog).getByText('Base URL')).toBeInTheDocument(); - expect(within(dialog).getByText('协议')).toBeInTheDocument(); - expect(within(dialog).getByText('启用')).toBeInTheDocument(); - expect(within(dialog).getByPlaceholderText('例如: openai')).toBeInTheDocument(); - expect(within(dialog).getByPlaceholderText('例如: OpenAI')).toBeInTheDocument(); - expect(within(dialog).getByPlaceholderText('例如: https://api.openai.com/v1')).toBeInTheDocument(); - }); + const dialog = getDialog() + expect(within(dialog).getByText('添加供应商')).toBeInTheDocument() + expect(within(dialog).getByText('ID')).toBeInTheDocument() + expect(within(dialog).getByText('名称')).toBeInTheDocument() + expect(within(dialog).getByText('API Key')).toBeInTheDocument() + expect(within(dialog).getByText('Base URL')).toBeInTheDocument() + expect(within(dialog).getByText('协议')).toBeInTheDocument() + expect(within(dialog).getByText('启用')).toBeInTheDocument() + expect(within(dialog).getByPlaceholderText('例如: openai')).toBeInTheDocument() + expect(within(dialog).getByPlaceholderText('例如: OpenAI')).toBeInTheDocument() + expect(within(dialog).getByPlaceholderText('例如: https://api.openai.com/v1')).toBeInTheDocument() + }) it('renders pre-filled fields in edit mode', () => { - render(); + render() - const dialog = getDialog(); - expect(within(dialog).getByText('编辑供应商')).toBeInTheDocument(); + const dialog = getDialog() + expect(within(dialog).getByText('编辑供应商')).toBeInTheDocument() - const idInput = within(dialog).getByPlaceholderText('例如: openai') as HTMLInputElement; - expect(idInput.value).toBe('openai'); - expect(idInput).toBeDisabled(); + const idInput = within(dialog).getByPlaceholderText('例如: openai') as HTMLInputElement + expect(idInput.value).toBe('openai') + expect(idInput).toBeDisabled() - const nameInput = within(dialog).getByPlaceholderText('例如: OpenAI') as HTMLInputElement; - expect(nameInput.value).toBe('OpenAI'); + const nameInput = within(dialog).getByPlaceholderText('例如: OpenAI') as HTMLInputElement + expect(nameInput.value).toBe('OpenAI') - const baseUrlInput = within(dialog).getByPlaceholderText('例如: https://api.openai.com/v1') as HTMLInputElement; - expect(baseUrlInput.value).toBe('https://api.openai.com/v1'); + const baseUrlInput = within(dialog).getByPlaceholderText('例如: https://api.openai.com/v1') as HTMLInputElement + expect(baseUrlInput.value).toBe('https://api.openai.com/v1') - const apiKeyInput = within(dialog).getByPlaceholderText('sk-...') as HTMLInputElement; - expect(apiKeyInput.value).toBe('sk-old-key'); - }); + const apiKeyInput = within(dialog).getByPlaceholderText('sk-...') as HTMLInputElement + expect(apiKeyInput.value).toBe('sk-old-key') + }) it('shows API Key label in edit mode', () => { - render(); + render() - const dialog = getDialog(); - expect(within(dialog).getByText('API Key')).toBeInTheDocument(); - }); + const dialog = getDialog() + expect(within(dialog).getByText('API Key')).toBeInTheDocument() + }) it('shows validation error messages for required fields', async () => { - const user = userEvent.setup(); - render(); + const user = userEvent.setup() + render() - const dialog = getDialog(); - const okButton = within(dialog).getByRole('button', { name: /保/ }); - await user.click(okButton); + const dialog = getDialog() + const okButton = within(dialog).getByRole('button', { name: /保/ }) + await user.click(okButton) // Wait for validation messages to appear - expect(await screen.findByText('请输入供应商 ID')).toBeInTheDocument(); - expect(screen.getByText('请输入名称')).toBeInTheDocument(); - expect(screen.getByText('请输入 API Key')).toBeInTheDocument(); - expect(screen.getByText('请输入 Base URL')).toBeInTheDocument(); - }); + expect(await screen.findByText('请输入供应商 ID')).toBeInTheDocument() + expect(screen.getByText('请输入名称')).toBeInTheDocument() + expect(screen.getByText('请输入 API Key')).toBeInTheDocument() + expect(screen.getByText('请输入 Base URL')).toBeInTheDocument() + }) it('calls onSave with form values on successful submission', async () => { - const onSave = vi.fn(); - render(); + const onSave = vi.fn() + render() - const dialog = getDialog(); + const dialog = getDialog() // Get form instance and set values directly - const idInput = within(dialog).getByPlaceholderText('例如: openai') as HTMLInputElement; - const nameInput = within(dialog).getByPlaceholderText('例如: OpenAI') as HTMLInputElement; - const apiKeyInput = within(dialog).getByPlaceholderText('sk-...') as HTMLInputElement; - const baseUrlInput = within(dialog).getByPlaceholderText('例如: https://api.openai.com/v1') as HTMLInputElement; + const idInput = within(dialog).getByPlaceholderText('例如: openai') as HTMLInputElement + const nameInput = within(dialog).getByPlaceholderText('例如: OpenAI') as HTMLInputElement + const apiKeyInput = within(dialog).getByPlaceholderText('sk-...') as HTMLInputElement + const baseUrlInput = within(dialog).getByPlaceholderText('例如: https://api.openai.com/v1') as HTMLInputElement // Simulate user input by directly setting values - fireEvent.change(idInput, { target: { value: 'test-provider' } }); - fireEvent.change(nameInput, { target: { value: 'Test Provider' } }); - fireEvent.change(apiKeyInput, { target: { value: 'sk-test-key' } }); - fireEvent.change(baseUrlInput, { target: { value: 'https://api.test.com/v1' } }); + fireEvent.change(idInput, { target: { value: 'test-provider' } }) + fireEvent.change(nameInput, { target: { value: 'Test Provider' } }) + fireEvent.change(apiKeyInput, { target: { value: 'sk-test-key' } }) + fireEvent.change(baseUrlInput, { target: { value: 'https://api.test.com/v1' } }) - const okButton = within(dialog).getByRole('button', { name: /保/ }); - fireEvent.click(okButton); + const okButton = within(dialog).getByRole('button', { name: /保/ }) + fireEvent.click(okButton) // Wait for the onSave to be called - await vi.waitFor(() => { - expect(onSave).toHaveBeenCalled(); - }, { timeout: 5000 }); - }, 10000); + await vi.waitFor( + () => { + expect(onSave).toHaveBeenCalled() + }, + { timeout: 5000 } + ) + }, 10000) it('calls onCancel when clicking cancel button', async () => { - const user = userEvent.setup(); - const onCancel = vi.fn(); - render(); + const user = userEvent.setup() + const onCancel = vi.fn() + render() - const dialog = getDialog(); - const cancelButton = within(dialog).getByRole('button', { name: /取/ }); - await user.click(cancelButton); - expect(onCancel).toHaveBeenCalledTimes(1); - }); + const dialog = getDialog() + const cancelButton = within(dialog).getByRole('button', { name: /取/ }) + await user.click(cancelButton) + expect(onCancel).toHaveBeenCalledTimes(1) + }) it('shows confirm loading state', () => { - render(); - const dialog = getDialog(); - const okButton = within(dialog).getByRole('button', { name: /保/ }); + render() + const dialog = getDialog() + const okButton = within(dialog).getByRole('button', { name: /保/ }) // TDesign uses t-is-loading class for loading state - expect(okButton).toHaveClass('t-is-loading'); - }); + expect(okButton).toHaveClass('t-is-loading') + }) it('shows validation error for invalid URL format', async () => { - const user = userEvent.setup(); - render(); + const user = userEvent.setup() + render() - const dialog = getDialog(); + const dialog = getDialog() // Fill in required fields - await user.type(within(dialog).getByPlaceholderText('例如: openai'), 'test-provider'); - await user.type(within(dialog).getByPlaceholderText('例如: OpenAI'), 'Test Provider'); - await user.type(within(dialog).getByPlaceholderText('sk-...'), 'sk-test-key'); + await user.type(within(dialog).getByPlaceholderText('例如: openai'), 'test-provider') + await user.type(within(dialog).getByPlaceholderText('例如: OpenAI'), 'Test Provider') + await user.type(within(dialog).getByPlaceholderText('sk-...'), 'sk-test-key') // Enter an invalid URL in the Base URL field - await user.type(within(dialog).getByPlaceholderText('例如: https://api.openai.com/v1'), 'not-a-url'); + await user.type(within(dialog).getByPlaceholderText('例如: https://api.openai.com/v1'), 'not-a-url') // Submit the form - const okButton = within(dialog).getByRole('button', { name: /保/ }); - await user.click(okButton); + const okButton = within(dialog).getByRole('button', { name: /保/ }) + await user.click(okButton) // Verify that a URL validation error message appears await vi.waitFor(() => { - expect(screen.getByText('请输入有效的 URL')).toBeInTheDocument(); - }); - }, 15000); + expect(screen.getByText('请输入有效的 URL')).toBeInTheDocument() + }) + }, 15000) it('renders protocol select field with default value', () => { - render(); + render() - const dialog = getDialog(); - expect(within(dialog).getByText('协议')).toBeInTheDocument(); - }); + const dialog = getDialog() + expect(within(dialog).getByText('协议')).toBeInTheDocument() + }) it('includes protocol field in form submission', async () => { - const onSave = vi.fn(); - render(); + const onSave = vi.fn() + render() - const dialog = getDialog(); + const dialog = getDialog() // Get form instance and set values directly - const idInput = within(dialog).getByPlaceholderText('例如: openai') as HTMLInputElement; - const nameInput = within(dialog).getByPlaceholderText('例如: OpenAI') as HTMLInputElement; - const apiKeyInput = within(dialog).getByPlaceholderText('sk-...') as HTMLInputElement; - const baseUrlInput = within(dialog).getByPlaceholderText('例如: https://api.openai.com/v1') as HTMLInputElement; + const idInput = within(dialog).getByPlaceholderText('例如: openai') as HTMLInputElement + const nameInput = within(dialog).getByPlaceholderText('例如: OpenAI') as HTMLInputElement + const apiKeyInput = within(dialog).getByPlaceholderText('sk-...') as HTMLInputElement + const baseUrlInput = within(dialog).getByPlaceholderText('例如: https://api.openai.com/v1') as HTMLInputElement // Simulate user input by directly setting values - fireEvent.change(idInput, { target: { value: 'test-provider' } }); - fireEvent.change(nameInput, { target: { value: 'Test Provider' } }); - fireEvent.change(apiKeyInput, { target: { value: 'sk-test-key' } }); - fireEvent.change(baseUrlInput, { target: { value: 'https://api.test.com/v1' } }); + fireEvent.change(idInput, { target: { value: 'test-provider' } }) + fireEvent.change(nameInput, { target: { value: 'Test Provider' } }) + fireEvent.change(apiKeyInput, { target: { value: 'sk-test-key' } }) + fireEvent.change(baseUrlInput, { target: { value: 'https://api.test.com/v1' } }) - const okButton = within(dialog).getByRole('button', { name: /保/ }); - fireEvent.click(okButton); + const okButton = within(dialog).getByRole('button', { name: /保/ }) + fireEvent.click(okButton) // Wait for the onSave to be called - await vi.waitFor(() => { - expect(onSave).toHaveBeenCalled(); - // Verify that the saved data includes a protocol field - const savedData = onSave.mock.calls[0][0]; - expect(savedData).toHaveProperty('protocol'); - }, { timeout: 5000 }); - }, 10000); -}); + await vi.waitFor( + () => { + expect(onSave).toHaveBeenCalled() + // Verify that the saved data includes a protocol field + const savedData = onSave.mock.calls[0][0] + expect(savedData).toHaveProperty('protocol') + }, + { timeout: 5000 } + ) + }, 10000) +}) diff --git a/frontend/src/__tests__/components/ProviderTable.test.tsx b/frontend/src/__tests__/components/ProviderTable.test.tsx index f7bf532..eba84d1 100644 --- a/frontend/src/__tests__/components/ProviderTable.test.tsx +++ b/frontend/src/__tests__/components/ProviderTable.test.tsx @@ -1,18 +1,24 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { describe, it, expect, vi } from 'vitest'; -import { ProviderTable } from '@/pages/Providers/ProviderTable'; -import type { Provider } from '@/types'; +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, it, expect, vi } from 'vitest' +import { ProviderTable } from '@/pages/Providers/ProviderTable' +import type { Provider } from '@/types' const mockModelsData = [ { id: 'model-1', providerId: 'openai', modelName: 'gpt-4o', enabled: true, unifiedId: 'openai/gpt-4o' }, - { id: 'model-2', providerId: 'openai', modelName: 'gpt-3.5-turbo', enabled: false, unifiedId: 'openai/gpt-3.5-turbo' }, -]; + { + id: 'model-2', + providerId: 'openai', + modelName: 'gpt-3.5-turbo', + enabled: false, + unifiedId: 'openai/gpt-3.5-turbo', + }, +] vi.mock('@/hooks/useModels', () => ({ useModels: vi.fn(() => ({ data: mockModelsData, isLoading: false })), useDeleteModel: vi.fn(() => ({ mutate: vi.fn() })), -})); +})) const mockProviders: Provider[] = [ { @@ -35,7 +41,7 @@ const mockProviders: Provider[] = [ createdAt: '2024-01-02T00:00:00Z', updatedAt: '2024-01-02T00:00:00Z', }, -]; +] const defaultProps = { providers: mockProviders, @@ -45,36 +51,36 @@ const defaultProps = { onDelete: vi.fn(), onAddModel: vi.fn(), onEditModel: vi.fn(), -}; +} describe('ProviderTable', () => { it('renders provider list with name, baseUrl, apiKey, and status tags', () => { - render(); + render() - expect(screen.getByText('供应商列表')).toBeInTheDocument(); + expect(screen.getByText('供应商列表')).toBeInTheDocument() - expect(screen.getAllByText('OpenAI').length).toBeGreaterThan(0); - expect(screen.getByText('https://api.openai.com/v1')).toBeInTheDocument(); - expect(screen.getByText('sk-abcdefgh12345678')).toBeInTheDocument(); + expect(screen.getAllByText('OpenAI').length).toBeGreaterThan(0) + expect(screen.getByText('https://api.openai.com/v1')).toBeInTheDocument() + expect(screen.getByText('sk-abcdefgh12345678')).toBeInTheDocument() - expect(screen.getAllByText('Anthropic').length).toBeGreaterThan(0); - expect(screen.getByText('https://api.anthropic.com')).toBeInTheDocument(); - expect(screen.getByText('sk-ant-test')).toBeInTheDocument(); + expect(screen.getAllByText('Anthropic').length).toBeGreaterThan(0) + expect(screen.getByText('https://api.anthropic.com')).toBeInTheDocument() + expect(screen.getByText('sk-ant-test')).toBeInTheDocument() - const enabledTags = screen.getAllByText('启用'); - const disabledTags = screen.getAllByText('禁用'); - expect(enabledTags.length).toBeGreaterThanOrEqual(1); - expect(disabledTags.length).toBeGreaterThanOrEqual(1); - }); + const enabledTags = screen.getAllByText('启用') + const disabledTags = screen.getAllByText('禁用') + expect(enabledTags.length).toBeGreaterThanOrEqual(1) + expect(disabledTags.length).toBeGreaterThanOrEqual(1) + }) it('renders within a Card component', () => { - const { container } = render(); + const { container } = render() // TDesign Card component - expect(container.querySelector('.t-card')).toBeInTheDocument(); - expect(container.querySelector('.t-card__header')).toBeInTheDocument(); - expect(container.querySelector('.t-card__body')).toBeInTheDocument(); - }); + expect(container.querySelector('.t-card')).toBeInTheDocument() + expect(container.querySelector('.t-card__header')).toBeInTheDocument() + expect(container.querySelector('.t-card__body')).toBeInTheDocument() + }) it('renders short api keys directly', () => { const shortKeyProvider: Provider[] = [ @@ -84,99 +90,99 @@ describe('ProviderTable', () => { name: 'ShortKey', apiKey: 'ab', }, - ]; - render(); + ] + render() - expect(screen.getByText('ab')).toBeInTheDocument(); - }); + expect(screen.getByText('ab')).toBeInTheDocument() + }) it('calls onAdd when clicking "添加供应商" button', async () => { - const user = userEvent.setup(); - const onAdd = vi.fn(); - render(); + const user = userEvent.setup() + const onAdd = vi.fn() + render() - await user.click(screen.getByRole('button', { name: '添加供应商' })); - expect(onAdd).toHaveBeenCalledTimes(1); - }); + await user.click(screen.getByRole('button', { name: '添加供应商' })) + expect(onAdd).toHaveBeenCalledTimes(1) + }) it('calls onEdit with correct provider when clicking "编辑"', async () => { - const user = userEvent.setup(); - const onEdit = vi.fn(); - render(); + const user = userEvent.setup() + const onEdit = vi.fn() + render() - const editButtons = screen.getAllByRole('button', { name: /编 ?辑/ }); - await user.click(editButtons[0]); + const editButtons = screen.getAllByRole('button', { name: /编 ?辑/ }) + await user.click(editButtons[0]) - expect(onEdit).toHaveBeenCalledTimes(1); - expect(onEdit).toHaveBeenCalledWith(mockProviders[0]); - }); + expect(onEdit).toHaveBeenCalledTimes(1) + expect(onEdit).toHaveBeenCalledWith(mockProviders[0]) + }) it('calls onDelete with correct provider ID when delete is confirmed', async () => { - const user = userEvent.setup(); - const onDelete = vi.fn(); - render(); + const user = userEvent.setup() + const onDelete = vi.fn() + render() // Find and click the delete button for the first row - const deleteButtons = screen.getAllByRole('button', { name: '删除' }); - await user.click(deleteButtons[0]); + const deleteButtons = screen.getAllByRole('button', { name: '删除' }) + await user.click(deleteButtons[0]) // TDesign Popconfirm renders confirmation popup with "确定" button - const confirmButton = await screen.findByRole('button', { name: '确定' }); - await user.click(confirmButton); + const confirmButton = await screen.findByRole('button', { name: '确定' }) + await user.click(confirmButton) // Assert that onDelete was called with the correct provider ID - expect(onDelete).toHaveBeenCalledTimes(1); - expect(onDelete).toHaveBeenCalledWith('openai'); - }, 10000); + expect(onDelete).toHaveBeenCalledTimes(1) + expect(onDelete).toHaveBeenCalledWith('openai') + }, 10000) it('shows loading state', () => { - const { container } = render(); + const { container } = render() // TDesign Table loading indicator - const loadingElement = container.querySelector('.t-table__loading') || container.querySelector('.t-loading'); - expect(loadingElement).toBeInTheDocument(); - }); + const loadingElement = container.querySelector('.t-table__loading') || container.querySelector('.t-loading') + expect(loadingElement).toBeInTheDocument() + }) it('renders expandable ModelTable when row is expanded', async () => { - const user = userEvent.setup(); - const { container } = render(); + const user = userEvent.setup() + const { container } = render() // TDesign Table expand icon is rendered as a button with specific class - const expandIcon = container.querySelector('.t-table__expandable-icon'); + const expandIcon = container.querySelector('.t-table__expandable-icon') if (expandIcon) { - await user.click(expandIcon); + await user.click(expandIcon) // Verify that ModelTable content is rendered with data from mocked useModels - expect(await screen.findByText('gpt-4o')).toBeInTheDocument(); - expect(screen.getByText('gpt-3.5-turbo')).toBeInTheDocument(); + expect(await screen.findByText('gpt-4o')).toBeInTheDocument() + expect(screen.getByText('gpt-3.5-turbo')).toBeInTheDocument() } else { // If no expand icon found, the test should still pass as expandable rows are optional - expect(true).toBe(true); + expect(true).toBe(true) } - }); + }) it('sets fixed width and ellipsis on name column', () => { - const { container } = render(); + const { container } = render() // TDesign Table - const table = container.querySelector('.t-table'); - expect(table).toBeInTheDocument(); - }); + const table = container.querySelector('.t-table') + expect(table).toBeInTheDocument() + }) it('shows custom empty text when providers list is empty', () => { - render(); - expect(screen.getByText('暂无供应商,点击上方按钮添加')).toBeInTheDocument(); - }); + render() + expect(screen.getByText('暂无供应商,点击上方按钮添加')).toBeInTheDocument() + }) it('renders protocol column with correct tags', () => { - const { container } = render(); + const { container } = render() // Check that protocol tags are displayed in the table - const protocolCells = container.querySelectorAll('[data-colkey="protocol"]'); - expect(protocolCells.length).toBeGreaterThan(0); + const protocolCells = container.querySelectorAll('[data-colkey="protocol"]') + expect(protocolCells.length).toBeGreaterThan(0) // Verify protocol tags exist - const tags = container.querySelectorAll('.t-tag'); - expect(tags.length).toBeGreaterThan(0); - }); + const tags = container.querySelectorAll('.t-tag') + expect(tags.length).toBeGreaterThan(0) + }) it('displays protocol tag for each provider', () => { const singleProvider: Provider[] = [ @@ -190,11 +196,11 @@ describe('ProviderTable', () => { createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z', }, - ]; - const { container } = render(); + ] + const { container } = render() // Should display protocol column - const protocolCell = container.querySelector('[data-colkey="protocol"]'); - expect(protocolCell).toBeInTheDocument(); - }); -}); + const protocolCell = container.querySelector('[data-colkey="protocol"]') + expect(protocolCell).toBeInTheDocument() + }) +}) diff --git a/frontend/src/__tests__/components/StatCards.test.tsx b/frontend/src/__tests__/components/StatCards.test.tsx index 6a9abfd..0ebae69 100644 --- a/frontend/src/__tests__/components/StatCards.test.tsx +++ b/frontend/src/__tests__/components/StatCards.test.tsx @@ -1,7 +1,7 @@ -import { render, screen } from '@testing-library/react'; -import { describe, it, expect } from 'vitest'; -import { StatCards } from '@/pages/Stats/StatCards'; -import type { UsageStats } from '@/types'; +import { render, screen } from '@testing-library/react' +import { describe, it, expect } from 'vitest' +import { StatCards } from '@/pages/Stats/StatCards' +import type { UsageStats } from '@/types' const mockStats: UsageStats[] = [ { @@ -25,31 +25,31 @@ const mockStats: UsageStats[] = [ requestCount: 150, date: '2024-01-02', }, -]; +] describe('StatCards', () => { it('renders all statistic cards', () => { - render(); + render() - expect(screen.getByText('总请求量')).toBeInTheDocument(); - expect(screen.getByText('活跃模型数')).toBeInTheDocument(); - expect(screen.getByText('活跃供应商数')).toBeInTheDocument(); - expect(screen.getByText('今日请求量')).toBeInTheDocument(); - }); + expect(screen.getByText('总请求量')).toBeInTheDocument() + expect(screen.getByText('活跃模型数')).toBeInTheDocument() + expect(screen.getByText('活跃供应商数')).toBeInTheDocument() + expect(screen.getByText('今日请求量')).toBeInTheDocument() + }) it('renders with empty stats', () => { - render(); + render() - expect(screen.getByText('总请求量')).toBeInTheDocument(); - expect(screen.getByText('活跃模型数')).toBeInTheDocument(); - expect(screen.getByText('活跃供应商数')).toBeInTheDocument(); - expect(screen.getByText('今日请求量')).toBeInTheDocument(); - }); + expect(screen.getByText('总请求量')).toBeInTheDocument() + expect(screen.getByText('活跃模型数')).toBeInTheDocument() + expect(screen.getByText('活跃供应商数')).toBeInTheDocument() + expect(screen.getByText('今日请求量')).toBeInTheDocument() + }) it('renders suffix units', () => { - render(); + render() - expect(screen.getAllByText('次').length).toBeGreaterThan(0); - expect(screen.getAllByText('个').length).toBeGreaterThan(0); - }); -}); + expect(screen.getAllByText('次').length).toBeGreaterThan(0) + expect(screen.getAllByText('个').length).toBeGreaterThan(0) + }) +}) diff --git a/frontend/src/__tests__/components/StatsTable.test.tsx b/frontend/src/__tests__/components/StatsTable.test.tsx index c7b5879..8a5c310 100644 --- a/frontend/src/__tests__/components/StatsTable.test.tsx +++ b/frontend/src/__tests__/components/StatsTable.test.tsx @@ -1,7 +1,7 @@ -import { render, screen } from '@testing-library/react'; -import { describe, it, expect, vi } from 'vitest'; -import { StatsTable } from '@/pages/Stats/StatsTable'; -import type { Provider, UsageStats } from '@/types'; +import { render, screen } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' +import { StatsTable } from '@/pages/Stats/StatsTable' +import type { Provider, UsageStats } from '@/types' const mockProviders: Provider[] = [ { @@ -24,7 +24,7 @@ const mockProviders: Provider[] = [ createdAt: '2024-01-02T00:00:00Z', updatedAt: '2024-01-02T00:00:00Z', }, -]; +] const mockStats: UsageStats[] = [ { @@ -41,7 +41,7 @@ const mockStats: UsageStats[] = [ requestCount: 50, date: '2024-01-15', }, -]; +] const defaultProps = { providers: mockProviders, @@ -53,80 +53,80 @@ const defaultProps = { onProviderIdChange: vi.fn(), onModelNameChange: vi.fn(), onDateRangeChange: vi.fn(), -}; +} describe('StatsTable', () => { it('renders stats table with data', () => { - render(); + render() - expect(screen.getByText('gpt-4o')).toBeInTheDocument(); - expect(screen.getByText('claude-3-opus')).toBeInTheDocument(); - const dateCells = screen.getAllByText('2024-01-15'); - expect(dateCells.length).toBe(2); - expect(screen.getByText('100')).toBeInTheDocument(); - expect(screen.getByText('50')).toBeInTheDocument(); - }); + expect(screen.getByText('gpt-4o')).toBeInTheDocument() + expect(screen.getByText('claude-3-opus')).toBeInTheDocument() + const dateCells = screen.getAllByText('2024-01-15') + expect(dateCells.length).toBe(2) + expect(screen.getByText('100')).toBeInTheDocument() + expect(screen.getByText('50')).toBeInTheDocument() + }) it('shows provider name from providers prop instead of providerId', () => { - render(); + render() - expect(screen.getByText('OpenAI')).toBeInTheDocument(); - const allAnthropic = screen.getAllByText('Anthropic'); - expect(allAnthropic.length).toBeGreaterThanOrEqual(1); - }); + expect(screen.getByText('OpenAI')).toBeInTheDocument() + const allAnthropic = screen.getAllByText('Anthropic') + expect(allAnthropic.length).toBeGreaterThanOrEqual(1) + }) it('renders filter controls with Select, Input, and DatePicker', () => { - const { container } = render(); + const { container } = render() // TDesign Select component - const selects = document.querySelectorAll('.t-select'); - expect(selects.length).toBeGreaterThanOrEqual(1); + const selects = document.querySelectorAll('.t-select') + expect(selects.length).toBeGreaterThanOrEqual(1) - const modelInput = screen.getByPlaceholderText('模型名称'); - expect(modelInput).toBeInTheDocument(); + const modelInput = screen.getByPlaceholderText('模型名称') + expect(modelInput).toBeInTheDocument() // TDesign Select placeholder is shown in the input - const selectInput = document.querySelector('.t-select .t-input__inner'); - expect(selectInput).toBeInTheDocument(); + const selectInput = document.querySelector('.t-select .t-input__inner') + expect(selectInput).toBeInTheDocument() // TDesign DateRangePicker - could be .t-date-picker or .t-range-input - const rangePicker = container.querySelector('.t-date-picker') || container.querySelector('.t-range-input'); - expect(rangePicker).toBeInTheDocument(); - }); + const rangePicker = container.querySelector('.t-date-picker') || container.querySelector('.t-range-input') + expect(rangePicker).toBeInTheDocument() + }) it('renders table headers correctly', () => { - render(); + render() - expect(screen.getAllByText('供应商').length).toBeGreaterThanOrEqual(1); - expect(screen.getAllByText('模型').length).toBeGreaterThanOrEqual(1); - expect(screen.getAllByText('日期').length).toBeGreaterThanOrEqual(1); - expect(screen.getAllByText('请求数').length).toBeGreaterThanOrEqual(1); - }); + expect(screen.getAllByText('供应商').length).toBeGreaterThanOrEqual(1) + expect(screen.getAllByText('模型').length).toBeGreaterThanOrEqual(1) + expect(screen.getAllByText('日期').length).toBeGreaterThanOrEqual(1) + expect(screen.getAllByText('请求数').length).toBeGreaterThanOrEqual(1) + }) it('falls back to providerId when provider not found in providers prop', () => { - const limitedProviders = [mockProviders[0]]; - render(); + const limitedProviders = [mockProviders[0]] + render() - expect(screen.getByText('OpenAI')).toBeInTheDocument(); - expect(screen.getByText('anthropic')).toBeInTheDocument(); - }); + expect(screen.getByText('OpenAI')).toBeInTheDocument() + expect(screen.getByText('anthropic')).toBeInTheDocument() + }) it('renders with empty stats data', () => { - render(); + render() - expect(screen.getAllByText('供应商').length).toBeGreaterThanOrEqual(1); - expect(screen.getAllByText('模型').length).toBeGreaterThanOrEqual(1); - }); + expect(screen.getAllByText('供应商').length).toBeGreaterThanOrEqual(1) + expect(screen.getAllByText('模型').length).toBeGreaterThanOrEqual(1) + }) it('shows loading state', () => { - const { container } = render(); + const { container } = render() // TDesign Table loading indicator - could be .t-table__loading or .t-loading - const loadingElement = container.querySelector('.t-table__loading') || container.querySelector('.t-loading'); - expect(loadingElement).toBeInTheDocument(); - }); + const loadingElement = container.querySelector('.t-table__loading') || container.querySelector('.t-loading') + expect(loadingElement).toBeInTheDocument() + }) it('shows custom empty text when stats data is empty', () => { - render(); - expect(screen.getByText('暂无统计数据')).toBeInTheDocument(); - }); -}); + render() + expect(screen.getByText('暂无统计数据')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/__tests__/components/UsageChart.test.tsx b/frontend/src/__tests__/components/UsageChart.test.tsx index 44b25cf..d539e73 100644 --- a/frontend/src/__tests__/components/UsageChart.test.tsx +++ b/frontend/src/__tests__/components/UsageChart.test.tsx @@ -1,18 +1,18 @@ -import { render, screen } from '@testing-library/react'; -import { describe, it, expect, vi } from 'vitest'; -import { UsageChart } from '@/pages/Stats/UsageChart'; -import type { UsageStats } from '@/types'; +import { render, screen } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' +import { UsageChart } from '@/pages/Stats/UsageChart' +import type { UsageStats } from '@/types' // Mock Recharts components vi.mock('recharts', () => ({ - ResponsiveContainer: vi.fn(({ children }) =>
{children}
), - AreaChart: vi.fn(() =>
), + ResponsiveContainer: vi.fn(({ children }) =>
{children}
), + AreaChart: vi.fn(() =>
), Area: vi.fn(() => null), XAxis: vi.fn(() => null), YAxis: vi.fn(() => null), CartesianGrid: vi.fn(() => null), Tooltip: vi.fn(() => null), -})); +})) const mockStats: UsageStats[] = [ { @@ -36,36 +36,36 @@ const mockStats: UsageStats[] = [ requestCount: 150, date: '2024-01-02', }, -]; +] describe('UsageChart', () => { it('renders chart title', () => { - render(); + render() - expect(screen.getByText('请求趋势')).toBeInTheDocument(); - }); + expect(screen.getByText('请求趋势')).toBeInTheDocument() + }) it('renders with data', () => { - const { container } = render(); + const { container } = render() // TDesign Card component - expect(container.querySelector('.t-card')).toBeInTheDocument(); + expect(container.querySelector('.t-card')).toBeInTheDocument() // Mocked chart container - expect(screen.getByTestId('mock-chart-container')).toBeInTheDocument(); - }); + expect(screen.getByTestId('mock-chart-container')).toBeInTheDocument() + }) it('renders empty state when no data', () => { - render(); + render() - expect(screen.getByText('暂无数据')).toBeInTheDocument(); - }); + expect(screen.getByText('暂无数据')).toBeInTheDocument() + }) it('aggregates data by date correctly', () => { - const { container } = render(); + const { container } = render() // TDesign Card component - expect(container.querySelector('.t-card')).toBeInTheDocument(); + expect(container.querySelector('.t-card')).toBeInTheDocument() // Mocked chart should render - expect(screen.getByTestId('mock-chart-container')).toBeInTheDocument(); - }); -}); + expect(screen.getByTestId('mock-chart-container')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/__tests__/eslint-rules/no-hardcoded-color.test.ts b/frontend/src/__tests__/eslint-rules/no-hardcoded-color.test.ts index 7a467aa..855db9b 100644 --- a/frontend/src/__tests__/eslint-rules/no-hardcoded-color.test.ts +++ b/frontend/src/__tests__/eslint-rules/no-hardcoded-color.test.ts @@ -1,8 +1,6 @@ import { RuleTester } from '@typescript-eslint/rule-tester' import { describe, it, afterAll } from 'vitest' -import rule, { - RULE_NAME, -} from '../../../eslint-rules/rules/no-hardcoded-color-in-style.js' +import rule, { RULE_NAME } from '../../../eslint-rules/rules/no-hardcoded-color-in-style.js' RuleTester.it = it RuleTester.describe = describe @@ -121,4 +119,4 @@ describe('no-hardcoded-color-in-style (ESLint rule)', () => { }, ], }) -}) \ No newline at end of file +}) diff --git a/frontend/src/__tests__/hooks/useModels.test.tsx b/frontend/src/__tests__/hooks/useModels.test.tsx index 2b15088..4a476f4 100644 --- a/frontend/src/__tests__/hooks/useModels.test.tsx +++ b/frontend/src/__tests__/hooks/useModels.test.tsx @@ -1,11 +1,11 @@ -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { renderHook, waitFor } from '@testing-library/react'; -import { http, HttpResponse } from 'msw'; -import { setupServer } from 'msw/node'; -import React from 'react'; -import { MessagePlugin } from 'tdesign-react'; -import { useModels, useCreateModel, useUpdateModel, useDeleteModel } from '@/hooks/useModels'; -import type { Model, CreateModelInput, UpdateModelInput } from '@/types'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { renderHook, waitFor } from '@testing-library/react' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import React from 'react' +import { MessagePlugin } from 'tdesign-react' +import { useModels, useCreateModel, useUpdateModel, useDeleteModel } from '@/hooks/useModels' +import type { Model, CreateModelInput, UpdateModelInput } from '@/types' // Mock MessagePlugin vi.mock('tdesign-react', () => ({ @@ -13,7 +13,7 @@ vi.mock('tdesign-react', () => ({ success: vi.fn(), error: vi.fn(), }, -})); +})) // Test data const mockModels: Model[] = [ @@ -33,7 +33,7 @@ const mockModels: Model[] = [ createdAt: '2026-01-02T00:00:00Z', unifiedId: 'gpt-4o-mini', }, -]; +] const mockFilteredModels: Model[] = [ { @@ -44,7 +44,7 @@ const mockFilteredModels: Model[] = [ createdAt: '2026-02-01T00:00:00Z', unifiedId: 'claude-sonnet-4-5', }, -]; +] const mockCreatedModel: Model = { id: 'model-4', @@ -53,36 +53,36 @@ const mockCreatedModel: Model = { enabled: true, createdAt: '2026-03-01T00:00:00Z', unifiedId: 'gpt-4.1', -}; +} // MSW handlers const handlers = [ http.get('/api/models', ({ request }) => { - const url = new URL(request.url); - const providerId = url.searchParams.get('provider_id'); + const url = new URL(request.url) + const providerId = url.searchParams.get('provider_id') if (providerId === 'provider-2') { - return HttpResponse.json(mockFilteredModels); + return HttpResponse.json(mockFilteredModels) } - return HttpResponse.json(mockModels); + return HttpResponse.json(mockModels) }), http.post('/api/models', async ({ request }) => { - const body = await request.json() as Record; + const body = (await request.json()) as Record return HttpResponse.json({ ...mockCreatedModel, ...body, - }); + }) }), http.put('/api/models/:id', async ({ request, params }) => { - const body = await request.json() as Record; - const existing = mockModels.find((m) => m.id === params['id']); - return HttpResponse.json({ ...existing, ...body }); + const body = (await request.json()) as Record + const existing = mockModels.find((m) => m.id === params['id']) + return HttpResponse.json({ ...existing, ...body }) }), http.delete('/api/models/:id', () => { - return new HttpResponse(null, { status: 204 }); + return new HttpResponse(null, { status: 204 }) }), -]; +] -const server = setupServer(...handlers); +const server = setupServer(...handlers) function createTestQueryClient() { return new QueryClient({ @@ -90,201 +90,185 @@ function createTestQueryClient() { queries: { retry: false }, mutations: { retry: false }, }, - }); + }) } function createWrapper() { - const testQueryClient = createTestQueryClient(); + const testQueryClient = createTestQueryClient() return function Wrapper({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); - }; + return {children} + } } -beforeAll(() => server.listen()); +beforeAll(() => server.listen()) afterEach(() => { - server.resetHandlers(); - vi.clearAllMocks(); -}); -afterAll(() => server.close()); + server.resetHandlers() + vi.clearAllMocks() +}) +afterAll(() => server.close()) describe('useModels', () => { it('fetches model list', async () => { const { result } = renderHook(() => useModels(), { wrapper: createWrapper(), - }); + }) - await waitFor(() => expect(result.current.isSuccess).toBe(true)); + await waitFor(() => expect(result.current.isSuccess).toBe(true)) - expect(result.current.data).toEqual(mockModels); - expect(result.current.data).toHaveLength(2); - expect(result.current.data![0]!.modelName).toBe('gpt-4o'); - }); + expect(result.current.data).toEqual(mockModels) + expect(result.current.data).toHaveLength(2) + expect(result.current.data![0]!.modelName).toBe('gpt-4o') + }) it('with providerId passes it to API and returns filtered models', async () => { const { result } = renderHook(() => useModels('provider-2'), { wrapper: createWrapper(), - }); + }) - await waitFor(() => expect(result.current.isSuccess).toBe(true)); + await waitFor(() => expect(result.current.isSuccess).toBe(true)) - expect(result.current.data).toEqual(mockFilteredModels); - expect(result.current.data).toHaveLength(1); - expect(result.current.data![0]!.modelName).toBe('claude-sonnet-4-5'); - }); -}); + expect(result.current.data).toEqual(mockFilteredModels) + expect(result.current.data).toHaveLength(1) + expect(result.current.data![0]!.modelName).toBe('claude-sonnet-4-5') + }) +}) describe('useCreateModel', () => { it('calls API and invalidates model queries', async () => { - const queryClient = createTestQueryClient(); - const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + const queryClient = createTestQueryClient() + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') function Wrapper({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); + return {children} } const { result } = renderHook(() => useCreateModel(), { wrapper: Wrapper, - }); + }) const input: CreateModelInput = { id: 'model-4', providerId: 'provider-1', modelName: 'gpt-4.1', enabled: true, - }; + } - result.current.mutate(input); + result.current.mutate(input) - await waitFor(() => expect(result.current.isSuccess).toBe(true)); + await waitFor(() => expect(result.current.isSuccess).toBe(true)) expect(result.current.data).toMatchObject({ id: 'model-4', modelName: 'gpt-4.1', - }); - expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['models'] }); - expect(MessagePlugin.success).toHaveBeenCalledWith('模型创建成功'); - }); + }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['models'] }) + expect(MessagePlugin.success).toHaveBeenCalledWith('模型创建成功') + }) it('calls message.error on failure', async () => { server.use( http.post('/api/models', () => { - return HttpResponse.json({ message: '创建失败' }, { status: 500 }); - }), - ); + return HttpResponse.json({ message: '创建失败' }, { status: 500 }) + }) + ) const { result } = renderHook(() => useCreateModel(), { wrapper: createWrapper(), - }); + }) const input: CreateModelInput = { id: 'model-4', providerId: 'provider-1', modelName: 'gpt-4.1', enabled: true, - }; + } - result.current.mutate(input); + result.current.mutate(input) - await waitFor(() => expect(result.current.isError).toBe(true)); - expect(MessagePlugin.error).toHaveBeenCalled(); - }); -}); + await waitFor(() => expect(result.current.isError).toBe(true)) + expect(MessagePlugin.error).toHaveBeenCalled() + }) +}) describe('useUpdateModel', () => { it('calls API and invalidates model queries', async () => { - const queryClient = createTestQueryClient(); - const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + const queryClient = createTestQueryClient() + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') function Wrapper({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); + return {children} } const { result } = renderHook(() => useUpdateModel(), { wrapper: Wrapper, - }); + }) - const input: UpdateModelInput = { modelName: 'gpt-4o-updated' }; + const input: UpdateModelInput = { modelName: 'gpt-4o-updated' } - result.current.mutate({ id: 'model-1', input }); + result.current.mutate({ id: 'model-1', input }) - await waitFor(() => expect(result.current.isSuccess).toBe(true)); + await waitFor(() => expect(result.current.isSuccess).toBe(true)) expect(result.current.data).toMatchObject({ modelName: 'gpt-4o-updated', - }); - expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['models'] }); - expect(MessagePlugin.success).toHaveBeenCalledWith('模型更新成功'); - }); + }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['models'] }) + expect(MessagePlugin.success).toHaveBeenCalledWith('模型更新成功') + }) it('calls message.error on failure', async () => { server.use( http.put('/api/models/:id', () => { - return HttpResponse.json({ message: '更新失败' }, { status: 500 }); - }), - ); + return HttpResponse.json({ message: '更新失败' }, { status: 500 }) + }) + ) const { result } = renderHook(() => useUpdateModel(), { wrapper: createWrapper(), - }); + }) - result.current.mutate({ id: 'model-1', input: { modelName: 'Updated' } }); + result.current.mutate({ id: 'model-1', input: { modelName: 'Updated' } }) - await waitFor(() => expect(result.current.isError).toBe(true)); - expect(MessagePlugin.error).toHaveBeenCalled(); - }); -}); + await waitFor(() => expect(result.current.isError).toBe(true)) + expect(MessagePlugin.error).toHaveBeenCalled() + }) +}) describe('useDeleteModel', () => { it('calls API and invalidates model queries', async () => { - const queryClient = createTestQueryClient(); - const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + const queryClient = createTestQueryClient() + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') function Wrapper({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); + return {children} } const { result } = renderHook(() => useDeleteModel(), { wrapper: Wrapper, - }); + }) - result.current.mutate('model-1'); + result.current.mutate('model-1') - await waitFor(() => expect(result.current.isSuccess).toBe(true)); + await waitFor(() => expect(result.current.isSuccess).toBe(true)) - expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['models'] }); - expect(MessagePlugin.success).toHaveBeenCalledWith('模型删除成功'); - }); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['models'] }) + expect(MessagePlugin.success).toHaveBeenCalledWith('模型删除成功') + }) it('calls message.error on failure', async () => { server.use( http.delete('/api/models/:id', () => { - return HttpResponse.json({ message: '删除失败' }, { status: 500 }); - }), - ); + return HttpResponse.json({ message: '删除失败' }, { status: 500 }) + }) + ) const { result } = renderHook(() => useDeleteModel(), { wrapper: createWrapper(), - }); + }) - result.current.mutate('model-1'); + result.current.mutate('model-1') - await waitFor(() => expect(result.current.isError).toBe(true)); - expect(MessagePlugin.error).toHaveBeenCalled(); - }); -}); + await waitFor(() => expect(result.current.isError).toBe(true)) + expect(MessagePlugin.error).toHaveBeenCalled() + }) +}) diff --git a/frontend/src/__tests__/hooks/useProviders.test.tsx b/frontend/src/__tests__/hooks/useProviders.test.tsx index e2d289c..f213a94 100644 --- a/frontend/src/__tests__/hooks/useProviders.test.tsx +++ b/frontend/src/__tests__/hooks/useProviders.test.tsx @@ -1,11 +1,11 @@ -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { renderHook, waitFor } from '@testing-library/react'; -import { http, HttpResponse } from 'msw'; -import { setupServer } from 'msw/node'; -import React from 'react'; -import { MessagePlugin } from 'tdesign-react'; -import { useProviders, useCreateProvider, useUpdateProvider, useDeleteProvider } from '@/hooks/useProviders'; -import type { Provider, CreateProviderInput, UpdateProviderInput } from '@/types'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { renderHook, waitFor } from '@testing-library/react' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import React from 'react' +import { MessagePlugin } from 'tdesign-react' +import { useProviders, useCreateProvider, useUpdateProvider, useDeleteProvider } from '@/hooks/useProviders' +import type { Provider, CreateProviderInput, UpdateProviderInput } from '@/types' // Mock MessagePlugin vi.mock('tdesign-react', () => ({ @@ -13,7 +13,7 @@ vi.mock('tdesign-react', () => ({ success: vi.fn(), error: vi.fn(), }, -})); +})) // Test data const mockProviders: Provider[] = [ @@ -37,7 +37,7 @@ const mockProviders: Provider[] = [ createdAt: '2026-02-01T00:00:00Z', updatedAt: '2026-02-01T00:00:00Z', }, -]; +] const mockCreatedProvider: Provider = { id: 'provider-3', @@ -48,31 +48,31 @@ const mockCreatedProvider: Provider = { enabled: true, createdAt: '2026-03-01T00:00:00Z', updatedAt: '2026-03-01T00:00:00Z', -}; +} // MSW handlers const handlers = [ http.get('/api/providers', () => { - return HttpResponse.json(mockProviders); + return HttpResponse.json(mockProviders) }), http.post('/api/providers', async ({ request }) => { - const body = await request.json() as Record; + const body = (await request.json()) as Record return HttpResponse.json({ ...mockCreatedProvider, ...body, - }); + }) }), http.put('/api/providers/:id', async ({ request, params }) => { - const body = await request.json() as Record; - const existing = mockProviders.find((p) => p.id === params['id']); - return HttpResponse.json({ ...existing, ...body }); + const body = (await request.json()) as Record + const existing = mockProviders.find((p) => p.id === params['id']) + return HttpResponse.json({ ...existing, ...body }) }), http.delete('/api/providers/:id', () => { - return new HttpResponse(null, { status: 204 }); + return new HttpResponse(null, { status: 204 }) }), -]; +] -const server = setupServer(...handlers); +const server = setupServer(...handlers) function createTestQueryClient() { return new QueryClient({ @@ -80,58 +80,50 @@ function createTestQueryClient() { queries: { retry: false }, mutations: { retry: false }, }, - }); + }) } function createWrapper() { - const testQueryClient = createTestQueryClient(); + const testQueryClient = createTestQueryClient() return function Wrapper({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); - }; + return {children} + } } -beforeAll(() => server.listen()); +beforeAll(() => server.listen()) afterEach(() => { - server.resetHandlers(); - vi.clearAllMocks(); -}); -afterAll(() => server.close()); + server.resetHandlers() + vi.clearAllMocks() +}) +afterAll(() => server.close()) describe('useProviders', () => { it('fetches and returns provider list', async () => { const { result } = renderHook(() => useProviders(), { wrapper: createWrapper(), - }); + }) - await waitFor(() => expect(result.current.isSuccess).toBe(true)); + await waitFor(() => expect(result.current.isSuccess).toBe(true)) - expect(result.current.data).toEqual(mockProviders); - expect(result.current.data).toHaveLength(2); - expect(result.current.data![0]!.name).toBe('OpenAI'); - expect(result.current.data![1]!.name).toBe('Anthropic'); - }); -}); + expect(result.current.data).toEqual(mockProviders) + expect(result.current.data).toHaveLength(2) + expect(result.current.data![0]!.name).toBe('OpenAI') + expect(result.current.data![1]!.name).toBe('Anthropic') + }) +}) describe('useCreateProvider', () => { it('calls API and invalidates provider queries', async () => { - const queryClient = createTestQueryClient(); - const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + const queryClient = createTestQueryClient() + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') function Wrapper({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); + return {children} } const { result } = renderHook(() => useCreateProvider(), { wrapper: Wrapper, - }); + }) const input: CreateProviderInput = { id: 'provider-3', @@ -140,30 +132,30 @@ describe('useCreateProvider', () => { baseUrl: 'https://api.newprovider.com', protocol: 'openai', enabled: true, - }; + } - result.current.mutate(input); + result.current.mutate(input) - await waitFor(() => expect(result.current.isSuccess).toBe(true)); + await waitFor(() => expect(result.current.isSuccess).toBe(true)) expect(result.current.data).toMatchObject({ id: 'provider-3', name: 'NewProvider', - }); - expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['providers'] }); - expect(MessagePlugin.success).toHaveBeenCalledWith('供应商创建成功'); - }); + }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['providers'] }) + expect(MessagePlugin.success).toHaveBeenCalledWith('供应商创建成功') + }) it('calls message.error on failure', async () => { server.use( http.post('/api/providers', () => { - return HttpResponse.json({ message: '创建失败' }, { status: 500 }); - }), - ); + return HttpResponse.json({ message: '创建失败' }, { status: 500 }) + }) + ) const { result } = renderHook(() => useCreateProvider(), { wrapper: createWrapper(), - }); + }) const input: CreateProviderInput = { id: 'provider-3', @@ -172,102 +164,94 @@ describe('useCreateProvider', () => { baseUrl: 'https://api.newprovider.com', protocol: 'openai', enabled: true, - }; + } - result.current.mutate(input); + result.current.mutate(input) - await waitFor(() => expect(result.current.isError).toBe(true)); - expect(MessagePlugin.error).toHaveBeenCalled(); - }); -}); + await waitFor(() => expect(result.current.isError).toBe(true)) + expect(MessagePlugin.error).toHaveBeenCalled() + }) +}) describe('useUpdateProvider', () => { it('calls API and invalidates provider queries', async () => { - const queryClient = createTestQueryClient(); - const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + const queryClient = createTestQueryClient() + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') function Wrapper({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); + return {children} } const { result } = renderHook(() => useUpdateProvider(), { wrapper: Wrapper, - }); + }) - const input: UpdateProviderInput = { name: 'UpdatedProvider' }; + const input: UpdateProviderInput = { name: 'UpdatedProvider' } - result.current.mutate({ id: 'provider-1', input }); + result.current.mutate({ id: 'provider-1', input }) - await waitFor(() => expect(result.current.isSuccess).toBe(true)); + await waitFor(() => expect(result.current.isSuccess).toBe(true)) expect(result.current.data).toMatchObject({ name: 'UpdatedProvider', - }); - expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['providers'] }); - expect(MessagePlugin.success).toHaveBeenCalledWith('供应商更新成功'); - }); + }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['providers'] }) + expect(MessagePlugin.success).toHaveBeenCalledWith('供应商更新成功') + }) it('calls message.error on failure', async () => { server.use( http.put('/api/providers/:id', () => { - return HttpResponse.json({ message: '更新失败' }, { status: 500 }); - }), - ); + return HttpResponse.json({ message: '更新失败' }, { status: 500 }) + }) + ) const { result } = renderHook(() => useUpdateProvider(), { wrapper: createWrapper(), - }); + }) - result.current.mutate({ id: 'provider-1', input: { name: 'Updated' } }); + result.current.mutate({ id: 'provider-1', input: { name: 'Updated' } }) - await waitFor(() => expect(result.current.isError).toBe(true)); - expect(MessagePlugin.error).toHaveBeenCalled(); - }); -}); + await waitFor(() => expect(result.current.isError).toBe(true)) + expect(MessagePlugin.error).toHaveBeenCalled() + }) +}) describe('useDeleteProvider', () => { it('calls API and invalidates provider queries', async () => { - const queryClient = createTestQueryClient(); - const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + const queryClient = createTestQueryClient() + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') function Wrapper({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); + return {children} } const { result } = renderHook(() => useDeleteProvider(), { wrapper: Wrapper, - }); + }) - result.current.mutate('provider-1'); + result.current.mutate('provider-1') - await waitFor(() => expect(result.current.isSuccess).toBe(true)); + await waitFor(() => expect(result.current.isSuccess).toBe(true)) - expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['providers'] }); - expect(MessagePlugin.success).toHaveBeenCalledWith('供应商删除成功'); - }); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['providers'] }) + expect(MessagePlugin.success).toHaveBeenCalledWith('供应商删除成功') + }) it('calls message.error on failure', async () => { server.use( http.delete('/api/providers/:id', () => { - return HttpResponse.json({ message: '删除失败' }, { status: 500 }); - }), - ); + return HttpResponse.json({ message: '删除失败' }, { status: 500 }) + }) + ) const { result } = renderHook(() => useDeleteProvider(), { wrapper: createWrapper(), - }); + }) - result.current.mutate('provider-1'); + result.current.mutate('provider-1') - await waitFor(() => expect(result.current.isError).toBe(true)); - expect(MessagePlugin.error).toHaveBeenCalled(); - }); -}); + await waitFor(() => expect(result.current.isError).toBe(true)) + expect(MessagePlugin.error).toHaveBeenCalled() + }) +}) diff --git a/frontend/src/__tests__/hooks/useStats.test.tsx b/frontend/src/__tests__/hooks/useStats.test.tsx index e4bf99e..13457d9 100644 --- a/frontend/src/__tests__/hooks/useStats.test.tsx +++ b/frontend/src/__tests__/hooks/useStats.test.tsx @@ -1,10 +1,10 @@ -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { renderHook, waitFor } from '@testing-library/react'; -import { http, HttpResponse } from 'msw'; -import { setupServer } from 'msw/node'; -import React from 'react'; -import { useStats } from '@/hooks/useStats'; -import type { UsageStats, StatsQueryParams } from '@/types'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { renderHook, waitFor } from '@testing-library/react' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import React from 'react' +import { useStats } from '@/hooks/useStats' +import type { UsageStats, StatsQueryParams } from '@/types' // Test data const mockStats: UsageStats[] = [ @@ -22,7 +22,7 @@ const mockStats: UsageStats[] = [ requestCount: 50, date: '2026-04-01', }, -]; +] const mockFilteredStats: UsageStats[] = [ { @@ -32,24 +32,24 @@ const mockFilteredStats: UsageStats[] = [ requestCount: 200, date: '2026-04-01', }, -]; +] // Track the request URL for assertions -let capturedUrl: URL | null = null; +let capturedUrl: URL | null = null // MSW handlers const handlers = [ http.get('/api/stats', ({ request }) => { - capturedUrl = new URL(request.url); - const providerId = capturedUrl.searchParams.get('provider_id'); + capturedUrl = new URL(request.url) + const providerId = capturedUrl.searchParams.get('provider_id') if (providerId === 'provider-2') { - return HttpResponse.json(mockFilteredStats); + return HttpResponse.json(mockFilteredStats) } - return HttpResponse.json(mockStats); + return HttpResponse.json(mockStats) }), -]; +] -const server = setupServer(...handlers); +const server = setupServer(...handlers) function createTestQueryClient() { return new QueryClient({ @@ -57,43 +57,39 @@ function createTestQueryClient() { queries: { retry: false }, mutations: { retry: false }, }, - }); + }) } function createWrapper() { - const testQueryClient = createTestQueryClient(); + const testQueryClient = createTestQueryClient() return function Wrapper({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); - }; + return {children} + } } -beforeAll(() => server.listen()); +beforeAll(() => server.listen()) afterEach(() => { - server.resetHandlers(); - capturedUrl = null; -}); -afterAll(() => server.close()); + server.resetHandlers() + capturedUrl = null +}) +afterAll(() => server.close()) describe('useStats', () => { it('fetches stats without params', async () => { const { result } = renderHook(() => useStats(), { wrapper: createWrapper(), - }); + }) - await waitFor(() => expect(result.current.isSuccess).toBe(true)); + await waitFor(() => expect(result.current.isSuccess).toBe(true)) - expect(result.current.data).toEqual(mockStats); - expect(result.current.data).toHaveLength(2); - expect(result.current.data![0]!.modelName).toBe('gpt-4o'); - expect(result.current.data![1]!.requestCount).toBe(50); + expect(result.current.data).toEqual(mockStats) + expect(result.current.data).toHaveLength(2) + expect(result.current.data![0]!.modelName).toBe('gpt-4o') + expect(result.current.data![1]!.requestCount).toBe(50) // Verify no query params were sent - expect(capturedUrl!.search).toBe(''); - }); + expect(capturedUrl!.search).toBe('') + }) it('with filter params passes them correctly', async () => { const params: StatsQueryParams = { @@ -101,40 +97,40 @@ describe('useStats', () => { modelName: 'claude-sonnet-4-5', startDate: '2026-04-01', endDate: '2026-04-15', - }; + } const { result } = renderHook(() => useStats(params), { wrapper: createWrapper(), - }); + }) - await waitFor(() => expect(result.current.isSuccess).toBe(true)); + await waitFor(() => expect(result.current.isSuccess).toBe(true)) - expect(result.current.data).toEqual(mockFilteredStats); - expect(result.current.data).toHaveLength(1); - expect(result.current.data![0]!.modelName).toBe('claude-sonnet-4-5'); + expect(result.current.data).toEqual(mockFilteredStats) + expect(result.current.data).toHaveLength(1) + expect(result.current.data![0]!.modelName).toBe('claude-sonnet-4-5') // Verify query params were passed correctly (snake_case) - expect(capturedUrl!.searchParams.get('provider_id')).toBe('provider-2'); - expect(capturedUrl!.searchParams.get('model_name')).toBe('claude-sonnet-4-5'); - expect(capturedUrl!.searchParams.get('start_date')).toBe('2026-04-01'); - expect(capturedUrl!.searchParams.get('end_date')).toBe('2026-04-15'); - }); + expect(capturedUrl!.searchParams.get('provider_id')).toBe('provider-2') + expect(capturedUrl!.searchParams.get('model_name')).toBe('claude-sonnet-4-5') + expect(capturedUrl!.searchParams.get('start_date')).toBe('2026-04-01') + expect(capturedUrl!.searchParams.get('end_date')).toBe('2026-04-15') + }) it('with partial filter params only sends provided params', async () => { const params: StatsQueryParams = { providerId: 'provider-1', - }; + } const { result } = renderHook(() => useStats(params), { wrapper: createWrapper(), - }); + }) - await waitFor(() => expect(result.current.isSuccess).toBe(true)); + await waitFor(() => expect(result.current.isSuccess).toBe(true)) // Verify only provider_id was sent - expect(capturedUrl!.searchParams.get('provider_id')).toBe('provider-1'); - expect(capturedUrl!.searchParams.get('model_name')).toBeNull(); - expect(capturedUrl!.searchParams.get('start_date')).toBeNull(); - expect(capturedUrl!.searchParams.get('end_date')).toBeNull(); - }); -}); + expect(capturedUrl!.searchParams.get('provider_id')).toBe('provider-1') + expect(capturedUrl!.searchParams.get('model_name')).toBeNull() + expect(capturedUrl!.searchParams.get('start_date')).toBeNull() + expect(capturedUrl!.searchParams.get('end_date')).toBeNull() + }) +}) diff --git a/frontend/src/__tests__/setup.ts b/frontend/src/__tests__/setup.ts index 064eb42..8ec8529 100644 --- a/frontend/src/__tests__/setup.ts +++ b/frontend/src/__tests__/setup.ts @@ -1,8 +1,8 @@ -import '@testing-library/jest-dom/vitest'; +import '@testing-library/jest-dom/vitest' // Ensure happy-dom environment is properly initialized if (typeof window === 'undefined' || typeof document === 'undefined') { - throw new Error('happy-dom environment not initialized. Check vitest config.'); + throw new Error('happy-dom environment not initialized. Check vitest config.') } // Polyfill window.matchMedia for jsdom (required by TDesign) @@ -18,38 +18,37 @@ Object.defineProperty(window, 'matchMedia', { removeEventListener: () => {}, dispatchEvent: () => false, }), -}); +}) // Polyfill window.getComputedStyle to suppress jsdom warnings -const originalGetComputedStyle = window.getComputedStyle; +const originalGetComputedStyle = window.getComputedStyle window.getComputedStyle = (elt: Element, pseudoElt?: string | null) => { try { - return originalGetComputedStyle(elt, pseudoElt); + return originalGetComputedStyle(elt, pseudoElt) } catch { - return {} as CSSStyleDeclaration; + return {} as CSSStyleDeclaration } -}; +} // Polyfill ResizeObserver for TDesign global.ResizeObserver = class ResizeObserver { observe() {} unobserve() {} disconnect() {} -}; +} // Suppress TDesign Form internal act() warnings // These warnings come from TDesign's FormItem component internal async state updates // They don't affect test reliability - all tests pass successfully -const originalError = console.error; +const originalError = console.error console.error = (...args: unknown[]) => { - const message = args[0]; + const message = args[0] // Filter out TDesign FormItem act() warnings if ( typeof message === 'string' && message.includes('An update to FormItem inside a test was not wrapped in act(...)') ) { - return; + return } - originalError(...args); -}; - + originalError(...args) +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 30fb7e2..dff4846 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,81 +1,77 @@ -import { ApiError } from '@/types'; +import { ApiError } from '@/types' -const API_BASE = import.meta.env.VITE_API_BASE || ''; +const API_BASE = import.meta.env.VITE_API_BASE || '' function toCamelCase(str: string): string { - return str.replace(/_([a-z])/g, (_, letter: string) => letter.toUpperCase()); + return str.replace(/_([a-z])/g, (_, letter: string) => letter.toUpperCase()) } function toSnakeCase(str: string): string { - return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`); + return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`) } function transformKeys(obj: unknown, transformer: (key: string) => string): T { if (Array.isArray(obj)) { - return obj.map((item) => transformKeys(item, transformer)) as T; + return obj.map((item) => transformKeys(item, transformer)) as T } if (obj !== null && typeof obj === 'object') { - const result: Record = {}; + const result: Record = {} for (const [key, value] of Object.entries(obj as Record)) { - result[transformer(key)] = transformKeys(value, transformer); + result[transformer(key)] = transformKeys(value, transformer) } - return result as T; + return result as T } - return obj as T; + return obj as T } export function fromApi(data: unknown): T { - return transformKeys(data, toCamelCase); + return transformKeys(data, toCamelCase) } export function toApi(data: unknown): T { - return transformKeys(data, toSnakeCase); + return transformKeys(data, toSnakeCase) } -export async function request( - method: string, - path: string, - body?: unknown, -): Promise { - const url = `${API_BASE}${path}`; +export async function request(method: string, path: string, body?: unknown): Promise { + const url = `${API_BASE}${path}` const options: RequestInit = { method, headers: { 'Content-Type': 'application/json' }, - }; - - if (body !== undefined) { - options.body = JSON.stringify(toApi(body)); } - const response = await fetch(url, options); + if (body !== undefined) { + options.body = JSON.stringify(toApi(body)) + } + + const response = await fetch(url, options) if (!response.ok) { - let message = `请求失败 (${response.status})`; - let code: string | undefined; + let message = `请求失败 (${response.status})` + let code: string | undefined try { - const errorData = await response.json(); + const errorData = await response.json() if (typeof errorData === 'object' && errorData !== null) { // 提取结构化错误响应 if ('error' in errorData && typeof errorData.error === 'string') { - message = errorData.error; + message = errorData.error } else if ('message' in errorData && typeof errorData.message === 'string') { - message = errorData.message; + message = errorData.message } // 提取错误码 if ('code' in errorData && typeof errorData.code === 'string') { - code = errorData.code; + code = errorData.code } } } catch { // ignore JSON parse error } - throw new ApiError(response.status, message, code); + throw new ApiError(response.status, message, code) } if (response.status === 204) { - return undefined as T; + return undefined as T } - const data = await response.json(); - return fromApi(data); + const data = await response.json() + return fromApi(data) } diff --git a/frontend/src/api/models.ts b/frontend/src/api/models.ts index 8326023..c7088b9 100644 --- a/frontend/src/api/models.ts +++ b/frontend/src/api/models.ts @@ -1,24 +1,19 @@ -import type { Model, CreateModelInput, UpdateModelInput } from '@/types'; -import { request } from './client'; +import type { Model, CreateModelInput, UpdateModelInput } from '@/types' +import { request } from './client' export async function listModels(providerId?: string): Promise { - const path = providerId - ? `/api/models?provider_id=${encodeURIComponent(providerId)}` - : '/api/models'; - return request('GET', path); + const path = providerId ? `/api/models?provider_id=${encodeURIComponent(providerId)}` : '/api/models' + return request('GET', path) } export async function createModel(input: CreateModelInput): Promise { - return request('POST', '/api/models', input); + return request('POST', '/api/models', input) } -export async function updateModel( - id: string, - input: UpdateModelInput, -): Promise { - return request('PUT', `/api/models/${id}`, input); +export async function updateModel(id: string, input: UpdateModelInput): Promise { + return request('PUT', `/api/models/${id}`, input) } export async function deleteModel(id: string): Promise { - return request('DELETE', `/api/models/${id}`); + return request('DELETE', `/api/models/${id}`) } diff --git a/frontend/src/api/providers.ts b/frontend/src/api/providers.ts index 9370f88..85a86f7 100644 --- a/frontend/src/api/providers.ts +++ b/frontend/src/api/providers.ts @@ -1,21 +1,18 @@ -import type { Provider, CreateProviderInput, UpdateProviderInput } from '@/types'; -import { request } from './client'; +import type { Provider, CreateProviderInput, UpdateProviderInput } from '@/types' +import { request } from './client' export async function listProviders(): Promise { - return request('GET', '/api/providers'); + return request('GET', '/api/providers') } export async function createProvider(input: CreateProviderInput): Promise { - return request('POST', '/api/providers', input); + return request('POST', '/api/providers', input) } -export async function updateProvider( - id: string, - input: UpdateProviderInput, -): Promise { - return request('PUT', `/api/providers/${id}`, input); +export async function updateProvider(id: string, input: UpdateProviderInput): Promise { + return request('PUT', `/api/providers/${id}`, input) } export async function deleteProvider(id: string): Promise { - return request('DELETE', `/api/providers/${id}`); + return request('DELETE', `/api/providers/${id}`) } diff --git a/frontend/src/api/stats.ts b/frontend/src/api/stats.ts index b1a5d2a..7e775f2 100644 --- a/frontend/src/api/stats.ts +++ b/frontend/src/api/stats.ts @@ -1,26 +1,26 @@ -import type { UsageStats, StatsQueryParams } from '@/types'; -import { request } from './client'; +import type { UsageStats, StatsQueryParams } from '@/types' +import { request } from './client' export async function getStats(params?: StatsQueryParams): Promise { if (!params) { - return request('GET', '/api/stats'); + return request('GET', '/api/stats') } - const query = new URLSearchParams(); + const query = new URLSearchParams() const snakeParams: Record = { provider_id: params.providerId, model_name: params.modelName, start_date: params.startDate, end_date: params.endDate, - }; + } for (const [key, value] of Object.entries(snakeParams)) { if (value) { - query.set(key, value); + query.set(key, value) } } - const queryString = query.toString(); - const path = queryString ? `/api/stats?${queryString}` : '/api/stats'; - return request('GET', path); + const queryString = query.toString() + const path = queryString ? `/api/stats?${queryString}` : '/api/stats' + return request('GET', path) } diff --git a/frontend/src/components/AppLayout/index.tsx b/frontend/src/components/AppLayout/index.tsx index e6429c0..463ce75 100644 --- a/frontend/src/components/AppLayout/index.tsx +++ b/frontend/src/components/AppLayout/index.tsx @@ -1,23 +1,23 @@ -import { useState } from 'react'; -import { Outlet, useLocation, useNavigate } from 'react-router'; -import { ServerIcon, ChartLineIcon, SettingIcon, ChevronLeftIcon, ChevronRightIcon } from 'tdesign-icons-react'; -import { Layout, Menu, Button } from 'tdesign-react'; +import { useState } from 'react' +import { Outlet, useLocation, useNavigate } from 'react-router' +import { ServerIcon, ChartLineIcon, SettingIcon, ChevronLeftIcon, ChevronRightIcon } from 'tdesign-icons-react' +import { Layout, Menu, Button } from 'tdesign-react' -const { MenuItem } = Menu; +const { MenuItem } = Menu export function AppLayout() { - const location = useLocation(); - const navigate = useNavigate(); - const [collapsed, setCollapsed] = useState(false); + const location = useLocation() + const navigate = useNavigate() + const [collapsed, setCollapsed] = useState(false) const getPageTitle = () => { - if (location.pathname === '/providers') return '供应商管理'; - if (location.pathname === '/stats') return '用量统计'; - if (location.pathname === '/settings') return '设置'; - return 'AI Gateway'; - }; + if (location.pathname === '/providers') return '供应商管理' + if (location.pathname === '/stats') return '用量统计' + if (location.pathname === '/settings') return '设置' + return 'AI Gateway' + } - const asideWidth = collapsed ? '64px' : '232px'; + const asideWidth = collapsed ? '64px' : '232px' return ( @@ -38,34 +38,36 @@ export function AppLayout() { collapsed={collapsed} width={['232px', '64px']} logo={ -
+
{!collapsed && 'AI Gateway'}
} operations={
- ); + ) } diff --git a/frontend/src/pages/Providers/ModelForm.tsx b/frontend/src/pages/Providers/ModelForm.tsx index 24f1ac6..38640d4 100644 --- a/frontend/src/pages/Providers/ModelForm.tsx +++ b/frontend/src/pages/Providers/ModelForm.tsx @@ -1,35 +1,27 @@ -import { useEffect } from 'react'; -import { Dialog, Form, Input, Select, Switch } from 'tdesign-react'; -import type { Provider, Model } from '@/types'; -import type { SubmitContext } from 'tdesign-react/es/form/type'; +import { useEffect } from 'react' +import { Dialog, Form, Input, Select, Switch } from 'tdesign-react' +import type { Provider, Model } from '@/types' +import type { SubmitContext } from 'tdesign-react/es/form/type' interface ModelFormValues { - providerId: string; - modelName: string; - enabled: boolean; + providerId: string + modelName: string + enabled: boolean } interface ModelFormProps { - open: boolean; - model?: Model; - providerId: string; - providers: Provider[]; - onSave: (values: ModelFormValues) => Promise | void; - onCancel: () => void; - loading: boolean; + open: boolean + model?: Model + providerId: string + providers: Provider[] + onSave: (values: ModelFormValues) => Promise | void + onCancel: () => void + loading: boolean } -export function ModelForm({ - open, - model, - providerId, - providers, - onSave, - onCancel, - loading, -}: ModelFormProps) { - const [form] = Form.useForm(); - const isEdit = !!model; +export function ModelForm({ open, model, providerId, providers, onSave, onCancel, loading }: ModelFormProps) { + const [form] = Form.useForm() + const isEdit = !!model // 当弹窗打开或model变化时,设置表单值 useEffect(() => { @@ -40,63 +32,56 @@ export function ModelForm({ providerId: model.providerId, modelName: model.modelName, enabled: model.enabled, - }); + }) } else { // 新增模式:重置表单并设置默认providerId - form.reset(); + form.reset() form.setFieldsValue({ providerId, - enabled: true - }); + enabled: true, + }) } } - }, [open, model, providerId]); // 移除form依赖,避免循环 + }, [open, model, providerId]) // 移除form依赖,避免循环 const handleSubmit = (context: SubmitContext) => { if (context.validateResult === true && form) { - const values = form.getFieldsValue(true) as ModelFormValues; - onSave(values); + const values = form.getFieldsValue(true) as ModelFormValues + onSave(values) } - }; + } return ( { form?.submit(); return false; }} + onConfirm={() => { + form?.submit() + return false + }} onClose={onCancel} confirmLoading={loading} - confirmBtn="保存" - cancelBtn="取消" + confirmBtn='保存' + cancelBtn='取消' > -
- - ({ label: p.name, value: p.id }))} /> - - + + - +
- ); + ) } diff --git a/frontend/src/pages/Providers/ModelTable.tsx b/frontend/src/pages/Providers/ModelTable.tsx index 6b41eeb..c989f8a 100644 --- a/frontend/src/pages/Providers/ModelTable.tsx +++ b/frontend/src/pages/Providers/ModelTable.tsx @@ -1,17 +1,17 @@ -import { Button, Table, Tag, Popconfirm, Space } from 'tdesign-react'; -import { useModels, useDeleteModel } from '@/hooks/useModels'; -import type { Model } from '@/types'; -import type { PrimaryTableCol } from 'tdesign-react/es/table/type'; +import { Button, Table, Tag, Popconfirm, Space } from 'tdesign-react' +import { useModels, useDeleteModel } from '@/hooks/useModels' +import type { Model } from '@/types' +import type { PrimaryTableCol } from 'tdesign-react/es/table/type' interface ModelTableProps { - providerId: string; - onAdd?: () => void; - onEdit?: (model: Model) => void; + providerId: string + onAdd?: () => void + onEdit?: (model: Model) => void } export function ModelTable({ providerId, onAdd, onEdit }: ModelTableProps) { - const { data: models = [], isLoading } = useModels(providerId); - const deleteModel = useDeleteModel(); + const { data: models = [], isLoading } = useModels(providerId) + const deleteModel = useDeleteModel() const columns: PrimaryTableCol[] = [ { @@ -32,9 +32,13 @@ export function ModelTable({ providerId, onAdd, onEdit }: ModelTableProps) { width: 80, cell: ({ row }) => row.enabled ? ( - 启用 + + 启用 + ) : ( - 禁用 + + 禁用 + ), }, { @@ -44,29 +48,26 @@ export function ModelTable({ providerId, onAdd, onEdit }: ModelTableProps) { cell: ({ row }) => ( {onEdit && ( - )} - deleteModel.mutate(row.id)} - > - ), }, - ]; + ] return (
关联模型 ({models.length}) {onAdd && ( - )} @@ -74,13 +75,13 @@ export function ModelTable({ providerId, onAdd, onEdit }: ModelTableProps) { columns={columns} data={models} - rowKey="id" + rowKey='id' loading={isLoading} stripe pagination={undefined} - size="small" - empty="暂无模型,点击上方按钮添加" + size='small' + empty='暂无模型,点击上方按钮添加' />
- ); + ) } diff --git a/frontend/src/pages/Providers/ProviderForm.tsx b/frontend/src/pages/Providers/ProviderForm.tsx index a62322f..ebf8198 100644 --- a/frontend/src/pages/Providers/ProviderForm.tsx +++ b/frontend/src/pages/Providers/ProviderForm.tsx @@ -1,34 +1,28 @@ -import { useEffect } from 'react'; -import { Dialog, Form, Input, Switch, Select } from 'tdesign-react'; -import type { Provider } from '@/types'; -import type { SubmitContext } from 'tdesign-react/es/form/type'; +import { useEffect } from 'react' +import { Dialog, Form, Input, Switch, Select } from 'tdesign-react' +import type { Provider } from '@/types' +import type { SubmitContext } from 'tdesign-react/es/form/type' interface ProviderFormValues { - id: string; - name: string; - apiKey: string; - baseUrl: string; - protocol: 'openai' | 'anthropic'; - enabled: boolean; + id: string + name: string + apiKey: string + baseUrl: string + protocol: 'openai' | 'anthropic' + enabled: boolean } interface ProviderFormProps { - open: boolean; - provider?: Provider; - onSave: (values: ProviderFormValues) => Promise | void; - onCancel: () => void; - loading: boolean; + open: boolean + provider?: Provider + onSave: (values: ProviderFormValues) => Promise | void + onCancel: () => void + loading: boolean } -export function ProviderForm({ - open, - provider, - onSave, - onCancel, - loading, -}: ProviderFormProps) { - const [form] = Form.useForm(); - const isEdit = !!provider; +export function ProviderForm({ open, provider, onSave, onCancel, loading }: ProviderFormProps) { + const [form] = Form.useForm() + const isEdit = !!provider useEffect(() => { if (open && form) { @@ -40,75 +34,74 @@ export function ProviderForm({ baseUrl: provider.baseUrl, protocol: provider.protocol, enabled: provider.enabled, - }); + }) } else { - form.reset(); - form.setFieldsValue({ enabled: true, protocol: 'openai' }); + form.reset() + form.setFieldsValue({ enabled: true, protocol: 'openai' }) } } - }, [open, provider]); + }, [open, provider]) const handleSubmit = (context: SubmitContext) => { if (context.validateResult === true && form) { - const values = form.getFieldsValue(true) as ProviderFormValues; - onSave(values); + const values = form.getFieldsValue(true) as ProviderFormValues + onSave(values) } - }; + } return ( { form?.submit(); return false; }} + onConfirm={() => { + form?.submit() + return false + }} onClose={onCancel} confirmLoading={loading} - confirmBtn="保存" - cancelBtn="取消" + confirmBtn='保存' + cancelBtn='取消' > -
- - + + + - - + + + + + + - - - - - + - + - +
- ); + ) } diff --git a/frontend/src/pages/Providers/ProviderTable.tsx b/frontend/src/pages/Providers/ProviderTable.tsx index 1df29f4..374fd32 100644 --- a/frontend/src/pages/Providers/ProviderTable.tsx +++ b/frontend/src/pages/Providers/ProviderTable.tsx @@ -1,16 +1,16 @@ -import { Button, Table, Tag, Popconfirm, Space, Card } from 'tdesign-react'; -import type { Provider, Model } from '@/types'; -import { ModelTable } from './ModelTable'; -import type { PrimaryTableCol } from 'tdesign-react/es/table/type'; +import { Button, Table, Tag, Popconfirm, Space, Card } from 'tdesign-react' +import type { Provider, Model } from '@/types' +import { ModelTable } from './ModelTable' +import type { PrimaryTableCol } from 'tdesign-react/es/table/type' interface ProviderTableProps { - providers: Provider[]; - loading: boolean; - onAdd: () => void; - onEdit: (provider: Provider) => void; - onDelete: (id: string) => void; - onAddModel: (providerId: string) => void; - onEditModel: (model: Model) => void; + providers: Provider[] + loading: boolean + onAdd: () => void + onEdit: (provider: Provider) => void + onDelete: (id: string) => void + onAddModel: (providerId: string) => void + onEditModel: (model: Model) => void } export function ProviderTable({ @@ -39,7 +39,7 @@ export function ProviderTable({ colKey: 'protocol', width: 100, cell: ({ row }) => ( - + {row.protocol === 'openai' ? 'OpenAI' : 'Anthropic'} ), @@ -55,9 +55,13 @@ export function ProviderTable({ width: 80, cell: ({ row }) => row.enabled ? ( - 启用 + + 启用 + ) : ( - 禁用 + + 禁用 + ), }, { @@ -66,29 +70,26 @@ export function ProviderTable({ width: 160, cell: ({ row }) => ( - - onDelete(row.id)} - > - ), }, - ]; + ] return ( + } @@ -96,19 +97,15 @@ export function ProviderTable({ columns={columns} data={providers} - rowKey="id" + rowKey='id' loading={loading} stripe expandedRow={({ row }) => ( - onAddModel(row.id)} - onEdit={onEditModel} - /> + onAddModel(row.id)} onEdit={onEditModel} /> )} pagination={undefined} - empty="暂无供应商,点击上方按钮添加" + empty='暂无供应商,点击上方按钮添加' /> - ); + ) } diff --git a/frontend/src/pages/Providers/index.tsx b/frontend/src/pages/Providers/index.tsx index b877e4e..b4ef74a 100644 --- a/frontend/src/pages/Providers/index.tsx +++ b/frontend/src/pages/Providers/index.tsx @@ -1,24 +1,24 @@ -import { useState } from 'react'; -import { useCreateModel, useUpdateModel } from '@/hooks/useModels'; -import { useProviders, useCreateProvider, useUpdateProvider, useDeleteProvider } from '@/hooks/useProviders'; -import type { Provider, Model, UpdateProviderInput, UpdateModelInput } from '@/types'; -import { ModelForm } from './ModelForm'; -import { ProviderForm } from './ProviderForm'; -import { ProviderTable } from './ProviderTable'; +import { useState } from 'react' +import { useCreateModel, useUpdateModel } from '@/hooks/useModels' +import { useProviders, useCreateProvider, useUpdateProvider, useDeleteProvider } from '@/hooks/useProviders' +import type { Provider, Model, UpdateProviderInput, UpdateModelInput } from '@/types' +import { ModelForm } from './ModelForm' +import { ProviderForm } from './ProviderForm' +import { ProviderTable } from './ProviderTable' export default function ProvidersPage() { - const { data: providers = [], isLoading } = useProviders(); - const createProvider = useCreateProvider(); - const updateProvider = useUpdateProvider(); - const deleteProvider = useDeleteProvider(); - const createModel = useCreateModel(); - const updateModel = useUpdateModel(); + const { data: providers = [], isLoading } = useProviders() + const createProvider = useCreateProvider() + const updateProvider = useUpdateProvider() + const deleteProvider = useDeleteProvider() + const createModel = useCreateModel() + const updateModel = useUpdateModel() - const [providerFormOpen, setProviderFormOpen] = useState(false); - const [editingProvider, setEditingProvider] = useState(); - const [modelFormOpen, setModelFormOpen] = useState(false); - const [editingModel, setEditingModel] = useState(); - const [modelFormProviderId, setModelFormProviderId] = useState(''); + const [providerFormOpen, setProviderFormOpen] = useState(false) + const [editingProvider, setEditingProvider] = useState() + const [modelFormOpen, setModelFormOpen] = useState(false) + const [editingModel, setEditingModel] = useState() + const [modelFormProviderId, setModelFormProviderId] = useState('') return (
@@ -26,23 +26,23 @@ export default function ProvidersPage() { providers={providers} loading={isLoading} onAdd={() => { - setEditingProvider(undefined); - setProviderFormOpen(true); + setEditingProvider(undefined) + setProviderFormOpen(true) }} onEdit={(provider) => { - setEditingProvider(provider); - setProviderFormOpen(true); + setEditingProvider(provider) + setProviderFormOpen(true) }} onDelete={(id) => deleteProvider.mutate(id)} onAddModel={(providerId) => { - setEditingModel(undefined); - setModelFormProviderId(providerId); - setModelFormOpen(true); + setEditingModel(undefined) + setModelFormProviderId(providerId) + setModelFormOpen(true) }} onEditModel={(model) => { - setEditingModel(model); - setModelFormProviderId(model.providerId); - setModelFormOpen(true); + setEditingModel(model) + setModelFormProviderId(model.providerId) + setModelFormOpen(true) }} /> @@ -53,16 +53,16 @@ export default function ProvidersPage() { onSave={async (values) => { try { if (editingProvider) { - const input: Partial = {}; - if (values.name !== editingProvider.name) input.name = values.name; - if (values.apiKey !== editingProvider.apiKey) input.apiKey = values.apiKey; - if (values.baseUrl !== editingProvider.baseUrl) input.baseUrl = values.baseUrl; - if (values.enabled !== editingProvider.enabled) input.enabled = values.enabled; - await updateProvider.mutateAsync({ id: editingProvider.id, input }); + const input: Partial = {} + if (values.name !== editingProvider.name) input.name = values.name + if (values.apiKey !== editingProvider.apiKey) input.apiKey = values.apiKey + if (values.baseUrl !== editingProvider.baseUrl) input.baseUrl = values.baseUrl + if (values.enabled !== editingProvider.enabled) input.enabled = values.enabled + await updateProvider.mutateAsync({ id: editingProvider.id, input }) } else { - await createProvider.mutateAsync(values); + await createProvider.mutateAsync(values) } - setProviderFormOpen(false); + setProviderFormOpen(false) } catch { // 错误已由 hooks 的 onError 处理 } @@ -79,15 +79,15 @@ export default function ProvidersPage() { onSave={async (values) => { try { if (editingModel) { - const input: Partial = {}; - if (values.providerId !== editingModel.providerId) input.providerId = values.providerId; - if (values.modelName !== editingModel.modelName) input.modelName = values.modelName; - if (values.enabled !== editingModel.enabled) input.enabled = values.enabled; - await updateModel.mutateAsync({ id: editingModel.id, input }); + const input: Partial = {} + if (values.providerId !== editingModel.providerId) input.providerId = values.providerId + if (values.modelName !== editingModel.modelName) input.modelName = values.modelName + if (values.enabled !== editingModel.enabled) input.enabled = values.enabled + await updateModel.mutateAsync({ id: editingModel.id, input }) } else { - await createModel.mutateAsync(values); + await createModel.mutateAsync(values) } - setModelFormOpen(false); + setModelFormOpen(false) } catch { // 错误已由 hooks 的 onError 处理 } @@ -95,5 +95,5 @@ export default function ProvidersPage() { onCancel={() => setModelFormOpen(false)} />
- ); + ) } diff --git a/frontend/src/pages/Settings/index.tsx b/frontend/src/pages/Settings/index.tsx index 1aeff76..529d8c9 100644 --- a/frontend/src/pages/Settings/index.tsx +++ b/frontend/src/pages/Settings/index.tsx @@ -1,11 +1,11 @@ -import { Card } from 'tdesign-react'; +import { Card } from 'tdesign-react' export default function SettingsPage() { return ( - +
设置功能开发中...
- ); + ) } diff --git a/frontend/src/pages/Stats/StatCards.tsx b/frontend/src/pages/Stats/StatCards.tsx index 7fc8340..f50600b 100644 --- a/frontend/src/pages/Stats/StatCards.tsx +++ b/frontend/src/pages/Stats/StatCards.tsx @@ -1,31 +1,29 @@ -import { ChartBarIcon, ChartLineIcon, ServerIcon, Calendar1Icon } from 'tdesign-icons-react'; -import { Row, Col, Card, Statistic } from 'tdesign-react'; -import type { UsageStats } from '@/types'; +import { ChartBarIcon, ChartLineIcon, ServerIcon, Calendar1Icon } from 'tdesign-icons-react' +import { Row, Col, Card, Statistic } from 'tdesign-react' +import type { UsageStats } from '@/types' interface StatCardsProps { - stats: UsageStats[]; + stats: UsageStats[] } export function StatCards({ stats }: StatCardsProps) { - const totalRequests = stats.reduce((sum, s) => sum + s.requestCount, 0); - const activeModels = new Set(stats.map((s) => s.modelName)).size; - const activeProviders = new Set(stats.map((s) => s.providerId)).size; + const totalRequests = stats.reduce((sum, s) => sum + s.requestCount, 0) + const activeModels = new Set(stats.map((s) => s.modelName)).size + const activeProviders = new Set(stats.map((s) => s.providerId)).size - const today = new Date().toISOString().split('T')[0]; - const todayRequests = stats - .filter((s) => s.date === today) - .reduce((sum, s) => sum + s.requestCount, 0); + const today = new Date().toISOString().split('T')[0] + const todayRequests = stats.filter((s) => s.date === today).reduce((sum, s) => sum + s.requestCount, 0) return ( } - suffix="次" + suffix='次' animation={{ duration: 800, valueFrom: 0 }} animationStart /> @@ -34,11 +32,11 @@ export function StatCards({ stats }: StatCardsProps) { } - suffix="个" + suffix='个' animation={{ duration: 800, valueFrom: 0 }} animationStart /> @@ -47,11 +45,11 @@ export function StatCards({ stats }: StatCardsProps) { } - suffix="个" + suffix='个' animation={{ duration: 800, valueFrom: 0 }} animationStart /> @@ -60,16 +58,16 @@ export function StatCards({ stats }: StatCardsProps) { } - suffix="次" + suffix='次' animation={{ duration: 800, valueFrom: 0 }} animationStart /> - ); + ) } diff --git a/frontend/src/pages/Stats/StatsTable.tsx b/frontend/src/pages/Stats/StatsTable.tsx index b6cd145..52625d5 100644 --- a/frontend/src/pages/Stats/StatsTable.tsx +++ b/frontend/src/pages/Stats/StatsTable.tsx @@ -1,18 +1,18 @@ -import { useMemo } from 'react'; -import { Table, Select, Input, DateRangePicker, Space, Card } from 'tdesign-react'; -import type { UsageStats, Provider } from '@/types'; -import type { PrimaryTableCol } from 'tdesign-react/es/table/type'; +import { useMemo } from 'react' +import { Table, Select, Input, DateRangePicker, Space, Card } from 'tdesign-react' +import type { UsageStats, Provider } from '@/types' +import type { PrimaryTableCol } from 'tdesign-react/es/table/type' interface StatsTableProps { - providers: Provider[]; - stats: UsageStats[]; - loading: boolean; - providerId?: string; - modelName?: string; - dateRange: [Date | null, Date | null] | null; - onProviderIdChange: (value: string | undefined) => void; - onModelNameChange: (value: string | undefined) => void; - onDateRangeChange: (dates: [Date | null, Date | null] | null) => void; + providers: Provider[] + stats: UsageStats[] + loading: boolean + providerId?: string + modelName?: string + dateRange: [Date | null, Date | null] | null + onProviderIdChange: (value: string | undefined) => void + onModelNameChange: (value: string | undefined) => void + onDateRangeChange: (dates: [Date | null, Date | null] | null) => void } export function StatsTable({ @@ -27,12 +27,12 @@ export function StatsTable({ onDateRangeChange, }: StatsTableProps) { const providerMap = useMemo(() => { - const map = new Map(); + const map = new Map() for (const p of providers) { - map.set(p.id, p.name); + map.set(p.id, p.name) } - return map; - }, [providers]); + return map + }, [providers]) const columns: PrimaryTableCol[] = [ { @@ -50,7 +50,7 @@ export function StatsTable({ cell: ({ row }) => { // 如果后端返回统一 ID 格式(包含 /),直接显示 // 否则显示原始 model_name - return row.modelName; + return row.modelName }, }, { @@ -64,25 +64,25 @@ export function StatsTable({ width: 100, align: 'right', }, - ]; + ] const handleDateChange = (value: unknown) => { if (Array.isArray(value) && value.length === 2) { // 将值转换为Date对象 - const startDate = value[0] ? new Date(value[0] as string | number | Date) : null; - const endDate = value[1] ? new Date(value[1] as string | number | Date) : null; - onDateRangeChange([startDate, endDate]); + const startDate = value[0] ? new Date(value[0] as string | number | Date) : null + const endDate = value[1] ? new Date(value[1] as string | number | Date) : null + onDateRangeChange([startDate, endDate]) } else { - onDateRangeChange(null); + onDateRangeChange(null) } - }; + } return ( - - + + onModelNameChange((value as string) || undefined)} /> @@ -105,12 +105,12 @@ export function StatsTable({ columns={columns} data={stats} - rowKey="id" + rowKey='id' loading={loading} stripe pagination={{ pageSize: 20 }} - empty="暂无统计数据" + empty='暂无统计数据' /> - ); + ) } diff --git a/frontend/src/pages/Stats/UsageChart.tsx b/frontend/src/pages/Stats/UsageChart.tsx index f03ff3c..fbdf84c 100644 --- a/frontend/src/pages/Stats/UsageChart.tsx +++ b/frontend/src/pages/Stats/UsageChart.tsx @@ -1,43 +1,43 @@ -import { AreaChart, Area, XAxis, YAxis, CartesianGrid, ResponsiveContainer, Tooltip } from 'recharts'; -import { Card } from 'tdesign-react'; -import type { UsageStats } from '@/types'; +import { AreaChart, Area, XAxis, YAxis, CartesianGrid, ResponsiveContainer, Tooltip } from 'recharts' +import { Card } from 'tdesign-react' +import type { UsageStats } from '@/types' interface UsageChartProps { - stats: UsageStats[]; - isLoading?: boolean; + stats: UsageStats[] + isLoading?: boolean } export function UsageChart({ stats, isLoading }: UsageChartProps) { const chartData = Object.entries( stats.reduce>((acc, s) => { - acc[s.date] = (acc[s.date] || 0) + s.requestCount; - return acc; + acc[s.date] = (acc[s.date] || 0) + s.requestCount + return acc }, {}) ) .map(([date, requestCount]) => ({ date, requestCount })) - .sort((a, b) => a.date.localeCompare(b.date)); + .sort((a, b) => a.date.localeCompare(b.date)) return ( - + {chartData.length > 0 ? ( - + - - - + + + - - + + @@ -47,5 +47,5 @@ export function UsageChart({ stats, isLoading }: UsageChartProps) {
)} - ); + ) } diff --git a/frontend/src/pages/Stats/index.tsx b/frontend/src/pages/Stats/index.tsx index 57f6449..89aca57 100644 --- a/frontend/src/pages/Stats/index.tsx +++ b/frontend/src/pages/Stats/index.tsx @@ -1,16 +1,16 @@ -import { useState, useMemo } from 'react'; -import { useProviders } from '@/hooks/useProviders'; -import { useStats } from '@/hooks/useStats'; -import { StatCards } from './StatCards'; -import { StatsTable } from './StatsTable'; -import { UsageChart } from './UsageChart'; +import { useState, useMemo } from 'react' +import { useProviders } from '@/hooks/useProviders' +import { useStats } from '@/hooks/useStats' +import { StatCards } from './StatCards' +import { StatsTable } from './StatsTable' +import { UsageChart } from './UsageChart' export default function StatsPage() { - const { data: providers = [] } = useProviders(); + const { data: providers = [] } = useProviders() - const [providerId, setProviderId] = useState(); - const [modelName, setModelName] = useState(); - const [dateRange, setDateRange] = useState<[Date | null, Date | null] | null>(null); + const [providerId, setProviderId] = useState() + const [modelName, setModelName] = useState() + const [dateRange, setDateRange] = useState<[Date | null, Date | null] | null>(null) const params = useMemo( () => ({ @@ -19,10 +19,10 @@ export default function StatsPage() { startDate: dateRange?.[0]?.toISOString().split('T')[0], endDate: dateRange?.[1]?.toISOString().split('T')[0], }), - [providerId, modelName, dateRange], - ); + [providerId, modelName, dateRange] + ) - const { data: stats = [], isLoading } = useStats(params); + const { data: stats = [], isLoading } = useStats(params) return (
@@ -40,5 +40,5 @@ export default function StatsPage() { onDateRangeChange={setDateRange} />
- ); + ) } diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx index b2c518d..05dc5b4 100644 --- a/frontend/src/routes/index.tsx +++ b/frontend/src/routes/index.tsx @@ -1,25 +1,25 @@ -import { lazy, Suspense } from 'react'; -import { Routes, Route, Navigate } from 'react-router'; -import { Loading } from 'tdesign-react'; -import { AppLayout } from '@/components/AppLayout'; +import { lazy, Suspense } from 'react' +import { Routes, Route, Navigate } from 'react-router' +import { Loading } from 'tdesign-react' +import { AppLayout } from '@/components/AppLayout' -const ProvidersPage = lazy(() => import('@/pages/Providers')); -const StatsPage = lazy(() => import('@/pages/Stats')); -const SettingsPage = lazy(() => import('@/pages/Settings')); -const NotFound = lazy(() => import('@/pages/NotFound')); +const ProvidersPage = lazy(() => import('@/pages/Providers')) +const StatsPage = lazy(() => import('@/pages/Stats')) +const SettingsPage = lazy(() => import('@/pages/Settings')) +const NotFound = lazy(() => import('@/pages/NotFound')) export function AppRoutes() { return ( }> }> - } /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> - ); + ) } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 5f7725e..d108459 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1,84 +1,80 @@ export interface Provider { - id: string; - name: string; - apiKey: string; - baseUrl: string; - protocol: 'openai' | 'anthropic'; - enabled: boolean; - createdAt: string; - updatedAt: string; + id: string + name: string + apiKey: string + baseUrl: string + protocol: 'openai' | 'anthropic' + enabled: boolean + createdAt: string + updatedAt: string } export interface Model { - id: string; - providerId: string; - modelName: string; - enabled: boolean; - createdAt: string; - unifiedId?: string; + id: string + providerId: string + modelName: string + enabled: boolean + createdAt: string + unifiedId?: string } export interface UsageStats { - id: number; - providerId: string; - modelName: string; - requestCount: number; - date: string; + id: number + providerId: string + modelName: string + requestCount: number + date: string } export interface CreateProviderInput { - id: string; - name: string; - apiKey: string; - baseUrl: string; - protocol: 'openai' | 'anthropic'; - enabled: boolean; + id: string + name: string + apiKey: string + baseUrl: string + protocol: 'openai' | 'anthropic' + enabled: boolean } export interface UpdateProviderInput { - name?: string; - apiKey?: string; - baseUrl?: string; - protocol?: 'openai' | 'anthropic'; - enabled?: boolean; + name?: string + apiKey?: string + baseUrl?: string + protocol?: 'openai' | 'anthropic' + enabled?: boolean } export interface CreateModelInput { - providerId: string; - modelName: string; - enabled: boolean; + providerId: string + modelName: string + enabled: boolean } export interface UpdateModelInput { - providerId?: string; - modelName?: string; - enabled?: boolean; + providerId?: string + modelName?: string + enabled?: boolean } export interface StatsQueryParams { - providerId?: string; - modelName?: string; - startDate?: string; - endDate?: string; + providerId?: string + modelName?: string + startDate?: string + endDate?: string } export class ApiError extends Error { - status: number; - code?: string; + status: number + code?: string - constructor( - status: number, - message: string, - code?: string, - ) { - super(message); - this.name = 'ApiError'; - this.status = status; - this.code = code; + constructor(status: number, message: string, code?: string) { + super(message) + this.name = 'ApiError' + this.status = status + this.code = code } } export interface ApiErrorResponse { - error: string; - code?: string; + error: string + code?: string } diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index 7756efe..827702f 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -17,12 +17,7 @@ export default defineConfig({ coverage: { provider: 'v8', include: ['src/**/*.{ts,tsx}'], - exclude: [ - 'src/__tests__/**', - 'src/main.tsx', - 'src/**/*.module.scss', - 'src/types/**', - ], + exclude: ['src/__tests__/**', 'src/main.tsx', 'src/**/*.module.scss', 'src/types/**'], }, }, }) diff --git a/openspec/specs/frontend-lint-rules/spec.md b/openspec/specs/frontend-lint-rules/spec.md index 1329830..b225e9d 100644 --- a/openspec/specs/frontend-lint-rules/spec.md +++ b/openspec/specs/frontend-lint-rules/spec.md @@ -49,18 +49,20 @@ TBD - 定义前端 ESLint 规则配置、构建集成 lint 检查、以及自定 ### Requirement: 构建集成 lint 检查 -前端 SHALL 在 `build` 命令中集成 ESLint 检查。 +前端 SHALL 在 `build` 命令中集成 ESLint 检查和 Prettier 格式检查。 -#### Scenario: 构建时执行 lint +#### Scenario: 构建时执行 lint 和格式检查 - **WHEN** 执行 `bun run build` -- **THEN** 构建 SHALL 依次执行 `tsc -b`、`eslint .`、`vite build` +- **THEN** 构建 SHALL 依次执行 `tsc -b`、`bun run check`、`vite build` +- **THEN** `bun run check` SHALL 执行 `bun run lint && bun run format:check` - **THEN** 若 `eslint .` 报告任何错误,构建 SHALL 中断 +- **THEN** 若 `prettier --check .` 报告任何格式问题,构建 SHALL 中断 #### Scenario: lint 警告不中断构建 - **WHEN** `eslint .` 仅报告警告(无错误) -- **THEN** 构建 SHALL 继续执行 `vite build` +- **THEN** 构建 SHALL 继续执行格式检查和 `vite build` #### Scenario: 单独执行 lint @@ -72,6 +74,19 @@ TBD - 定义前端 ESLint 规则配置、构建集成 lint 检查、以及自定 - **WHEN** 执行 `bun run lint:fix` - **THEN** SHALL 运行 `eslint . --fix` +#### Scenario: 统一检查命令 + +- **WHEN** 执行 `bun run check` +- **THEN** SHALL 运行 `bun run lint && bun run format:check` +- **THEN** lint 错误和格式问题 SHALL 都被检查 + +#### Scenario: 统一修复命令 + +- **WHEN** 执行 `bun run fix` +- **THEN** SHALL 运行 `bun run lint:fix && bun run format` +- **THEN** lint 问题 SHALL 被修复 +- **THEN** 文件 SHALL 被格式化 + ### Requirement: 自定义规则禁止硬编码颜色 前端 SHALL 提供自定义 ESLint 规则 `no-hardcoded-color-in-style`,检测 JSX style 属性中的硬编码颜色值。 @@ -112,3 +127,14 @@ TBD - 定义前端 ESLint 规则配置、构建集成 lint 检查、以及自定 - **THEN** 规则文件 SHALL 放置在 `frontend/eslint-rules/` 目录下 - **THEN** `eslint.config.js` SHALL 通过相对路径引用本地插件 - **THEN** 自定义规则 SHALL NOT 作为 npm 包发布 + +### Requirement: ESLint 与 Prettier 集成配置 + +前端 SHALL 在 `eslint.config.js` 中集成 `eslint-config-prettier`,确保 ESLint 和 Prettier 职责分离且不冲突。 + +#### Scenario: 职责分离 + +- **WHEN** 检查代码 +- **THEN** ESLint SHALL 负责代码质量检查(如未使用变量、语法错误) +- **THEN** Prettier SHALL 负责代码格式化(如缩进、引号、分号) +- **THEN** 两者 SHALL NOT 重复检查同一规则 diff --git a/openspec/specs/frontend/spec.md b/openspec/specs/frontend/spec.md index a77e6e6..342cd5b 100644 --- a/openspec/specs/frontend/spec.md +++ b/openspec/specs/frontend/spec.md @@ -508,8 +508,30 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面 - **THEN** Vite SHALL 对业务代码执行混淆处理 - **THEN** 混淆 SHALL 仅应用于 src 目录下的业务代码 - **THEN** 混淆 SHALL NOT 应用于 node_modules 中的第三方库 -- **THEN** 构建流程 SHALL 在 vite build 之前执行 ESLint 检查 +- **THEN** 构建流程 SHALL 在 vite build 之前执行 ESLint 检查和 Prettier 格式检查 - **THEN** ESLint 检查失败 SHALL 中断构建 +- **THEN** Prettier 格式检查失败 SHALL 中断构建 + +### Requirement: 开发环境格式化工具 + +前端 SHALL 配置开发环境格式化工具,确保开发者保存时自动格式化代码。 + +#### Scenario: VS Code 保存时自动格式化 + +- **WHEN** 开发者在 VS Code 中保存文件 +- **THEN** 文件 SHALL 自动使用 Prettier 格式化 +- **THEN** ESLint 可修复的问题 SHALL 自动修复 + +#### Scenario: 编辑器统一配置 + +- **WHEN** 开发者在编辑器中打开项目 +- **THEN** 编辑器 SHALL 自动应用 `.editorconfig` 配置 +- **THEN** 编辑器 SHALL 使用 2 空格缩进、UTF-8 编码、Unix 换行符 + +#### Scenario: VS Code 推荐安装扩展 + +- **WHEN** 开发者使用 VS Code 打开项目 +- **THEN** VS Code SHALL 提示安装 Prettier 和 ESLint 扩展 ### Requirement: 与后端 API 通信 diff --git a/openspec/specs/prettier-formatting/spec.md b/openspec/specs/prettier-formatting/spec.md new file mode 100644 index 0000000..8b88f91 --- /dev/null +++ b/openspec/specs/prettier-formatting/spec.md @@ -0,0 +1,232 @@ +# Prettier 代码格式化 + +## Purpose + +定义前端代码格式化规则、工具集成、编辑器配置,确保多人协作时代码风格一致。 + +## Requirements + +### Requirement: Prettier 核心配置 + +前端 SHALL 在 `.prettierrc` 文件中配置以下格式化规则: + +- `semi`: `false` — 语句末尾 SHALL NOT 使用分号 +- `singleQuote`: `true` — 字符串 SHALL 使用单引号 +- `jsxSingleQuote`: `true` — JSX 属性 SHALL 使用单引号 +- `tabWidth`: `2` — 缩进 SHALL 使用 2 个空格 +- `useTabs`: `false` — 缩进 SHALL NOT 使用制表符 +- `trailingComma`: `"es5"` — 多行结构末尾 SHALL 使用 ES5 兼容的尾随逗号 +- `printWidth`: `120` — 每行最大字符数 SHALL 为 120 +- `bracketSpacing`: `true` — 对象字面量花括号内 SHALL 有空格 +- `arrowParens`: `"always"` — 箭头函数参数 SHALL 始终使用括号 +- `endOfLine`: `"lf"` — 换行符 SHALL 使用 Unix 风格 (LF) +- `proseWrap`: `"preserve"` — Markdown 文本换行 SHALL 保持原样 +- `htmlWhitespaceSensitivity`: `"css"` — HTML 空白处理 SHALL 根据 CSS display 属性 +- `embeddedLanguageFormatting`: `"auto"` — 嵌入语言(如 Markdown 中的代码块)SHALL 自动格式化 +- `singleAttributePerLine`: `false` — JSX 多属性 SHALL NOT 强制每行一个 + +#### Scenario: 格式化 JavaScript 代码 + +- **WHEN** 运行 `prettier --write` 格式化 JavaScript 文件 +- **THEN** 代码 SHALL 使用单引号、无分号、2 空格缩进 +- **THEN** 行宽超过 120 字符时 SHALL 自动换行 + +#### Scenario: 格式化 TypeScript 代码 + +- **WHEN** 运行 `prettier --write` 格式化 TypeScript 文件 +- **THEN** 代码 SHALL 使用单引号、无分号、2 空格缩进 +- **THEN** type import SHALL 保持内联风格 `import { type Foo }` + +#### Scenario: 格式化 JSX 代码 + +- **WHEN** 运行 `prettier --write` 格式化 JSX 文件 +- **THEN** JSX 属性 SHALL 使用单引号 +- **THEN** 多属性 SHALL 根据行宽自动换行 + +#### Scenario: 格式化 SCSS 代码 + +- **WHEN** 运行 `prettier --write` 格式化 SCSS 文件 +- **THEN** 代码 SHALL 使用 2 空格缩进 +- **THEN** CSS 规则 SHALL 保持一致的格式 + +#### Scenario: 格式化 JSON 文件 + +- **WHEN** 运行 `prettier --write` 格式化 JSON 文件 +- **THEN** JSON SHALL 使用 2 空格缩进 +- **THEN** JSON SHALL 保持尾随换行 + +#### Scenario: 格式化 Markdown 文件 + +- **WHEN** 运行 `prettier --write` 格式化 Markdown 文件 +- **THEN** 文本换行 SHALL 保持原样 +- **THEN** 代码块 SHALL 自动格式化 + +### Requirement: Prettier 忽略文件配置 + +前端 SHALL 在 `.prettierignore` 文件中配置以下忽略规则: + +- `node_modules` — 依赖目录 SHALL NOT 格式化 +- `dist` — 构建输出 SHALL NOT 格式化 +- `dist-ssr` — SSR 构建输出 SHALL NOT 格式化 +- `bun.lock` — Bun 锁文件 SHALL NOT 格式化 +- `package-lock.json` — npm 锁文件 SHALL NOT 格式化 +- `yarn.lock` — Yarn 锁文件 SHALL NOT 格式化 +- `pnpm-lock.yaml` — pnpm 锁文件 SHALL NOT 格式化 +- `.env.*` — 环境变量文件 SHALL NOT 格式化 +- `*.local` — 本地配置文件 SHALL NOT 格式化 +- `coverage` — 测试覆盖率报告 SHALL NOT 格式化 +- `**/*.snap` — Jest snapshot 文件 SHALL NOT 格式化 +- `**/__snapshots__/**` — Jest snapshot 目录 SHALL NOT 格式化 +- `*.svg` — SVG 文件 SHALL NOT 格式化 +- `*.min.js` — 压缩的 JS 文件 SHALL NOT 格式化 +- `*.min.css` — 压缩的 CSS 文件 SHALL NOT 格式化 +- `openspec/changes/archive/` — 已归档的变更 SHALL NOT 格式化 + +#### Scenario: 不格式化依赖目录 + +- **WHEN** 运行 `prettier --write .` +- **THEN** `node_modules` 目录 SHALL NOT 被格式化 + +#### Scenario: 不格式化锁文件 + +- **WHEN** 运行 `prettier --write .` +- **THEN** `bun.lock` 文件 SHALL NOT 被格式化 + +#### Scenario: 不格式化测试快照 + +- **WHEN** 运行 `prettier --write .` +- **THEN** `**/*.snap` 文件 SHALL NOT 被格式化 +- **THEN** `**/__snapshots__/**` 目录 SHALL NOT 被格式化 + +#### Scenario: 不格式化 SVG 文件 + +- **WHEN** 运行 `prettier --write .` +- **THEN** `*.svg` 文件 SHALL NOT 被格式化 + +### Requirement: EditorConfig 配置 + +前端 SHALL 在 `.editorconfig` 文件中配置以下编辑器设置: + +- `root = true` — 声明为根配置文件 +- `[*]` `charset = utf-8` — 所有文件 SHALL 使用 UTF-8 编码 +- `[*]` `indent_style = space` — 所有文件 SHALL 使用空格缩进 +- `[*]` `indent_size = 2` — 所有文件 SHALL 使用 2 空格缩进 +- `[*]` `end_of_line = lf` — 所有文件 SHALL 使用 Unix 换行符 +- `[*]` `insert_final_newline = true` — 所有文件 SHALL 在末尾插入空行 +- `[*]` `trim_trailing_whitespace = true` — 所有文件 SHALL 删除行尾空白 +- `[*.md]` `trim_trailing_whitespace = false` — Markdown 文件 SHALL NOT 删除行尾空白(Markdown 语法需要) + +#### Scenario: 编辑器使用统一缩进 + +- **WHEN** 开发者在编辑器中打开项目 +- **THEN** 编辑器 SHALL 自动使用 2 空格缩进 +- **THEN** 编辑器 SHALL NOT 使用制表符缩进 + +#### Scenario: 编辑器使用统一换行符 + +- **WHEN** 开发者在编辑器中创建新文件 +- **THEN** 编辑器 SHALL 使用 Unix 换行符 (LF) +- **THEN** 编辑器 SHALL NOT 使用 Windows 换行符 (CRLF) + +#### Scenario: 编辑器使用统一编码 + +- **WHEN** 开发者在编辑器中保存文件 +- **THEN** 文件 SHALL 使用 UTF-8 编码保存 + +### Requirement: VS Code 扩展推荐 + +前端 SHALL 在 `.vscode/extensions.json` 文件中推荐以下扩展: + +- `esbenp.prettier-vscode` — Prettier 格式化扩展 +- `dbaeumer.vscode-eslint` — ESLint 检查扩展 + +#### Scenario: VS Code 提示安装扩展 + +- **WHEN** 开发者使用 VS Code 打开项目 +- **THEN** VS Code SHALL 提示安装推荐的扩展 +- **THEN** 推荐列表 SHALL 包含 Prettier 和 ESLint 扩展 + +### Requirement: VS Code 格式化设置 + +前端 SHALL 在 `.vscode/settings.json` 文件中配置以下设置: + +- `editor.formatOnSave = true` — 保存时 SHALL 自动格式化 +- `editor.defaultFormatter = "esbenp.prettier-vscode"` — 默认格式化器 SHALL 为 Prettier +- `editor.codeActionsOnSave.source.fixAll.eslint = "explicit"` — 保存时 SHALL 自动修复 ESLint 问题 + +#### Scenario: 保存时自动格式化 + +- **WHEN** 开发者在 VS Code 中保存文件 +- **THEN** 文件 SHALL 自动使用 Prettier 格式化 +- **THEN** ESLint 可修复的问题 SHALL 自动修复 + +#### Scenario: 使用 Prettier 作为默认格式化器 + +- **WHEN** 开发者在 VS Code 中使用格式化命令 +- **THEN** SHALL 使用 Prettier 进行格式化 +- **THEN** SHALL NOT 使用其他格式化器 + +### Requirement: Prettier 与 ESLint 集成 + +前端 SHALL 在 `eslint.config.js` 中导入 `eslint-config-prettier` 配置,关闭与 Prettier 冲突的 ESLint 规则。 + +#### Scenario: ESLint 配置集成 Prettier + +- **WHEN** 配置 `eslint.config.js` +- **THEN** SHALL 导入 `eslint-config-prettier` +- **THEN** `eslint-config-prettier` SHALL 放在配置数组的最后 +- **THEN** 与 Prettier 冲突的 ESLint 规则 SHALL 被关闭 + +#### Scenario: ESLint 与 Prettier 不冲突 + +- **WHEN** 运行 `eslint .` 和 `prettier --check .` +- **THEN** ESLint 检查和 Prettier 格式化 SHALL NOT 产生冲突 +- **THEN** 同一文件 SHALL NOT 同时报告 ESLint 错误和 Prettier 格式问题 + +### Requirement: 格式化脚本配置 + +前端 SHALL 在 `package.json` 中配置以下脚本: + +- `format = "prettier --write ."` — 格式化所有文件 +- `format:check = "prettier --check ."` — 检查文件格式 +- `check = "bun run lint && bun run format:check"` — 检查 lint 和格式 +- `fix = "bun run lint:fix && bun run format"` — 修复 lint 问题并格式化 + +#### Scenario: 运行格式化命令 + +- **WHEN** 执行 `bun run format` +- **THEN** SHALL 运行 `prettier --write .` +- **THEN** 所有文件 SHALL 被格式化 + +#### Scenario: 运行格式检查命令 + +- **WHEN** 执行 `bun run format:check` +- **THEN** SHALL 运行 `prettier --check .` +- **THEN** 未格式化的文件 SHALL 报告错误 + +#### Scenario: 运行统一检查命令 + +- **WHEN** 执行 `bun run check` +- **THEN** SHALL 运行 `bun run lint && bun run format:check` +- **THEN** lint 错误和格式问题 SHALL 都被检查 + +#### Scenario: 运行统一修复命令 + +- **WHEN** 执行 `bun run fix` +- **THEN** SHALL 运行 `bun run lint:fix && bun run format` +- **THEN** lint 问题 SHALL 被修复 +- **THEN** 文件 SHALL 被格式化 + +### Requirement: Prettier 依赖安装 + +前端 SHALL 安装以下依赖: + +- `prettier` — Prettier 核心库 +- `eslint-config-prettier` — 关闭与 Prettier 冲突的 ESLint 规则 + +#### Scenario: 安装 Prettier 依赖 + +- **WHEN** 执行 `bun install` +- **THEN** `prettier` SHALL 被安装 +- **THEN** `eslint-config-prettier` SHALL 被安装 +- **THEN** 依赖版本 SHALL 在 `package.json` 中声明 From bcf5ca89e57512b052d80a3f4da2c2d73a53edc3 Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Fri, 24 Apr 2026 13:50:51 +0800 Subject: [PATCH 3/3] =?UTF-8?q?refactor:=20Makefile=20=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E5=91=BD=E4=BB=A4=E8=87=AA=E5=8A=A8=E5=AE=89=E8=A3=85=E4=BE=9D?= =?UTF-8?q?=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Makefile | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/Makefile b/Makefile index a1d3acb..8739080 100644 --- a/Makefile +++ b/Makefile @@ -119,25 +119,28 @@ test-mysql-quick: # 前端 # ============================================ -frontend-build: - cd frontend && bun install && bun run build +frontend-install: + cd frontend && bun install -frontend-dev: +frontend-build: frontend-install + cd frontend && bun run build + +frontend-dev: frontend-install cd frontend && bun dev -frontend-test: +frontend-test: frontend-install cd frontend && bun run test -frontend-test-watch: +frontend-test-watch: frontend-install cd frontend && bun run test:watch -frontend-test-coverage: +frontend-test-coverage: frontend-install cd frontend && bun run test:coverage -frontend-test-e2e: +frontend-test-e2e: frontend-install cd frontend && bun run test:e2e -frontend-lint: +frontend-lint: frontend-install cd frontend && bun run lint frontend-clean: