refactor: 全面重构前端 Dashboard 为 TDesign + TanStack Query 分组表格布局
- 卡片式布局改为分组 PrimaryTable,Modal 改为 Drawer - 手写 hooks 替换为 TanStack Query(轮询/缓存/条件查询) - CSS 607行精简至73行,颜色迁移至 TDesign tokens - 可用率进度条颜色按 10% 一档红→绿渐变 - 新增纯函数测试 34 项全通过(排序/筛选/色阶阈值) - 同步更新主 specs 并归档变更文档
This commit is contained in:
@@ -27,9 +27,9 @@ src/
|
||||
shared/
|
||||
api.ts 前后端共享 TypeScript 类型
|
||||
web/ Vite + React 前端 Dashboard
|
||||
components/ UI 组件(卡片、分组、模态框、状态条等)
|
||||
constants/ 常量定义(类型映射等)
|
||||
hooks/ 数据轮询和详情管理 hooks
|
||||
components/ UI 组件(表格、分组、Drawer、状态条等)
|
||||
constants/ 常量定义(列配置、类型映射、排序/筛选/颜色阈值函数)
|
||||
hooks/ TanStack Query 数据层(useTargetDetail 集成轮询/条件查询)
|
||||
utils/ 前端工具函数
|
||||
scripts/ 开发、构建和 smoke test 脚本
|
||||
tests/ Bun test 测试
|
||||
|
||||
74
bun.lock
74
bun.lock
@@ -5,15 +5,19 @@
|
||||
"": {
|
||||
"name": "gateway-checker",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.100.10",
|
||||
"@xmldom/xmldom": "^0.9.10",
|
||||
"cheerio": "^1.2.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"recharts": "^3.8.1",
|
||||
"tdesign-icons-react": "^0.6.4",
|
||||
"tdesign-react": "^1.16.9",
|
||||
"xpath": "^0.0.34",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tanstack/react-query-devtools": "^5.100.10",
|
||||
"@types/bun": "^1.3.13",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
@@ -55,6 +59,8 @@
|
||||
|
||||
"@babel/parser": ["@babel/parser@7.29.3", "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.3.tgz", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA=="],
|
||||
|
||||
"@babel/runtime": ["@babel/runtime@7.29.2", "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.29.2.tgz", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
|
||||
|
||||
"@babel/template": ["@babel/template@7.28.6", "https://registry.npmmirror.com/@babel/template/-/template-7.28.6.tgz", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
|
||||
|
||||
"@babel/traverse": ["@babel/traverse@7.29.0", "https://registry.npmmirror.com/@babel/traverse/-/traverse-7.29.0.tgz", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
|
||||
@@ -107,6 +113,8 @@
|
||||
|
||||
"@oxc-project/types": ["@oxc-project/types@0.128.0", "https://registry.npmmirror.com/@oxc-project/types/-/types-0.128.0.tgz", {}, "sha512-huv1Y/LzBJkBVHt3OlC7u0zHBW9qXf1FdD7sGmc1rXc2P1mTwHssYv7jyGx5KAACSCH+9B3Bhn6Z9luHRvf7pQ=="],
|
||||
|
||||
"@popperjs/core": ["@popperjs/core@2.11.8", "https://registry.npmmirror.com/@popperjs/core/-/core-2.11.8.tgz", {}, "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="],
|
||||
|
||||
"@reduxjs/toolkit": ["@reduxjs/toolkit@2.11.2", "https://registry.npmmirror.com/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ=="],
|
||||
|
||||
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.18", "https://registry.npmmirror.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.18.tgz", { "os": "android", "cpu": "arm64" }, "sha512-lIDyUAfD7U3+BWKzdxMbJcsYHuqXqmGz40aeRqvuAm3y5TkJSYTBW2RDrn65DJFPQqVjUAUqq5uz8urzQ8aBdQ=="],
|
||||
@@ -145,6 +153,14 @@
|
||||
|
||||
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "https://registry.npmmirror.com/@standard-schema/utils/-/utils-0.3.0.tgz", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
|
||||
|
||||
"@tanstack/query-core": ["@tanstack/query-core@5.100.10", "https://registry.npmmirror.com/@tanstack/query-core/-/query-core-5.100.10.tgz", {}, "sha512-8UR0yJR+GiQ40m3lPhUr0xbfAupe6GSQiksSBSa9SM2NjezFyxXCIA69/lz8cSoNKZLrw1/PktIyQBJcVeMi3w=="],
|
||||
|
||||
"@tanstack/query-devtools": ["@tanstack/query-devtools@5.100.10", "https://registry.npmmirror.com/@tanstack/query-devtools/-/query-devtools-5.100.10.tgz", {}, "sha512-3DmJf25hDPus5IpVvp6ujXv6bKV2zPzI9vpbAmpJigsL/H6DPvPjmf7/Q9yVKEke//8fgeQ45abjgnLuyYxAiw=="],
|
||||
|
||||
"@tanstack/react-query": ["@tanstack/react-query@5.100.10", "https://registry.npmmirror.com/@tanstack/react-query/-/react-query-5.100.10.tgz", { "dependencies": { "@tanstack/query-core": "5.100.10" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-FLaZf2RCrA/Zgp4aiu5tG3TyasTRO7aZ99skxQpr3Hg/zXOhu6yq5FZCYQ/tRaJtM9ylnoK8tFK7PolXQadv6Q=="],
|
||||
|
||||
"@tanstack/react-query-devtools": ["@tanstack/react-query-devtools@5.100.10", "https://registry.npmmirror.com/@tanstack/react-query-devtools/-/react-query-devtools-5.100.10.tgz", { "dependencies": { "@tanstack/query-devtools": "5.100.10" }, "peerDependencies": { "@tanstack/react-query": "^5.100.10", "react": "^18 || ^19" } }, "sha512-zes0+o9ef5rAZXJ9f/SeaLs2nufJaeVkZkl/Or9NGrWVF41kL9Od9ED9nCwtQlgiF2VGtrzhEw5AU/igAO+aAg=="],
|
||||
|
||||
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.13", "https://registry.npmmirror.com/@types/bun/-/bun-1.3.13.tgz", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="],
|
||||
@@ -179,8 +195,12 @@
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.2.3", "https://registry.npmmirror.com/@types/react-dom/-/react-dom-19.2.3.tgz", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||
|
||||
"@types/sortablejs": ["@types/sortablejs@1.15.9", "https://registry.npmmirror.com/@types/sortablejs/-/sortablejs-1.15.9.tgz", {}, "sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ=="],
|
||||
|
||||
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "https://registry.npmmirror.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
|
||||
|
||||
"@types/validator": ["@types/validator@13.15.10", "https://registry.npmmirror.com/@types/validator/-/validator-13.15.10.tgz", {}, "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.59.2", "https://registry.npmmirror.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.2.tgz", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.2", "@typescript-eslint/type-utils": "8.59.2", "@typescript-eslint/utils": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.59.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ=="],
|
||||
|
||||
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.59.2", "https://registry.npmmirror.com/@typescript-eslint/parser/-/parser-8.59.2.tgz", { "dependencies": { "@typescript-eslint/scope-manager": "8.59.2", "@typescript-eslint/types": "8.59.2", "@typescript-eslint/typescript-estree": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ=="],
|
||||
@@ -229,6 +249,8 @@
|
||||
|
||||
"cheerio-select": ["cheerio-select@2.1.0", "https://registry.npmmirror.com/cheerio-select/-/cheerio-select-2.1.0.tgz", { "dependencies": { "boolbase": "^1.0.0", "css-select": "^5.1.0", "css-what": "^6.1.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1" } }, "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g=="],
|
||||
|
||||
"classnames": ["classnames@2.5.1", "https://registry.npmmirror.com/classnames/-/classnames-2.5.1.tgz", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="],
|
||||
|
||||
"clsx": ["clsx@2.1.1", "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
"convert-source-map": ["convert-source-map@2.0.0", "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
@@ -263,6 +285,8 @@
|
||||
|
||||
"d3-timer": ["d3-timer@3.0.1", "https://registry.npmmirror.com/d3-timer/-/d3-timer-3.0.1.tgz", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
|
||||
|
||||
"dayjs": ["dayjs@1.11.10", "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.10.tgz", {}, "sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"decimal.js-light": ["decimal.js-light@2.5.1", "https://registry.npmmirror.com/decimal.js-light/-/decimal.js-light-2.5.1.tgz", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="],
|
||||
@@ -271,6 +295,8 @@
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"dom-helpers": ["dom-helpers@5.2.1", "https://registry.npmmirror.com/dom-helpers/-/dom-helpers-5.2.1.tgz", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="],
|
||||
|
||||
"dom-serializer": ["dom-serializer@2.0.0", "https://registry.npmmirror.com/dom-serializer/-/dom-serializer-2.0.0.tgz", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
|
||||
|
||||
"domelementtype": ["domelementtype@2.3.0", "https://registry.npmmirror.com/domelementtype/-/domelementtype-2.3.0.tgz", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
|
||||
@@ -339,6 +365,8 @@
|
||||
|
||||
"hermes-parser": ["hermes-parser@0.25.1", "https://registry.npmmirror.com/hermes-parser/-/hermes-parser-0.25.1.tgz", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
|
||||
|
||||
"hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "https://registry.npmmirror.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="],
|
||||
|
||||
"htmlparser2": ["htmlparser2@10.1.0", "https://registry.npmmirror.com/htmlparser2/-/htmlparser2-10.1.0.tgz", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="],
|
||||
|
||||
"iconv-lite": ["iconv-lite@0.6.3", "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
|
||||
@@ -399,10 +427,16 @@
|
||||
|
||||
"locate-path": ["locate-path@6.0.0", "https://registry.npmmirror.com/locate-path/-/locate-path-6.0.0.tgz", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
||||
|
||||
"lodash-es": ["lodash-es@4.18.1", "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.18.1.tgz", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="],
|
||||
|
||||
"loose-envify": ["loose-envify@1.4.0", "https://registry.npmmirror.com/loose-envify/-/loose-envify-1.4.0.tgz", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
|
||||
|
||||
"lru-cache": ["lru-cache@5.1.1", "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"mitt": ["mitt@3.0.1", "https://registry.npmmirror.com/mitt/-/mitt-3.0.1.tgz", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.12", "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.12.tgz", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],
|
||||
@@ -413,6 +447,8 @@
|
||||
|
||||
"nth-check": ["nth-check@2.1.1", "https://registry.npmmirror.com/nth-check/-/nth-check-2.1.1.tgz", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
|
||||
|
||||
"object-assign": ["object-assign@4.1.1", "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||
|
||||
"optionator": ["optionator@0.9.4", "https://registry.npmmirror.com/optionator/-/optionator-0.9.4.tgz", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
|
||||
|
||||
"p-limit": ["p-limit@3.1.0", "https://registry.npmmirror.com/p-limit/-/p-limit-3.1.0.tgz", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
|
||||
@@ -429,6 +465,8 @@
|
||||
|
||||
"path-key": ["path-key@3.1.1", "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
|
||||
"performance-now": ["performance-now@2.1.0", "https://registry.npmmirror.com/performance-now/-/performance-now-2.1.0.tgz", {}, "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.4", "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||
@@ -439,22 +477,32 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"prop-types": ["prop-types@15.8.1", "https://registry.npmmirror.com/prop-types/-/prop-types-15.8.1.tgz", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
|
||||
|
||||
"punycode": ["punycode@2.3.1", "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
|
||||
"raf": ["raf@3.4.1", "https://registry.npmmirror.com/raf/-/raf-3.4.1.tgz", { "dependencies": { "performance-now": "^2.1.0" } }, "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA=="],
|
||||
|
||||
"react": ["react@19.2.6", "https://registry.npmmirror.com/react/-/react-19.2.6.tgz", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="],
|
||||
|
||||
"react-dom": ["react-dom@19.2.6", "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.6.tgz", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="],
|
||||
|
||||
"react-fast-compare": ["react-fast-compare@3.2.2", "https://registry.npmmirror.com/react-fast-compare/-/react-fast-compare-3.2.2.tgz", {}, "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ=="],
|
||||
|
||||
"react-is": ["react-is@19.2.6", "https://registry.npmmirror.com/react-is/-/react-is-19.2.6.tgz", {}, "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw=="],
|
||||
|
||||
"react-redux": ["react-redux@9.2.0", "https://registry.npmmirror.com/react-redux/-/react-redux-9.2.0.tgz", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="],
|
||||
|
||||
"react-transition-group": ["react-transition-group@4.4.5", "https://registry.npmmirror.com/react-transition-group/-/react-transition-group-4.4.5.tgz", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="],
|
||||
|
||||
"recharts": ["recharts@3.8.1", "https://registry.npmmirror.com/recharts/-/recharts-3.8.1.tgz", { "dependencies": { "@reduxjs/toolkit": "^1.9.0 || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", "reselect": "5.1.1", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg=="],
|
||||
|
||||
"redux": ["redux@5.0.1", "https://registry.npmmirror.com/redux/-/redux-5.0.1.tgz", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="],
|
||||
|
||||
"redux-thunk": ["redux-thunk@3.1.0", "https://registry.npmmirror.com/redux-thunk/-/redux-thunk-3.1.0.tgz", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="],
|
||||
|
||||
"regenerator-runtime": ["regenerator-runtime@0.14.1", "https://registry.npmmirror.com/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", {}, "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="],
|
||||
|
||||
"reselect": ["reselect@5.1.1", "https://registry.npmmirror.com/reselect/-/reselect-5.1.1.tgz", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="],
|
||||
|
||||
"rolldown": ["rolldown@1.0.0-rc.18", "https://registry.npmmirror.com/rolldown/-/rolldown-1.0.0-rc.18.tgz", { "dependencies": { "@oxc-project/types": "=0.128.0", "@rolldown/pluginutils": "1.0.0-rc.18" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.18", "@rolldown/binding-darwin-arm64": "1.0.0-rc.18", "@rolldown/binding-darwin-x64": "1.0.0-rc.18", "@rolldown/binding-freebsd-x64": "1.0.0-rc.18", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.18", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.18", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.18", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.18", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.18", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.18", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.18", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.18", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.18", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.18", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.18" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-phmyKBpuBdRYDf4hgyynGAYn/rDDe+iZXKVJ7WX5b1zQzpLkP5oJRPGsfJuHdzPMlyyEO/4sPW6yfSx2gf7lVg=="],
|
||||
@@ -469,15 +517,21 @@
|
||||
|
||||
"shebang-regex": ["shebang-regex@3.0.0", "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
|
||||
"sortablejs": ["sortablejs@1.15.7", "https://registry.npmmirror.com/sortablejs/-/sortablejs-1.15.7.tgz", {}, "sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"tdesign-icons-react": ["tdesign-icons-react@0.6.4", "https://registry.npmmirror.com/tdesign-icons-react/-/tdesign-icons-react-0.6.4.tgz", { "dependencies": { "@babel/runtime": "^7.16.5", "classnames": "^2.2.6" }, "peerDependencies": { "react": ">=16.13.1", "react-dom": ">=16.13.1" } }, "sha512-USAoi9vBWcwcJT45VqR3dRqX1MeAsn/RhHVx4bLwplhrlvE80ZQ1N9V+6F3HqE1Qe9mMDbtRM8Ul80+lALScww=="],
|
||||
|
||||
"tdesign-react": ["tdesign-react@1.16.9", "https://registry.npmmirror.com/tdesign-react/-/tdesign-react-1.16.9.tgz", { "dependencies": { "@babel/runtime": "~7.26.7", "@popperjs/core": "~2.11.2", "@types/sortablejs": "^1.10.7", "@types/validator": "^13.1.3", "classnames": "~2.5.1", "dayjs": "1.11.10", "hoist-non-react-statics": "~3.3.2", "lodash-es": "^4.17.21", "mitt": "^3.0.0", "raf": "~3.4.1", "react-fast-compare": "^3.2.2", "react-is": "^18.2.0", "react-transition-group": "~4.4.1", "sortablejs": "^1.15.0", "tdesign-icons-react": "^0.6.4", "tslib": "~2.3.1", "validator": "~13.15.0" }, "peerDependencies": { "react": ">=16.13.1", "react-dom": ">=16.13.1" } }, "sha512-C3uZRTkJ1iQ62BrMkuvqvBK+4HEuhl82rABxa6kAHGHL3eBI4DPfzAJGF0T3b+DKCBeJxb0x10elumT6NkQEaw=="],
|
||||
|
||||
"tiny-invariant": ["tiny-invariant@1.3.3", "https://registry.npmmirror.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.16", "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.16.tgz", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
|
||||
|
||||
"ts-api-utils": ["ts-api-utils@2.5.0", "https://registry.npmmirror.com/ts-api-utils/-/ts-api-utils-2.5.0.tgz", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
"tslib": ["tslib@2.3.1", "https://registry.npmmirror.com/tslib/-/tslib-2.3.1.tgz", {}, "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="],
|
||||
|
||||
"type-check": ["type-check@0.4.0", "https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
||||
|
||||
@@ -495,6 +549,8 @@
|
||||
|
||||
"use-sync-external-store": ["use-sync-external-store@1.6.0", "https://registry.npmmirror.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
|
||||
|
||||
"validator": ["validator@13.15.35", "https://registry.npmmirror.com/validator/-/validator-13.15.35.tgz", {}, "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw=="],
|
||||
|
||||
"victory-vendor": ["victory-vendor@37.3.6", "https://registry.npmmirror.com/victory-vendor/-/victory-vendor-37.3.6.tgz", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="],
|
||||
|
||||
"vite": ["vite@8.0.11", "https://registry.npmmirror.com/vite/-/vite-8.0.11.tgz", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.14", "rolldown": "1.0.0-rc.18", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.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", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-Jz1mxtUBR5xTT65VOdJZUUeoyLtqljmFkiUXhPTLZka3RDc9vpi/xXkyrnsdRcm2lIi3l3GPMnAidTsEGIj3Ow=="],
|
||||
@@ -517,18 +573,34 @@
|
||||
|
||||
"zod-validation-error": ["zod-validation-error@4.0.2", "https://registry.npmmirror.com/zod-validation-error/-/zod-validation-error-4.0.2.tgz", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
|
||||
|
||||
"@emnapi/core/tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"@emnapi/runtime/tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"@emnapi/wasi-threads/tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@reduxjs/toolkit/immer": ["immer@11.1.8", "https://registry.npmmirror.com/immer/-/immer-11.1.8.tgz", {}, "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA=="],
|
||||
|
||||
"@tybys/wasm-util/tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "https://registry.npmmirror.com/ignore/-/ignore-7.0.5.tgz", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/semver": ["semver@7.8.0", "https://registry.npmmirror.com/semver/-/semver-7.8.0.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="],
|
||||
|
||||
"hoist-non-react-statics/react-is": ["react-is@16.13.1", "https://registry.npmmirror.com/react-is/-/react-is-16.13.1.tgz", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
||||
|
||||
"htmlparser2/entities": ["entities@7.0.1", "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
|
||||
"parse5/entities": ["entities@6.0.1", "https://registry.npmmirror.com/entities/-/entities-6.0.1.tgz", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
|
||||
|
||||
"prop-types/react-is": ["react-is@16.13.1", "https://registry.npmmirror.com/react-is/-/react-is-16.13.1.tgz", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
||||
|
||||
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.18", "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.18.tgz", {}, "sha512-CUY5Mnhe64xQBGZEEXQ5WyZwsc1JU3vAZLIxtrsBt3LO6UOb+C8GunVKqe9sT8NeWb4lqSaoJtp2xo6GxT1MNw=="],
|
||||
|
||||
"tdesign-react/@babel/runtime": ["@babel/runtime@7.26.10", "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.26.10.tgz", { "dependencies": { "regenerator-runtime": "^0.14.0" } }, "sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw=="],
|
||||
|
||||
"tdesign-react/react-is": ["react-is@18.3.1", "https://registry.npmmirror.com/react-is/-/react-is-18.3.1.tgz", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,10 @@ context: |
|
||||
- 涉及模块结构、API、实体等变更时同步更新README.md
|
||||
- 新增代码优先复用已有组件、工具、依赖库,不引入新依赖
|
||||
- 新增的逻辑必须编写完善的测试,并保证测试的正确性,不允许跳过任何测试
|
||||
- 这是基于bun实现的前端后一体化项目,使用bun作为唯一包管理器,严禁使用pnpm、npm,使用bunx运行工具,严禁使用npx、pnpx
|
||||
- src/server目录下是基于bun实现的后端代码
|
||||
- src/web目录下是基于vite、react、TDesign实现的前端代码
|
||||
- 代码开发优先使用公共组件实现功能逻辑(优先级:官方库>主流三方库>项目公共工具>自行实现)
|
||||
- Git提交: 仅中文; 格式"类型: 简短描述", 类型: feat/fix/refactor/docs/style/test/chore; 多行描述空行后写详细说明
|
||||
- 禁止创建git操作task
|
||||
- 积极使用subagents精心设计并行任务,节省上下文空间,加速任务执行
|
||||
|
||||
@@ -1,98 +1,81 @@
|
||||
## Purpose
|
||||
|
||||
定义 Dashboard 的卡片式分组布局:按分组展示目标卡片、响应式网格、卡片内容结构和交互行为。
|
||||
定义 Dashboard 的分组表格布局:按分组展示目标表格行、TDesign PrimaryTable 列定义、排序筛选、行交互和 DOWN 行视觉强化。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 分组卡片布局
|
||||
Dashboard SHALL 按分组展示所有拨测目标,每个分组包含带统计的分组标题和固定宽度的卡片网格。
|
||||
Dashboard SHALL 按 group 字段将目标分组,每个分组包含带统计的分组标题和独立 TDesign PrimaryTable 表格。
|
||||
|
||||
#### Scenario: 按分组展示目标
|
||||
- **WHEN** 用户打开 Dashboard 页面
|
||||
- **THEN** 页面 SHALL 按分组展示目标卡片,"默认分组" 排在最上面,其余分组按 YAML 配置顺序排列
|
||||
- **THEN** 页面 SHALL 按 group 字段将目标分组展示,"default" 分组排在最上面,其余分组按 YAML 配置顺序排列
|
||||
|
||||
#### Scenario: 分组标题带统计徽章
|
||||
#### Scenario: 分组标题带统计标签
|
||||
- **WHEN** 页面渲染某个分组
|
||||
- **THEN** 分组标题 SHALL 显示分组名称和三个徽章:总数(蓝色)、正常数(绿色)、异常数(红色),徽章仅显示数字
|
||||
- **THEN** 分组标题 SHALL 使用 TDesign Space + Tag 组件显示分组名称和三个标签:总数(theme=primary, variant=light)、正常数(theme=success, variant=light)、异常数(theme=danger, variant=light),标签仅显示数字
|
||||
|
||||
#### Scenario: 分组统计徽章提示
|
||||
- **WHEN** 鼠标悬停在分组统计徽章上
|
||||
- **THEN** 徽章 SHALL 显示提示文字("总数"、"正常"、"异常")
|
||||
#### Scenario: 分组统计标签提示
|
||||
- **WHEN** 鼠标悬停在分组统计标签上
|
||||
- **THEN** 标签 SHALL 通过 TDesign Tag 的 title 属性显示提示文字("总数"、"正常"、"异常")
|
||||
|
||||
#### Scenario: "default" 分组显示名称
|
||||
- **WHEN** 分组名称为 "default"
|
||||
- **THEN** 分组标题 SHALL 显示 "默认分组"
|
||||
|
||||
### Requirement: 响应式卡片网格
|
||||
Dashboard SHALL 使用固定宽度的卡片配合 Flexbox 流动布局,容器无最大宽度限制。
|
||||
Dashboard SHALL 使用 TDesign PrimaryTable 展示每个分组的目标,表格宽度自适应容器。
|
||||
|
||||
#### Scenario: Dashboard 容器占满宽度
|
||||
- **WHEN** 用户打开 Dashboard 页面
|
||||
- **THEN** Dashboard 容器 SHALL 占满浏览器宽度,不设置 max-width 限制
|
||||
|
||||
#### Scenario: 卡片固定宽度
|
||||
- **WHEN** 页面渲染卡片(包括 Summary Cards 和 Target Cards)
|
||||
- **THEN** 每个卡片 SHALL 固定宽度 280px,使用 CSS 变量 `--dashboard-card-width` 统一控制
|
||||
|
||||
#### Scenario: 流动式布局
|
||||
#### Scenario: 表格自适应宽度
|
||||
- **WHEN** 视口宽度变化
|
||||
- **THEN** 卡片网格 SHALL 使用 Flexbox wrap 自动换行,根据可用宽度调整单行卡片数量
|
||||
- **THEN** 每个分组的 PrimaryTable SHALL 自适应容器宽度,不设置固定宽度
|
||||
|
||||
#### Scenario: 卡片左对齐
|
||||
- **WHEN** 页面渲染卡片网格
|
||||
- **THEN** 卡片 SHALL 左对齐排列,右侧自然留白
|
||||
|
||||
#### Scenario: 统一间距
|
||||
- **WHEN** 页面渲染 Summary Cards 和 Target Cards
|
||||
- **THEN** 两种卡片网格 SHALL 使用相同的 gap 间距(16px)
|
||||
#### Scenario: 分组间统一间距
|
||||
- **WHEN** 页面渲染多个分组
|
||||
- **THEN** 分组之间 SHALL 使用 TDesign Space 组件(direction=vertical, size=32px)统一间距
|
||||
|
||||
### Requirement: 目标卡片内容
|
||||
每个目标卡片 SHALL 展示目标名称、当前状态、类型标签、状态条和迷你耗时趋势线,采用垂直三层布局。
|
||||
每个分组的目标 SHALL 以 TDesign PrimaryTable 行的形式展示,包含状态、名称、类型、可用率、最近状态条、延迟和间隔 7 列。
|
||||
|
||||
#### Scenario: 卡片第一层内容
|
||||
- **WHEN** 卡片渲染
|
||||
- **THEN** 卡片第一层 SHALL 展示状态指示圆点(UP 绿色 / DOWN 红色)、目标名称和类型标签(HTTP / CMD)
|
||||
#### Scenario: 状态列渲染
|
||||
- **WHEN** 表格行渲染
|
||||
- **THEN** 状态列 SHALL 使用 StatusDot 组件渲染指示圆点,matched=true 显示绿色(--td-success-color),matched=false 显示红色(--td-error-color),宽度 80px,fixed="left",居中对齐
|
||||
|
||||
#### Scenario: 卡片名称完整提示
|
||||
- **WHEN** 目标名称过长被截断显示
|
||||
- **THEN** 鼠标悬停在名称上 SHALL 通过浏览器原生 tooltip 显示完整名称
|
||||
#### Scenario: 名称列渲染
|
||||
- **WHEN** 表格行渲染
|
||||
- **THEN** 名称列 SHALL 显示目标名称,超长名称自动省略(ellipsis)并通过 Tooltip 显示全名
|
||||
|
||||
#### Scenario: 卡片状态指示圆点
|
||||
- **WHEN** 目标最近一次拨测 matched=true
|
||||
- **THEN** 卡片状态圆点 SHALL 显示为绿色
|
||||
- **WHEN** 目标最近一次拨测 matched=false
|
||||
- **THEN** 卡片状态圆点 SHALL 显示为红色
|
||||
#### Scenario: 类型列渲染
|
||||
- **WHEN** 表格行渲染
|
||||
- **THEN** 类型列 SHALL 使用 TDesign Tag 组件(size=small, theme=primary, variant=light-outline)显示类型名称,宽度 80px
|
||||
|
||||
#### Scenario: 卡片第二层状态条
|
||||
- **WHEN** 卡片渲染且 recentSamples 数据可用
|
||||
- **THEN** 卡片第二层 SHALL 独占一行展示状态条,包含 30 个色块,每个采样点为一个色块:UP 显示绿色(#1fbf75),DOWN 显示红色(#e5484d),无数据显示灰色(#e2e8f0)
|
||||
#### Scenario: 可用率列渲染
|
||||
- **WHEN** 表格行渲染
|
||||
- **THEN** 可用率列 SHALL 使用 TDesign Progress 组件(theme=line, size=small)渲染,颜色按可用率数值每 10% 一档:0-10% 最红(#d54941),每升高 10% 色阶偏移一档,经过橙色区间,90-100% 最绿(#3dba60),宽度 160px
|
||||
|
||||
#### Scenario: 卡片第三层迷你耗时趋势线
|
||||
- **WHEN** 卡片渲染且 recentSamples 中有 durationMs 数据
|
||||
- **THEN** 卡片第三层 SHALL 独占一行展示基于 recharts 的迷你折线图,宽度占满卡片内容区(约 238px),高度 40px,展示最近 30 次检查的耗时趋势
|
||||
#### Scenario: 最近状态列渲染
|
||||
- **WHEN** 表格行渲染且 recentSamples 数据可用
|
||||
- **THEN** 最近状态列 SHALL 使用 StatusBar 组件展示 30 个色块,色块颜色使用 TDesign tokens:UP 使用 --td-success-color、DOWN 使用 --td-error-color、无数据使用 --td-bg-color-component-disabled,宽度 220px
|
||||
|
||||
#### Scenario: 卡片垂直布局间距
|
||||
- **WHEN** 卡片渲染
|
||||
- **THEN** 卡片三层之间 SHALL 使用 12px 的间距(gap)
|
||||
#### Scenario: 延迟列渲染
|
||||
- **WHEN** 表格行渲染
|
||||
- **THEN** 延迟列 SHALL 显示最近一次检查的延迟毫秒数,右对齐,颜色根据阈值变化:≤100ms 绿色、100-500ms 橙色、>500ms 红色,无数据显示"-",宽度 80px
|
||||
|
||||
#### Scenario: 间隔列渲染
|
||||
- **WHEN** 表格行渲染
|
||||
- **THEN** 间隔列 SHALL 显示检查间隔(如 "5s"、"30s"),居中对齐,宽度 72px
|
||||
|
||||
### Requirement: 卡片交互
|
||||
卡片 SHALL 支持 hover 效果和点击打开模态框。
|
||||
表格行 SHALL 支持 hover 效果和点击打开 Drawer。
|
||||
|
||||
#### Scenario: 卡片 hover 效果
|
||||
- **WHEN** 鼠标悬停在卡片上
|
||||
- **THEN** 卡片 SHALL 显示上浮效果(阴影加深)
|
||||
#### Scenario: 行 hover 效果
|
||||
- **WHEN** 鼠标悬停在表格行上
|
||||
- **THEN** 行 SHALL 显示 TDesign Table 内建的 hover 高亮效果
|
||||
|
||||
#### Scenario: 卡片点击打开详情
|
||||
- **WHEN** 用户点击某个目标卡片
|
||||
- **THEN** 系统 SHALL 打开该目标的详情模态框
|
||||
|
||||
### Requirement: 平滑过渡动画
|
||||
卡片 SHALL 具有平滑的交互过渡动画效果。
|
||||
|
||||
#### Scenario: 卡片悬停动画
|
||||
- **WHEN** 鼠标悬停在卡片上
|
||||
- **THEN** 卡片 SHALL 平滑过渡显示上浮效果(阴影加深、轻微上移),过渡时长 0.3s
|
||||
|
||||
#### Scenario: 布局变化过渡
|
||||
- **WHEN** 视口宽度变化导致卡片重新排列
|
||||
- **THEN** 卡片位置变化 SHALL 有平滑的过渡动画
|
||||
#### Scenario: 行点击打开详情
|
||||
- **WHEN** 用户点击某个目标表格行
|
||||
- **THEN** 系统 SHALL 打开该目标的详情 Drawer
|
||||
|
||||
@@ -1,57 +1,57 @@
|
||||
## Purpose
|
||||
|
||||
定义拨测系统的 React 前端 Dashboard:统计卡片、按分组卡片式布局、状态条和迷你趋势线可视化、目标详情模态框和时间范围筛选。
|
||||
定义拨测系统的 React 前端 Dashboard:TDesign Statistic 统计卡片、按分组表格布局、目标详情 Drawer、TanStack Query 数据轮询和页面加载/错误状态。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 总览统计卡片
|
||||
Dashboard SHALL 在页面顶部展示总览统计卡片,包含总目标数、正常数和异常数。
|
||||
Dashboard SHALL 在页面顶部使用 TDesign Statistic 组件展示总览统计,包含总目标数、正常数和异常数。
|
||||
|
||||
#### Scenario: 展示统计卡片
|
||||
- **WHEN** 用户打开 Dashboard 页面
|
||||
- **THEN** 页面顶部 SHALL 显示 3 个统计卡片:全部目标数、正常目标数、异常目标数
|
||||
- **THEN** 页面顶部 SHALL 使用 TDesign Row/Col 布局展示 3 个 TDesign Card + Statistic 组合:全部目标数(color=blue)、正常目标数(color=green)、异常目标数(color=red)
|
||||
|
||||
#### Scenario: 统计数据自动刷新
|
||||
- **WHEN** 页面处于打开状态
|
||||
- **THEN** 统计卡片 SHALL 每 5-10 秒自动刷新数据
|
||||
- **THEN** 统计卡片 SHALL 通过 TanStack Query 的 refetchInterval=8000 自动刷新数据
|
||||
|
||||
### Requirement: 卡片式分组布局
|
||||
Dashboard SHALL 使用按分组展示的卡片式布局替代表格布局,每个分组包含带统计的分组标题和响应式卡片网格。
|
||||
Dashboard SHALL 使用按分组展示的表格布局,每个分组包含带统计的分组标题和独立 TDesign PrimaryTable。
|
||||
|
||||
> 卡片布局、响应式网格、卡片内容和交互的详细规范见 `card-dashboard`。
|
||||
> 表格列定义、排序、筛选、行交互的详细规范见 `target-table`。
|
||||
|
||||
#### Scenario: 按分组渲染卡片
|
||||
#### Scenario: 按分组渲染表格
|
||||
- **WHEN** 用户打开 Dashboard 页面
|
||||
- **THEN** 页面 SHALL 按 group 字段将目标分组展示,每组一个区域,"默认分组" 排在最上面
|
||||
- **THEN** 页面 SHALL 按 group 字段将目标分组展示,每组一个 PrimaryTable,"default" 分组排在最上面
|
||||
|
||||
#### Scenario: 无分组时的展示
|
||||
- **WHEN** 所有目标均属于 "default" 分组
|
||||
- **THEN** 页面 SHALL 显示一个 "默认分组" 区域,卡片正常展示
|
||||
- **THEN** 页面 SHALL 显示一个 "默认分组" 区域,表格正常展示
|
||||
|
||||
### Requirement: 目标详情模态框
|
||||
Dashboard SHALL 提供模态框展示目标详情,包含时间范围筛选、多维统计图和分页检查记录列表。
|
||||
### Requirement: 目标详情 Drawer
|
||||
Dashboard SHALL 使用 TDesign Drawer 展示目标详情,包含时间范围筛选、Tabs 组织的统计图表和分页检查记录列表。
|
||||
|
||||
> 模态框的时间范围筛选、统计图表、检查结果列表和布局的详细规范见 `target-detail-modal`。
|
||||
> Drawer 的时间范围筛选、Tabs 面板内容、检查结果列表的详细规范见 `target-detail-drawer`。
|
||||
|
||||
#### Scenario: 打开模态框
|
||||
- **WHEN** 用户点击某个目标卡片
|
||||
- **THEN** 系统 SHALL 弹出模态框,占据视口 80% 宽度,展示该目标的详情
|
||||
#### Scenario: 打开 Drawer
|
||||
- **WHEN** 用户点击某个目标表格行
|
||||
- **THEN** 系统 SHALL 从右侧滑出 Drawer(placement="right", size="60%"),展示该目标的详情
|
||||
|
||||
#### Scenario: 关闭模态框
|
||||
- **WHEN** 用户点击模态框关闭按钮或模态框外部区域
|
||||
- **THEN** 模态框 SHALL 关闭
|
||||
#### Scenario: 关闭 Drawer
|
||||
- **WHEN** 用户点击 Drawer 关闭按钮、ESC 键或遮罩层
|
||||
- **THEN** Drawer SHALL 关闭
|
||||
|
||||
### Requirement: 页面加载与错误状态
|
||||
Dashboard SHALL 正确处理加载状态和 API 错误,适配卡片式布局。
|
||||
Dashboard SHALL 使用 TDesign 组件正确处理加载状态和 API 错误。
|
||||
|
||||
#### Scenario: 首次加载
|
||||
- **WHEN** 页面首次加载且数据尚未返回
|
||||
- **THEN** 页面 SHALL 显示加载状态指示
|
||||
- **THEN** 表格 SHALL 显示 TDesign Loading 加载状态
|
||||
|
||||
#### Scenario: API 请求失败
|
||||
- **WHEN** 前端轮询 API 请求失败
|
||||
- **THEN** 页面 SHALL 显示错误提示,并在下一次轮询周期自动重试
|
||||
- **WHEN** 前端 API 请求失败
|
||||
- **THEN** 页面 SHALL 使用 TDesign Alert 组件(theme=error)显示错误提示
|
||||
|
||||
#### Scenario: 模态框内部加载状态
|
||||
- **WHEN** 模态框内趋势数据或历史记录正在加载
|
||||
- **THEN** 对应图表或列表区域 SHALL 显示加载指示
|
||||
#### Scenario: Drawer 内部加载状态
|
||||
- **WHEN** Drawer 内趋势数据或历史记录正在加载
|
||||
- **THEN** 趋势面板 SHALL 显示 TDesign Skeleton 加载占位,记录表格 SHALL 显示 loading 状态
|
||||
|
||||
79
openspec/specs/tanstack-query-data-layer/spec.md
Normal file
79
openspec/specs/tanstack-query-data-layer/spec.md
Normal file
@@ -0,0 +1,79 @@
|
||||
## Purpose
|
||||
|
||||
定义 TanStack Query 数据层:QueryClient 配置、queryKey 工厂、轮询策略、条件查询和开发调试面板。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: TanStack Query 数据层
|
||||
前端 SHALL 使用 TanStack Query(@tanstack/react-query)管理所有 API 请求,替代手写 fetch hooks。
|
||||
|
||||
#### Scenario: QueryClient 配置
|
||||
- **WHEN** 应用启动
|
||||
- **THEN** 系统 SHALL 创建 QueryClient,默认配置 retry=1、refetchOnWindowFocus=true、staleTime=5000
|
||||
|
||||
#### Scenario: QueryClientProvider 挂载
|
||||
- **WHEN** 应用渲染
|
||||
- **THEN** 根组件 SHALL 包裹在 QueryClientProvider 中,提供 QueryClient 实例
|
||||
|
||||
### Requirement: queryKey 工厂
|
||||
系统 SHALL 提供统一的 queryKey 工厂函数,确保 queryKey 的唯一性和一致性。
|
||||
|
||||
#### Scenario: summary queryKey
|
||||
- **WHEN** 查询 summary 数据
|
||||
- **THEN** queryKey SHALL 为 ["summary"]
|
||||
|
||||
#### Scenario: targets queryKey
|
||||
- **WHEN** 查询 targets 数据
|
||||
- **THEN** queryKey SHALL 为 ["targets"]
|
||||
|
||||
#### Scenario: trend queryKey
|
||||
- **WHEN** 查询某目标的趋势数据
|
||||
- **THEN** queryKey SHALL 为 ["trend", targetId, from, to]
|
||||
|
||||
#### Scenario: history queryKey
|
||||
- **WHEN** 查询某目标的历史记录
|
||||
- **THEN** queryKey SHALL 为 ["history", targetId, from, to, page]
|
||||
|
||||
### Requirement: Summary 轮询查询
|
||||
系统 SHALL 使用 useQuery 实现总览统计的自动轮询。
|
||||
|
||||
#### Scenario: summary 自动轮询
|
||||
- **WHEN** Dashboard 页面处于打开状态
|
||||
- **THEN** 系统 SHALL 每 8 秒自动请求 /api/summary,使用 refetchInterval=8000
|
||||
|
||||
#### Scenario: summary 后台刷新
|
||||
- **WHEN** 页面处于后台标签页
|
||||
- **THEN** 系统 SHALL 暂停轮询(refetchIntervalInBackground=false)
|
||||
|
||||
### Requirement: Targets 轮询查询
|
||||
系统 SHALL 使用 useQuery 实现目标列表的自动轮询。
|
||||
|
||||
#### Scenario: targets 自动轮询
|
||||
- **WHEN** Dashboard 页面处于打开状态
|
||||
- **THEN** 系统 SHALL 每 8 秒自动请求 /api/targets,使用 refetchInterval=8000
|
||||
|
||||
### Requirement: 条件查询
|
||||
趋势和历史记录查询 SHALL 使用 enabled 条件控制,仅在目标被选中时触发。
|
||||
|
||||
#### Scenario: 未选中目标时不请求
|
||||
- **WHEN** 用户未点击任何目标表格行
|
||||
- **THEN** trend 和 history 的 useQuery SHALL enabled=false,不发起请求
|
||||
|
||||
#### Scenario: 选中目标时自动请求
|
||||
- **WHEN** 用户点击目标表格行
|
||||
- **THEN** trend 和 history 的 useQuery SHALL enabled=true,自动发起请求
|
||||
|
||||
#### Scenario: 时间范围变化时重新请求
|
||||
- **WHEN** 用户更改时间范围
|
||||
- **THEN** trend 和 history 的 useQuery SHALL 因 queryKey 变化自动重新请求
|
||||
|
||||
### Requirement: 开发调试面板
|
||||
开发环境下 SHALL 挂载 TanStack Query Devtools。
|
||||
|
||||
#### Scenario: 开发环境显示 Devtools
|
||||
- **WHEN** 应用在开发模式下运行
|
||||
- **THEN** 页面 SHALL 显示 ReactQueryDevtools 浮动面板
|
||||
|
||||
#### Scenario: 生产环境排除 Devtools
|
||||
- **WHEN** 应用在生产模式下构建
|
||||
- **THEN** ReactQueryDevtools SHALL 不被包含在产物中
|
||||
99
openspec/specs/target-detail-drawer/spec.md
Normal file
99
openspec/specs/target-detail-drawer/spec.md
Normal file
@@ -0,0 +1,99 @@
|
||||
## Purpose
|
||||
|
||||
定义目标详情 Drawer:时间范围筛选(TDesign RadioGroup + DateRangePicker)、Tabs 组织概览/趋势/记录三个面板、统计图表和分页检查结果列表。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 目标详情 Drawer
|
||||
Dashboard SHALL 在用户点击目标表格行后从右侧滑出 Drawer,展示该目标的详细统计信息和检查记录。
|
||||
|
||||
#### Scenario: 打开 Drawer
|
||||
- **WHEN** 用户点击某个目标表格行
|
||||
- **THEN** 系统 SHALL 从右侧滑出 Drawer(placement="right"),宽度为视口 60%
|
||||
|
||||
#### Scenario: Drawer 标题栏
|
||||
- **WHEN** Drawer 渲染
|
||||
- **THEN** 标题栏 SHALL 显示 StatusDot、目标名称和类型标签(TDesign Tag),以及内建关闭按钮
|
||||
|
||||
#### Scenario: 关闭 Drawer
|
||||
- **WHEN** 用户点击关闭按钮、ESC 键或遮罩层
|
||||
- **THEN** Drawer SHALL 关闭
|
||||
|
||||
#### Scenario: Drawer 数据同步
|
||||
- **WHEN** Drawer 打开期间后台轮询刷新了 targets 数据
|
||||
- **THEN** Drawer 中 selectedTarget 的状态 SHALL 随之同步更新
|
||||
|
||||
### Requirement: 时间范围选择器
|
||||
Drawer SHALL 在 Tabs 外层提供时间范围选择器,影响概览/趋势/记录三个面板的数据。
|
||||
|
||||
#### Scenario: 快捷时间按钮
|
||||
- **WHEN** Drawer 渲染
|
||||
- **THEN** 时间选择区 SHALL 显示 TDesign RadioGroup(variant=default-filled)快捷按钮:1h、6h、24h、7d
|
||||
|
||||
#### Scenario: 点击快捷按钮
|
||||
- **WHEN** 用户点击快捷按钮(如 "24h")
|
||||
- **THEN** 系统 SHALL 自动设置对应的起止时间,DateRangePicker 显示对应的时间范围,该按钮高亮
|
||||
|
||||
#### Scenario: 自定义日期时间范围
|
||||
- **WHEN** 用户通过 TDesign DateRangePicker(mode=date, enableTimePicker)修改时间范围
|
||||
- **THEN** 快捷按钮 SHALL 取消高亮,系统重新请求对应时间范围的数据
|
||||
|
||||
#### Scenario: 默认时间范围
|
||||
- **WHEN** Drawer 打开
|
||||
- **THEN** 时间选择器 SHALL 默认选中 "24h" 快捷按钮
|
||||
|
||||
#### Scenario: 筛选触发数据刷新
|
||||
- **WHEN** 时间范围发生变化
|
||||
- **THEN** 系统 SHALL 重新请求趋势数据和历史记录
|
||||
|
||||
### Requirement: Tabs 内容组织
|
||||
Drawer 内部 SHALL 使用 TDesign Tabs 组织概览、趋势、记录三个面板。
|
||||
|
||||
#### Scenario: Tab 标签
|
||||
- **WHEN** Drawer 渲染
|
||||
- **THEN** Tabs SHALL 显示三个标签:概览、趋势、记录
|
||||
|
||||
### Requirement: 概览面板
|
||||
概览 Tab SHALL 展示目标统计摘要和基本信息。
|
||||
|
||||
#### Scenario: 统计数值卡片
|
||||
- **WHEN** 概览面板渲染
|
||||
- **THEN** 面板 SHALL 使用 TDesign Statistic 组件展示 4 个统计值:总检查(color=blue)、正常(color=green)、异常(color=red)、可用率(color=green, suffix="%"),使用 TDesign Row/Col 横向排列
|
||||
|
||||
#### Scenario: 元信息展示
|
||||
- **WHEN** 概览面板渲染
|
||||
- **THEN** 面板 SHALL 使用 TDesign Descriptions 组件展示目标元信息:目标地址、检查间隔、最新检查时间、状态详情
|
||||
|
||||
#### Scenario: 状态分布环形图
|
||||
- **WHEN** 概览面板渲染
|
||||
- **THEN** 面板 SHALL 展示 recharts 环形图(StatusDonut),外圈显示 UP/DOWN 比例,中间显示可用率百分比
|
||||
|
||||
### Requirement: 趋势面板
|
||||
趋势 Tab SHALL 展示可用率和耗时趋势折线图。
|
||||
|
||||
#### Scenario: 趋势折线图
|
||||
- **WHEN** 趋势面板渲染且数据可用
|
||||
- **THEN** 面板 SHALL 展示 recharts 双 Y 轴折线图:耗时线(--td-brand-color)和可用率线(--td-success-color)
|
||||
|
||||
#### Scenario: 趋势数据加载中
|
||||
- **WHEN** 趋势数据正在加载
|
||||
- **THEN** 面板 SHALL 显示 TDesign Skeleton 加载占位
|
||||
|
||||
### Requirement: 记录面板
|
||||
记录 Tab SHALL 展示分页检查结果列表,使用 TDesign PrimaryTable。
|
||||
|
||||
#### Scenario: 检查结果表格
|
||||
- **WHEN** 记录面板渲染且数据可用
|
||||
- **THEN** 面板 SHALL 使用 TDesign PrimaryTable 展示检查结果,列包含:状态(TDesign Tag theme=success/danger)、时间、详情、耗时、错误信息
|
||||
|
||||
#### Scenario: 服务端分页
|
||||
- **WHEN** 检查结果总数超过一页
|
||||
- **THEN** 表格 SHALL 使用内建 pagination(disableDataPage=true),分页器显示在表格底部
|
||||
|
||||
#### Scenario: 翻页触发请求
|
||||
- **WHEN** 用户切换分页页码
|
||||
- **THEN** 系统 SHALL 请求对应页码的服务端数据,表格更新
|
||||
|
||||
#### Scenario: 记录数据加载中
|
||||
- **WHEN** 历史记录正在加载
|
||||
- **THEN** 表格 SHALL 显示 loading 状态
|
||||
@@ -1,87 +1,91 @@
|
||||
## Purpose
|
||||
|
||||
定义目标详情模态框:时间范围筛选(快捷按钮 + 日期选择器)、多维统计图(可用率趋势、耗时趋势、状态分布环形图)和分页检查结果列表。
|
||||
定义目标详情 Drawer:时间范围筛选(TDesign RadioGroup 快捷按钮 + DateRangePicker)、Tabs 组织概览/趋势/记录三个面板、统计图表和分页检查结果列表。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 目标详情模态框
|
||||
Dashboard SHALL 在用户点击目标卡片后弹出模态框,展示该目标的详细统计图表和检查结果列表。
|
||||
### Requirement: 目标详情 Drawer
|
||||
Dashboard SHALL 在用户点击目标表格行后从右侧滑出 Drawer,展示该目标的详细统计图表和检查结果列表。
|
||||
|
||||
#### Scenario: 打开模态框
|
||||
- **WHEN** 用户点击某个目标卡片
|
||||
- **THEN** 系统 SHALL 弹出模态框,占据视口 80% 宽度,展示该目标的详情
|
||||
#### Scenario: 打开 Drawer
|
||||
- **WHEN** 用户点击某个目标表格行
|
||||
- **THEN** 系统 SHALL 从右侧滑出 Drawer(placement="right", size="60%"),展示该目标的详情
|
||||
|
||||
#### Scenario: 模态框默认时间范围
|
||||
- **WHEN** 模态框打开
|
||||
- **THEN** 筛选器 SHALL 默认选中"最近 24 小时"
|
||||
#### Scenario: Drawer 默认时间范围
|
||||
- **WHEN** Drawer 打开
|
||||
- **THEN** 筛选器 SHALL 默认选中 "24h" 快捷按钮
|
||||
|
||||
#### Scenario: 关闭模态框
|
||||
- **WHEN** 用户点击模态框关闭按钮或模态框外部区域
|
||||
- **THEN** 模态框 SHALL 关闭
|
||||
#### Scenario: 关闭 Drawer
|
||||
- **WHEN** 用户点击 Drawer 关闭按钮、ESC 键或遮罩层
|
||||
- **THEN** Drawer SHALL 关闭
|
||||
|
||||
### Requirement: 时间范围筛选
|
||||
模态框 SHALL 支持通过快捷按钮和自定义日期时间选择器筛选数据的时间范围。
|
||||
Drawer SHALL 支持通过 TDesign RadioGroup 快捷按钮和 DateRangePicker 筛选数据的时间范围。
|
||||
|
||||
#### Scenario: 快捷时间范围按钮
|
||||
- **WHEN** 模态框渲染
|
||||
- **THEN** 筛选栏 SHALL 显示快捷按钮:1h、6h、24h、7d,当前选中的按钮高亮显示
|
||||
- **WHEN** Drawer 渲染
|
||||
- **THEN** 筛选栏 SHALL 显示 TDesign RadioGroup(variant=default-filled)快捷按钮:1h、6h、24h、7d,当前选中的按钮高亮显示
|
||||
|
||||
#### Scenario: 点击快捷按钮
|
||||
- **WHEN** 用户点击快捷按钮(如 "24h")
|
||||
- **THEN** 筛选器 SHALL 自动设置对应的起止时间,日期选择器显示对应的时间范围,该按钮高亮
|
||||
- **THEN** 筛选器 SHALL 自动设置对应的起止时间,DateRangePicker 显示对应的时间范围,该按钮高亮
|
||||
|
||||
#### Scenario: 自定义日期时间选择
|
||||
- **WHEN** 用户通过日期时间选择器修改起止时间(分钟精度)
|
||||
- **WHEN** 用户通过 TDesign DateRangePicker(mode=date, enableTimePicker)修改起止时间
|
||||
- **THEN** 快捷按钮 SHALL 取消高亮,表示当前为自定义时间范围
|
||||
|
||||
#### Scenario: 筛选触发数据刷新
|
||||
- **WHEN** 时间范围发生变化(快捷按钮或自定义选择)
|
||||
- **THEN** 系统 SHALL 重新请求该时间范围内的趋势数据和历史记录
|
||||
- **THEN** 系统 SHALL 通过 TanStack Query 重新请求该时间范围内的趋势数据和历史记录
|
||||
|
||||
### Requirement: 统计图表展示
|
||||
模态框图表区 SHALL 展示可用率趋势折线图、耗时趋势折线图和状态分布环形图。
|
||||
Drawer 概览和趋势面板 SHALL 展示统计数值、目标元信息和可用率趋势折线图、状态分布环形图。
|
||||
|
||||
#### Scenario: 概览面板统计数值
|
||||
- **WHEN** 概览 Tab 加载完成
|
||||
- **THEN** 面板 SHALL 使用 TDesign Statistic 组件展示总检查、正常、异常、可用率四个数值,使用 TDesign Row/Col 横向排列
|
||||
|
||||
#### Scenario: 概览面板元信息
|
||||
- **WHEN** 概览 Tab 加载完成
|
||||
- **THEN** 面板 SHALL 使用 TDesign Descriptions 组件展示目标地址、检查间隔、最新检查时间、状态详情
|
||||
|
||||
#### Scenario: 可用率趋势折线图
|
||||
- **WHEN** 模态框加载完成且趋势数据可用
|
||||
- **THEN** 图表区 SHALL 展示可用率随时间变化的折线图,Y 轴为可用率百分比
|
||||
|
||||
#### Scenario: 耗时趋势折线图
|
||||
- **WHEN** 模态框加载完成且趋势数据可用
|
||||
- **THEN** 图表区 SHALL 展示耗时随时间变化的折线图,Y 轴为耗时毫秒数
|
||||
- **WHEN** 趋势 Tab 加载完成且数据可用
|
||||
- **THEN** 面板 SHALL 展示 recharts 双 Y 轴折线图:耗时线颜色使用 --td-brand-color,可用率线颜色使用 --td-success-color
|
||||
|
||||
#### Scenario: 状态分布环形图
|
||||
- **WHEN** 模态框加载完成
|
||||
- **THEN** 图表区 SHALL 展示环形图(Donut Chart),外圈显示 UP/DOWN 比例(绿色/红色),中间显示可用率百分比数字
|
||||
- **WHEN** 概览 Tab 加载完成
|
||||
- **THEN** 面板 SHALL 展示 recharts 环形图(Donut Chart),UP 颜色使用 --td-success-color,DOWN 颜色使用 --td-error-color,中间显示可用率百分比数字
|
||||
|
||||
### Requirement: 检查结果列表
|
||||
模态框检查记录列表 SHALL 展示当前筛选时间范围内的检查结果列表,支持分页浏览。
|
||||
Drawer 记录面板 SHALL 使用 TDesign PrimaryTable 展示检查结果,支持服务端分页。
|
||||
|
||||
#### Scenario: 展示检查结果
|
||||
- **WHEN** 模态框加载完成且历史记录可用
|
||||
- **THEN** 检查记录列表 SHALL 展示检查结果,每条包含时间戳、UP/DOWN 状态标记、耗时毫秒数、statusDetail 和 failure 信息
|
||||
- **WHEN** 记录 Tab 加载完成且历史记录可用
|
||||
- **THEN** 表格 SHALL 展示检查结果,每条包含状态(TDesign Tag)、时间戳、statusDetail、耗时毫秒数和 failure 信息
|
||||
|
||||
#### Scenario: 分页导航
|
||||
- **WHEN** 检查结果总数超过一页
|
||||
- **THEN** 列表底部 SHALL 展示分页器,用户可点击切换页码
|
||||
- **THEN** 表格底部 SHALL 展示内建 pagination 分页器(disableDataPage=true)
|
||||
|
||||
#### Scenario: 翻页刷新
|
||||
- **WHEN** 用户点击分页器切换页码
|
||||
- **THEN** 系统 SHALL 请求对应页码的历史记录数据,列表更新
|
||||
- **WHEN** 用户切换分页页码
|
||||
- **THEN** 系统 SHALL 通过 TanStack Query 请求对应页码的历史记录数据,表格更新
|
||||
|
||||
### Requirement: 模态框布局
|
||||
模态框 SHALL 采用自上而下布局,上方展示统计图表,下方展示检查记录列表。
|
||||
### Requirement: 内容组织布局
|
||||
Drawer SHALL 使用 TDesign Tabs 组织概览、趋势、记录三个面板。
|
||||
|
||||
#### Scenario: 自上而下渲染
|
||||
- **WHEN** 模态框渲染
|
||||
- **THEN** 内容区域 SHALL 分为上下两部分,上方展示统计图表,下方展示检查结果列表和分页器
|
||||
#### Scenario: Tabs 组织内容
|
||||
- **WHEN** Drawer 渲染
|
||||
- **THEN** 内容区域 SHALL 使用 TDesign Tabs 组件分为概览、趋势、记录三个标签页
|
||||
|
||||
### Requirement: 模态框标题栏类型标签
|
||||
模态框标题栏 SHALL 显示目标类型标签,使用统一的类型显示映射系统。
|
||||
### Requirement: 标题栏类型标签
|
||||
Drawer 标题栏 SHALL 显示目标类型标签,使用统一的类型显示映射系统。
|
||||
|
||||
#### Scenario: 类型标签显示
|
||||
- **WHEN** 模态框标题栏渲染
|
||||
- **THEN** 标题栏 SHALL 在目标名称旁显示类型标签(HTTP / CMD)
|
||||
- **WHEN** Drawer 标题栏渲染
|
||||
- **THEN** 标题栏 SHALL 在目标名称旁显示 TDesign Tag 类型标签(HTTP / CMD)
|
||||
|
||||
#### Scenario: 类型标签使用映射系统
|
||||
- **WHEN** 模态框渲染类型标签
|
||||
- **WHEN** Drawer 渲染类型标签
|
||||
- **THEN** 类型标签 SHALL 使用统一的类型显示映射函数,不硬编码映射逻辑
|
||||
|
||||
98
openspec/specs/target-table/spec.md
Normal file
98
openspec/specs/target-table/spec.md
Normal file
@@ -0,0 +1,98 @@
|
||||
## Purpose
|
||||
|
||||
定义分组表格的列配置、排序、筛选、行交互和 DOWN 行视觉强化。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 分组表格展示
|
||||
Dashboard SHALL 按 group 字段将目标分组,每个分组渲染一个独立的 TDesign PrimaryTable,分组间使用 TDesign Space 垂直排列。
|
||||
|
||||
#### Scenario: 按分组渲染独立表格
|
||||
- **WHEN** 用户打开 Dashboard 页面
|
||||
- **THEN** 页面 SHALL 按 group 字段将目标分组,每个分组包含带统计的分组标题和一个独立 PrimaryTable
|
||||
|
||||
#### Scenario: 分组顺序
|
||||
- **WHEN** 页面渲染多个分组
|
||||
- **THEN** "default" 分组 SHALL 排在最上面,其余分组按 YAML 配置中首次出现的顺序排列
|
||||
|
||||
#### Scenario: 分组标题统计标签
|
||||
- **WHEN** 页面渲染某个分组的标题
|
||||
- **THEN** 标题 SHALL 使用 TDesign Tag 组件显示分组名称和三个统计标签:总数(theme=primary, variant=light)、正常数(theme=success, variant=light)、异常数(theme=danger, variant=light)
|
||||
|
||||
#### Scenario: "default" 分组显示名称
|
||||
- **WHEN** 分组名称为 "default"
|
||||
- **THEN** 分组标题 SHALL 显示 "默认分组"
|
||||
|
||||
### Requirement: 表格列定义
|
||||
每个分组的 PrimaryTable SHALL 包含状态、名称、类型、可用率、最近状态条、延迟、间隔 7 列,不含分组列(同组内冗余)。
|
||||
|
||||
#### Scenario: 状态列
|
||||
- **WHEN** 表格渲染
|
||||
- **THEN** 状态列 SHALL 使用 StatusDot 组件渲染,fixed="left",宽度 80px,居中对齐,支持筛选(UP/DOWN/全部)
|
||||
|
||||
#### Scenario: 名称列
|
||||
- **WHEN** 表格渲染
|
||||
- **THEN** 名称列 SHALL 显示目标名称,支持字母排序(zh-CN),ellipsis 超长名称自动省略并 Tooltip 显示全名
|
||||
|
||||
#### Scenario: 类型列
|
||||
- **WHEN** 表格渲染
|
||||
- **THEN** 类型列 SHALL 使用 TDesign Tag 组件(size=small, theme=primary, variant=light-outline)显示类型名称,支持单选筛选
|
||||
|
||||
#### Scenario: 可用率列
|
||||
- **WHEN** 表格渲染
|
||||
- **THEN** 可用率列 SHALL 使用 TDesign Progress 组件(theme=line, size=small)渲染,颜色按可用率数值每 10% 一档:0-10% 最红(#d54941),每升高 10% 色阶偏移一档,经过橙色区间,90-100% 最绿(#3dba60),label 显示百分比数值,支持排序(升序优先,最差排最前)
|
||||
|
||||
#### Scenario: 最近状态列
|
||||
- **WHEN** 表格渲染
|
||||
- **THEN** 最近状态列 SHALL 使用 StatusBar 组件渲染 30 格采样色块,宽度 220px,色块使用 flex:1 自适应列宽
|
||||
|
||||
#### Scenario: 延迟列
|
||||
- **WHEN** 表格渲染
|
||||
- **THEN** 延迟列 SHALL 显示最近一次检查的延迟毫秒数,右对齐,颜色根据阈值变化:≤100ms 使用 --td-success-color、100-500ms 使用 --td-warning-color、>500ms 使用 --td-error-color,无数据显示"-",支持数值排序
|
||||
|
||||
#### Scenario: 间隔列
|
||||
- **WHEN** 表格渲染
|
||||
- **THEN** 间隔列 SHALL 显示检查间隔(如 "5s"、"30s"),居中对齐,宽度 72px
|
||||
|
||||
### Requirement: 默认排序
|
||||
表格 SHALL 默认按状态降序排列,异常(DOWN)目标排在最前面。
|
||||
|
||||
#### Scenario: 页面初始排序
|
||||
- **WHEN** 用户打开 Dashboard 页面
|
||||
- **THEN** 每个分组表格 SHALL 默认按状态降序排列,DOWN 目标排在同组最前面
|
||||
|
||||
### Requirement: DOWN 行视觉强化
|
||||
表格中状态为 DOWN 的行 SHALL 具有视觉区分。
|
||||
|
||||
#### Scenario: DOWN 行背景色
|
||||
- **WHEN** 目标最近一次检查 matched=false
|
||||
- **THEN** 该行 SHALL 使用浅红色背景(--td-error-color-light),与正常行形成视觉区分
|
||||
|
||||
### Requirement: 行点击交互
|
||||
表格行 SHALL 支持点击打开目标详情 Drawer。
|
||||
|
||||
#### Scenario: 点击行打开 Drawer
|
||||
- **WHEN** 用户点击某一行
|
||||
- **THEN** 系统 SHALL 打开该目标的详情 Drawer
|
||||
|
||||
#### Scenario: 行 hover 效果
|
||||
- **WHEN** 鼠标悬停在表格行上
|
||||
- **THEN** 行 SHALL 显示 hover 高亮效果(TDesign Table hover prop)
|
||||
|
||||
#### Scenario: 行 cursor 样式
|
||||
- **WHEN** 鼠标悬停在表格行上
|
||||
- **THEN** cursor SHALL 显示为 pointer
|
||||
|
||||
### Requirement: 表格外观
|
||||
表格 SHALL 使用 TDesign PrimaryTable 统一外观。
|
||||
|
||||
#### Scenario: 表格样式
|
||||
- **WHEN** 表格渲染
|
||||
- **THEN** 表格 SHALL 设置 size="small"、stripe、hover、bordered
|
||||
|
||||
### Requirement: 列定义复用
|
||||
所有分组的表格 SHALL 共享同一套列定义常量。
|
||||
|
||||
#### Scenario: 列定义提取为常量
|
||||
- **WHEN** 多个分组表格渲染
|
||||
- **THEN** 列定义 SHALL 从独立的 constants/target-table-columns.tsx 导入,不在组件中重复定义
|
||||
@@ -1,23 +1,23 @@
|
||||
## Purpose
|
||||
|
||||
定义目标类型(Target Type)的前端显示名称映射系统,支持从后端类型标识符到前端展示名称的可扩展转换。
|
||||
定义目标类型(Target Type)的前端显示名称映射系统,支持从后端类型标识符到 TDesign Tag 组件展示的可扩展转换。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 类型显示名称映射
|
||||
系统 SHALL 提供目标类型到显示名称的映射,将后端类型标识符转换为前端展示的简短名称。
|
||||
系统 SHALL 提供目标类型到显示名称的映射,将后端类型标识符转换为 TDesign Tag 组件的展示文本。
|
||||
|
||||
#### Scenario: HTTP 类型显示
|
||||
- **WHEN** 目标类型为 "http"
|
||||
- **THEN** 前端 SHALL 显示 "HTTP"
|
||||
- **THEN** 前端 SHALL 使用 TDesign Tag 组件(size=small, theme=primary, variant=light-outline)显示 "HTTP"
|
||||
|
||||
#### Scenario: Command 类型显示
|
||||
- **WHEN** 目标类型为 "command"
|
||||
- **THEN** 前端 SHALL 显示 "CMD"
|
||||
- **THEN** 前端 SHALL 使用 TDesign Tag 组件显示 "CMD"
|
||||
|
||||
#### Scenario: 未知类型处理
|
||||
- **WHEN** 目标类型不在映射表中
|
||||
- **THEN** 前端 SHALL 将类型名称转换为大写显示
|
||||
- **THEN** 前端 SHALL 将类型名称转换为大写显示在 TDesign Tag 组件中
|
||||
|
||||
### Requirement: 映射可扩展性
|
||||
类型映射系统 SHALL 支持后续新增类型,无需修改多处代码。
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tanstack/react-query-devtools": "^5.100.10",
|
||||
"@types/bun": "^1.3.13",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
@@ -34,11 +35,14 @@
|
||||
"vite": "^8.0.11"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.100.10",
|
||||
"@xmldom/xmldom": "^0.9.10",
|
||||
"cheerio": "^1.2.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"recharts": "^3.8.1",
|
||||
"tdesign-icons-react": "^0.6.4",
|
||||
"tdesign-react": "^1.16.9",
|
||||
"xpath": "^0.0.34"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { useEffect } from "react";
|
||||
import { useSummary } from "./hooks/useSummary";
|
||||
import { useTargets } from "./hooks/useTargets";
|
||||
import { useTargetDetail } from "./hooks/useTargetDetail";
|
||||
import { Alert, Loading } from "tdesign-react";
|
||||
import { useSummary, useTargets, useTargetDetail } from "./hooks/useTargetDetail";
|
||||
import { SummaryCards } from "./components/SummaryCards";
|
||||
import { TargetBoard } from "./components/TargetBoard";
|
||||
import { TargetDetailModal } from "./components/TargetDetailModal";
|
||||
import { TargetDetailDrawer } from "./components/TargetDetailDrawer";
|
||||
|
||||
export function App() {
|
||||
const { data: summary, loading: summaryLoading, error: summaryError } = useSummary();
|
||||
const { data: targets, error: targetsError } = useTargets();
|
||||
const { data: summary, isLoading: summaryLoading, error: summaryError } = useSummary();
|
||||
const { data: targets, isLoading: targetsLoading, error: targetsError } = useTargets();
|
||||
const {
|
||||
selectedTarget,
|
||||
trendData,
|
||||
@@ -17,25 +15,14 @@ export function App() {
|
||||
historyLoading,
|
||||
timeFrom,
|
||||
timeTo,
|
||||
openModal,
|
||||
closeModal,
|
||||
openDrawer,
|
||||
closeDrawer,
|
||||
handleTimeChange,
|
||||
handlePageChange,
|
||||
} = useTargetDetail();
|
||||
|
||||
const error = summaryError || targetsError;
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTarget) {
|
||||
document.body.style.overflow = "hidden";
|
||||
} else {
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [selectedTarget]);
|
||||
|
||||
return (
|
||||
<main className="dashboard">
|
||||
<header className="dashboard-header">
|
||||
@@ -43,25 +30,29 @@ export function App() {
|
||||
<p className="dashboard-subtitle">统一拨测平台</p>
|
||||
</header>
|
||||
|
||||
{error && <div className="error-banner">请求失败: {error},将在下一次轮询周期自动重试</div>}
|
||||
{error && <Alert theme="error" message={`请求失败: ${error.message}`} closeBtn />}
|
||||
|
||||
<SummaryCards summary={summary} loading={summaryLoading} />
|
||||
<TargetBoard targets={targets} onTargetClick={openModal} />
|
||||
|
||||
{selectedTarget && (
|
||||
<TargetDetailModal
|
||||
target={selectedTarget}
|
||||
trendData={trendData}
|
||||
trendLoading={trendLoading}
|
||||
historyData={historyData}
|
||||
historyLoading={historyLoading}
|
||||
timeFrom={timeFrom}
|
||||
timeTo={timeTo}
|
||||
onTimeChange={handleTimeChange}
|
||||
onPageChange={handlePageChange}
|
||||
onClose={closeModal}
|
||||
/>
|
||||
{summaryLoading && targetsLoading ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<>
|
||||
<SummaryCards summary={summary ?? null} />
|
||||
<TargetBoard targets={targets ?? []} onTargetClick={openDrawer} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<TargetDetailDrawer
|
||||
target={selectedTarget}
|
||||
trendData={trendData}
|
||||
trendLoading={trendLoading}
|
||||
historyData={historyData}
|
||||
historyLoading={historyLoading}
|
||||
timeFrom={timeFrom}
|
||||
timeTo={timeTo}
|
||||
onTimeChange={handleTimeChange}
|
||||
onPageChange={handlePageChange}
|
||||
onClose={closeDrawer}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { TargetStatus } from "../../shared/api";
|
||||
import { TargetCard } from "./TargetCard";
|
||||
|
||||
interface CardGridProps {
|
||||
targets: TargetStatus[];
|
||||
onTargetClick: (target: TargetStatus) => void;
|
||||
}
|
||||
|
||||
export function CardGrid({ targets, onTargetClick }: CardGridProps) {
|
||||
return (
|
||||
<div className="card-grid">
|
||||
{targets.map((target) => (
|
||||
<TargetCard key={target.id} target={target} onClick={() => onTargetClick(target)} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Space, Tag } from "tdesign-react";
|
||||
|
||||
interface GroupHeaderProps {
|
||||
name: string;
|
||||
total: number;
|
||||
@@ -9,19 +11,17 @@ export function GroupHeader({ name, total, up, down }: GroupHeaderProps) {
|
||||
const displayName = name === "default" ? "默认分组" : name;
|
||||
|
||||
return (
|
||||
<div className="group-header">
|
||||
<h2 className="group-title">{displayName}</h2>
|
||||
<div className="group-stats">
|
||||
<span className="stat-badge stat-badge-total" title="总数">
|
||||
{total}
|
||||
</span>
|
||||
<span className="stat-badge stat-badge-up" title="正常">
|
||||
{up}
|
||||
</span>
|
||||
<span className="stat-badge stat-badge-down" title="异常">
|
||||
{down}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Space align="center" size={8} style={{ marginBottom: 12 }}>
|
||||
<h2 style={{ margin: 0, fontSize: "1.1rem", fontWeight: 600 }}>{displayName}</h2>
|
||||
<Tag theme="primary" variant="light" title="总数">
|
||||
{total}
|
||||
</Tag>
|
||||
<Tag theme="success" variant="light" title="正常">
|
||||
{up}
|
||||
</Tag>
|
||||
<Tag theme="danger" variant="light" title="异常">
|
||||
{down}
|
||||
</Tag>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { Line, LineChart, ResponsiveContainer } from "recharts";
|
||||
import type { RecentSample } from "../../shared/api";
|
||||
|
||||
interface MiniSparklineProps {
|
||||
data: RecentSample[];
|
||||
}
|
||||
|
||||
export function MiniSparkline({ data }: MiniSparklineProps) {
|
||||
const chartData = data
|
||||
.filter((s) => s.durationMs !== null)
|
||||
.map((s) => ({ duration: s.durationMs! }))
|
||||
.reverse();
|
||||
|
||||
if (chartData.length === 0) {
|
||||
return <span className="sparkline-empty">-</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={40}>
|
||||
<LineChart data={chartData}>
|
||||
<Line type="monotone" dataKey="duration" stroke="#356dd2" strokeWidth={1.5} dot={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
interface PaginationProps {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
export function Pagination({ page, pageSize, total, onPageChange }: PaginationProps) {
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
const pages: number[] = [];
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
|
||||
const visiblePages = pages.filter((p) => {
|
||||
if (totalPages <= 7) return true;
|
||||
if (p === 1 || p === totalPages) return true;
|
||||
if (Math.abs(p - page) <= 1) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="pagination">
|
||||
<button className="pagination-btn" disabled={page <= 1} onClick={() => onPageChange(page - 1)}>
|
||||
<
|
||||
</button>
|
||||
{visiblePages.map((p, idx) => {
|
||||
const prev = visiblePages[idx - 1];
|
||||
const showEllipsis = prev !== undefined && p - prev > 1;
|
||||
return (
|
||||
<span key={p} className="pagination-items">
|
||||
{showEllipsis && <span className="pagination-ellipsis">...</span>}
|
||||
<button className={`pagination-btn ${p === page ? "active" : ""}`} onClick={() => onPageChange(p)}>
|
||||
{p}
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
<button className="pagination-btn" disabled={page >= totalPages} onClick={() => onPageChange(page + 1)}>
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,9 +7,17 @@ export function StatusBar({ samples }: StatusBarProps) {
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const sample = samples[i];
|
||||
if (sample) {
|
||||
blocks.push(<span key={i} className={`status-bar-block ${sample.up ? "status-bar-up" : "status-bar-down"}`} />);
|
||||
blocks.push(
|
||||
<span
|
||||
key={i}
|
||||
className="status-bar-block"
|
||||
style={{ background: sample.up ? "var(--td-success-color)" : "var(--td-error-color)" }}
|
||||
/>,
|
||||
);
|
||||
} else {
|
||||
blocks.push(<span key={i} className="status-bar-block status-bar-empty" />);
|
||||
blocks.push(
|
||||
<span key={i} className="status-bar-block" style={{ background: "var(--td-bg-color-component-disabled)" }} />,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@ interface StatusDonutProps {
|
||||
down: number;
|
||||
}
|
||||
|
||||
const UP_COLOR = "#1fbf75";
|
||||
const DOWN_COLOR = "#e5484d";
|
||||
const EMPTY_COLOR = "#e2e8f0";
|
||||
const UP_COLOR = "var(--td-success-color)";
|
||||
const DOWN_COLOR = "var(--td-error-color)";
|
||||
const EMPTY_COLOR = "var(--td-bg-color-component-disabled)";
|
||||
|
||||
export function StatusDonut({ up, down }: StatusDonutProps) {
|
||||
const total = up + down;
|
||||
|
||||
@@ -3,5 +3,12 @@ interface StatusDotProps {
|
||||
}
|
||||
|
||||
export function StatusDot({ up }: StatusDotProps) {
|
||||
return <span className={`status-dot ${up ? "status-up" : "status-down"}`} />;
|
||||
const color = up ? "var(--td-success-color)" : "var(--td-error-color)";
|
||||
const shadow = up ? "var(--td-success-color)" : "var(--td-error-color)";
|
||||
return (
|
||||
<span
|
||||
className="status-dot"
|
||||
style={{ background: color, boxShadow: `0 0 0 6px color-mix(in srgb, ${shadow} 14%, transparent)` }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,31 +1,28 @@
|
||||
import { Row, Col, Card, Statistic } from "tdesign-react";
|
||||
import type { SummaryResponse } from "../../shared/api";
|
||||
|
||||
interface SummaryCardsProps {
|
||||
summary: SummaryResponse | null;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function SummaryCards({ summary, loading }: SummaryCardsProps) {
|
||||
if (loading && !summary) {
|
||||
return <div className="summary-cards">加载中...</div>;
|
||||
}
|
||||
|
||||
export function SummaryCards({ summary }: SummaryCardsProps) {
|
||||
if (!summary) return null;
|
||||
|
||||
const cards = [
|
||||
{ label: "全部目标", value: summary.total, className: "card-total" },
|
||||
{ label: "正常", value: summary.up, className: "card-up" },
|
||||
{ label: "异常", value: summary.down, className: "card-down" },
|
||||
{ label: "全部目标", value: summary.total, color: "blue" as const },
|
||||
{ label: "正常", value: summary.up, color: "green" as const },
|
||||
{ label: "异常", value: summary.down, color: "red" as const },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="summary-cards">
|
||||
<Row gutter={16} style={{ marginBottom: 32 }}>
|
||||
{cards.map((card) => (
|
||||
<div key={card.className} className={`summary-card ${card.className}`}>
|
||||
<div className="card-value">{card.value}</div>
|
||||
<div className="card-label">{card.label}</div>
|
||||
</div>
|
||||
<Col key={card.label} span={4}>
|
||||
<Card bordered>
|
||||
<Statistic title={card.label} value={card.value} color={card.color} />
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</div>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Space } from "tdesign-react";
|
||||
import type { TargetStatus } from "../../shared/api";
|
||||
import { TargetGroup } from "./TargetGroup";
|
||||
|
||||
@@ -18,11 +19,16 @@ export function TargetBoard({ targets, onTargetClick }: TargetBoardProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const sortedGroups = Array.from(groups.entries()).sort(([a]) => {
|
||||
if (a === "default") return -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="target-board">
|
||||
{Array.from(groups.entries()).map(([name, groupTargets]) => (
|
||||
<Space direction="vertical" size={32} style={{ width: "100%" }}>
|
||||
{sortedGroups.map(([name, groupTargets]) => (
|
||||
<TargetGroup key={name} name={name} targets={groupTargets} onTargetClick={onTargetClick} />
|
||||
))}
|
||||
</div>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import type { TargetStatus } from "../../shared/api";
|
||||
import { StatusDot } from "./StatusDot";
|
||||
import { StatusBar } from "./StatusBar";
|
||||
import { MiniSparkline } from "./MiniSparkline";
|
||||
import { getTargetTypeDisplay } from "../constants/target-type-display";
|
||||
|
||||
interface TargetCardProps {
|
||||
target: TargetStatus;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function TargetCard({ target, onClick }: TargetCardProps) {
|
||||
const isUp = target.latestCheck?.matched;
|
||||
|
||||
return (
|
||||
<div className="target-card" onClick={onClick} role="button" tabIndex={0}>
|
||||
<div className="card-header">
|
||||
<StatusDot up={!!isUp} />
|
||||
<span className="card-name" title={target.name}>
|
||||
{target.name}
|
||||
</span>
|
||||
<span className="card-type-badge">{getTargetTypeDisplay(target.type)}</span>
|
||||
</div>
|
||||
<div className="card-status-bar">
|
||||
<StatusBar samples={target.recentSamples} />
|
||||
</div>
|
||||
<div className="card-sparkline">
|
||||
<MiniSparkline data={target.recentSamples} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
215
src/web/components/TargetDetailDrawer.tsx
Normal file
215
src/web/components/TargetDetailDrawer.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import {
|
||||
Drawer,
|
||||
Tabs,
|
||||
RadioGroup,
|
||||
DateRangePicker,
|
||||
Tag,
|
||||
Row,
|
||||
Col,
|
||||
Statistic,
|
||||
Descriptions,
|
||||
Skeleton,
|
||||
PrimaryTable,
|
||||
} from "tdesign-react";
|
||||
import type { TabValue } from "tdesign-react";
|
||||
import type { CheckResult, TargetStatus, TrendPoint, HistoryResponse } from "../../shared/api";
|
||||
import { StatusDot } from "./StatusDot";
|
||||
import { StatusDonut } from "./StatusDonut";
|
||||
import { TrendChart } from "./TrendChart";
|
||||
import { getTargetTypeDisplay } from "../constants/target-type-display";
|
||||
import { subtractHours } from "../utils/time";
|
||||
|
||||
interface TargetDetailDrawerProps {
|
||||
target: TargetStatus | null;
|
||||
trendData: TrendPoint[];
|
||||
trendLoading: boolean;
|
||||
historyData: HistoryResponse;
|
||||
historyLoading: boolean;
|
||||
timeFrom: string;
|
||||
timeTo: string;
|
||||
onTimeChange: (from: string, to: string) => void;
|
||||
onPageChange: (page: number) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const TIME_SHORTCUTS = [
|
||||
{ label: "1h", hours: 1, value: "1h" },
|
||||
{ label: "6h", hours: 6, value: "6h" },
|
||||
{ label: "24h", hours: 24, value: "24h" },
|
||||
{ label: "7d", hours: 168, value: "7d" },
|
||||
] as const;
|
||||
|
||||
const HISTORY_COLUMNS = [
|
||||
{
|
||||
colKey: "matched",
|
||||
title: "状态",
|
||||
width: 72,
|
||||
cell: ({ row }: { row: CheckResult; rowIndex: number; col: unknown; colIndex: number }) => (
|
||||
<Tag theme={row.matched ? "success" : "danger"} size="small">
|
||||
{row.matched ? "UP" : "DOWN"}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
colKey: "timestamp",
|
||||
title: "时间",
|
||||
width: 170,
|
||||
cell: ({ row }: { row: CheckResult; rowIndex: number; col: unknown; colIndex: number }) =>
|
||||
new Date(row.timestamp).toLocaleString("zh-CN"),
|
||||
},
|
||||
{
|
||||
colKey: "statusDetail",
|
||||
title: "详情",
|
||||
width: 100,
|
||||
cell: ({ row }: { row: CheckResult; rowIndex: number; col: unknown; colIndex: number }) => row.statusDetail ?? "-",
|
||||
},
|
||||
{
|
||||
colKey: "durationMs",
|
||||
title: "耗时",
|
||||
width: 80,
|
||||
align: "right" as const,
|
||||
cell: ({ row }: { row: CheckResult; rowIndex: number; col: unknown; colIndex: number }) =>
|
||||
row.durationMs !== null ? `${Math.round(row.durationMs)}ms` : "-",
|
||||
},
|
||||
{
|
||||
colKey: "failure",
|
||||
title: "错误信息",
|
||||
cell: ({ row }: { row: CheckResult; rowIndex: number; col: unknown; colIndex: number }) =>
|
||||
row.failure?.message ?? "",
|
||||
},
|
||||
];
|
||||
|
||||
export function TargetDetailDrawer({
|
||||
target,
|
||||
trendData,
|
||||
trendLoading,
|
||||
historyData,
|
||||
historyLoading,
|
||||
timeFrom,
|
||||
timeTo,
|
||||
onTimeChange,
|
||||
onPageChange,
|
||||
onClose,
|
||||
}: TargetDetailDrawerProps) {
|
||||
const [activeShortcut, setActiveShortcut] = useState<string>("24h");
|
||||
const [activeTab, setActiveTab] = useState<TabValue>("overview");
|
||||
|
||||
const handleShortcut = useCallback(
|
||||
(value: string) => {
|
||||
const shortcut = TIME_SHORTCUTS.find((s) => s.value === value);
|
||||
if (!shortcut) return;
|
||||
const now = new Date();
|
||||
const from = subtractHours(now, shortcut.hours);
|
||||
onTimeChange(from.toISOString(), now.toISOString());
|
||||
setActiveShortcut(value);
|
||||
},
|
||||
[onTimeChange],
|
||||
);
|
||||
|
||||
const handleDateRangeChange = useCallback(
|
||||
(value: Array<string | number | Date>) => {
|
||||
if (value && value.length === 2) {
|
||||
onTimeChange(new Date(value[0]!).toISOString(), new Date(value[1]!).toISOString());
|
||||
setActiveShortcut("");
|
||||
}
|
||||
},
|
||||
[onTimeChange],
|
||||
);
|
||||
|
||||
if (!target) return null;
|
||||
|
||||
const isUp = target.latestCheck?.matched;
|
||||
const totalChecks = trendData.reduce((sum, p) => sum + p.totalChecks, 0);
|
||||
const upChecks = trendData.reduce((sum, p) => sum + Math.round((p.availability / 100) * p.totalChecks), 0);
|
||||
const downChecks = totalChecks - upChecks;
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
visible={!!target}
|
||||
placement="right"
|
||||
size="60%"
|
||||
onClose={onClose}
|
||||
header={
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<StatusDot up={!!isUp} />
|
||||
<span style={{ fontWeight: 600 }}>{target.name}</span>
|
||||
<Tag size="small" theme="primary" variant="light-outline">
|
||||
{getTargetTypeDisplay(target.type)}
|
||||
</Tag>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 16, marginBottom: 16 }}>
|
||||
<RadioGroup
|
||||
variant="default-filled"
|
||||
value={activeShortcut}
|
||||
options={TIME_SHORTCUTS.map((s) => ({ label: s.label, value: s.value }))}
|
||||
onChange={handleShortcut}
|
||||
/>
|
||||
<DateRangePicker
|
||||
mode="date"
|
||||
enableTimePicker
|
||||
value={timeFrom && timeTo ? [timeFrom, timeTo] : undefined}
|
||||
onChange={handleDateRangeChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onChange={(val: TabValue) => setActiveTab(val)}>
|
||||
<Tabs.TabPanel value="overview" label="概览">
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col span={3}>
|
||||
<Statistic title="总检查" value={totalChecks} color="blue" />
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<Statistic title="正常" value={upChecks} color="green" />
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<Statistic title="异常" value={downChecks} color="red" />
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<Statistic title="可用率" value={target.stats?.availability ?? 0} color="green" suffix="%" />
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Descriptions
|
||||
items={[
|
||||
{ label: "目标地址", content: target.target },
|
||||
{ label: "检查间隔", content: target.interval },
|
||||
{
|
||||
label: "最新检查时间",
|
||||
content: target.latestCheck ? new Date(target.latestCheck.timestamp).toLocaleString("zh-CN") : "-",
|
||||
},
|
||||
{ label: "状态详情", content: target.latestCheck?.statusDetail ?? "-" },
|
||||
]}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
|
||||
<StatusDonut up={upChecks} down={downChecks} />
|
||||
</Tabs.TabPanel>
|
||||
|
||||
<Tabs.TabPanel value="trend" label="趋势">
|
||||
{trendLoading ? <Skeleton animation="gradient" /> : <TrendChart data={trendData} loading={false} />}
|
||||
</Tabs.TabPanel>
|
||||
|
||||
<Tabs.TabPanel value="history" label="记录">
|
||||
<PrimaryTable
|
||||
columns={HISTORY_COLUMNS}
|
||||
data={historyData.items}
|
||||
rowKey="timestamp"
|
||||
loading={historyLoading}
|
||||
disableDataPage
|
||||
pagination={{
|
||||
current: historyData.page,
|
||||
pageSize: historyData.pageSize,
|
||||
total: historyData.total,
|
||||
}}
|
||||
onPageChange={({ current }) => {
|
||||
if (current) onPageChange(current);
|
||||
}}
|
||||
/>
|
||||
</Tabs.TabPanel>
|
||||
</Tabs>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
import type { CheckResult, TargetStatus, TrendPoint } from "../../shared/api";
|
||||
import { TrendChart } from "./TrendChart";
|
||||
import { StatusDonut } from "./StatusDonut";
|
||||
import { StatusDot } from "./StatusDot";
|
||||
import { TimeRangePicker } from "./TimeRangePicker";
|
||||
import { Pagination } from "./Pagination";
|
||||
import { getTargetTypeDisplay } from "../constants/target-type-display";
|
||||
|
||||
interface TargetDetailModalProps {
|
||||
target: TargetStatus;
|
||||
trendData: TrendPoint[];
|
||||
trendLoading: boolean;
|
||||
historyData: HistoryData;
|
||||
historyLoading: boolean;
|
||||
timeFrom: string;
|
||||
timeTo: string;
|
||||
onTimeChange: (from: string, to: string) => void;
|
||||
onPageChange: (page: number) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface HistoryData {
|
||||
items: CheckResult[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export function TargetDetailModal({
|
||||
target,
|
||||
trendData,
|
||||
trendLoading,
|
||||
historyData,
|
||||
historyLoading,
|
||||
timeFrom,
|
||||
timeTo,
|
||||
onTimeChange,
|
||||
onPageChange,
|
||||
onClose,
|
||||
}: TargetDetailModalProps) {
|
||||
const isUp = target.latestCheck?.matched;
|
||||
const totalChecks = trendData.reduce((sum, p) => sum + p.totalChecks, 0);
|
||||
const upChecks = trendData.reduce((sum, p) => sum + Math.round((p.availability / 100) * p.totalChecks), 0);
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={onClose}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<div className="modal-title-row">
|
||||
<StatusDot up={!!isUp} />
|
||||
<h3 className="modal-title">{target.name}</h3>
|
||||
<span className="card-type-badge">{getTargetTypeDisplay(target.type)}</span>
|
||||
</div>
|
||||
<button className="modal-close-btn" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<TimeRangePicker from={timeFrom} to={timeTo} onChange={onTimeChange} />
|
||||
|
||||
<div className="modal-body">
|
||||
<div className="modal-charts">
|
||||
<div className="modal-chart-section">
|
||||
<h4>状态分布</h4>
|
||||
<StatusDonut up={upChecks} down={totalChecks - upChecks} />
|
||||
</div>
|
||||
<div className="modal-chart-section">
|
||||
<h4>趋势图</h4>
|
||||
<TrendChart data={trendData} loading={trendLoading} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-history-section">
|
||||
<h4>检查记录</h4>
|
||||
{historyLoading ? (
|
||||
<div className="history-loading">加载中...</div>
|
||||
) : historyData.items.length > 0 ? (
|
||||
<>
|
||||
<table className="history-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="ht-col-status">状态</th>
|
||||
<th className="ht-col-time">时间</th>
|
||||
<th className="ht-col-detail">详情</th>
|
||||
<th className="ht-col-latency">耗时</th>
|
||||
<th className="ht-col-error">错误信息</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{historyData.items.map((item, idx) => (
|
||||
<tr key={idx}>
|
||||
<td>
|
||||
<span className={`ht-status ${item.matched ? "text-up" : "text-down"}`}>
|
||||
{item.matched ? "UP" : "DOWN"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="ht-time">{new Date(item.timestamp).toLocaleString("zh-CN")}</td>
|
||||
<td className="ht-detail">{item.statusDetail ?? "-"}</td>
|
||||
<td className="ht-latency">
|
||||
{item.durationMs !== null ? `${Math.round(item.durationMs)}ms` : "-"}
|
||||
</td>
|
||||
<td className="ht-error">{item.failure?.message ?? ""}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<Pagination
|
||||
page={historyData.page}
|
||||
pageSize={historyData.pageSize}
|
||||
total={historyData.total}
|
||||
onPageChange={onPageChange}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="history-empty">暂无检查记录</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { TargetStatus } from "../../shared/api";
|
||||
import { GroupHeader } from "./GroupHeader";
|
||||
import { CardGrid } from "./CardGrid";
|
||||
import { PrimaryTable } from "tdesign-react";
|
||||
import { TARGET_TABLE_COLUMNS } from "../constants/target-table-columns";
|
||||
|
||||
interface TargetGroupProps {
|
||||
name: string;
|
||||
@@ -13,9 +14,24 @@ export function TargetGroup({ name, targets, onTargetClick }: TargetGroupProps)
|
||||
const down = targets.length - up;
|
||||
|
||||
return (
|
||||
<div className="target-group">
|
||||
<div>
|
||||
<GroupHeader name={name} total={targets.length} up={up} down={down} />
|
||||
<CardGrid targets={targets} onTargetClick={onTargetClick} />
|
||||
<PrimaryTable
|
||||
columns={TARGET_TABLE_COLUMNS}
|
||||
data={targets}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
stripe
|
||||
hover
|
||||
bordered
|
||||
defaultSort={[{ sortBy: "latestCheck.matched", descending: true }]}
|
||||
onRowClick={({ row }) => onTargetClick(row as TargetStatus)}
|
||||
rowClassName={({ row }) => {
|
||||
const target = row as TargetStatus;
|
||||
return target.latestCheck?.matched === false ? "row-down" : "";
|
||||
}}
|
||||
style={{ cursor: "pointer" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { subtractHours } from "../utils/time";
|
||||
|
||||
interface TimeRangePickerProps {
|
||||
from: string;
|
||||
to: string;
|
||||
onChange: (from: string, to: string) => void;
|
||||
}
|
||||
|
||||
const SHORTCUTS = [
|
||||
{ label: "1h", hours: 1 },
|
||||
{ label: "6h", hours: 6 },
|
||||
{ label: "24h", hours: 24 },
|
||||
{ label: "7d", hours: 168 },
|
||||
];
|
||||
|
||||
function toLocalDatetimeInput(date: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||
}
|
||||
|
||||
export function TimeRangePicker({ from, to, onChange }: TimeRangePickerProps) {
|
||||
const [activeShortcut, setActiveShortcut] = useState<string | null>("24h");
|
||||
|
||||
const handleShortcut = (label: string, hours: number) => {
|
||||
const now = new Date();
|
||||
const newFrom = subtractHours(now, hours);
|
||||
onChange(newFrom.toISOString(), now.toISOString());
|
||||
setActiveShortcut(label);
|
||||
};
|
||||
|
||||
const handleFromChange = (value: string) => {
|
||||
onChange(new Date(value).toISOString(), to);
|
||||
setActiveShortcut(null);
|
||||
};
|
||||
|
||||
const handleToChange = (value: string) => {
|
||||
onChange(from, new Date(value).toISOString());
|
||||
setActiveShortcut(null);
|
||||
};
|
||||
|
||||
const fromDate = new Date(from);
|
||||
const toDate = new Date(to);
|
||||
|
||||
return (
|
||||
<div className="time-range-picker">
|
||||
<div className="time-shortcuts">
|
||||
{SHORTCUTS.map((s) => (
|
||||
<button
|
||||
key={s.label}
|
||||
className={`time-shortcut-btn ${activeShortcut === s.label ? "active" : ""}`}
|
||||
onClick={() => handleShortcut(s.label, s.hours)}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="time-inputs">
|
||||
<input
|
||||
type="datetime-local"
|
||||
className="time-input"
|
||||
value={toLocalDatetimeInput(fromDate)}
|
||||
onChange={(e) => handleFromChange(e.target.value)}
|
||||
/>
|
||||
<span className="time-separator">~</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
className="time-input"
|
||||
value={toLocalDatetimeInput(toDate)}
|
||||
onChange={(e) => handleToChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,12 +24,12 @@ export function TrendChart({ data, loading }: TrendChartProps) {
|
||||
<div className="trend-chart">
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
|
||||
<XAxis dataKey="hour" tick={{ fontSize: 12 }} stroke="#94a3b8" />
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--td-border-level-2-color)" />
|
||||
<XAxis dataKey="hour" tick={{ fontSize: 12 }} stroke="var(--td-text-color-secondary)" />
|
||||
<YAxis
|
||||
yAxisId="duration"
|
||||
tick={{ fontSize: 12 }}
|
||||
stroke="#94a3b8"
|
||||
stroke="var(--td-text-color-secondary)"
|
||||
label={{ value: "ms", position: "insideTopRight", fontSize: 11 }}
|
||||
/>
|
||||
<YAxis
|
||||
@@ -37,7 +37,7 @@ export function TrendChart({ data, loading }: TrendChartProps) {
|
||||
orientation="right"
|
||||
domain={[0, 100]}
|
||||
tick={{ fontSize: 12 }}
|
||||
stroke="#94a3b8"
|
||||
stroke="var(--td-text-color-secondary)"
|
||||
label={{ value: "%", position: "insideTopLeft", fontSize: 11 }}
|
||||
/>
|
||||
<Tooltip
|
||||
@@ -53,7 +53,7 @@ export function TrendChart({ data, loading }: TrendChartProps) {
|
||||
yAxisId="duration"
|
||||
type="monotone"
|
||||
dataKey="avgDurationMs"
|
||||
stroke="#356dd2"
|
||||
stroke="var(--td-brand-color)"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
name="avgDurationMs"
|
||||
@@ -62,7 +62,7 @@ export function TrendChart({ data, loading }: TrendChartProps) {
|
||||
yAxisId="availability"
|
||||
type="monotone"
|
||||
dataKey="availability"
|
||||
stroke="#1fbf75"
|
||||
stroke="var(--td-success-color)"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
name="availability"
|
||||
|
||||
24
src/web/constants/color-threshold.ts
Normal file
24
src/web/constants/color-threshold.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
const AVAILABILITY_COLORS = [
|
||||
"#d54941", // 0-10%
|
||||
"#d96241", // 10-20%
|
||||
"#e37318", // 20-30%
|
||||
"#e89318", // 30-40%
|
||||
"#d9a818", // 40-50%
|
||||
"#b8b020", // 50-60%
|
||||
"#8dba30", // 60-70%
|
||||
"#6dba3f", // 70-80%
|
||||
"#4dba50", // 80-90%
|
||||
"#3dba60", // 90-100%
|
||||
];
|
||||
|
||||
export function getAvailabilityProgressColor(availability: number): string {
|
||||
const index = Math.min(Math.floor(availability / 10), 9);
|
||||
return AVAILABILITY_COLORS[index]!;
|
||||
}
|
||||
|
||||
|
||||
export function getLatencyColor(ms: number): string {
|
||||
if (ms <= 100) return "var(--td-success-color)";
|
||||
if (ms <= 500) return "var(--td-warning-color)";
|
||||
return "var(--td-error-color)";
|
||||
}
|
||||
89
src/web/constants/target-table-columns.tsx
Normal file
89
src/web/constants/target-table-columns.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import type { PrimaryTableCol, PrimaryTableCellParams } from "tdesign-react";
|
||||
import { Tag, Progress } from "tdesign-react";
|
||||
import type { TargetStatus } from "../../shared/api";
|
||||
import { StatusDot } from "../components/StatusDot";
|
||||
import { StatusBar } from "../components/StatusBar";
|
||||
import { getTargetTypeDisplay } from "./target-type-display";
|
||||
import { getAvailabilityProgressColor, getLatencyColor } from "./color-threshold";
|
||||
import { availabilitySorter, latencySorter, nameSorter } from "./target-table-sorters";
|
||||
import { statusFilter, typeFilter } from "./target-table-filters";
|
||||
|
||||
export const TARGET_TABLE_COLUMNS: PrimaryTableCol<TargetStatus>[] = [
|
||||
{
|
||||
colKey: "latestCheck.matched",
|
||||
title: "状态",
|
||||
width: 80,
|
||||
fixed: "left",
|
||||
align: "center",
|
||||
filter: statusFilter,
|
||||
cell: ({ row }: PrimaryTableCellParams<TargetStatus>) => <StatusDot up={!!row.latestCheck?.matched} />,
|
||||
},
|
||||
{
|
||||
colKey: "name",
|
||||
title: "名称",
|
||||
ellipsis: true,
|
||||
sorter: nameSorter,
|
||||
sortType: "all",
|
||||
},
|
||||
{
|
||||
colKey: "type",
|
||||
title: "类型",
|
||||
width: 80,
|
||||
filter: typeFilter,
|
||||
cell: ({ row }: PrimaryTableCellParams<TargetStatus>) => (
|
||||
<Tag size="small" theme="primary" variant="light-outline">
|
||||
{getTargetTypeDisplay(row.type)}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
colKey: "stats.availability",
|
||||
title: "可用率",
|
||||
width: 160,
|
||||
sorter: availabilitySorter,
|
||||
sortType: "all",
|
||||
cell: ({ row }: PrimaryTableCellParams<TargetStatus>) => {
|
||||
const availability = row.stats?.availability;
|
||||
if (availability === undefined || availability === null) return "-";
|
||||
const color = getAvailabilityProgressColor(availability);
|
||||
return (
|
||||
<Progress
|
||||
theme="line"
|
||||
size="small"
|
||||
percentage={availability}
|
||||
color={color}
|
||||
label={`${availability.toFixed(1)}%`}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
colKey: "recentSamples",
|
||||
title: "最近状态",
|
||||
width: 220,
|
||||
cell: ({ row }: PrimaryTableCellParams<TargetStatus>) => <StatusBar samples={row.recentSamples} />,
|
||||
},
|
||||
{
|
||||
colKey: "latestCheck.durationMs",
|
||||
title: "延迟",
|
||||
width: 80,
|
||||
align: "right",
|
||||
sorter: latencySorter,
|
||||
sortType: "all",
|
||||
cell: ({ row }: PrimaryTableCellParams<TargetStatus>) => {
|
||||
const ms = row.latestCheck?.durationMs;
|
||||
if (ms === null || ms === undefined) return <span style={{ color: "var(--td-text-color-disabled)" }}>-</span>;
|
||||
const color = getLatencyColor(ms);
|
||||
return <span style={{ color, fontVariantNumeric: "tabular-nums" }}>{Math.round(ms)}ms</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
colKey: "interval",
|
||||
title: "间隔",
|
||||
width: 72,
|
||||
align: "center",
|
||||
},
|
||||
];
|
||||
|
||||
export { statusSorter, availabilitySorter, latencySorter, nameSorter } from "./target-table-sorters";
|
||||
export { statusFilter, typeFilter } from "./target-table-filters";
|
||||
19
src/web/constants/target-table-filters.ts
Normal file
19
src/web/constants/target-table-filters.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { PrimaryTableCol } from "tdesign-react";
|
||||
|
||||
export const statusFilter: PrimaryTableCol["filter"] = {
|
||||
type: "single",
|
||||
list: [
|
||||
{ label: "全部", value: "" },
|
||||
{ label: "UP", value: "up" },
|
||||
{ label: "DOWN", value: "down" },
|
||||
],
|
||||
};
|
||||
|
||||
export const typeFilter: PrimaryTableCol["filter"] = {
|
||||
type: "single",
|
||||
list: [
|
||||
{ label: "全部", value: "" },
|
||||
{ label: "HTTP", value: "http" },
|
||||
{ label: "CMD", value: "command" },
|
||||
],
|
||||
};
|
||||
27
src/web/constants/target-table-sorters.ts
Normal file
27
src/web/constants/target-table-sorters.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { TargetStatus } from "../../shared/api";
|
||||
|
||||
const STATUS_ORDER: Record<string, number> = {
|
||||
down: 0,
|
||||
up: 1,
|
||||
};
|
||||
|
||||
function getStatusRank(target: TargetStatus): number {
|
||||
if (!target.latestCheck) return 2;
|
||||
return target.latestCheck.matched ? STATUS_ORDER["up"]! : STATUS_ORDER["down"]!;
|
||||
}
|
||||
|
||||
export function statusSorter(a: TargetStatus, b: TargetStatus): number {
|
||||
return getStatusRank(a) - getStatusRank(b);
|
||||
}
|
||||
|
||||
export function availabilitySorter(a: TargetStatus, b: TargetStatus): number {
|
||||
return (a.stats?.availability ?? 0) - (b.stats?.availability ?? 0);
|
||||
}
|
||||
|
||||
export function latencySorter(a: TargetStatus, b: TargetStatus): number {
|
||||
return (a.latestCheck?.durationMs ?? Infinity) - (b.latestCheck?.durationMs ?? Infinity);
|
||||
}
|
||||
|
||||
export function nameSorter(a: TargetStatus, b: TargetStatus): number {
|
||||
return a.name.localeCompare(b.name, "zh-CN");
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import type { HistoryResponse } from "../../shared/api";
|
||||
|
||||
export function useHistory(targetId: number | null) {
|
||||
const [data, setData] = useState<HistoryResponse>({ items: [], total: 0, page: 1, pageSize: 20 });
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const fetchHistory = useCallback(
|
||||
async (from: string, to: string, page = 1, pageSize = 20) => {
|
||||
if (targetId === null) return;
|
||||
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/targets/${targetId}/history?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}&page=${page}&pageSize=${pageSize}`,
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const result = (await response.json()) as HistoryResponse;
|
||||
setData(result);
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") return;
|
||||
setError(err instanceof Error ? err.message : "请求失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[targetId],
|
||||
);
|
||||
|
||||
return { data, error, loading, fetchHistory };
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { SummaryResponse } from "../../shared/api";
|
||||
|
||||
export function useSummary(intervalMs = 8000) {
|
||||
const [data, setData] = useState<SummaryResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const fetchSummary = useCallback(async () => {
|
||||
try {
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
const response = await fetch("/api/summary", { signal: controller.signal });
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const result = (await response.json()) as SummaryResponse;
|
||||
setData(result);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") return;
|
||||
setError(err instanceof Error ? err.message : "请求失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
fetchSummary();
|
||||
const timer = setInterval(fetchSummary, intervalMs);
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, [fetchSummary, intervalMs]);
|
||||
|
||||
return { data, error, loading, refresh: fetchSummary };
|
||||
}
|
||||
@@ -1,71 +1,110 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { TargetStatus } from "../../shared/api";
|
||||
import { useTrend } from "./useTrend";
|
||||
import { useHistory } from "./useHistory";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { HistoryResponse, SummaryResponse, TargetStatus, TrendPoint } from "../../shared/api";
|
||||
import { subtractHours } from "../utils/time";
|
||||
|
||||
const queryKeys = {
|
||||
summary: () => ["summary"] as const,
|
||||
targets: () => ["targets"] as const,
|
||||
trend: (targetId: number, from: string, to: string) => ["trend", targetId, from, to] as const,
|
||||
history: (targetId: number, from: string, to: string, page: number) => ["history", targetId, from, to, page] as const,
|
||||
};
|
||||
|
||||
async function fetchJson<T>(url: string): Promise<T> {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export function useSummary() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.summary(),
|
||||
queryFn: () => fetchJson<SummaryResponse>("/api/summary"),
|
||||
refetchInterval: 8000,
|
||||
refetchIntervalInBackground: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTargets() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.targets(),
|
||||
queryFn: () => fetchJson<TargetStatus[]>("/api/targets"),
|
||||
refetchInterval: 8000,
|
||||
refetchIntervalInBackground: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTargetDetail() {
|
||||
const [selectedTarget, setSelectedTarget] = useState<TargetStatus | null>(null);
|
||||
const [timeFrom, setTimeFrom] = useState<string>("");
|
||||
const [timeTo, setTimeTo] = useState<string>("");
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedTargetId, setSelectedTargetId] = useState<number | null>(null);
|
||||
const [timeFrom, setTimeFrom] = useState("");
|
||||
const [timeTo, setTimeTo] = useState("");
|
||||
const [historyPage, setHistoryPage] = useState(1);
|
||||
|
||||
const { data: trendData, loading: trendLoading, fetchTrend } = useTrend(selectedTarget?.id ?? null);
|
||||
const { data: historyData, loading: historyLoading, fetchHistory } = useHistory(selectedTarget?.id ?? null);
|
||||
const initialFetchRef = useRef(false);
|
||||
const { data: targetsData } = useTargets();
|
||||
|
||||
const openModal = useCallback((target: TargetStatus) => {
|
||||
setSelectedTarget(target);
|
||||
const selectedTarget =
|
||||
selectedTargetId !== null ? (targetsData?.find((t) => t.id === selectedTargetId) ?? null) : null;
|
||||
|
||||
const trend = useQuery({
|
||||
queryKey:
|
||||
selectedTargetId !== null && timeFrom && timeTo
|
||||
? queryKeys.trend(selectedTargetId, timeFrom, timeTo)
|
||||
: ["trend", "disabled"],
|
||||
queryFn: () =>
|
||||
fetchJson<TrendPoint[]>(
|
||||
`/api/targets/${selectedTargetId}/trend?from=${encodeURIComponent(timeFrom)}&to=${encodeURIComponent(timeTo)}`,
|
||||
),
|
||||
enabled: selectedTargetId !== null && !!timeFrom && !!timeTo,
|
||||
});
|
||||
|
||||
const history = useQuery({
|
||||
queryKey:
|
||||
selectedTargetId !== null && timeFrom && timeTo
|
||||
? queryKeys.history(selectedTargetId, timeFrom, timeTo, historyPage)
|
||||
: ["history", "disabled"],
|
||||
queryFn: () =>
|
||||
fetchJson<HistoryResponse>(
|
||||
`/api/targets/${selectedTargetId}/history?from=${encodeURIComponent(timeFrom)}&to=${encodeURIComponent(timeTo)}&page=${historyPage}&pageSize=20`,
|
||||
),
|
||||
enabled: selectedTargetId !== null && !!timeFrom && !!timeTo,
|
||||
});
|
||||
|
||||
const openDrawer = useCallback((target: TargetStatus) => {
|
||||
setSelectedTargetId(target.id);
|
||||
const now = new Date();
|
||||
const from = subtractHours(now, 24);
|
||||
setTimeFrom(from.toISOString());
|
||||
setTimeTo(now.toISOString());
|
||||
initialFetchRef.current = false;
|
||||
setHistoryPage(1);
|
||||
}, []);
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
setSelectedTarget(null);
|
||||
initialFetchRef.current = false;
|
||||
const closeDrawer = useCallback(() => {
|
||||
setSelectedTargetId(null);
|
||||
queryClient.removeQueries({ queryKey: ["trend"] });
|
||||
queryClient.removeQueries({ queryKey: ["history"] });
|
||||
}, [queryClient]);
|
||||
|
||||
const handleTimeChange = useCallback((from: string, to: string) => {
|
||||
setTimeFrom(from);
|
||||
setTimeTo(to);
|
||||
setHistoryPage(1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTarget && timeFrom && timeTo && !initialFetchRef.current) {
|
||||
initialFetchRef.current = true;
|
||||
fetchTrend(timeFrom, timeTo);
|
||||
fetchHistory(timeFrom, timeTo);
|
||||
}
|
||||
}, [selectedTarget, timeFrom, timeTo, fetchTrend, fetchHistory]);
|
||||
|
||||
const handleTimeChange = useCallback(
|
||||
(from: string, to: string) => {
|
||||
setTimeFrom(from);
|
||||
setTimeTo(to);
|
||||
if (selectedTarget) {
|
||||
fetchTrend(from, to);
|
||||
fetchHistory(from, to);
|
||||
}
|
||||
},
|
||||
[fetchTrend, fetchHistory, selectedTarget],
|
||||
);
|
||||
|
||||
const handlePageChange = useCallback(
|
||||
(page: number) => {
|
||||
if (timeFrom && timeTo) {
|
||||
fetchHistory(timeFrom, timeTo, page);
|
||||
}
|
||||
},
|
||||
[timeFrom, timeTo, fetchHistory],
|
||||
);
|
||||
const handlePageChange = useCallback((page: number) => {
|
||||
setHistoryPage(page);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
selectedTarget,
|
||||
trendData,
|
||||
trendLoading,
|
||||
historyData,
|
||||
historyLoading,
|
||||
trendData: trend.data ?? [],
|
||||
trendLoading: trend.isLoading,
|
||||
historyData: history.data ?? { items: [], total: 0, page: 1, pageSize: 20 },
|
||||
historyLoading: history.isLoading,
|
||||
timeFrom,
|
||||
timeTo,
|
||||
openModal,
|
||||
closeModal,
|
||||
openDrawer,
|
||||
closeDrawer,
|
||||
handleTimeChange,
|
||||
handlePageChange,
|
||||
};
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { TargetStatus } from "../../shared/api";
|
||||
|
||||
export function useTargets(intervalMs = 8000) {
|
||||
const [data, setData] = useState<TargetStatus[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const fetchTargets = useCallback(async () => {
|
||||
try {
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
const response = await fetch("/api/targets", { signal: controller.signal });
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const result = (await response.json()) as TargetStatus[];
|
||||
setData(result);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") return;
|
||||
setError(err instanceof Error ? err.message : "请求失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
fetchTargets();
|
||||
const timer = setInterval(fetchTargets, intervalMs);
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, [fetchTargets, intervalMs]);
|
||||
|
||||
return { data, error, loading, refresh: fetchTargets };
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import type { TrendPoint } from "../../shared/api";
|
||||
|
||||
export function useTrend(targetId: number | null) {
|
||||
const [data, setData] = useState<TrendPoint[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const fetchTrend = useCallback(
|
||||
async (from: string, to: string) => {
|
||||
if (targetId === null) return;
|
||||
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/targets/${targetId}/trend?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`,
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const result = (await response.json()) as TrendPoint[];
|
||||
setData(result);
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") return;
|
||||
setError(err instanceof Error ? err.message : "请求失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[targetId],
|
||||
);
|
||||
|
||||
return { data, error, loading, fetchTrend };
|
||||
}
|
||||
@@ -1,8 +1,22 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import { App } from "./app";
|
||||
import "tdesign-react/dist/reset.css";
|
||||
import "tdesign-react/dist/tdesign.min.css";
|
||||
import "./styles.css";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: true,
|
||||
staleTime: 5000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const rootElement = document.getElementById("root");
|
||||
|
||||
if (!rootElement) {
|
||||
@@ -11,6 +25,9 @@ if (!rootElement) {
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
@@ -1,27 +1,3 @@
|
||||
:root {
|
||||
color: #102033;
|
||||
background: #edf3f8;
|
||||
font-family:
|
||||
Inter,
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
sans-serif;
|
||||
--dashboard-card-width: 280px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dashboard {
|
||||
padding: 32px 24px;
|
||||
width: 100%;
|
||||
@@ -39,184 +15,10 @@ body {
|
||||
|
||||
.dashboard-subtitle {
|
||||
margin: 0;
|
||||
color: #61728a;
|
||||
color: var(--td-text-color-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid rgba(229, 72, 77, 0.25);
|
||||
border-radius: 12px;
|
||||
color: #9f2228;
|
||||
background: rgba(255, 240, 240, 0.8);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.summary-cards {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
width: var(--dashboard-card-width);
|
||||
flex-shrink: 0;
|
||||
padding: 20px;
|
||||
border: 1px solid rgba(49, 83, 126, 0.12);
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
box-shadow: 0 4px 16px rgba(34, 57, 91, 0.08);
|
||||
transition:
|
||||
transform 0.3s ease,
|
||||
box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.summary-card:hover {
|
||||
box-shadow: 0 8px 32px rgba(34, 57, 91, 0.12);
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.card-value {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.card-label {
|
||||
margin-top: 4px;
|
||||
color: #61728a;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.card-up .card-value {
|
||||
color: #1fbf75;
|
||||
}
|
||||
|
||||
.card-down .card-value {
|
||||
color: #e5484d;
|
||||
}
|
||||
|
||||
.target-board {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.target-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.group-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.group-title {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.group-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.stat-badge {
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
min-width: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-badge-total {
|
||||
background: rgba(53, 109, 210, 0.1);
|
||||
color: #356dd2;
|
||||
}
|
||||
|
||||
.stat-badge-up {
|
||||
background: rgba(31, 191, 117, 0.1);
|
||||
color: #1fbf75;
|
||||
}
|
||||
|
||||
.stat-badge-down {
|
||||
background: rgba(229, 72, 77, 0.1);
|
||||
color: #e5484d;
|
||||
}
|
||||
|
||||
.card-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.target-card {
|
||||
width: var(--dashboard-card-width);
|
||||
flex-shrink: 0;
|
||||
padding: 16px;
|
||||
border: 1px solid rgba(49, 83, 126, 0.12);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
box-shadow: 0 2px 8px rgba(34, 57, 91, 0.06);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
box-shadow 0.3s ease,
|
||||
transform 0.3s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.target-card:hover {
|
||||
box-shadow: 0 6px 24px rgba(34, 57, 91, 0.14);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.card-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.card-type-badge {
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
background: rgba(53, 109, 210, 0.1);
|
||||
color: #356dd2;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.card-status-bar {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card-sparkline {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
@@ -225,16 +27,6 @@ body {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-up {
|
||||
background: #1fbf75;
|
||||
box-shadow: 0 0 0 6px rgba(31, 191, 117, 0.14);
|
||||
}
|
||||
|
||||
.status-down {
|
||||
background: #e5484d;
|
||||
box-shadow: 0 0 0 6px rgba(229, 72, 77, 0.14);
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
@@ -243,173 +35,16 @@ body {
|
||||
}
|
||||
|
||||
.status-bar-block {
|
||||
width: 6px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 16px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.status-bar-up {
|
||||
background: #1fbf75;
|
||||
}
|
||||
|
||||
.status-bar-down {
|
||||
background: #e5484d;
|
||||
}
|
||||
|
||||
.status-bar-empty {
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
.sparkline-empty {
|
||||
color: #94a3b8;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(16, 32, 51, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
width: 80vw;
|
||||
max-height: 85vh;
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 16px 48px rgba(16, 32, 51, 0.2);
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid rgba(49, 83, 126, 0.1);
|
||||
}
|
||||
|
||||
.modal-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
margin: 0;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.modal-close-btn {
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 1.5rem;
|
||||
color: #61728a;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.modal-close-btn:hover {
|
||||
color: #102033;
|
||||
}
|
||||
|
||||
.time-range-picker {
|
||||
padding: 12px 24px;
|
||||
border-bottom: 1px solid rgba(49, 83, 126, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.time-shortcuts {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.time-shortcut-btn {
|
||||
padding: 4px 12px;
|
||||
border: 1px solid rgba(49, 83, 126, 0.2);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #42546c;
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.time-shortcut-btn:hover {
|
||||
background: rgba(53, 109, 210, 0.08);
|
||||
}
|
||||
|
||||
.time-shortcut-btn.active {
|
||||
background: #356dd2;
|
||||
color: #fff;
|
||||
border-color: #356dd2;
|
||||
}
|
||||
|
||||
.time-inputs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.time-input {
|
||||
padding: 4px 8px;
|
||||
border: 1px solid rgba(49, 83, 126, 0.2);
|
||||
border-radius: 6px;
|
||||
font-size: 0.82rem;
|
||||
color: #102033;
|
||||
}
|
||||
|
||||
.time-separator {
|
||||
color: #61728a;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px 24px;
|
||||
gap: 24px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.modal-charts {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.modal-chart-section {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.modal-chart-section h4 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 0.9rem;
|
||||
color: #42546c;
|
||||
}
|
||||
|
||||
.modal-history-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.modal-history-section h4 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 0.9rem;
|
||||
color: #42546c;
|
||||
}
|
||||
|
||||
.status-donut {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.donut-center-label {
|
||||
@@ -419,7 +54,6 @@ body {
|
||||
transform: translate(-50%, -60%);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: #102033;
|
||||
}
|
||||
|
||||
.trend-chart {
|
||||
@@ -427,181 +61,13 @@ body {
|
||||
}
|
||||
|
||||
.trend-loading,
|
||||
.trend-empty,
|
||||
.history-loading,
|
||||
.history-empty {
|
||||
.trend-empty {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: #94a3b8;
|
||||
color: var(--td-text-color-placeholder);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.history-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border: 1px solid #d1d9e6;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.history-table thead {
|
||||
background: #f0f4fa;
|
||||
}
|
||||
|
||||
.history-table th {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: #61728a;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
border-bottom: 1px solid #d1d9e6;
|
||||
}
|
||||
|
||||
.history-table td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #e8edf4;
|
||||
}
|
||||
|
||||
.history-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.history-table tbody tr:hover {
|
||||
background: rgba(236, 243, 252, 0.6);
|
||||
}
|
||||
|
||||
.ht-col-status {
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.ht-col-time {
|
||||
width: 170px;
|
||||
}
|
||||
|
||||
.ht-col-detail {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.ht-col-latency {
|
||||
width: 80px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.ht-col-error {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.ht-status {
|
||||
font-weight: 700;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.ht-time {
|
||||
color: #61728a;
|
||||
}
|
||||
|
||||
.ht-detail {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
color: #42546c;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.ht-latency {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #356dd2;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.ht-error {
|
||||
color: #e5484d;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.history-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.history-status {
|
||||
font-weight: 700;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.text-up {
|
||||
color: #1fbf75;
|
||||
}
|
||||
|
||||
.text-down {
|
||||
color: #e5484d;
|
||||
}
|
||||
|
||||
.history-time {
|
||||
color: #61728a;
|
||||
}
|
||||
|
||||
.history-code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
color: #42546c;
|
||||
}
|
||||
|
||||
.history-latency {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #356dd2;
|
||||
}
|
||||
|
||||
.history-error {
|
||||
color: #e5484d;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.pagination-btn {
|
||||
border: 1px solid rgba(49, 83, 126, 0.2);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #42546c;
|
||||
padding: 4px 10px;
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pagination-btn:hover:not(:disabled) {
|
||||
background: rgba(53, 109, 210, 0.08);
|
||||
}
|
||||
|
||||
.pagination-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.pagination-btn.active {
|
||||
background: #356dd2;
|
||||
color: #fff;
|
||||
border-color: #356dd2;
|
||||
}
|
||||
|
||||
.pagination-items {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.pagination-ellipsis {
|
||||
color: #94a3b8;
|
||||
padding: 0 4px;
|
||||
.row-down {
|
||||
background: color-mix(in srgb, var(--td-error-color) 6%, transparent) !important;
|
||||
}
|
||||
|
||||
95
tests/web/constants/color-threshold.test.ts
Normal file
95
tests/web/constants/color-threshold.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { getAvailabilityProgressColor, getLatencyColor } from "../../../src/web/constants/color-threshold";
|
||||
|
||||
describe("color-threshold", () => {
|
||||
describe("getAvailabilityProgressColor", () => {
|
||||
test("0-10% 返回第一档颜色", () => {
|
||||
expect(getAvailabilityProgressColor(0)).toBe("#d54941");
|
||||
expect(getAvailabilityProgressColor(5)).toBe("#d54941");
|
||||
expect(getAvailabilityProgressColor(9.99)).toBe("#d54941");
|
||||
});
|
||||
|
||||
test("10-20% 返回第二档颜色", () => {
|
||||
expect(getAvailabilityProgressColor(10)).toBe("#d96241");
|
||||
expect(getAvailabilityProgressColor(15)).toBe("#d96241");
|
||||
expect(getAvailabilityProgressColor(19.99)).toBe("#d96241");
|
||||
});
|
||||
|
||||
test("20-30% 返回第三档颜色", () => {
|
||||
expect(getAvailabilityProgressColor(20)).toBe("#e37318");
|
||||
expect(getAvailabilityProgressColor(25)).toBe("#e37318");
|
||||
});
|
||||
|
||||
test("30-40% 返回第四档颜色", () => {
|
||||
expect(getAvailabilityProgressColor(30)).toBe("#e89318");
|
||||
expect(getAvailabilityProgressColor(35)).toBe("#e89318");
|
||||
});
|
||||
|
||||
test("40-50% 返回第五档颜色", () => {
|
||||
expect(getAvailabilityProgressColor(40)).toBe("#d9a818");
|
||||
expect(getAvailabilityProgressColor(45)).toBe("#d9a818");
|
||||
});
|
||||
|
||||
test("50-60% 返回第六档颜色", () => {
|
||||
expect(getAvailabilityProgressColor(50)).toBe("#b8b020");
|
||||
expect(getAvailabilityProgressColor(55)).toBe("#b8b020");
|
||||
});
|
||||
|
||||
test("60-70% 返回第七档颜色", () => {
|
||||
expect(getAvailabilityProgressColor(60)).toBe("#8dba30");
|
||||
expect(getAvailabilityProgressColor(65)).toBe("#8dba30");
|
||||
});
|
||||
|
||||
test("70-80% 返回第八档颜色", () => {
|
||||
expect(getAvailabilityProgressColor(70)).toBe("#6dba3f");
|
||||
expect(getAvailabilityProgressColor(75)).toBe("#6dba3f");
|
||||
});
|
||||
|
||||
test("80-90% 返回第九档颜色", () => {
|
||||
expect(getAvailabilityProgressColor(80)).toBe("#4dba50");
|
||||
expect(getAvailabilityProgressColor(85)).toBe("#4dba50");
|
||||
});
|
||||
|
||||
test("90-100% 返回第十档颜色", () => {
|
||||
expect(getAvailabilityProgressColor(90)).toBe("#3dba60");
|
||||
expect(getAvailabilityProgressColor(95)).toBe("#3dba60");
|
||||
expect(getAvailabilityProgressColor(99.9)).toBe("#3dba60");
|
||||
expect(getAvailabilityProgressColor(100)).toBe("#3dba60");
|
||||
});
|
||||
|
||||
test("边界值", () => {
|
||||
expect(getAvailabilityProgressColor(9.999)).toBe("#d54941");
|
||||
expect(getAvailabilityProgressColor(10)).toBe("#d96241");
|
||||
expect(getAvailabilityProgressColor(19.999)).toBe("#d96241");
|
||||
expect(getAvailabilityProgressColor(20)).toBe("#e37318");
|
||||
expect(getAvailabilityProgressColor(89.999)).toBe("#4dba50");
|
||||
expect(getAvailabilityProgressColor(90)).toBe("#3dba60");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLatencyColor", () => {
|
||||
test("<=100ms 返回 success 色", () => {
|
||||
expect(getLatencyColor(0)).toBe("var(--td-success-color)");
|
||||
expect(getLatencyColor(50)).toBe("var(--td-success-color)");
|
||||
expect(getLatencyColor(100)).toBe("var(--td-success-color)");
|
||||
});
|
||||
|
||||
test("100-500ms 返回 warning 色", () => {
|
||||
expect(getLatencyColor(101)).toBe("var(--td-warning-color)");
|
||||
expect(getLatencyColor(250)).toBe("var(--td-warning-color)");
|
||||
expect(getLatencyColor(500)).toBe("var(--td-warning-color)");
|
||||
});
|
||||
|
||||
test(">500ms 返回 error 色", () => {
|
||||
expect(getLatencyColor(501)).toBe("var(--td-error-color)");
|
||||
expect(getLatencyColor(1000)).toBe("var(--td-error-color)");
|
||||
});
|
||||
|
||||
test("边界值", () => {
|
||||
expect(getLatencyColor(100)).toBe("var(--td-success-color)");
|
||||
expect(getLatencyColor(100.01)).toBe("var(--td-warning-color)");
|
||||
expect(getLatencyColor(500)).toBe("var(--td-warning-color)");
|
||||
expect(getLatencyColor(500.01)).toBe("var(--td-error-color)");
|
||||
});
|
||||
});
|
||||
});
|
||||
28
tests/web/constants/target-table-filters.test.ts
Normal file
28
tests/web/constants/target-table-filters.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { statusFilter, typeFilter } from "../../../src/web/constants/target-table-filters";
|
||||
|
||||
describe("target-table-filters", () => {
|
||||
describe("statusFilter", () => {
|
||||
test("包含全部选项", () => {
|
||||
expect(statusFilter).toBeDefined();
|
||||
expect(statusFilter!.type).toBe("single");
|
||||
const list = statusFilter!.list!;
|
||||
expect(list).toHaveLength(3);
|
||||
expect(list[0]!.label).toBe("全部");
|
||||
expect(list[1]!.label).toBe("UP");
|
||||
expect(list[2]!.label).toBe("DOWN");
|
||||
});
|
||||
});
|
||||
|
||||
describe("typeFilter", () => {
|
||||
test("包含全部选项", () => {
|
||||
expect(typeFilter).toBeDefined();
|
||||
expect(typeFilter!.type).toBe("single");
|
||||
const list = typeFilter!.list!;
|
||||
expect(list).toHaveLength(3);
|
||||
expect(list[0]!.label).toBe("全部");
|
||||
expect(list[1]!.label).toBe("HTTP");
|
||||
expect(list[2]!.label).toBe("CMD");
|
||||
});
|
||||
});
|
||||
});
|
||||
111
tests/web/constants/target-table-sorters.test.ts
Normal file
111
tests/web/constants/target-table-sorters.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import {
|
||||
statusSorter,
|
||||
availabilitySorter,
|
||||
latencySorter,
|
||||
nameSorter,
|
||||
} from "../../../src/web/constants/target-table-sorters";
|
||||
import type { TargetStatus } from "../../../src/shared/api";
|
||||
|
||||
function makeTarget(overrides: Partial<TargetStatus> = {}): TargetStatus {
|
||||
return {
|
||||
id: 1,
|
||||
name: "test",
|
||||
type: "http",
|
||||
target: "https://example.com",
|
||||
group: "default",
|
||||
interval: "5s",
|
||||
latestCheck: null,
|
||||
stats: { totalChecks: 0, availability: 100 },
|
||||
recentSamples: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("statusSorter", () => {
|
||||
test("DOWN 排在 UP 前面", () => {
|
||||
const up = makeTarget({
|
||||
latestCheck: { timestamp: "", matched: true, durationMs: 10, statusDetail: null, failure: null },
|
||||
});
|
||||
const down = makeTarget({
|
||||
latestCheck: { timestamp: "", matched: false, durationMs: 10, statusDetail: null, failure: null },
|
||||
});
|
||||
expect(statusSorter(down, up)).toBeLessThan(0);
|
||||
expect(statusSorter(up, down)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("相同状态返回 0", () => {
|
||||
const a = makeTarget({
|
||||
latestCheck: { timestamp: "", matched: true, durationMs: 10, statusDetail: null, failure: null },
|
||||
});
|
||||
const b = makeTarget({
|
||||
latestCheck: { timestamp: "", matched: true, durationMs: 20, statusDetail: null, failure: null },
|
||||
});
|
||||
expect(statusSorter(a, b)).toBe(0);
|
||||
});
|
||||
|
||||
test("无 latestCheck 的目标排在最后", () => {
|
||||
const noCheck = makeTarget();
|
||||
const up = makeTarget({
|
||||
latestCheck: { timestamp: "", matched: true, durationMs: 10, statusDetail: null, failure: null },
|
||||
});
|
||||
expect(statusSorter(noCheck, up)).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("availabilitySorter", () => {
|
||||
test("低可用率排前面", () => {
|
||||
const low = makeTarget({ stats: { totalChecks: 100, availability: 95 } });
|
||||
const high = makeTarget({ stats: { totalChecks: 100, availability: 99.9 } });
|
||||
expect(availabilitySorter(low, high)).toBeLessThan(0);
|
||||
});
|
||||
|
||||
test("相同可用率返回 0", () => {
|
||||
const a = makeTarget({ stats: { totalChecks: 100, availability: 99.9 } });
|
||||
const b = makeTarget({ stats: { totalChecks: 50, availability: 99.9 } });
|
||||
expect(availabilitySorter(a, b)).toBe(0);
|
||||
});
|
||||
|
||||
test("无 stats 按 0 处理", () => {
|
||||
const noStats = makeTarget({ stats: undefined as unknown as TargetStatus["stats"] });
|
||||
const high = makeTarget({ stats: { totalChecks: 100, availability: 99.9 } });
|
||||
expect(availabilitySorter(noStats, high)).toBeLessThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("latencySorter", () => {
|
||||
test("低延迟排前面", () => {
|
||||
const fast = makeTarget({
|
||||
latestCheck: { timestamp: "", matched: true, durationMs: 50, statusDetail: null, failure: null },
|
||||
});
|
||||
const slow = makeTarget({
|
||||
latestCheck: { timestamp: "", matched: true, durationMs: 200, statusDetail: null, failure: null },
|
||||
});
|
||||
expect(latencySorter(fast, slow)).toBeLessThan(0);
|
||||
});
|
||||
|
||||
test("无延迟排最后", () => {
|
||||
const noLatency = makeTarget({
|
||||
latestCheck: { timestamp: "", matched: true, durationMs: null, statusDetail: null, failure: null },
|
||||
});
|
||||
const hasLatency = makeTarget({
|
||||
latestCheck: { timestamp: "", matched: true, durationMs: 100, statusDetail: null, failure: null },
|
||||
});
|
||||
expect(latencySorter(noLatency, hasLatency)).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("nameSorter", () => {
|
||||
test("按名称字母排序", () => {
|
||||
const a = makeTarget({ name: "Alpha" });
|
||||
const b = makeTarget({ name: "Beta" });
|
||||
expect(nameSorter(a, b)).toBeLessThan(0);
|
||||
});
|
||||
|
||||
test("中文名称排序", () => {
|
||||
const a = makeTarget({ name: "百度" });
|
||||
const b = makeTarget({ name: "谷歌" });
|
||||
const result = nameSorter(a, b);
|
||||
expect(typeof result).toBe("number");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user