diff --git a/frontend/admin/.env.development b/frontend/admin/.env.development new file mode 100644 index 0000000..4a55014 --- /dev/null +++ b/frontend/admin/.env.development @@ -0,0 +1,2 @@ +# 开发环境配置 +VITE_API_BASE_URL=/api/v1 diff --git a/frontend/admin/.env.example b/frontend/admin/.env.example new file mode 100644 index 0000000..6ae8a96 --- /dev/null +++ b/frontend/admin/.env.example @@ -0,0 +1,5 @@ +# Admin Frontend 环境变量配置示例 +# 复制此文件为 .env.local 进行本地开发配置 + +# API 基础地址 +VITE_API_BASE_URL=/api/v1 diff --git a/frontend/admin/.env.production b/frontend/admin/.env.production new file mode 100644 index 0000000..898688e --- /dev/null +++ b/frontend/admin/.env.production @@ -0,0 +1,3 @@ +# 生产环境配置 +# 部署时根据实际后端地址修改 +VITE_API_BASE_URL=/api/v1 diff --git a/frontend/admin/.gitignore b/frontend/admin/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/admin/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/admin/README.md b/frontend/admin/README.md new file mode 100644 index 0000000..7dbf7eb --- /dev/null +++ b/frontend/admin/README.md @@ -0,0 +1,73 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` diff --git a/frontend/admin/eslint.config.js b/frontend/admin/eslint.config.js new file mode 100644 index 0000000..b50d4e1 --- /dev/null +++ b/frontend/admin/eslint.config.js @@ -0,0 +1,23 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist', 'coverage']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]) diff --git a/frontend/admin/index.html b/frontend/admin/index.html new file mode 100644 index 0000000..6910800 --- /dev/null +++ b/frontend/admin/index.html @@ -0,0 +1,13 @@ + + + + + + + 用户管理系统 + + +
+ + + diff --git a/frontend/admin/package-lock.json b/frontend/admin/package-lock.json new file mode 100644 index 0000000..08639c7 --- /dev/null +++ b/frontend/admin/package-lock.json @@ -0,0 +1,5451 @@ +{ + "name": "admin", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "admin", + "version": "0.0.0", + "dependencies": { + "@ant-design/icons": "^6.1.0", + "antd": "^5.29.3", + "dayjs": "^1.11.20", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.30.3" + }, + "devDependencies": { + "@eslint/js": "^9.39.4", + "@playwright/test": "^1.49.1", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.1.0", + "@testing-library/user-event": "^14.5.2", + "@types/node": "^24.12.0", + "@types/react": "^18.3.28", + "@types/react-dom": "^18.3.7", + "@vitejs/plugin-react": "^6.0.0", + "@vitest/coverage-v8": "^4.1.2", + "eslint": "^9.39.4", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.4.0", + "jsdom": "^26.0.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.57.2", + "vite": "^8.0.3", + "vitest": "^4.1.2" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmmirror.com/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@ant-design/colors": { + "version": "8.0.1", + "resolved": "https://registry.npmmirror.com/@ant-design/colors/-/colors-8.0.1.tgz", + "integrity": "sha512-foPVl0+SWIslGUtD/xBr1p9U4AKzPhNYEseXYRRo5QSzGACYZrQbe11AYJbYfAWnWSpGBx6JjBmSeugUsD9vqQ==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^3.0.0" + } + }, + "node_modules/@ant-design/cssinjs": { + "version": "1.24.0", + "resolved": "https://registry.npmmirror.com/@ant-design/cssinjs/-/cssinjs-1.24.0.tgz", + "integrity": "sha512-K4cYrJBsgvL+IoozUXYjbT6LHHNt+19a9zkvpBPxLjFHas1UpPM2A5MlhROb0BT8N8WoavM5VsP9MeSeNK/3mg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "@emotion/hash": "^0.8.0", + "@emotion/unitless": "^0.7.5", + "classnames": "^2.3.1", + "csstype": "^3.1.3", + "rc-util": "^5.35.0", + "stylis": "^4.3.4" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/cssinjs-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/@ant-design/cssinjs-utils/-/cssinjs-utils-1.1.3.tgz", + "integrity": "sha512-nOoQMLW1l+xR1Co8NFVYiP8pZp3VjIIzqV6D6ShYF2ljtdwWJn5WSsH+7kvCktXL/yhEtWURKOfH5Xz/gzlwsg==", + "license": "MIT", + "dependencies": { + "@ant-design/cssinjs": "^1.21.0", + "@babel/runtime": "^7.23.2", + "rc-util": "^5.38.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@ant-design/fast-color": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/@ant-design/fast-color/-/fast-color-3.0.1.tgz", + "integrity": "sha512-esKJegpW4nckh0o6kV3Tkb7NPIZYbPnnFxmQDUmL08ukXZAvV85TZBr70eGuke/CIArLaP6aw8lt9KILjnWuOw==", + "license": "MIT", + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@ant-design/icons": { + "version": "6.1.0", + "resolved": "https://registry.npmmirror.com/@ant-design/icons/-/icons-6.1.0.tgz", + "integrity": "sha512-KrWMu1fIg3w/1F2zfn+JlfNDU8dDqILfA5Tg85iqs1lf8ooyGlbkA+TkwfOKKgqpUmAiRY1PTFpuOU2DAIgSUg==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^8.0.0", + "@ant-design/icons-svg": "^4.4.0", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/icons-svg": { + "version": "4.4.2", + "resolved": "https://registry.npmmirror.com/@ant-design/icons-svg/-/icons-svg-4.4.2.tgz", + "integrity": "sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA==", + "license": "MIT" + }, + "node_modules/@ant-design/react-slick": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@ant-design/react-slick/-/react-slick-1.1.2.tgz", + "integrity": "sha512-EzlvzE6xQUBrZuuhSAFTdsr4P2bBBHGZwKFemEfq8gIGyIQCxalYfZW/T2ORbtQx5rU69o+WycP3exY/7T1hGA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.4", + "classnames": "^2.2.5", + "json2mq": "^0.2.0", + "resize-observer-polyfill": "^1.5.1", + "throttle-debounce": "^5.0.0" + }, + "peerDependencies": { + "react": ">=16.9.0" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmmirror.com/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmmirror.com/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmmirror.com/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmmirror.com/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmmirror.com/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmmirror.com/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@emnapi/core": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", + "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", + "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.8.0", + "resolved": "https://registry.npmmirror.com/@emotion/hash/-/hash-0.8.0.tgz", + "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", + "license": "MIT" + }, + "node_modules/@emotion/unitless": { + "version": "0.7.5", + "resolved": "https://registry.npmmirror.com/@emotion/unitless/-/unitless-0.7.5.tgz", + "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==", + "license": "MIT" + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmmirror.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmmirror.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmmirror.com/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmmirror.com/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmmirror.com/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmmirror.com/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmmirror.com/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmmirror.com/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmmirror.com/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmmirror.com/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmmirror.com/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmmirror.com/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", + "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.122.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", + "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmmirror.com/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@rc-component/async-validator": { + "version": "5.1.0", + "resolved": "https://registry.npmmirror.com/@rc-component/async-validator/-/async-validator-5.1.0.tgz", + "integrity": "sha512-n4HcR5siNUXRX23nDizbZBQPO0ZM/5oTtmKZ6/eqL0L2bo747cklFdZGRN2f+c9qWGICwDzrhW0H7tE9PptdcA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.4" + }, + "engines": { + "node": ">=14.x" + } + }, + "node_modules/@rc-component/color-picker": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/@rc-component/color-picker/-/color-picker-2.0.1.tgz", + "integrity": "sha512-WcZYwAThV/b2GISQ8F+7650r5ZZJ043E57aVBFkQ+kSY4C6wdofXgB0hBx+GPGpIU0Z81eETNoDUJMr7oy/P8Q==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^2.0.6", + "@babel/runtime": "^7.23.6", + "classnames": "^2.2.6", + "rc-util": "^5.38.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/color-picker/node_modules/@ant-design/fast-color": { + "version": "2.0.6", + "resolved": "https://registry.npmmirror.com/@ant-design/fast-color/-/fast-color-2.0.6.tgz", + "integrity": "sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@rc-component/context": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/@rc-component/context/-/context-1.4.0.tgz", + "integrity": "sha512-kFcNxg9oLRMoL3qki0OMxK+7g5mypjgaaJp/pkOis/6rVxma9nJBF/8kCIuTYHUQNr0ii7MxqE33wirPZLJQ2w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/mini-decimal": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/@rc-component/mini-decimal/-/mini-decimal-1.1.3.tgz", + "integrity": "sha512-bk/FJ09fLf+NLODMAFll6CfYrHPBioTedhW6lxDBuuWucJEqFUd4l/D/5JgIi3dina6sYahB8iuPAZTNz2pMxw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@rc-component/mutate-observer": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@rc-component/mutate-observer/-/mutate-observer-1.1.0.tgz", + "integrity": "sha512-QjrOsDXQusNwGZPf4/qRQasg7UFEj06XiCJ8iuiq/Io7CrHrgVi6Uuetw60WAMG1799v+aM8kyc+1L/GBbHSlw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/portal": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@rc-component/portal/-/portal-1.1.2.tgz", + "integrity": "sha512-6f813C0IsasTZms08kfA8kPAGxbbkYToa8ALaiDIGGECU4i9hj8Plgbx0sNJDrey3EtHO30hmdaxtT0138xZcg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/qrcode": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/@rc-component/qrcode/-/qrcode-1.1.1.tgz", + "integrity": "sha512-LfLGNymzKdUPjXUbRP+xOhIWY4jQ+YMj5MmWAcgcAq1Ij8XP7tRmAXqyuv96XvLUBE/5cA8hLFl9eO1JQMujrA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/tour": { + "version": "1.15.1", + "resolved": "https://registry.npmmirror.com/@rc-component/tour/-/tour-1.15.1.tgz", + "integrity": "sha512-Tr2t7J1DKZUpfJuDZWHxyxWpfmj8EZrqSgyMZ+BCdvKZ6r1UDsfU46M/iWAAFBy961Ssfom2kv5f3UcjIL2CmQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "@rc-component/portal": "^1.0.0-9", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/trigger": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/@rc-component/trigger/-/trigger-2.3.1.tgz", + "integrity": "sha512-ORENF39PeXTzM+gQEshuk460Z8N4+6DkjpxlpE7Q3gYy1iBpLrx0FOJz3h62ryrJZ/3zCAUIkT1Pb/8hHWpb3A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2", + "@rc-component/portal": "^1.1.0", + "classnames": "^2.3.2", + "rc-motion": "^2.0.0", + "rc-resize-observer": "^1.3.1", + "rc-util": "^5.44.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/util": { + "version": "1.10.0", + "resolved": "https://registry.npmmirror.com/@rc-component/util/-/util-1.10.0.tgz", + "integrity": "sha512-aY9GLBuiUdpyfIUpAWSYer4Tu3mVaZCo5A0q9NtXcazT3MRiI3/WNHCR+DUn5VAtR6iRRf0ynCqQUcHli5UdYw==", + "license": "MIT", + "dependencies": { + "is-mobile": "^5.0.0", + "react-is": "^18.2.0" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.2", + "resolved": "https://registry.npmmirror.com/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz", + "integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz", + "integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz", + "integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz", + "integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz", + "integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz", + "integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz", + "integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz", + "integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.7", + "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", + "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmmirror.com/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmmirror.com/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmmirror.com/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmmirror.com/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmmirror.com/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmmirror.com/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmmirror.com/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.12.0", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-24.12.0.tgz", + "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmmirror.com/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmmirror.com/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmmirror.com/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.2.tgz", + "integrity": "sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/type-utils": "8.57.2", + "@typescript-eslint/utils": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.57.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.2.tgz", + "integrity": "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.2.tgz", + "integrity": "sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.57.2", + "@typescript-eslint/types": "^8.57.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.2.tgz", + "integrity": "sha512-snZKH+W4WbWkrBqj4gUNRIGb/jipDW3qMqVJ4C9rzdFc+wLwruxk+2a5D+uoFcKPAqyqEnSb4l2ULuZf95eSkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.2.tgz", + "integrity": "sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.2.tgz", + "integrity": "sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2", + "@typescript-eslint/utils": "8.57.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.2.tgz", + "integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.2.tgz", + "integrity": "sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.57.2", + "@typescript-eslint/tsconfig-utils": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.2.tgz", + "integrity": "sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.2.tgz", + "integrity": "sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.2", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", + "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.7" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.2.tgz", + "integrity": "sha512-sPK//PHO+kAkScb8XITeB1bf7fsk85Km7+rt4eeuRR3VS1/crD47cmV5wicisJmjNdfeokTZwjMk4Mj2d58Mgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.2", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.2", + "vitest": "4.1.2" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz", + "integrity": "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz", + "integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.2", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz", + "integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.2.tgz", + "integrity": "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.2", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.2.tgz", + "integrity": "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.2", + "@vitest/utils": "4.1.2", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz", + "integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz", + "integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.2", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmmirror.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmmirror.com/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/antd": { + "version": "5.29.3", + "resolved": "https://registry.npmmirror.com/antd/-/antd-5.29.3.tgz", + "integrity": "sha512-3DdbGCa9tWAJGcCJ6rzR8EJFsv2CtyEbkVabZE14pfgUHfCicWCj0/QzQVLDYg8CPfQk9BH7fHCoTXHTy7MP/A==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^7.2.1", + "@ant-design/cssinjs": "^1.23.0", + "@ant-design/cssinjs-utils": "^1.1.3", + "@ant-design/fast-color": "^2.0.6", + "@ant-design/icons": "^5.6.1", + "@ant-design/react-slick": "~1.1.2", + "@babel/runtime": "^7.26.0", + "@rc-component/color-picker": "~2.0.1", + "@rc-component/mutate-observer": "^1.1.0", + "@rc-component/qrcode": "~1.1.0", + "@rc-component/tour": "~1.15.1", + "@rc-component/trigger": "^2.3.0", + "classnames": "^2.5.1", + "copy-to-clipboard": "^3.3.3", + "dayjs": "^1.11.11", + "rc-cascader": "~3.34.0", + "rc-checkbox": "~3.5.0", + "rc-collapse": "~3.9.0", + "rc-dialog": "~9.6.0", + "rc-drawer": "~7.3.0", + "rc-dropdown": "~4.2.1", + "rc-field-form": "~2.7.1", + "rc-image": "~7.12.0", + "rc-input": "~1.8.0", + "rc-input-number": "~9.5.0", + "rc-mentions": "~2.20.0", + "rc-menu": "~9.16.1", + "rc-motion": "^2.9.5", + "rc-notification": "~5.6.4", + "rc-pagination": "~5.1.0", + "rc-picker": "~4.11.3", + "rc-progress": "~4.0.0", + "rc-rate": "~2.13.1", + "rc-resize-observer": "^1.4.3", + "rc-segmented": "~2.7.0", + "rc-select": "~14.16.8", + "rc-slider": "~11.1.9", + "rc-steps": "~6.0.1", + "rc-switch": "~4.1.0", + "rc-table": "~7.54.0", + "rc-tabs": "~15.7.0", + "rc-textarea": "~1.10.2", + "rc-tooltip": "~6.4.0", + "rc-tree": "~5.13.1", + "rc-tree-select": "~5.27.0", + "rc-upload": "~4.11.0", + "rc-util": "^5.44.4", + "scroll-into-view-if-needed": "^3.1.0", + "throttle-debounce": "^5.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ant-design" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/antd/node_modules/@ant-design/colors": { + "version": "7.2.1", + "resolved": "https://registry.npmmirror.com/@ant-design/colors/-/colors-7.2.1.tgz", + "integrity": "sha512-lCHDcEzieu4GA3n8ELeZ5VQ8pKQAWcGGLRTQ50aQM2iqPpq2evTxER84jfdPvsPAtEcZ7m44NI45edFMo8oOYQ==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^2.0.6" + } + }, + "node_modules/antd/node_modules/@ant-design/fast-color": { + "version": "2.0.6", + "resolved": "https://registry.npmmirror.com/@ant-design/fast-color/-/fast-color-2.0.6.tgz", + "integrity": "sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/antd/node_modules/@ant-design/icons": { + "version": "5.6.1", + "resolved": "https://registry.npmmirror.com/@ant-design/icons/-/icons-5.6.1.tgz", + "integrity": "sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^7.0.0", + "@ant-design/icons-svg": "^4.4.0", + "@babel/runtime": "^7.24.8", + "classnames": "^2.2.6", + "rc-util": "^5.31.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmmirror.com/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", + "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.8", + "resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.8.tgz", + "integrity": "sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001780", + "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001780.tgz", + "integrity": "sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmmirror.com/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/compute-scroll-into-view": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz", + "integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==", + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/copy-to-clipboard": { + "version": "3.3.3", + "resolved": "https://registry.npmmirror.com/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz", + "integrity": "sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==", + "license": "MIT", + "dependencies": { + "toggle-selection": "^1.0.6" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmmirror.com/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmmirror.com/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmmirror.com/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmmirror.com/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmmirror.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/electron-to-chromium": { + "version": "1.5.321", + "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz", + "integrity": "sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmmirror.com/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.2", + "resolved": "https://registry.npmmirror.com/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz", + "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmmirror.com/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmmirror.com/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmmirror.com/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmmirror.com/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmmirror.com/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmmirror.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmmirror.com/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.4.0", + "resolved": "https://registry.npmmirror.com/globals/-/globals-17.4.0.tgz", + "integrity": "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmmirror.com/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmmirror.com/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmmirror.com/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmmirror.com/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmmirror.com/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-mobile": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/is-mobile/-/is-mobile-5.0.0.tgz", + "integrity": "sha512-Tz/yndySvLAEXh+Uk8liFCxOwVH6YutuR74utvOcu7I9Di+DwM0mtdPVZNaVvvBUM2OXxne/NhOs1zAO7riusQ==", + "license": "MIT" + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmmirror.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmmirror.com/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json2mq": { + "version": "0.2.0", + "resolved": "https://registry.npmmirror.com/json2mq/-/json2mq-0.2.0.tgz", + "integrity": "sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==", + "license": "MIT", + "dependencies": { + "string-convert": "^0.2.0" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmmirror.com/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmmirror.com/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmmirror.com/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.2", + "resolved": "https://registry.npmmirror.com/magicast/-/magicast-0.5.2.tgz", + "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmmirror.com/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmmirror.com/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmmirror.com/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmmirror.com/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmmirror.com/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmmirror.com/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/rc-cascader": { + "version": "3.34.0", + "resolved": "https://registry.npmmirror.com/rc-cascader/-/rc-cascader-3.34.0.tgz", + "integrity": "sha512-KpXypcvju9ptjW9FaN2NFcA2QH9E9LHKq169Y0eWtH4e/wHQ5Wh5qZakAgvb8EKZ736WZ3B0zLLOBsrsja5Dag==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.7", + "classnames": "^2.3.1", + "rc-select": "~14.16.2", + "rc-tree": "~5.13.0", + "rc-util": "^5.43.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-checkbox": { + "version": "3.5.0", + "resolved": "https://registry.npmmirror.com/rc-checkbox/-/rc-checkbox-3.5.0.tgz", + "integrity": "sha512-aOAQc3E98HteIIsSqm6Xk2FPKIER6+5vyEFMZfo73TqM+VVAIqOkHoPjgKLqSNtVLWScoaM7vY2ZrGEheI79yg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.3.2", + "rc-util": "^5.25.2" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-collapse": { + "version": "3.9.0", + "resolved": "https://registry.npmmirror.com/rc-collapse/-/rc-collapse-3.9.0.tgz", + "integrity": "sha512-swDdz4QZ4dFTo4RAUMLL50qP0EY62N2kvmk2We5xYdRwcRn8WcYtuetCJpwpaCbUfUt5+huLpVxhvmnK+PHrkA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.3.4", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-dialog": { + "version": "9.6.0", + "resolved": "https://registry.npmmirror.com/rc-dialog/-/rc-dialog-9.6.0.tgz", + "integrity": "sha512-ApoVi9Z8PaCQg6FsUzS8yvBEQy0ZL2PkuvAgrmohPkN3okps5WZ5WQWPc1RNuiOKaAYv8B97ACdsFU5LizzCqg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/portal": "^1.0.0-8", + "classnames": "^2.2.6", + "rc-motion": "^2.3.0", + "rc-util": "^5.21.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-drawer": { + "version": "7.3.0", + "resolved": "https://registry.npmmirror.com/rc-drawer/-/rc-drawer-7.3.0.tgz", + "integrity": "sha512-DX6CIgiBWNpJIMGFO8BAISFkxiuKitoizooj4BDyee8/SnBn0zwO2FHrNDpqqepj0E/TFTDpmEBCyFuTgC7MOg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@rc-component/portal": "^1.1.1", + "classnames": "^2.2.6", + "rc-motion": "^2.6.1", + "rc-util": "^5.38.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-dropdown": { + "version": "4.2.1", + "resolved": "https://registry.npmmirror.com/rc-dropdown/-/rc-dropdown-4.2.1.tgz", + "integrity": "sha512-YDAlXsPv3I1n42dv1JpdM7wJ+gSUBfeyPK59ZpBD9jQhK9jVuxpjj3NmWQHOBceA1zEPVX84T2wbdb2SD0UjmA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.2.6", + "rc-util": "^5.44.1" + }, + "peerDependencies": { + "react": ">=16.11.0", + "react-dom": ">=16.11.0" + } + }, + "node_modules/rc-field-form": { + "version": "2.7.1", + "resolved": "https://registry.npmmirror.com/rc-field-form/-/rc-field-form-2.7.1.tgz", + "integrity": "sha512-vKeSifSJ6HoLaAB+B8aq/Qgm8a3dyxROzCtKNCsBQgiverpc4kWDQihoUwzUj+zNWJOykwSY4dNX3QrGwtVb9A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "@rc-component/async-validator": "^5.0.3", + "rc-util": "^5.32.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-image": { + "version": "7.12.0", + "resolved": "https://registry.npmmirror.com/rc-image/-/rc-image-7.12.0.tgz", + "integrity": "sha512-cZ3HTyyckPnNnUb9/DRqduqzLfrQRyi+CdHjdqgsyDpI3Ln5UX1kXnAhPBSJj9pVRzwRFgqkN7p9b6HBDjmu/Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.2", + "@rc-component/portal": "^1.0.2", + "classnames": "^2.2.6", + "rc-dialog": "~9.6.0", + "rc-motion": "^2.6.2", + "rc-util": "^5.34.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-input": { + "version": "1.8.0", + "resolved": "https://registry.npmmirror.com/rc-input/-/rc-input-1.8.0.tgz", + "integrity": "sha512-KXvaTbX+7ha8a/k+eg6SYRVERK0NddX8QX7a7AnRvUa/rEH0CNMlpcBzBkhI0wp2C8C4HlMoYl8TImSN+fuHKA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-util": "^5.18.1" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/rc-input-number": { + "version": "9.5.0", + "resolved": "https://registry.npmmirror.com/rc-input-number/-/rc-input-number-9.5.0.tgz", + "integrity": "sha512-bKaEvB5tHebUURAEXw35LDcnRZLq3x1k7GxfAqBMzmpHkDGzjAtnUL8y4y5N15rIFIg5IJgwr211jInl3cipag==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/mini-decimal": "^1.0.1", + "classnames": "^2.2.5", + "rc-input": "~1.8.0", + "rc-util": "^5.40.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-mentions": { + "version": "2.20.0", + "resolved": "https://registry.npmmirror.com/rc-mentions/-/rc-mentions-2.20.0.tgz", + "integrity": "sha512-w8HCMZEh3f0nR8ZEd466ATqmXFCMGMN5UFCzEUL0bM/nGw/wOS2GgRzKBcm19K++jDyuWCOJOdgcKGXU3fXfbQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.22.5", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.2.6", + "rc-input": "~1.8.0", + "rc-menu": "~9.16.0", + "rc-textarea": "~1.10.0", + "rc-util": "^5.34.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-menu": { + "version": "9.16.1", + "resolved": "https://registry.npmmirror.com/rc-menu/-/rc-menu-9.16.1.tgz", + "integrity": "sha512-ghHx6/6Dvp+fw8CJhDUHFHDJ84hJE3BXNCzSgLdmNiFErWSOaZNsihDAsKq9ByTALo/xkNIwtDFGIl6r+RPXBg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/trigger": "^2.0.0", + "classnames": "2.x", + "rc-motion": "^2.4.3", + "rc-overflow": "^1.3.1", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-motion": { + "version": "2.9.5", + "resolved": "https://registry.npmmirror.com/rc-motion/-/rc-motion-2.9.5.tgz", + "integrity": "sha512-w+XTUrfh7ArbYEd2582uDrEhmBHwK1ZENJiSJVb7uRxdE7qJSYjbO2eksRXmndqyKqKoYPc9ClpPh5242mV1vA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-util": "^5.44.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-notification": { + "version": "5.6.4", + "resolved": "https://registry.npmmirror.com/rc-notification/-/rc-notification-5.6.4.tgz", + "integrity": "sha512-KcS4O6B4qzM3KH7lkwOB7ooLPZ4b6J+VMmQgT51VZCeEcmghdeR4IrMcFq0LG+RPdnbe/ArT086tGM8Snimgiw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.9.0", + "rc-util": "^5.20.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-overflow": { + "version": "1.5.0", + "resolved": "https://registry.npmmirror.com/rc-overflow/-/rc-overflow-1.5.0.tgz", + "integrity": "sha512-Lm/v9h0LymeUYJf0x39OveU52InkdRXqnn2aYXfWmo8WdOonIKB2kfau+GF0fWq6jPgtdO9yMqveGcK6aIhJmg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.37.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-pagination": { + "version": "5.1.0", + "resolved": "https://registry.npmmirror.com/rc-pagination/-/rc-pagination-5.1.0.tgz", + "integrity": "sha512-8416Yip/+eclTFdHXLKTxZvn70duYVGTvUUWbckCCZoIl3jagqke3GLsFrMs0bsQBikiYpZLD9206Ej4SOdOXQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.3.2", + "rc-util": "^5.38.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-picker": { + "version": "4.11.3", + "resolved": "https://registry.npmmirror.com/rc-picker/-/rc-picker-4.11.3.tgz", + "integrity": "sha512-MJ5teb7FlNE0NFHTncxXQ62Y5lytq6sh5nUw0iH8OkHL/TjARSEvSHpr940pWgjGANpjCwyMdvsEV55l5tYNSg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.2.1", + "rc-overflow": "^1.3.2", + "rc-resize-observer": "^1.4.0", + "rc-util": "^5.43.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "date-fns": ">= 2.x", + "dayjs": ">= 1.x", + "luxon": ">= 3.x", + "moment": ">= 2.x", + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + }, + "peerDependenciesMeta": { + "date-fns": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true + } + } + }, + "node_modules/rc-progress": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/rc-progress/-/rc-progress-4.0.0.tgz", + "integrity": "sha512-oofVMMafOCokIUIBnZLNcOZFsABaUw8PPrf1/y0ZBvKZNpOiu5h4AO9vv11Sw0p4Hb3D0yGWuEattcQGtNJ/aw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.6", + "rc-util": "^5.16.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-rate": { + "version": "2.13.1", + "resolved": "https://registry.npmmirror.com/rc-rate/-/rc-rate-2.13.1.tgz", + "integrity": "sha512-QUhQ9ivQ8Gy7mtMZPAjLbxBt5y9GRp65VcUyGUMF3N3fhiftivPHdpuDIaWIMOTEprAjZPC08bls1dQB+I1F2Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.5", + "rc-util": "^5.0.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-resize-observer": { + "version": "1.4.3", + "resolved": "https://registry.npmmirror.com/rc-resize-observer/-/rc-resize-observer-1.4.3.tgz", + "integrity": "sha512-YZLjUbyIWox8E9i9C3Tm7ia+W7euPItNWSPX5sCcQTYbnwDb5uNpnLHQCG1f22oZWUhLw4Mv2tFmeWe68CDQRQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.7", + "classnames": "^2.2.1", + "rc-util": "^5.44.1", + "resize-observer-polyfill": "^1.5.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-segmented": { + "version": "2.7.1", + "resolved": "https://registry.npmmirror.com/rc-segmented/-/rc-segmented-2.7.1.tgz", + "integrity": "sha512-izj1Nw/Dw2Vb7EVr+D/E9lUTkBe+kKC+SAFSU9zqr7WV2W5Ktaa9Gc7cB2jTqgk8GROJayltaec+DBlYKc6d+g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-motion": "^2.4.4", + "rc-util": "^5.17.0" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/rc-select": { + "version": "14.16.8", + "resolved": "https://registry.npmmirror.com/rc-select/-/rc-select-14.16.8.tgz", + "integrity": "sha512-NOV5BZa1wZrsdkKaiK7LHRuo5ZjZYMDxPP6/1+09+FB4KoNi8jcG1ZqLE3AVCxEsYMBe65OBx71wFoHRTP3LRg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/trigger": "^2.1.1", + "classnames": "2.x", + "rc-motion": "^2.0.1", + "rc-overflow": "^1.3.1", + "rc-util": "^5.16.1", + "rc-virtual-list": "^3.5.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/rc-slider": { + "version": "11.1.9", + "resolved": "https://registry.npmmirror.com/rc-slider/-/rc-slider-11.1.9.tgz", + "integrity": "sha512-h8IknhzSh3FEM9u8ivkskh+Ef4Yo4JRIY2nj7MrH6GQmrwV6mcpJf5/4KgH5JaVI1H3E52yCdpOlVyGZIeph5A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.5", + "rc-util": "^5.36.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-steps": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/rc-steps/-/rc-steps-6.0.1.tgz", + "integrity": "sha512-lKHL+Sny0SeHkQKKDJlAjV5oZ8DwCdS2hFhAkIjuQt1/pB81M0cA0ErVFdHq9+jmPmFw1vJB2F5NBzFXLJxV+g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.16.7", + "classnames": "^2.2.3", + "rc-util": "^5.16.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-switch": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/rc-switch/-/rc-switch-4.1.0.tgz", + "integrity": "sha512-TI8ufP2Az9oEbvyCeVE4+90PDSljGyuwix3fV58p7HV2o4wBnVToEyomJRVyTaZeqNPAp+vqeo4Wnj5u0ZZQBg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.21.0", + "classnames": "^2.2.1", + "rc-util": "^5.30.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-table": { + "version": "7.54.0", + "resolved": "https://registry.npmmirror.com/rc-table/-/rc-table-7.54.0.tgz", + "integrity": "sha512-/wDTkki6wBTjwylwAGjpLKYklKo9YgjZwAU77+7ME5mBoS32Q4nAwoqhA2lSge6fobLW3Tap6uc5xfwaL2p0Sw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/context": "^1.4.0", + "classnames": "^2.2.5", + "rc-resize-observer": "^1.1.0", + "rc-util": "^5.44.3", + "rc-virtual-list": "^3.14.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-tabs": { + "version": "15.7.0", + "resolved": "https://registry.npmmirror.com/rc-tabs/-/rc-tabs-15.7.0.tgz", + "integrity": "sha512-ZepiE+6fmozYdWf/9gVp7k56PKHB1YYoDsKeQA1CBlJ/POIhjkcYiv0AGP0w2Jhzftd3AVvZP/K+V+Lpi2ankA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.2", + "classnames": "2.x", + "rc-dropdown": "~4.2.0", + "rc-menu": "~9.16.0", + "rc-motion": "^2.6.2", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.34.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-textarea": { + "version": "1.10.2", + "resolved": "https://registry.npmmirror.com/rc-textarea/-/rc-textarea-1.10.2.tgz", + "integrity": "sha512-HfaeXiaSlpiSp0I/pvWpecFEHpVysZ9tpDLNkxQbMvMz6gsr7aVZ7FpWP9kt4t7DB+jJXesYS0us1uPZnlRnwQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.1", + "rc-input": "~1.8.0", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-tooltip": { + "version": "6.4.0", + "resolved": "https://registry.npmmirror.com/rc-tooltip/-/rc-tooltip-6.4.0.tgz", + "integrity": "sha512-kqyivim5cp8I5RkHmpsp1Nn/Wk+1oeloMv9c7LXNgDxUpGm+RbXJGL+OPvDlcRnx9DBeOe4wyOIl4OKUERyH1g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.2", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.3.1", + "rc-util": "^5.44.3" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-tree": { + "version": "5.13.1", + "resolved": "https://registry.npmmirror.com/rc-tree/-/rc-tree-5.13.1.tgz", + "integrity": "sha512-FNhIefhftobCdUJshO7M8uZTA9F4OPGVXqGfZkkD/5soDeOhwO06T/aKTrg0WD8gRg/pyfq+ql3aMymLHCTC4A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.0.1", + "rc-util": "^5.16.1", + "rc-virtual-list": "^3.5.1" + }, + "engines": { + "node": ">=10.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/rc-tree-select": { + "version": "5.27.0", + "resolved": "https://registry.npmmirror.com/rc-tree-select/-/rc-tree-select-5.27.0.tgz", + "integrity": "sha512-2qTBTzwIT7LRI1o7zLyrCzmo5tQanmyGbSaGTIf7sYimCklAToVVfpMC6OAldSKolcnjorBYPNSKQqJmN3TCww==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.7", + "classnames": "2.x", + "rc-select": "~14.16.2", + "rc-tree": "~5.13.0", + "rc-util": "^5.43.0" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/rc-upload": { + "version": "4.11.0", + "resolved": "https://registry.npmmirror.com/rc-upload/-/rc-upload-4.11.0.tgz", + "integrity": "sha512-ZUyT//2JAehfHzjWowqROcwYJKnZkIUGWaTE/VogVrepSl7AFNbQf4+zGfX4zl9Vrj/Jm8scLO0R6UlPDKK4wA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "classnames": "^2.2.5", + "rc-util": "^5.2.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-util": { + "version": "5.44.4", + "resolved": "https://registry.npmmirror.com/rc-util/-/rc-util-5.44.4.tgz", + "integrity": "sha512-resueRJzmHG9Q6rI/DfK6Kdv9/Lfls05vzMs1Sk3M2P+3cJa+MakaZyWY8IPfehVuhPJFKrIY1IK4GqbiaiY5w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "react-is": "^18.2.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-virtual-list": { + "version": "3.19.2", + "resolved": "https://registry.npmmirror.com/rc-virtual-list/-/rc-virtual-list-3.19.2.tgz", + "integrity": "sha512-Ys6NcjwGkuwkeaWBDqfI3xWuZ7rDiQXlH1o2zLfFzATfEgXcqpk8CkgMfbJD81McqjcJVez25a3kPxCR807evA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.0", + "classnames": "^2.2.6", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.36.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmmirror.com/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmmirror.com/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/react-router": { + "version": "6.30.3", + "resolved": "https://registry.npmmirror.com/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.3", + "resolved": "https://registry.npmmirror.com/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmmirror.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", + "license": "MIT" + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz", + "integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.122.0", + "@rolldown/pluginutils": "1.0.0-rc.12" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-x64": "1.0.0-rc.12", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz", + "integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmmirror.com/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/scroll-into-view-if-needed": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz", + "integrity": "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==", + "license": "MIT", + "dependencies": { + "compute-scroll-into-view": "^3.0.2" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-convert": { + "version": "0.2.1", + "resolved": "https://registry.npmmirror.com/string-convert/-/string-convert-0.2.1.tgz", + "integrity": "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==", + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stylis": { + "version": "4.3.6", + "resolved": "https://registry.npmmirror.com/stylis/-/stylis-4.3.6.tgz", + "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==", + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmmirror.com/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/throttle-debounce": { + "version": "5.0.2", + "resolved": "https://registry.npmmirror.com/throttle-debounce/-/throttle-debounce-5.0.2.tgz", + "integrity": "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==", + "license": "MIT", + "engines": { + "node": ">=12.22" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmmirror.com/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmmirror.com/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/toggle-selection": { + "version": "1.0.6", + "resolved": "https://registry.npmmirror.com/toggle-selection/-/toggle-selection-1.0.6.tgz", + "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==", + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmmirror.com/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.2.tgz", + "integrity": "sha512-VEPQ0iPgWO/sBaZOU1xo4nuNdODVOajPnTIbog2GKYr31nIlZ0fWPoCQgGfF3ETyBl1vn63F/p50Um9Z4J8O8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.57.2", + "@typescript-eslint/parser": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2", + "@typescript-eslint/utils": "8.57.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmmirror.com/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz", + "integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.12", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.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" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz", + "integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.2", + "@vitest/mocker": "4.1.2", + "@vitest/pretty-format": "4.1.2", + "@vitest/runner": "4.1.2", + "@vitest/snapshot": "4.1.2", + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.2", + "@vitest/browser-preview": "4.1.2", + "@vitest/browser-webdriverio": "4.1.2", + "@vitest/ui": "4.1.2", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmmirror.com/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmmirror.com/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmmirror.com/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/frontend/admin/package.json b/frontend/admin/package.json new file mode 100644 index 0000000..a812c77 --- /dev/null +++ b/frontend/admin/package.json @@ -0,0 +1,61 @@ +{ + "name": "admin", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite --configLoader native", + "build": "tsc -b && vite build --configLoader native", + "lint": "eslint .", + "preview": "vite preview", + "test": "node ./scripts/run-vitest.mjs", + "test:ui": "node ./scripts/run-vitest.mjs --ui", + "test:coverage": "node ./scripts/run-vitest.mjs --run --coverage", + "test:run": "node ./scripts/run-vitest.mjs --run", + "e2e": "node ./scripts/run-playwright-cdp-e2e.mjs", + "e2e:full": "node ./scripts/run-playwright-cdp-e2e.mjs", + "e2e:full:win": "powershell -ExecutionPolicy Bypass -File ./scripts/run-playwright-auth-e2e.ps1", + "e2e:smoke": "node ./scripts/run-cdp-smoke.mjs", + "e2e:smoke:win": "powershell -ExecutionPolicy Bypass -File ./scripts/run-cdp-smoke-bootstrap.ps1", + "e2e:auth-smoke:win": "powershell -ExecutionPolicy Bypass -File ./scripts/run-cdp-auth-smoke.ps1", + "e2e:report": "node -e \"console.error('Playwright runner report is not supported in this environment; use docs/evidence instead.'); process.exit(1)\"" + }, + "dependencies": { + "@ant-design/icons": "^6.1.0", + "antd": "^5.29.3", + "dayjs": "^1.11.20", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.30.3" + }, + "devDependencies": { + "@eslint/js": "^9.39.4", + "@playwright/test": "^1.49.1", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.1.0", + "@testing-library/user-event": "^14.5.2", + "@types/node": "^24.12.0", + "@types/react": "^18.3.28", + "@types/react-dom": "^18.3.7", + "@vitejs/plugin-react": "^6.0.0", + "@vitest/coverage-v8": "^4.1.2", + "eslint": "^9.39.4", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.4.0", + "jsdom": "^26.0.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.57.2", + "vite": "^8.0.3", + "vitest": "^4.1.2" + }, + "overrides": { + "picomatch": "4.0.4", + "minimatch@3": { + "brace-expansion": "1.1.13" + }, + "minimatch@10": { + "brace-expansion": "5.0.5" + } + } +} diff --git a/frontend/admin/public/favicon.svg b/frontend/admin/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/frontend/admin/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/admin/public/icons.svg b/frontend/admin/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/frontend/admin/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/admin/scripts/mock-smtp-capture.mjs b/frontend/admin/scripts/mock-smtp-capture.mjs new file mode 100644 index 0000000..3b1d318 --- /dev/null +++ b/frontend/admin/scripts/mock-smtp-capture.mjs @@ -0,0 +1,185 @@ +import process from 'node:process' +import path from 'node:path' +import net from 'node:net' +import { appendFile, mkdir, writeFile } from 'node:fs/promises' + +function parseArgs(argv) { + const args = new Map() + + for (let index = 0; index < argv.length; index += 1) { + const value = argv[index] + if (!value.startsWith('--')) { + continue + } + + const key = value.slice(2) + const nextValue = argv[index + 1] + if (nextValue && !nextValue.startsWith('--')) { + args.set(key, nextValue) + index += 1 + continue + } + + args.set(key, 'true') + } + + return args +} + +const args = parseArgs(process.argv.slice(2)) +const port = Number(args.get('port') ?? process.env.SMTP_CAPTURE_PORT ?? 2525) +const outputPath = path.resolve(args.get('output') ?? process.env.SMTP_CAPTURE_OUTPUT ?? './smtp-capture.jsonl') + +if (!Number.isInteger(port) || port <= 0) { + throw new Error(`Invalid SMTP capture port: ${port}`) +} + +await mkdir(path.dirname(outputPath), { recursive: true }) +await writeFile(outputPath, '', 'utf8') + +let writeQueue = Promise.resolve() + +function queueMessageWrite(message) { + writeQueue = writeQueue.then(() => appendFile(outputPath, `${JSON.stringify(message)}\n`, 'utf8')) + return writeQueue +} + +function createSessionState() { + return { + buffer: '', + dataMode: false, + mailFrom: '', + rcptTo: [], + data: '', + } +} + +const server = net.createServer((socket) => { + socket.setEncoding('utf8') + let session = createSessionState() + + const reply = (line) => { + socket.write(`${line}\r\n`) + } + + const resetMessageState = () => { + session.dataMode = false + session.mailFrom = '' + session.rcptTo = [] + session.data = '' + } + + const flushBuffer = async () => { + while (true) { + if (session.dataMode) { + const messageTerminatorIndex = session.buffer.indexOf('\r\n.\r\n') + if (messageTerminatorIndex === -1) { + session.data += session.buffer + session.buffer = '' + return + } + + session.data += session.buffer.slice(0, messageTerminatorIndex) + session.buffer = session.buffer.slice(messageTerminatorIndex + 5) + + const capturedMessage = { + timestamp: new Date().toISOString(), + mailFrom: session.mailFrom, + rcptTo: session.rcptTo, + data: session.data.replace(/\r\n\.\./g, '\r\n.'), + } + + await queueMessageWrite(capturedMessage) + resetMessageState() + reply('250 OK') + continue + } + + const lineEndIndex = session.buffer.indexOf('\r\n') + if (lineEndIndex === -1) { + return + } + + const line = session.buffer.slice(0, lineEndIndex) + session.buffer = session.buffer.slice(lineEndIndex + 2) + const normalized = line.toUpperCase() + + if (normalized.startsWith('EHLO')) { + socket.write('250-localhost\r\n250 OK\r\n') + continue + } + + if (normalized.startsWith('HELO')) { + reply('250 OK') + continue + } + + if (normalized.startsWith('MAIL FROM:')) { + resetMessageState() + session.mailFrom = line.slice('MAIL FROM:'.length).trim() + reply('250 OK') + continue + } + + if (normalized.startsWith('RCPT TO:')) { + session.rcptTo.push(line.slice('RCPT TO:'.length).trim()) + reply('250 OK') + continue + } + + if (normalized === 'DATA') { + session.dataMode = true + session.data = '' + reply('354 End data with .') + continue + } + + if (normalized === 'RSET') { + resetMessageState() + reply('250 OK') + continue + } + + if (normalized === 'NOOP') { + reply('250 OK') + continue + } + + if (normalized === 'QUIT') { + reply('221 Bye') + socket.end() + return + } + + reply('250 OK') + } + } + + socket.on('data', (chunk) => { + session.buffer += chunk + void flushBuffer().catch((error) => { + console.error(error?.stack ?? String(error)) + socket.destroy(error) + }) + }) + + socket.on('error', () => {}) + reply('220 localhost ESMTP ready') +}) + +server.listen(port, '127.0.0.1', () => { + console.log(`SMTP capture listening on 127.0.0.1:${port}`) +}) + +async function shutdown() { + server.close() + await writeQueue.catch(() => {}) +} + +process.on('SIGINT', () => { + void shutdown().finally(() => process.exit(0)) +}) + +process.on('SIGTERM', () => { + void shutdown().finally(() => process.exit(0)) +}) diff --git a/frontend/admin/scripts/run-cdp-auth-smoke.ps1 b/frontend/admin/scripts/run-cdp-auth-smoke.ps1 new file mode 100644 index 0000000..b3206ce --- /dev/null +++ b/frontend/admin/scripts/run-cdp-auth-smoke.ps1 @@ -0,0 +1,316 @@ +param( + [string]$AdminUsername = 'e2e_admin', + [string]$AdminPassword = 'E2EAdmin@123456', + [string]$AdminEmail = 'e2e_admin@example.com', + [int]$BrowserPort = 0 +) + +$ErrorActionPreference = 'Stop' + +$projectRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..\..')).Path +$frontendRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +$tempCacheRoot = Join-Path $env:TEMP 'ums-e2e-cache' +$goCacheDir = Join-Path $tempCacheRoot 'go-build' +$goModCacheDir = Join-Path $tempCacheRoot 'gomod' +$goPathDir = Join-Path $tempCacheRoot 'gopath' +$serverExePath = Join-Path $env:TEMP ("ums-server-e2e-" + [guid]::NewGuid().ToString('N') + '.exe') + +New-Item -ItemType Directory -Force $goCacheDir, $goModCacheDir, $goPathDir | Out-Null + +function Test-UrlReady { + param( + [Parameter(Mandatory = $true)][string]$Url + ) + + try { + $response = Invoke-WebRequest $Url -UseBasicParsing -TimeoutSec 2 + return $response.StatusCode -ge 200 -and $response.StatusCode -lt 500 + } catch { + return $false + } +} + +function Wait-UrlReady { + param( + [Parameter(Mandatory = $true)][string]$Url, + [Parameter(Mandatory = $true)][string]$Label, + [int]$RetryCount = 120, + [int]$DelayMs = 500 + ) + + for ($i = 0; $i -lt $RetryCount; $i++) { + if (Test-UrlReady -Url $Url) { + return + } + Start-Sleep -Milliseconds $DelayMs + } + + throw "$Label did not become ready: $Url" +} + +function Start-ManagedProcess { + param( + [Parameter(Mandatory = $true)][string]$Name, + [Parameter(Mandatory = $true)][string]$FilePath, + [string[]]$ArgumentList = @(), + [Parameter(Mandatory = $true)][string]$WorkingDirectory + ) + + $stdoutPath = Join-Path $env:TEMP "$Name-stdout.log" + $stderrPath = Join-Path $env:TEMP "$Name-stderr.log" + Remove-Item $stdoutPath, $stderrPath -Force -ErrorAction SilentlyContinue + + if ($ArgumentList -and $ArgumentList.Count -gt 0) { + $process = Start-Process ` + -FilePath $FilePath ` + -ArgumentList $ArgumentList ` + -WorkingDirectory $WorkingDirectory ` + -PassThru ` + -WindowStyle Hidden ` + -RedirectStandardOutput $stdoutPath ` + -RedirectStandardError $stderrPath + } else { + $process = Start-Process ` + -FilePath $FilePath ` + -WorkingDirectory $WorkingDirectory ` + -PassThru ` + -WindowStyle Hidden ` + -RedirectStandardOutput $stdoutPath ` + -RedirectStandardError $stderrPath + } + + return [pscustomobject]@{ + Name = $Name + Process = $process + StdOut = $stdoutPath + StdErr = $stderrPath + } +} + +function Stop-ManagedProcess { + param( + [Parameter(Mandatory = $false)]$Handle + ) + + if (-not $Handle) { + return + } + + if ($Handle.Process -and -not $Handle.Process.HasExited) { + try { + taskkill /PID $Handle.Process.Id /T /F *> $null + } catch { + Stop-Process -Id $Handle.Process.Id -Force -ErrorAction SilentlyContinue + } + } +} + +function Show-ManagedProcessLogs { + param( + [Parameter(Mandatory = $false)]$Handle + ) + + if (-not $Handle) { + return + } + + if (Test-Path $Handle.StdOut) { + Get-Content $Handle.StdOut -ErrorAction SilentlyContinue + } + if (Test-Path $Handle.StdErr) { + Get-Content $Handle.StdErr -ErrorAction SilentlyContinue + } +} + +function Remove-ManagedProcessLogs { + param( + [Parameter(Mandatory = $false)]$Handle + ) + + if (-not $Handle) { + return + } + + Remove-Item $Handle.StdOut, $Handle.StdErr -Force -ErrorAction SilentlyContinue +} + +$backendHandle = $null +$frontendHandle = $null +$startedBackend = $false +$startedFrontend = $false +$adminInitialized = $false + +try { + Push-Location $projectRoot + try { + $env:GOCACHE = $goCacheDir + $env:GOMODCACHE = $goModCacheDir + $env:GOPATH = $goPathDir + go build -o $serverExePath .\cmd\server\main.go + if ($LASTEXITCODE -ne 0) { + throw 'server build failed' + } + } finally { + Pop-Location + Remove-Item Env:GOCACHE -ErrorAction SilentlyContinue + Remove-Item Env:GOMODCACHE -ErrorAction SilentlyContinue + Remove-Item Env:GOPATH -ErrorAction SilentlyContinue + } + + $backendWasRunning = Test-UrlReady -Url 'http://127.0.0.1:8080/health' + + Push-Location $projectRoot + try { + $env:GOCACHE = $goCacheDir + $env:GOMODCACHE = $goModCacheDir + $env:GOPATH = $goPathDir + $env:UMS_ADMIN_USERNAME = $AdminUsername + $env:UMS_ADMIN_PASSWORD = $AdminPassword + $env:UMS_ADMIN_EMAIL = $AdminEmail + $env:UMS_ADMIN_RESET_PASSWORD = 'true' + + $previousErrorActionPreference = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + $initOutput = go run .\tools\init_admin.go 2>&1 | Out-String + $initExitCode = $LASTEXITCODE + $ErrorActionPreference = $previousErrorActionPreference + + if ($initExitCode -eq 0) { + $adminInitialized = $true + } else { + $verifyOutput = go run .\tools\verify_admin.go 2>&1 | Out-String + if ($LASTEXITCODE -eq 0 -and $verifyOutput -match 'password valid: True|password valid: true') { + Write-Host 'init_admin fallback: existing admin credentials verified' + $adminInitialized = $true + } else { + Write-Host $initOutput + } + } + } finally { + Pop-Location + Remove-Item Env:GOCACHE -ErrorAction SilentlyContinue + Remove-Item Env:GOMODCACHE -ErrorAction SilentlyContinue + Remove-Item Env:GOPATH -ErrorAction SilentlyContinue + Remove-Item Env:UMS_ADMIN_USERNAME -ErrorAction SilentlyContinue + Remove-Item Env:UMS_ADMIN_PASSWORD -ErrorAction SilentlyContinue + Remove-Item Env:UMS_ADMIN_EMAIL -ErrorAction SilentlyContinue + Remove-Item Env:UMS_ADMIN_RESET_PASSWORD -ErrorAction SilentlyContinue + } + + if (-not $adminInitialized -and -not $backendWasRunning) { + $backendHandle = Start-ManagedProcess ` + -Name 'ums-backend-bootstrap' ` + -FilePath $serverExePath ` + -WorkingDirectory $projectRoot + $startedBackend = $true + + try { + Wait-UrlReady -Url 'http://127.0.0.1:8080/health' -Label 'backend bootstrap' + } catch { + Show-ManagedProcessLogs $backendHandle + throw + } + + Stop-ManagedProcess $backendHandle + Remove-ManagedProcessLogs $backendHandle + $backendHandle = $null + Start-Sleep -Seconds 1 + + Push-Location $projectRoot + try { + $env:GOCACHE = $goCacheDir + $env:GOMODCACHE = $goModCacheDir + $env:GOPATH = $goPathDir + $env:UMS_ADMIN_USERNAME = $AdminUsername + $env:UMS_ADMIN_PASSWORD = $AdminPassword + $env:UMS_ADMIN_EMAIL = $AdminEmail + $env:UMS_ADMIN_RESET_PASSWORD = 'true' + + $previousErrorActionPreference = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + $initOutput = go run .\tools\init_admin.go 2>&1 | Out-String + $initExitCode = $LASTEXITCODE + $ErrorActionPreference = $previousErrorActionPreference + + if ($initExitCode -eq 0) { + $adminInitialized = $true + } else { + $verifyOutput = go run .\tools\verify_admin.go 2>&1 | Out-String + if ($LASTEXITCODE -eq 0 -and $verifyOutput -match 'password valid: True|password valid: true') { + Write-Host 'init_admin fallback: existing admin credentials verified' + $adminInitialized = $true + } else { + Write-Host $initOutput + } + } + } finally { + Pop-Location + Remove-Item Env:GOCACHE -ErrorAction SilentlyContinue + Remove-Item Env:GOMODCACHE -ErrorAction SilentlyContinue + Remove-Item Env:GOPATH -ErrorAction SilentlyContinue + Remove-Item Env:UMS_ADMIN_USERNAME -ErrorAction SilentlyContinue + Remove-Item Env:UMS_ADMIN_PASSWORD -ErrorAction SilentlyContinue + Remove-Item Env:UMS_ADMIN_EMAIL -ErrorAction SilentlyContinue + Remove-Item Env:UMS_ADMIN_RESET_PASSWORD -ErrorAction SilentlyContinue + } + } + + if (-not $adminInitialized) { + throw 'init_admin failed' + } + + if (-not $backendWasRunning) { + $backendHandle = Start-ManagedProcess ` + -Name 'ums-backend' ` + -FilePath $serverExePath ` + -ArgumentList @() ` + -WorkingDirectory $projectRoot + + try { + Wait-UrlReady -Url 'http://127.0.0.1:8080/health' -Label 'backend' + } catch { + Show-ManagedProcessLogs $backendHandle + throw + } + } + + if (-not (Test-UrlReady -Url 'http://127.0.0.1:3000')) { + $frontendHandle = Start-ManagedProcess ` + -Name 'ums-frontend' ` + -FilePath 'npm.cmd' ` + -ArgumentList @('run', 'dev', '--', '--host', '127.0.0.1', '--port', '3000') ` + -WorkingDirectory $frontendRoot + $startedFrontend = $true + + try { + Wait-UrlReady -Url 'http://127.0.0.1:3000' -Label 'frontend' + } catch { + Show-ManagedProcessLogs $frontendHandle + throw + } + } + + $env:E2E_LOGIN_USERNAME = $AdminUsername + $env:E2E_LOGIN_PASSWORD = $AdminPassword + + Push-Location $frontendRoot + try { + & (Join-Path $PSScriptRoot 'run-cdp-smoke.ps1') -Port $BrowserPort + } finally { + Pop-Location + Remove-Item Env:E2E_LOGIN_USERNAME -ErrorAction SilentlyContinue + Remove-Item Env:E2E_LOGIN_PASSWORD -ErrorAction SilentlyContinue + } +} finally { + if ($startedFrontend) { + Stop-ManagedProcess $frontendHandle + Remove-ManagedProcessLogs $frontendHandle + } + + if ($startedBackend) { + Stop-ManagedProcess $backendHandle + Remove-ManagedProcessLogs $backendHandle + } + + Remove-Item $serverExePath -Force -ErrorAction SilentlyContinue +} diff --git a/frontend/admin/scripts/run-cdp-smoke-bootstrap.ps1 b/frontend/admin/scripts/run-cdp-smoke-bootstrap.ps1 new file mode 100644 index 0000000..23d2180 --- /dev/null +++ b/frontend/admin/scripts/run-cdp-smoke-bootstrap.ps1 @@ -0,0 +1,205 @@ +param( + [int]$BrowserPort = 0 +) + +$ErrorActionPreference = 'Stop' + +$projectRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..\..')).Path +$frontendRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +$tempCacheRoot = Join-Path $env:TEMP 'ums-e2e-cache' +$goCacheDir = Join-Path $tempCacheRoot 'go-build' +$goModCacheDir = Join-Path $tempCacheRoot 'gomod' +$goPathDir = Join-Path $tempCacheRoot 'gopath' +$serverExePath = Join-Path $env:TEMP ("ums-server-smoke-" + [guid]::NewGuid().ToString('N') + '.exe') + +New-Item -ItemType Directory -Force $goCacheDir, $goModCacheDir, $goPathDir | Out-Null + +function Test-UrlReady { + param( + [Parameter(Mandatory = $true)][string]$Url + ) + + try { + $response = Invoke-WebRequest $Url -UseBasicParsing -TimeoutSec 2 + return $response.StatusCode -ge 200 -and $response.StatusCode -lt 500 + } catch { + return $false + } +} + +function Wait-UrlReady { + param( + [Parameter(Mandatory = $true)][string]$Url, + [Parameter(Mandatory = $true)][string]$Label, + [int]$RetryCount = 120, + [int]$DelayMs = 500 + ) + + for ($i = 0; $i -lt $RetryCount; $i++) { + if (Test-UrlReady -Url $Url) { + return + } + Start-Sleep -Milliseconds $DelayMs + } + + throw "$Label did not become ready: $Url" +} + +function Start-ManagedProcess { + param( + [Parameter(Mandatory = $true)][string]$Name, + [Parameter(Mandatory = $true)][string]$FilePath, + [string[]]$ArgumentList = @(), + [Parameter(Mandatory = $true)][string]$WorkingDirectory + ) + + $stdoutPath = Join-Path $env:TEMP "$Name-stdout.log" + $stderrPath = Join-Path $env:TEMP "$Name-stderr.log" + Remove-Item $stdoutPath, $stderrPath -Force -ErrorAction SilentlyContinue + + if ($ArgumentList -and $ArgumentList.Count -gt 0) { + $process = Start-Process ` + -FilePath $FilePath ` + -ArgumentList $ArgumentList ` + -WorkingDirectory $WorkingDirectory ` + -PassThru ` + -WindowStyle Hidden ` + -RedirectStandardOutput $stdoutPath ` + -RedirectStandardError $stderrPath + } else { + $process = Start-Process ` + -FilePath $FilePath ` + -WorkingDirectory $WorkingDirectory ` + -PassThru ` + -WindowStyle Hidden ` + -RedirectStandardOutput $stdoutPath ` + -RedirectStandardError $stderrPath + } + + return [pscustomobject]@{ + Name = $Name + Process = $process + StdOut = $stdoutPath + StdErr = $stderrPath + } +} + +function Stop-ManagedProcess { + param( + [Parameter(Mandatory = $false)]$Handle + ) + + if (-not $Handle) { + return + } + + if ($Handle.Process -and -not $Handle.Process.HasExited) { + try { + taskkill /PID $Handle.Process.Id /T /F *> $null + } catch { + Stop-Process -Id $Handle.Process.Id -Force -ErrorAction SilentlyContinue + } + } +} + +function Show-ManagedProcessLogs { + param( + [Parameter(Mandatory = $false)]$Handle + ) + + if (-not $Handle) { + return + } + + if (Test-Path $Handle.StdOut) { + Get-Content $Handle.StdOut -ErrorAction SilentlyContinue + } + if (Test-Path $Handle.StdErr) { + Get-Content $Handle.StdErr -ErrorAction SilentlyContinue + } +} + +function Remove-ManagedProcessLogs { + param( + [Parameter(Mandatory = $false)]$Handle + ) + + if (-not $Handle) { + return + } + + Remove-Item $Handle.StdOut, $Handle.StdErr -Force -ErrorAction SilentlyContinue +} + +$backendHandle = $null +$frontendHandle = $null +$startedBackend = $false +$startedFrontend = $false + +try { + Push-Location $projectRoot + try { + $env:GOCACHE = $goCacheDir + $env:GOMODCACHE = $goModCacheDir + $env:GOPATH = $goPathDir + go build -o $serverExePath .\cmd\server\main.go + if ($LASTEXITCODE -ne 0) { + throw 'server build failed' + } + } finally { + Pop-Location + Remove-Item Env:GOCACHE -ErrorAction SilentlyContinue + Remove-Item Env:GOMODCACHE -ErrorAction SilentlyContinue + Remove-Item Env:GOPATH -ErrorAction SilentlyContinue + } + + if (-not (Test-UrlReady -Url 'http://127.0.0.1:8080/health')) { + $backendHandle = Start-ManagedProcess ` + -Name 'ums-backend-smoke' ` + -FilePath $serverExePath ` + -WorkingDirectory $projectRoot + $startedBackend = $true + + try { + Wait-UrlReady -Url 'http://127.0.0.1:8080/health' -Label 'backend smoke' + } catch { + Show-ManagedProcessLogs $backendHandle + throw + } + } + + if (-not (Test-UrlReady -Url 'http://127.0.0.1:3000')) { + $frontendHandle = Start-ManagedProcess ` + -Name 'ums-frontend-smoke' ` + -FilePath 'npm.cmd' ` + -ArgumentList @('run', 'dev', '--', '--host', '127.0.0.1', '--port', '3000') ` + -WorkingDirectory $frontendRoot + $startedFrontend = $true + + try { + Wait-UrlReady -Url 'http://127.0.0.1:3000' -Label 'frontend smoke' + } catch { + Show-ManagedProcessLogs $frontendHandle + throw + } + } + + Push-Location $frontendRoot + try { + & (Join-Path $PSScriptRoot 'run-cdp-smoke.ps1') -Port $BrowserPort + } finally { + Pop-Location + } +} finally { + if ($startedFrontend) { + Stop-ManagedProcess $frontendHandle + Remove-ManagedProcessLogs $frontendHandle + } + + if ($startedBackend) { + Stop-ManagedProcess $backendHandle + Remove-ManagedProcessLogs $backendHandle + } + + Remove-Item $serverExePath -Force -ErrorAction SilentlyContinue +} diff --git a/frontend/admin/scripts/run-cdp-smoke.mjs b/frontend/admin/scripts/run-cdp-smoke.mjs new file mode 100644 index 0000000..709896a --- /dev/null +++ b/frontend/admin/scripts/run-cdp-smoke.mjs @@ -0,0 +1,1624 @@ +import { mkdtemp, readdir, rm, access, mkdir } from 'node:fs/promises' +import { constants as fsConstants } from 'node:fs' +import { spawn } from 'node:child_process' +import { tmpdir } from 'node:os' +import path from 'node:path' +import process from 'node:process' +import net from 'node:net' + +const TEXT = { + appTitle: '\u7528\u6237\u7ba1\u7406\u7cfb\u7edf', + welcomeLogin: '\u6b22\u8fce\u767b\u5f55', + loginAction: '\u767b\u5f55', + passwordLogin: '\u5bc6\u7801\u767b\u5f55', + emailCodeLogin: '\u90ae\u7bb1\u9a8c\u8bc1\u7801', + smsCodeLogin: '\u77ed\u4fe1\u9a8c\u8bc1\u7801', + forgotPassword: '\u5fd8\u8bb0\u5bc6\u7801', + usernamePlaceholder: '\u7528\u6237\u540d', + passwordPlaceholder: '\u5bc6\u7801', + emailPlaceholder: '\u90ae\u7bb1', + phonePlaceholder: '\u624b\u673a', + codePlaceholder: '\u9a8c\u8bc1\u7801', + dashboard: '\u603b\u89c8', + users: '\u7528\u6237\u7ba1\u7406', + roles: '\u89d2\u8272\u7ba1\u7406', + logout: '\u9000\u51fa\u767b\u5f55', + usersFilter: '\u7528\u6237\u540d/\u90ae\u7bb1/\u624b\u673a\u53f7', + rolesFilter: '\u89d2\u8272\u540d\u79f0/\u4ee3\u7801', + userDetail: '\u7528\u6237\u8be6\u60c5', + assignRoles: '\u5206\u914d\u89d2\u8272', + assignPermissions: '\u5206\u914d\u6743\u9650', + assignableRoles: '\u53ef\u5206\u914d\u89d2\u8272', + assignedRoles: '\u5df2\u5206\u914d\u89d2\u8272', + createRole: '\u521b\u5efa\u89d2\u8272', + roleNameCode: '\u89d2\u8272\u540d\u79f0/\u4ee3\u7801', + adminRoleName: '\u7ba1\u7406\u5458', + userId: '\u7528\u6237 ID', + totalUsers: '\u7528\u6237\u603b\u6570', + todaySuccessLogins: '\u4eca\u65e5\u6210\u529f\u767b\u5f55', + permissionsHint: '\u9009\u62e9\u8981\u5206\u914d\u7ed9\u8be5\u89d2\u8272\u7684\u6743\u9650', +} + +const DEFAULT_BASE_URL = process.env.E2E_BASE_URL ?? 'http://127.0.0.1:3000' +const DEFAULT_LOGIN_PATH = process.env.E2E_LOGIN_PATH ?? '/login' +const BASE_URL = new URL(DEFAULT_BASE_URL).toString().replace(/\/$/, '') +const LOGIN_URL = new URL(DEFAULT_LOGIN_PATH, `${BASE_URL}/`).toString() +const DASHBOARD_URL = new URL('/dashboard', `${BASE_URL}/`).toString() +const USERS_URL = new URL('/users', `${BASE_URL}/`).toString() + +const LOGIN_USERNAME = (process.env.E2E_LOGIN_USERNAME ?? '').trim() +const LOGIN_PASSWORD = process.env.E2E_LOGIN_PASSWORD ?? '' + +const EXTERNAL_BROWSER = process.env.E2E_SKIP_BROWSER_LAUNCH === '1' +const EXTERNAL_CDP_PORT = Number(process.env.E2E_CDP_PORT ?? 0) +const EXTERNAL_CDP_JSON_URL = process.env.E2E_CDP_JSON_URL ?? '' +const EXTERNAL_CDP_BASE_URL = process.env.E2E_CDP_BASE_URL ?? '' + +const STARTUP_TIMEOUT_MS = Number(process.env.E2E_STARTUP_TIMEOUT_MS ?? 30000) +const ASSERT_TIMEOUT_MS = Number(process.env.E2E_ASSERT_TIMEOUT_MS ?? 15000) +const NAVIGATION_TIMEOUT_MS = Number(process.env.E2E_NAVIGATION_TIMEOUT_MS ?? 15000) +const COMMAND_TIMEOUT_MS = Number(process.env.E2E_COMMAND_TIMEOUT_MS ?? 120000) +const DEBUG = process.env.E2E_DEBUG === '1' + +async function main() { + let browserPath + let port + let profileDir + let browser + let connection + + try { + await waitForHttp(`${BASE_URL}/`, STARTUP_TIMEOUT_MS, 'frontend dev server') + + let cdpBaseUrl + if (EXTERNAL_BROWSER) { + cdpBaseUrl = resolveExternalCdpBaseUrl() + } else { + browserPath = await resolveBrowserPath() + port = await getFreePort() + profileDir = await createBrowserProfileDir(browserPath, port) + browser = startBrowser(browserPath, port, profileDir) + cdpBaseUrl = `http://127.0.0.1:${port}` + } + + logDebug(`connecting to ${cdpBaseUrl}`) + const version = await waitForJson(`${cdpBaseUrl}/json/version`, STARTUP_TIMEOUT_MS) + let summary + + for (let attempt = 1; attempt <= 2; attempt++) { + const pageTarget = await getOrCreatePageTarget(cdpBaseUrl, version, STARTUP_TIMEOUT_MS) + connection = await CDPConnection.connect(pageTarget.webSocketDebuggerUrl) + + try { + logDebug(`running smoke attempt ${attempt}`) + summary = await runSmoke(connection, { + loginUrl: LOGIN_URL, + dashboardUrl: DASHBOARD_URL, + usersUrl: USERS_URL, + assertTimeoutMs: ASSERT_TIMEOUT_MS, + navigationTimeoutMs: NAVIGATION_TIMEOUT_MS, + browserVersion: version.Browser ?? version.product ?? 'unknown', + loginUsername: LOGIN_USERNAME, + loginPassword: LOGIN_PASSWORD, + }) + break + } catch (error) { + const shouldRetry = attempt < 2 && isRetryableCDPError(error) + await connection?.close().catch(() => {}) + connection = null + + if (!shouldRetry) { + throw error + } + + logDebug(`retrying smoke after transient CDP failure: ${formatError(error)}`) + await delay(1000) + } + } + + printSummary(summary) + } finally { + logDebug('closing connection') + await connection?.close().catch(() => {}) + + if (browser) { + await killBrowserTree(browser) + } + + if (profileDir) { + await rm(profileDir, { recursive: true, force: true }).catch(() => {}) + } + } +} + +function resolveExternalCdpBaseUrl() { + if (EXTERNAL_CDP_BASE_URL) { + return EXTERNAL_CDP_BASE_URL.replace(/\/$/, '') + } + + if (EXTERNAL_CDP_JSON_URL) { + return new URL(EXTERNAL_CDP_JSON_URL).origin + } + + if (EXTERNAL_CDP_PORT > 0) { + return `http://127.0.0.1:${EXTERNAL_CDP_PORT}` + } + + throw new Error( + 'external browser mode requires E2E_CDP_PORT, E2E_CDP_BASE_URL, or E2E_CDP_JSON_URL', + ) +} + +function startBrowser(browserPath, port, profileDir) { + const args = [`--remote-debugging-port=${port}`, `--user-data-dir=${profileDir}`, '--no-sandbox'] + + if (isHeadlessShell(browserPath)) { + args.push('--single-process') + } else { + args.push( + '--disable-dev-shm-usage', + '--disable-background-networking', + '--disable-background-timer-throttling', + '--disable-renderer-backgrounding', + '--disable-sync', + '--headless=new', + ) + } + + args.push('about:blank') + + const browser = spawn(browserPath, args, { + stdio: 'ignore', + windowsHide: true, + }) + + browser.on('exit', (code, signal) => { + if (code !== 0 && signal == null) { + console.error(`browser exited unexpectedly with code ${code}`) + } + }) + + return browser +} + +async function createBrowserProfileDir(browserPath, port) { + if (!isHeadlessShell(browserPath)) { + return await mkdtemp(path.join(tmpdir(), 'pw-profile-cdp-')) + } + + const profileRoot = path.join(process.cwd(), '.cache', 'cdp-profiles') + await mkdir(profileRoot, { recursive: true }) + return path.join(profileRoot, `pw-profile-cdp-smoke-node-${port}`) +} + +async function resolveBrowserPath() { + const envPath = + process.env.CHROME_HEADLESS_SHELL_PATH ?? + process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH + + if (envPath) { + await assertFileExists(envPath) + return envPath + } + + for (const candidate of [ + 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', + 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe', + 'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe', + 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe', + ]) { + try { + await assertFileExists(candidate) + return candidate + } catch { + continue + } + } + + const baseDir = path.join(process.env.LOCALAPPDATA ?? '', 'ms-playwright') + const candidates = [] + + try { + const entries = await readdir(baseDir, { withFileTypes: true }) + for (const entry of entries) { + if (!entry.isDirectory()) { + continue + } + if (!entry.name.startsWith('chromium_headless_shell-')) { + continue + } + candidates.push( + path.join( + baseDir, + entry.name, + 'chrome-headless-shell-win64', + 'chrome-headless-shell.exe', + ), + ) + } + } catch { + throw new Error('failed to scan Playwright browser cache under LOCALAPPDATA') + } + + candidates.sort().reverse() + + for (const candidate of candidates) { + try { + await assertFileExists(candidate) + return candidate + } catch { + continue + } + } + + throw new Error('chrome-headless-shell.exe not found; set CHROME_HEADLESS_SHELL_PATH') +} + +async function assertFileExists(filePath) { + await access(filePath, fsConstants.F_OK) +} + +function isHeadlessShell(browserPath) { + return path.basename(browserPath).toLowerCase().includes('headless-shell') +} + +async function killBrowserTree(browser) { + if (browser.exitCode != null || browser.pid == null) { + return + } + + await new Promise((resolve) => { + const killer = spawn('taskkill', ['/PID', String(browser.pid), '/T', '/F'], { + stdio: 'ignore', + windowsHide: true, + }) + + killer.once('error', () => { + try { + browser.kill('SIGKILL') + } catch { + // ignore + } + resolve() + }) + + killer.once('exit', () => resolve()) + }) +} + +async function getFreePort() { + return await new Promise((resolve, reject) => { + const server = net.createServer() + server.unref() + server.on('error', reject) + server.listen(0, '127.0.0.1', () => { + const address = server.address() + if (address == null || typeof address === 'string') { + server.close(() => reject(new Error('failed to resolve a free port'))) + return + } + + server.close((error) => { + if (error) { + reject(error) + return + } + resolve(address.port) + }) + }) + }) +} + +async function waitForHttp(url, timeoutMs, label) { + const startedAt = Date.now() + let lastError + + while (Date.now() - startedAt < timeoutMs) { + try { + const response = await fetch(url) + if (response.ok) { + return response + } + lastError = new Error(`${label} returned ${response.status}`) + } catch (error) { + lastError = error + } + await delay(250) + } + + throw new Error(`timed out waiting for ${label}: ${formatError(lastError)}`) +} + +async function waitForJson(url, timeoutMs) { + const response = await waitForHttp(url, timeoutMs, url) + return await response.json() +} + +async function waitForPageTarget(cdpBaseUrl, timeoutMs) { + const startedAt = Date.now() + let lastTargets = [] + + while (Date.now() - startedAt < timeoutMs) { + try { + const targets = await waitForJson(`${cdpBaseUrl}/json/list`, 5000) + lastTargets = Array.isArray(targets) ? targets : [] + const pageTarget = lastTargets.find( + (target) => target.type === 'page' && typeof target.webSocketDebuggerUrl === 'string', + ) + if (pageTarget) { + return pageTarget + } + } catch { + // retry + } + + await delay(250) + } + + throw new Error(`timed out waiting for page target: ${JSON.stringify(lastTargets)}`) +} + +async function getOrCreatePageTarget(cdpBaseUrl, version, timeoutMs) { + try { + return await waitForPageTarget(cdpBaseUrl, Math.min(timeoutMs, 5000)) + } catch (firstError) { + const browserWsUrl = version?.webSocketDebuggerUrl + if (typeof browserWsUrl !== 'string' || browserWsUrl.length === 0) { + throw firstError + } + + await createPageTarget(browserWsUrl) + return await waitForPageTarget(cdpBaseUrl, timeoutMs) + } +} + +async function createPageTarget(browserWsUrl) { + const browserConnection = await CDPConnection.connect(browserWsUrl) + try { + await browserConnection.send('Target.createTarget', { url: 'about:blank' }) + } finally { + await browserConnection.close().catch(() => {}) + } +} + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +function isRetryableCDPError(error) { + const message = formatError(error) + return ( + message === 'CDP websocket closed' || + message.startsWith('CDP command timed out:') + ) +} + +class CDPConnection { + static async connect(wsUrl) { + const WebSocketImpl = globalThis.WebSocket ?? (await import('ws')).default + const ws = new WebSocketImpl(wsUrl) + + return await new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + try { + ws.close() + } catch { + // ignore + } + reject(new Error('timed out opening CDP websocket')) + }, STARTUP_TIMEOUT_MS) + + ws.addEventListener('open', () => { + clearTimeout(timeoutId) + resolve(new CDPConnection(ws)) + }) + + ws.addEventListener('error', () => { + clearTimeout(timeoutId) + reject(new Error('CDP websocket error')) + }) + }) + } + + constructor(ws) { + this.ws = ws + this.lastId = 0 + this.pending = new Map() + this.listeners = new Set() + + ws.addEventListener('message', (event) => { + const payload = JSON.parse(event.data.toString()) + + if (payload.id != null) { + const request = this.pending.get(payload.id) + if (!request) { + return + } + + this.pending.delete(payload.id) + clearTimeout(request.timeoutId) + + if (payload.error) { + request.reject(new Error(payload.error.message ?? 'CDP command failed')) + return + } + + request.resolve(payload.result ?? {}) + return + } + + for (const listener of this.listeners) { + listener(payload) + } + }) + + ws.addEventListener('close', () => { + for (const request of this.pending.values()) { + clearTimeout(request.timeoutId) + request.reject(new Error('CDP websocket closed')) + } + this.pending.clear() + }) + } + + send(method, params = {}) { + const id = ++this.lastId + const message = { id, method, params } + + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + this.pending.delete(id) + reject(new Error(`CDP command timed out: ${method}`)) + }, COMMAND_TIMEOUT_MS) + + this.pending.set(id, { resolve, reject, timeoutId }) + + try { + this.ws.send(JSON.stringify(message)) + } catch (error) { + clearTimeout(timeoutId) + this.pending.delete(id) + reject(error) + } + }) + } + + onEvent(listener) { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } + + waitForEvent(predicate, timeoutMs, label) { + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + unsubscribe() + reject(new Error(`timed out waiting for ${label}`)) + }, timeoutMs) + + const unsubscribe = this.onEvent((event) => { + if (!predicate(event)) { + return + } + clearTimeout(timeoutId) + unsubscribe() + resolve(event) + }) + }) + } + + async close() { + if (this.ws.readyState === 3) { + return + } + + await Promise.race([ + new Promise((resolve) => { + this.ws.addEventListener('close', () => resolve(), { once: true }) + this.ws.close() + }), + delay(3000), + ]) + } +} + +async function runSmoke(connection, options) { + const consoleErrors = [] + const runtimeExceptions = [] + const networkFailures = [] + const consoleEntries = [] + const javascriptDialogs = [] + const popupWindows = [] + + const unsubscribe = connection.onEvent((event) => { + if (event.method === 'Runtime.consoleAPICalled') { + const entry = formatConsoleEntry(event.params) + consoleEntries.push(entry) + if (entry.type === 'error' && !isIgnorableConsoleError(entry.text)) { + consoleErrors.push(entry.text) + } + return + } + + if (event.method === 'Runtime.exceptionThrown') { + runtimeExceptions.push(event.params.exceptionDetails?.text ?? 'unknown exception') + return + } + + if (event.method === 'Page.javascriptDialogOpening') { + const dialog = { + type: event.params.type ?? 'unknown', + message: event.params.message ?? '', + defaultPrompt: event.params.defaultPrompt ?? '', + } + javascriptDialogs.push(dialog) + void connection.send('Page.handleJavaScriptDialog', { accept: false }).catch(() => {}) + return + } + + if (event.method === 'Page.windowOpen') { + popupWindows.push({ + url: event.params.url ?? '', + windowName: event.params.windowName ?? '', + userGesture: event.params.userGesture ?? false, + }) + return + } + + if (event.method === 'Network.loadingFailed') { + const failure = { + errorText: event.params.errorText, + canceled: event.params.canceled, + type: event.params.type, + } + if (!isIgnorableNetworkFailure(failure)) { + networkFailures.push(failure) + } + } + }) + + try { + logDebug('enabling domains') + await connection.send('Page.enable') + await connection.send('Runtime.enable') + await connection.send('Log.enable') + await connection.send('Network.enable') + + const loadTimings = [] + + logDebug('checking protected route redirect') + const protectedRedirects = { + dashboard: await assertProtectedRouteRedirect( + connection, + options.dashboardUrl, + '/dashboard', + options, + ), + users: await assertProtectedRouteRedirect(connection, options.usersUrl, '/users', options), + } + + logDebug('navigating to login') + const initialLoadMs = await navigateAndWait(connection, options.loginUrl, options) + loadTimings.push({ name: 'login-initial', ms: initialLoadMs }) + + logDebug('checking initial page state') + const initialState = await waitForCondition( + connection, + ` + (() => { + const isVisible = (element) => { + if (!(element instanceof HTMLElement)) { + return false + } + const style = window.getComputedStyle(element) + return style.display !== 'none' && style.visibility !== 'hidden' && element.getClientRects().length > 0 + } + + const visibleInputs = Array.from(document.querySelectorAll('input')) + .filter(isVisible) + .map((input) => input.getAttribute('placeholder') ?? '') + + const visibleLinks = Array.from(document.querySelectorAll('a')) + .filter(isVisible) + .map((link) => link.textContent?.trim() ?? '') + + const visibleTabs = Array.from(document.querySelectorAll('[role="tab"], .ant-tabs-tab-btn')) + .filter(isVisible) + .map((tab) => tab.textContent?.trim() ?? '') + .filter(Boolean) + + return { + title: document.title, + lang: document.documentElement.lang, + bodyText: document.body?.innerText ?? '', + path: location.pathname, + visibleInputs, + visibleLinks, + visibleTabs, + capabilities: null, + } + })() + `, + (state) => + state.path === '/login' && + state.title.includes(TEXT.appTitle) && + state.lang === 'zh-CN' && + state.bodyText.includes(TEXT.welcomeLogin) && + state.visibleInputs.some((placeholder) => placeholder.includes(TEXT.usernamePlaceholder)) && + state.visibleInputs.some((placeholder) => placeholder.includes(TEXT.passwordPlaceholder)), + options.assertTimeoutMs, + 'login page state', + ) + + const authCapabilities = + (await evaluate( + connection, + ` + (async () => { + const response = await fetch('/api/v1/auth/capabilities') + if (!response.ok) { + return null + } + const payload = await response.json() + return payload?.data ?? null + })() + `, + )) ?? + { + password: true, + email_code: initialState.visibleTabs.some((tab) => tab.includes(TEXT.emailCodeLogin)), + sms_code: initialState.visibleTabs.some((tab) => tab.includes(TEXT.smsCodeLogin)), + password_reset: initialState.visibleLinks.some((link) => link.includes(TEXT.forgotPassword)), + oauth_providers: [], + } + if (authCapabilities.password !== true) { + throw new Error(`unexpected auth capabilities: ${JSON.stringify(authCapabilities)}`) + } + if ( + Boolean(authCapabilities.email_code) !== + initialState.visibleTabs.some((tab) => tab.includes(TEXT.emailCodeLogin)) + ) { + throw new Error( + `email capability mismatch: capabilities=${JSON.stringify(authCapabilities)} state=${JSON.stringify(initialState)}`, + ) + } + if ( + Boolean(authCapabilities.sms_code) !== + initialState.visibleTabs.some((tab) => tab.includes(TEXT.smsCodeLogin)) + ) { + throw new Error( + `sms capability mismatch: capabilities=${JSON.stringify(authCapabilities)} state=${JSON.stringify(initialState)}`, + ) + } + if ( + Boolean(authCapabilities.password_reset) !== + initialState.visibleLinks.some((link) => link.includes(TEXT.forgotPassword)) + ) { + throw new Error( + `password reset capability mismatch: capabilities=${JSON.stringify(authCapabilities)} state=${JSON.stringify(initialState)}`, + ) + } + + let emailState = null + if (authCapabilities.email_code) { + logDebug('switching to email tab') + await clickText(connection, '.ant-tabs-tab-btn, [role="tab"]', TEXT.emailCodeLogin) + emailState = await waitForCondition( + connection, + ` + (() => Array.from(document.querySelectorAll('input')) + .filter((element) => { + const style = window.getComputedStyle(element) + return style.display !== 'none' && style.visibility !== 'hidden' && element.getClientRects().length > 0 + }) + .map((input) => input.getAttribute('placeholder') ?? '') + )() + `, + (placeholders) => + placeholders.some((placeholder) => placeholder.includes(TEXT.emailPlaceholder)) && + placeholders.some((placeholder) => placeholder.includes(TEXT.codePlaceholder)), + options.assertTimeoutMs, + 'email login tab', + ) + } + + let smsState = null + if (authCapabilities.sms_code) { + logDebug('switching to sms tab') + await clickText(connection, '.ant-tabs-tab-btn, [role="tab"]', TEXT.smsCodeLogin) + smsState = await waitForCondition( + connection, + ` + (() => Array.from(document.querySelectorAll('input')) + .filter((element) => { + const style = window.getComputedStyle(element) + return style.display !== 'none' && style.visibility !== 'hidden' && element.getClientRects().length > 0 + }) + .map((input) => input.getAttribute('placeholder') ?? '') + )() + `, + (placeholders) => + placeholders.some((placeholder) => placeholder.includes(TEXT.phonePlaceholder)) && + placeholders.some((placeholder) => placeholder.includes(TEXT.codePlaceholder)), + options.assertTimeoutMs, + 'sms login tab', + ) + } + + let forgotState = null + if (authCapabilities.password_reset) { + logDebug('opening forgot password route') + await navigateAndWait(connection, options.loginUrl, options) + await clickText(connection, 'a', TEXT.forgotPassword) + forgotState = await waitForCondition( + connection, + ` + (() => ({ + path: location.pathname, + bodyText: document.body?.innerText ?? '', + title: document.title, + }))() + `, + (state) => state.path === '/forgot-password' && state.bodyText.includes(TEXT.forgotPassword), + options.assertTimeoutMs, + 'forgot password route', + ) + } + + logDebug('running responsive checks') + const responsiveChecks = [] + for (const viewport of [ + { name: 'desktop', width: 1920, height: 1080, mobile: false }, + { name: 'tablet', width: 768, height: 1024, mobile: false }, + { name: 'mobile', width: 375, height: 667, mobile: true }, + ]) { + logDebug(`viewport ${viewport.name}`) + await connection.send('Emulation.setDeviceMetricsOverride', { + width: viewport.width, + height: viewport.height, + deviceScaleFactor: 1, + mobile: viewport.mobile, + }) + + const loadMs = await navigateAndWait(connection, options.loginUrl, options) + loadTimings.push({ name: `login-${viewport.name}`, ms: loadMs }) + + const state = await waitForCondition( + connection, + ` + (() => ({ + innerWidth: window.innerWidth, + bodyScrollWidth: document.body.scrollWidth, + path: location.pathname, + visibleInputs: Array.from(document.querySelectorAll('input')) + .filter((element) => { + if (!(element instanceof HTMLElement)) { + return false + } + const style = window.getComputedStyle(element) + return style.display !== 'none' && style.visibility !== 'hidden' && element.getClientRects().length > 0 + }) + .map((input) => input.getAttribute('placeholder') ?? ''), + visibleLinks: Array.from(document.querySelectorAll('a')) + .filter((element) => { + if (!(element instanceof HTMLElement)) { + return false + } + const style = window.getComputedStyle(element) + return style.display !== 'none' && style.visibility !== 'hidden' && element.getClientRects().length > 0 + }) + .map((link) => link.textContent?.trim() ?? ''), + visibleTabs: Array.from(document.querySelectorAll('[role="tab"], .ant-tabs-tab-btn')) + .filter((element) => { + if (!(element instanceof HTMLElement)) { + return false + } + const style = window.getComputedStyle(element) + return style.display !== 'none' && style.visibility !== 'hidden' && element.getClientRects().length > 0 + }) + .map((tab) => tab.textContent?.trim() ?? ''), + }))() + `, + (state) => + state.path === '/login' && + state.visibleInputs.some((placeholder) => placeholder.includes(TEXT.usernamePlaceholder)) && + state.visibleInputs.some((placeholder) => placeholder.includes(TEXT.passwordPlaceholder)) && + Boolean(authCapabilities.email_code) === + state.visibleTabs.some((tab) => tab.includes(TEXT.emailCodeLogin)) && + Boolean(authCapabilities.sms_code) === + state.visibleTabs.some((tab) => tab.includes(TEXT.smsCodeLogin)) && + Boolean(authCapabilities.password_reset) === + state.visibleLinks.some((link) => link.includes(TEXT.forgotPassword)), + options.assertTimeoutMs, + `${viewport.name} viewport`, + ) + + if (Math.abs(state.innerWidth - viewport.width) > 1) { + throw new Error( + `${viewport.name} viewport width mismatch: expected ${viewport.width}, got ${state.innerWidth}`, + ) + } + + if (state.bodyScrollWidth > state.innerWidth + 4) { + throw new Error( + `${viewport.name} viewport overflows horizontally: ${state.bodyScrollWidth} > ${state.innerWidth}`, + ) + } + + responsiveChecks.push({ + name: viewport.name, + width: state.innerWidth, + bodyScrollWidth: state.bodyScrollWidth, + }) + } + + let authFlow = null + if (options.loginUsername && options.loginPassword) { + await setViewport(connection, { width: 1920, height: 1080, mobile: false }) + logDebug('running authenticated flow') + authFlow = await runAuthenticatedFlow(connection, options) + } + + if (consoleErrors.length > 0) { + throw new Error(`console errors detected: ${consoleErrors.join(' | ')}`) + } + + if (runtimeExceptions.length > 0) { + throw new Error(`runtime exceptions detected: ${runtimeExceptions.join(' | ')}`) + } + + if (networkFailures.length > 0) { + throw new Error(`network failures detected: ${JSON.stringify(networkFailures)}`) + } + + if (javascriptDialogs.length > 0) { + throw new Error(`javascript dialogs detected: ${JSON.stringify(javascriptDialogs)}`) + } + + if (popupWindows.length > 0) { + throw new Error(`popup windows detected: ${JSON.stringify(popupWindows)}`) + } + + return { + browserVersion: options.browserVersion, + protectedRedirects, + initialState, + authCapabilities, + emailState, + smsState, + forgotState, + responsiveChecks, + loadTimings, + consoleEntries, + authFlow, + } + } finally { + unsubscribe() + } +} + +async function assertProtectedRouteRedirect(connection, url, expectedFromPath, options) { + await navigateAndWait(connection, url, options) + + return await waitForCondition( + connection, + ` + (() => ({ + path: location.pathname, + bodyText: document.body?.innerText ?? '', + title: document.title, + redirectFrom: history.state?.usr?.from?.pathname ?? null, + visibleInputs: Array.from(document.querySelectorAll('input')) + .filter((element) => { + if (!(element instanceof HTMLElement)) { + return false + } + const style = window.getComputedStyle(element) + return style.display !== 'none' && style.visibility !== 'hidden' && element.getClientRects().length > 0 + }) + .map((input) => input.getAttribute('placeholder') ?? ''), + }))() + `, + (state) => + state.path === '/login' && + state.title.includes(TEXT.appTitle) && + state.bodyText.includes(TEXT.loginAction) && + state.visibleInputs.some((placeholder) => placeholder.includes(TEXT.usernamePlaceholder)) && + state.visibleInputs.some((placeholder) => placeholder.includes(TEXT.passwordPlaceholder)) && + state.redirectFrom === expectedFromPath, + options.assertTimeoutMs, + `protected route redirect for ${expectedFromPath}`, + ) +} + +async function runAuthenticatedFlow(connection, options) { + const preLoginRedirect = await assertProtectedRouteRedirect( + connection, + options.usersUrl, + '/users', + options, + ) + + await setInputValue( + connection, + 'input[autocomplete="username"], input[type="text"], input[type="email"]', + options.loginUsername, + ) + await setInputValue( + connection, + 'input[autocomplete="current-password"], input[type="password"]', + options.loginPassword, + ) + await clickFirstVisible(connection, 'button[type="submit"]') + + const loginState = await waitForUsersPage(connection, options, 'login success') + + const userDetailState = await openUserDetailDrawer(connection, options.loginUsername, options) + logDebug('reloading users page after user detail drawer') + await navigateAndWait(connection, options.usersUrl, options) + await waitForUsersPage(connection, options, 'users page after user detail drawer') + + logDebug('opening assign roles modal') + const assignRolesState = await openAssignRolesModal(connection, options.loginUsername, options) + logDebug('reloading users page after assign roles modal') + await navigateAndWait(connection, options.usersUrl, options) + await waitForUsersPage(connection, options, 'users page after assign roles modal') + + logDebug('navigating to roles page from sidebar') + await clickSidebarMenuItem(connection, TEXT.roles) + + logDebug('waiting for roles page') + const rolesState = await waitForRolesPage(connection, options) + logDebug('opening role permissions modal') + const rolePermissionsState = await openRolePermissionsModal(connection, options) + + logDebug('navigating to dashboard after roles page checks') + await navigateAndWait(connection, options.dashboardUrl, options) + + logDebug('waiting for dashboard page') + const dashboardState = await waitForDashboardPage(connection, options) + + logDebug('opening user menu for logout') + await hoverFirstVisible(connection, '.ant-dropdown-trigger, [class*="userTrigger"]') + await waitForCondition( + connection, + `(() => document.body?.innerText ?? '')()`, + (bodyText) => typeof bodyText === 'string' && bodyText.includes(TEXT.logout), + Math.max(options.assertTimeoutMs, 10000), + 'logout menu', + ) + await clickText( + connection, + '[role="menuitem"], .ant-dropdown-menu-item, .ant-dropdown-menu-title-content', + TEXT.logout, + ) + + const logoutState = await waitForCondition( + connection, + ` + (() => ({ + path: location.pathname, + bodyText: document.body?.innerText ?? '', + refreshToken: localStorage.getItem('admin_refresh_token'), + visibleInputs: Array.from(document.querySelectorAll('input')) + .filter((element) => { + if (!(element instanceof HTMLElement)) { + return false + } + const style = window.getComputedStyle(element) + return style.display !== 'none' && style.visibility !== 'hidden' && element.getClientRects().length > 0 + }) + .map((input) => input.getAttribute('placeholder') ?? ''), + }))() + `, + (state) => + state.path === '/login' && + state.bodyText.includes(TEXT.loginAction) && + state.visibleInputs.some((placeholder) => placeholder.includes(TEXT.usernamePlaceholder)) && + state.visibleInputs.some((placeholder) => placeholder.includes(TEXT.passwordPlaceholder)) && + state.refreshToken === null, + Math.max(options.assertTimeoutMs, 20000), + 'logout success', + ) + + const postLogoutRedirects = { + dashboard: await assertProtectedRouteRedirect( + connection, + options.dashboardUrl, + '/dashboard', + options, + ), + users: await assertProtectedRouteRedirect(connection, options.usersUrl, '/users', options), + } + + return { + preLoginRedirect, + loginState, + userDetailState, + assignRolesState, + rolesState, + rolePermissionsState, + dashboardState, + logoutState, + postLogoutRedirects, + } +} + +async function waitForDashboardPage(connection, options) { + return await waitForCondition( + connection, + ` + (() => ({ + path: location.pathname, + title: document.title, + bodyText: document.body?.innerText ?? '', + refreshToken: localStorage.getItem('admin_refresh_token'), + }))() + `, + (state) => + state.path === '/dashboard' && + state.title.includes(TEXT.appTitle) && + state.bodyText.includes(TEXT.dashboard) && + state.bodyText.includes(TEXT.totalUsers) && + state.bodyText.includes(TEXT.todaySuccessLogins) && + typeof state.refreshToken === 'string' && + state.refreshToken.length > 0, + Math.max(options.assertTimeoutMs, 20000), + 'dashboard access after login', + ) +} + +async function waitForUsersPage(connection, options, label) { + return await waitForCondition( + connection, + ` + (() => ({ + path: location.pathname, + title: document.title, + bodyText: document.body?.innerText ?? '', + refreshToken: localStorage.getItem('admin_refresh_token'), + rowTexts: Array.from(document.querySelectorAll('tbody tr')) + .map((row) => row.textContent?.trim() ?? '') + .filter(Boolean), + visibleInputs: Array.from(document.querySelectorAll('input')) + .filter((element) => { + if (!(element instanceof HTMLElement)) { + return false + } + const style = window.getComputedStyle(element) + return style.display !== 'none' && style.visibility !== 'hidden' && element.getClientRects().length > 0 + }) + .map((input) => input.getAttribute('placeholder') ?? '') + .filter(Boolean), + }))() + `, + (state) => + state.path === '/users' && + state.title.includes(TEXT.appTitle) && + state.bodyText.includes(TEXT.users) && + state.bodyText.includes(options.loginUsername) && + state.visibleInputs.some((text) => text.includes(TEXT.usersFilter)) && + state.rowTexts.some((text) => text.includes(options.loginUsername)) && + typeof state.refreshToken === 'string' && + state.refreshToken.length > 0, + Math.max(options.assertTimeoutMs, 20000), + label, + ) +} + +async function waitForRolesPage(connection, options) { + return await waitForCondition( + connection, + ` + (() => ({ + path: location.pathname, + title: document.title, + bodyText: document.body?.innerText ?? '', + rowTexts: Array.from(document.querySelectorAll('tbody tr')) + .map((row) => row.textContent?.trim() ?? '') + .filter(Boolean), + visibleInputs: Array.from(document.querySelectorAll('input')) + .filter((element) => { + if (!(element instanceof HTMLElement)) { + return false + } + const style = window.getComputedStyle(element) + return style.display !== 'none' && style.visibility !== 'hidden' && element.getClientRects().length > 0 + }) + .map((input) => input.getAttribute('placeholder') ?? '') + .filter(Boolean), + }))() + `, + (state) => + state.path === '/roles' && + state.title.includes(TEXT.appTitle) && + state.bodyText.includes(TEXT.roles) && + state.bodyText.includes(TEXT.createRole) && + state.visibleInputs.some((text) => text.includes(TEXT.rolesFilter)) && + state.rowTexts.some((text) => text.includes(TEXT.adminRoleName)), + Math.max(options.assertTimeoutMs, 20000), + 'roles page', + ) +} + +async function openUserDetailDrawer(connection, username, options) { + logDebug(`opening user detail drawer for ${username}`) + await clickActionInTableRow(connection, username, '\u8be6\u60c5') + + return await waitForCondition( + connection, + ` + (() => { + const drawer = Array.from(document.querySelectorAll('.ant-drawer')) + .find((element) => { + if (!(element instanceof HTMLElement)) { + return false + } + return element.getClientRects().length > 0 + }) + + return { + path: location.pathname, + title: drawer?.querySelector('.ant-drawer-title')?.textContent?.trim() ?? '', + bodyText: drawer?.textContent?.trim() ?? '', + } + })() + `, + (state) => + state.path === '/users' && + state.title.includes(TEXT.userDetail) && + state.bodyText.includes(TEXT.userId) && + state.bodyText.includes(username), + Math.max(options.assertTimeoutMs, 20000), + 'user detail drawer', + ) +} + +async function openAssignRolesModal(connection, username, options) { + logDebug(`opening assign roles modal for ${username}`) + await clickActionInTableRow(connection, username, '\u89d2\u8272') + + return await waitForCondition( + connection, + ` + (() => { + const modal = Array.from(document.querySelectorAll('.ant-modal-root')) + .find((element) => { + if (!(element instanceof HTMLElement)) { + return false + } + return element.getClientRects().length > 0 + }) + + return { + path: location.pathname, + title: modal?.querySelector('.ant-modal-title')?.textContent?.trim() ?? '', + bodyText: modal?.textContent?.trim() ?? '', + } + })() + `, + (state) => + state.path === '/users' && + state.title.includes(TEXT.assignRoles) && + state.title.includes(username) && + state.bodyText.includes(TEXT.assignableRoles) && + state.bodyText.includes(TEXT.assignedRoles), + Math.max(options.assertTimeoutMs, 20000), + 'assign roles modal', + ) +} + +async function openRolePermissionsModal(connection, options) { + logDebug(`opening role permissions modal for ${TEXT.adminRoleName}`) + await clickActionInTableRow(connection, TEXT.adminRoleName, '\u6743\u9650') + + return await waitForCondition( + connection, + ` + (() => { + const modal = Array.from(document.querySelectorAll('.ant-modal-root')) + .find((element) => { + if (!(element instanceof HTMLElement)) { + return false + } + return element.getClientRects().length > 0 + }) + + return { + path: location.pathname, + title: modal?.querySelector('.ant-modal-title')?.textContent?.trim() ?? '', + bodyText: modal?.textContent?.trim() ?? '', + treeNodeCount: modal?.querySelectorAll('.ant-tree-treenode').length ?? 0, + } + })() + `, + (state) => + state.path === '/roles' && + state.title.includes(TEXT.assignPermissions) && + state.title.includes(TEXT.adminRoleName) && + state.bodyText.includes(TEXT.permissionsHint) && + (state.treeNodeCount > 0 || state.bodyText.includes('\u6682\u65e0\u6743\u9650\u6570\u636e')), + Math.max(options.assertTimeoutMs, 20000), + 'role permissions modal', + ) +} + +async function navigateAndWait(connection, url, options) { + const startedAt = Date.now() + const previousHref = + (await evaluate(connection, `(() => location.href)()`).catch(() => null)) ?? null + const loadEvent = connection.waitForEvent( + (event) => event.method === 'Page.loadEventFired', + options.navigationTimeoutMs, + `load event for ${url}`, + ) + + const result = await connection.send('Page.navigate', { url }) + if (result.errorText) { + throw new Error(`navigation failed for ${url}: ${result.errorText}`) + } + + try { + await loadEvent + } catch (error) { + logDebug(`load event fallback for ${url}: ${formatError(error)}`) + await waitForCondition( + connection, + ` + (() => ({ + readyState: document.readyState, + href: location.href, + }))() + `, + (state) => + state.readyState === 'complete' && + (state.href === url || state.href !== previousHref), + Math.max(options.navigationTimeoutMs, 15000), + `document ready after navigation to ${url}`, + ) + } + return Date.now() - startedAt +} + +async function setViewport(connection, viewport) { + await connection.send('Emulation.setDeviceMetricsOverride', { + width: viewport.width, + height: viewport.height, + deviceScaleFactor: 1, + mobile: viewport.mobile, + }) +} + +async function evaluate(connection, expression, options = {}) { + const result = await connection.send('Runtime.evaluate', { + expression, + awaitPromise: true, + returnByValue: true, + userGesture: options.userGesture ?? false, + }) + + if (result.exceptionDetails) { + const description = + result.exceptionDetails.exception?.description ?? + result.exceptionDetails.exception?.value ?? + result.exceptionDetails.text ?? + 'Runtime.evaluate failed' + throw new Error(String(description)) + } + + return result.result?.value +} + +async function waitForCondition(connection, expression, predicate, timeoutMs, label) { + const startedAt = Date.now() + let lastValue + + while (Date.now() - startedAt < timeoutMs) { + lastValue = await evaluate(connection, expression) + if (predicate(lastValue)) { + return lastValue + } + await delay(150) + } + + throw new Error(`timed out waiting for ${label}: ${JSON.stringify(lastValue)}`) +} + +async function clickText(connection, selector, text) { + const clicked = await evaluate( + connection, + ` + (() => { + const isVisible = (element) => { + if (!(element instanceof HTMLElement)) { + return false + } + const style = window.getComputedStyle(element) + return style.display !== 'none' && style.visibility !== 'hidden' && element.getClientRects().length > 0 + } + + const element = Array.from(document.querySelectorAll(${JSON.stringify(selector)})) + .find((node) => isVisible(node) && (node.textContent ?? '').includes(${JSON.stringify(text)})) + + if (!element) { + return false + } + + const target = element.closest('[role="menuitem"], button, a, li, .ant-dropdown-menu-item') || element + target.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })) + target.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })) + target.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })) + target.dispatchEvent(new MouseEvent('click', { bubbles: true })) + if (typeof target.click === 'function') { + target.click() + } + return true + })() + `, + { userGesture: true }, + ) + + if (!clicked) { + throw new Error(`failed to find clickable text "${text}" using selector "${selector}"`) + } +} + +async function clickFirstVisible(connection, selector) { + const clicked = await evaluate( + connection, + ` + (() => { + const isVisible = (element) => { + if (!(element instanceof HTMLElement)) { + return false + } + const style = window.getComputedStyle(element) + return style.display !== 'none' && style.visibility !== 'hidden' && element.getClientRects().length > 0 + } + + const element = Array.from(document.querySelectorAll(${JSON.stringify(selector)})) + .find((node) => isVisible(node)) + + if (!element) { + return false + } + + element.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })) + element.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })) + element.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })) + element.dispatchEvent(new MouseEvent('click', { bubbles: true })) + if (typeof element.click === 'function') { + element.click() + } + return true + })() + `, + { userGesture: true }, + ) + + if (!clicked) { + throw new Error(`failed to find visible selector "${selector}"`) + } +} + +async function clickActionInTableRow(connection, rowText, actionText) { + const clicked = await evaluate( + connection, + ` + (() => { + const isVisible = (element) => { + if (!(element instanceof HTMLElement)) { + return false + } + const style = window.getComputedStyle(element) + return style.display !== 'none' && style.visibility !== 'hidden' && element.getClientRects().length > 0 + } + + const rows = Array.from(document.querySelectorAll('tbody tr')) + .filter((row) => isVisible(row) && (row.textContent ?? '').includes(${JSON.stringify(rowText)})) + + const action = rows + .flatMap((row) => Array.from(row.querySelectorAll('button, a, [role="button"]'))) + .find((node) => isVisible(node) && (node.textContent ?? '').includes(${JSON.stringify(actionText)})) + + if (!(action instanceof HTMLElement)) { + return false + } + + action.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })) + action.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })) + action.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })) + action.dispatchEvent(new MouseEvent('click', { bubbles: true })) + if (typeof action.click === 'function') { + action.click() + } + return true + })() + `, + { userGesture: true }, + ) + + if (!clicked) { + throw new Error(`failed to click action "${actionText}" in row "${rowText}"`) + } +} + +async function clickSidebarMenuItem(connection, text) { + await clickText( + connection, + '.ant-layout-sider .ant-menu-item, .ant-layout-sider [role="menuitem"]', + text, + ) +} + +async function setInputValue(connection, selector, value) { + const updated = await evaluate( + connection, + ` + (() => { + const isVisible = (element) => { + if (!(element instanceof HTMLElement)) { + return false + } + const style = window.getComputedStyle(element) + return style.display !== 'none' && style.visibility !== 'hidden' && element.getClientRects().length > 0 + } + + const input = Array.from(document.querySelectorAll(${JSON.stringify(selector)})) + .find((node) => node instanceof HTMLInputElement && isVisible(node)) + + if (!(input instanceof HTMLInputElement)) { + return false + } + + const prototype = Object.getPrototypeOf(input) + const descriptor = Object.getOwnPropertyDescriptor(prototype, 'value') + if (!descriptor || typeof descriptor.set !== 'function') { + input.value = ${JSON.stringify(value)} + } else { + descriptor.set.call(input, ${JSON.stringify(value)}) + } + + input.dispatchEvent(new Event('input', { bubbles: true })) + input.dispatchEvent(new Event('change', { bubbles: true })) + return true + })() + `, + { userGesture: true }, + ) + + if (!updated) { + throw new Error(`failed to set input value for selector "${selector}"`) + } +} + +async function hoverFirstVisible(connection, selector) { + const hovered = await evaluate( + connection, + ` + (() => { + const isVisible = (element) => { + if (!(element instanceof HTMLElement)) { + return false + } + const style = window.getComputedStyle(element) + return style.display !== 'none' && style.visibility !== 'hidden' && element.getClientRects().length > 0 + } + + const element = Array.from(document.querySelectorAll(${JSON.stringify(selector)})) + .find((node) => isVisible(node)) + + if (!(element instanceof HTMLElement)) { + return false + } + + element.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })) + element.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true })) + return true + })() + `, + { userGesture: true }, + ) + + if (!hovered) { + throw new Error(`failed to hover visible selector "${selector}"`) + } +} + +function formatConsoleEntry(params) { + const text = (params.args ?? []) + .map((arg) => { + if (arg.value != null) { + return String(arg.value) + } + if (arg.unserializableValue != null) { + return arg.unserializableValue + } + if (arg.description != null) { + return arg.description + } + return arg.type ?? 'unknown' + }) + .join(' ') + + return { + type: params.type ?? 'log', + text, + } +} + +function isIgnorableNetworkFailure(failure) { + return ( + failure.canceled === true || + failure.errorText === 'net::ERR_ABORTED' || + failure.errorText === 'net::ERR_FAILED' + ) +} + +function isIgnorableConsoleError(text) { + return text.includes('Static function can not consume context like dynamic theme') +} + +function printSummary(summary) { + console.log('CDP smoke completed successfully') + console.log(`browser: ${summary.browserVersion}`) + console.log(`title: ${summary.initialState.title}`) + console.log( + `capabilities: password=${summary.authCapabilities.password} email=${summary.authCapabilities.email_code} sms=${summary.authCapabilities.sms_code} passwordReset=${summary.authCapabilities.password_reset}`, + ) + console.log(`tabs: ${summary.initialState.visibleTabs.join(', ')}`) + if (summary.forgotState) { + console.log(`forgot-password path: ${summary.forgotState.path}`) + } else { + console.log('forgot-password path: disabled') + } + console.log( + `protected dashboard redirect: ${summary.protectedRedirects.dashboard.path} (from=${summary.protectedRedirects.dashboard.redirectFrom})`, + ) + console.log( + `protected users redirect: ${summary.protectedRedirects.users.path} (from=${summary.protectedRedirects.users.redirectFrom})`, + ) + if (summary.authFlow) { + console.log(`pre-login users redirect from: ${summary.authFlow.preLoginRedirect.redirectFrom}`) + console.log(`login landing path: ${summary.authFlow.loginState.path}`) + console.log(`user detail title: ${summary.authFlow.userDetailState.title}`) + console.log(`assign roles title: ${summary.authFlow.assignRolesState.title}`) + console.log(`roles path: ${summary.authFlow.rolesState.path}`) + console.log(`permissions title: ${summary.authFlow.rolePermissionsState.title}`) + console.log(`dashboard path: ${summary.authFlow.dashboardState.path}`) + console.log(`logout path: ${summary.authFlow.logoutState.path}`) + console.log( + `post-logout dashboard redirect: ${summary.authFlow.postLogoutRedirects.dashboard.path} (from=${summary.authFlow.postLogoutRedirects.dashboard.redirectFrom})`, + ) + console.log( + `post-logout users redirect: ${summary.authFlow.postLogoutRedirects.users.path} (from=${summary.authFlow.postLogoutRedirects.users.redirectFrom})`, + ) + } + console.log('responsive:') + for (const viewport of summary.responsiveChecks) { + console.log( + ` - ${viewport.name}: innerWidth=${viewport.width}, bodyScrollWidth=${viewport.bodyScrollWidth}`, + ) + } + console.log('load timings:') + for (const timing of summary.loadTimings) { + console.log(` - ${timing.name}: ${timing.ms}ms`) + } +} + +function logDebug(message) { + if (DEBUG) { + console.log(`[debug] ${message}`) + } +} + +function formatError(error) { + if (error instanceof Error) { + return error.message + } + return String(error) +} + +await main().catch((error) => { + console.error(`CDP smoke failed: ${formatError(error)}`) + process.exitCode = 1 +}) diff --git a/frontend/admin/scripts/run-cdp-smoke.ps1 b/frontend/admin/scripts/run-cdp-smoke.ps1 new file mode 100644 index 0000000..51d824d --- /dev/null +++ b/frontend/admin/scripts/run-cdp-smoke.ps1 @@ -0,0 +1,397 @@ +param( + [int]$Port = 0, + [string[]]$Command = @('node', './scripts/run-cdp-smoke.mjs') +) + +$ErrorActionPreference = 'Stop' + +if (-not $Command -or $Command.Count -eq 0) { + throw 'Command must not be empty' +} + +function Test-UrlReady { + param( + [Parameter(Mandatory = $true)][string]$Url + ) + + try { + $response = Invoke-WebRequest $Url -UseBasicParsing -TimeoutSec 2 + return $response.StatusCode -ge 200 -and $response.StatusCode -lt 500 + } catch { + return $false + } +} + +function Wait-UrlReady { + param( + [Parameter(Mandatory = $true)][string]$Url, + [Parameter(Mandatory = $true)][string]$Label, + [int]$RetryCount = 60, + [int]$DelayMs = 500 + ) + + for ($i = 0; $i -lt $RetryCount; $i++) { + if (Test-UrlReady -Url $Url) { + return + } + Start-Sleep -Milliseconds $DelayMs + } + + throw "$Label did not become ready: $Url" +} + +function Get-FreeTcpPort { + $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0) + $listener.Start() + try { + return ([System.Net.IPEndPoint]$listener.LocalEndpoint).Port + } finally { + $listener.Stop() + } +} + +function Resolve-BrowserPath { + if ($env:E2E_BROWSER_PATH) { + return $env:E2E_BROWSER_PATH + } + + if ($env:CHROME_HEADLESS_SHELL_PATH) { + return $env:CHROME_HEADLESS_SHELL_PATH + } + + if ($env:PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH) { + return $env:PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH + } + + $baseDir = Join-Path $env:LOCALAPPDATA 'ms-playwright' + $candidate = Get-ChildItem $baseDir -Directory -Filter 'chromium_headless_shell-*' | + Sort-Object Name -Descending | + Select-Object -First 1 + + if ($candidate) { + return (Join-Path $candidate.FullName 'chrome-headless-shell-win64\chrome-headless-shell.exe') + } + + foreach ($fallback in @( + 'C:\Program Files\Google\Chrome\Application\chrome.exe', + 'C:\Program Files (x86)\Google\Chrome\Application\chrome.exe', + 'C:\Program Files\Microsoft\Edge\Application\msedge.exe', + 'C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe' + )) { + if (Test-Path $fallback) { + return $fallback + } + } + + throw 'No compatible browser found; set E2E_BROWSER_PATH or CHROME_HEADLESS_SHELL_PATH explicitly if needed' +} + +function Test-HeadlessShellBrowser { + param( + [Parameter(Mandatory = $true)][string]$BrowserPath + ) + + return [System.IO.Path]::GetFileName($BrowserPath).ToLowerInvariant().Contains('headless-shell') +} + +function Get-BrowserArguments { + param( + [Parameter(Mandatory = $true)][string]$BrowserPath, + [Parameter(Mandatory = $true)][int]$Port, + [Parameter(Mandatory = $true)][string]$ProfileDir + ) + + $arguments = @( + "--remote-debugging-port=$Port", + "--user-data-dir=$ProfileDir", + '--no-sandbox' + ) + + if (Test-HeadlessShellBrowser -BrowserPath $BrowserPath) { + $arguments += '--single-process' + } else { + $arguments += @( + '--disable-dev-shm-usage', + '--disable-background-networking', + '--disable-background-timer-throttling', + '--disable-renderer-backgrounding', + '--disable-sync', + '--headless=new' + ) + } + + $arguments += 'about:blank' + return $arguments +} + +function Get-BrowserProcessIds { + param( + [Parameter(Mandatory = $true)][string]$BrowserPath + ) + + $processName = [System.IO.Path]::GetFileNameWithoutExtension($BrowserPath) + try { + return @(Get-Process -Name $processName -ErrorAction Stop | Select-Object -ExpandProperty Id) + } catch { + return @() + } +} + +function Get-BrowserProcessesByProfile { + param( + [Parameter(Mandatory = $true)][string]$BrowserPath, + [Parameter(Mandatory = $true)][string]$ProfileDir + ) + + $processFileName = [System.IO.Path]::GetFileName($BrowserPath) + $profileFragment = $ProfileDir.ToLowerInvariant() + + try { + return @( + Get-CimInstance Win32_Process -Filter ("Name = '{0}'" -f $processFileName) -ErrorAction Stop | + Where-Object { + $commandLine = $_.CommandLine + $commandLine -and $commandLine.ToLowerInvariant().Contains($profileFragment) + } + ) + } catch { + return @() + } +} + +function Get-ChildProcessIds { + param( + [Parameter(Mandatory = $true)][int]$ParentId + ) + + $pending = [System.Collections.Generic.Queue[int]]::new() + $seen = [System.Collections.Generic.HashSet[int]]::new() + $pending.Enqueue($ParentId) + + while ($pending.Count -gt 0) { + $currentParentId = $pending.Dequeue() + + try { + $children = @(Get-CimInstance Win32_Process -Filter ("ParentProcessId = {0}" -f $currentParentId) -ErrorAction Stop) + } catch { + $children = @() + } + + foreach ($child in $children) { + if ($seen.Add([int]$child.ProcessId)) { + $pending.Enqueue([int]$child.ProcessId) + } + } + } + + return @($seen) +} + +function Get-BrowserCleanupIds { + param( + [Parameter(Mandatory = $true)]$Handle + ) + + $ids = [System.Collections.Generic.HashSet[int]]::new() + + if ($Handle.Process) { + $null = $ids.Add([int]$Handle.Process.Id) + foreach ($childId in Get-ChildProcessIds -ParentId $Handle.Process.Id) { + $null = $ids.Add([int]$childId) + } + } + + foreach ($processInfo in Get-BrowserProcessesByProfile -BrowserPath $Handle.BrowserPath -ProfileDir $Handle.ProfileDir) { + $null = $ids.Add([int]$processInfo.ProcessId) + } + + $liveIds = @() + foreach ($processId in $ids) { + try { + Get-Process -Id $processId -ErrorAction Stop | Out-Null + $liveIds += $processId + } catch { + # Process already exited. + } + } + + return @($liveIds | Sort-Object -Unique) +} + +function Start-BrowserProcess { + param( + [Parameter(Mandatory = $true)][string]$BrowserPath, + [Parameter(Mandatory = $true)][int]$Port, + [Parameter(Mandatory = $true)][string]$ProfileDir + ) + + $baselineIds = Get-BrowserProcessIds -BrowserPath $BrowserPath + $arguments = Get-BrowserArguments -BrowserPath $BrowserPath -Port $Port -ProfileDir $ProfileDir + $stdoutPath = Join-Path $ProfileDir 'browser-stdout.log' + $stderrPath = Join-Path $ProfileDir 'browser-stderr.log' + Remove-Item $stdoutPath, $stderrPath -Force -ErrorAction SilentlyContinue + + $process = Start-Process ` + -FilePath $BrowserPath ` + -ArgumentList $arguments ` + -PassThru ` + -WindowStyle Hidden ` + -RedirectStandardOutput $stdoutPath ` + -RedirectStandardError $stderrPath + + return [pscustomobject]@{ + BrowserPath = $BrowserPath + BaselineIds = $baselineIds + ProfileDir = $ProfileDir + Process = $process + StdOut = $stdoutPath + StdErr = $stderrPath + } +} + +function Show-BrowserLogs { + param( + [Parameter(Mandatory = $false)]$Handle + ) + + if (-not $Handle) { + return + } + + foreach ($path in @($Handle.StdOut, $Handle.StdErr)) { + if (-not [string]::IsNullOrWhiteSpace($path) -and (Test-Path $path)) { + Get-Content $path -ErrorAction SilentlyContinue + } + } +} + +function Stop-BrowserProcess { + param( + [Parameter(Mandatory = $false)]$Handle + ) + + if (-not $Handle) { + return + } + + if ($Handle.Process -and -not $Handle.Process.HasExited) { + foreach ($cleanupCommand in @( + { param($id) taskkill /PID $id /T /F *> $null }, + { param($id) Stop-Process -Id $id -Force -ErrorAction Stop } + )) { + try { + & $cleanupCommand $Handle.Process.Id + } catch { + # Ignore cleanup errors here; the residual PID check below is authoritative. + } + } + } + + $residualIds = @() + + for ($attempt = 0; $attempt -lt 12; $attempt++) { + $residualIds = @(Get-BrowserCleanupIds -Handle $Handle) + + foreach ($processId in $residualIds) { + foreach ($cleanupCommand in @( + { param($id) taskkill /PID $id /T /F *> $null }, + { param($id) Stop-Process -Id $id -Force -ErrorAction Stop } + )) { + try { + & $cleanupCommand $processId + } catch { + # Ignore per-process cleanup errors during retry loop. + } + } + } + + Start-Sleep -Milliseconds 500 + $residualIds = @(Get-BrowserCleanupIds -Handle $Handle) + + if ($residualIds.Count -eq 0) { + break + } + } + + if ($residualIds.Count -gt 0) { + throw "browser cleanup leaked PIDs: $($residualIds -join ', ')" + } +} + +function Remove-BrowserLogs { + param( + [Parameter(Mandatory = $false)]$Handle + ) + + if (-not $Handle) { + return + } + + $paths = @($Handle.StdOut, $Handle.StdErr) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + if ($paths.Count -gt 0) { + Remove-Item $paths -Force -ErrorAction SilentlyContinue + } +} + +$browserPath = Resolve-BrowserPath +Write-Host "CDP browser: $browserPath" +$Port = if ($Port -gt 0) { $Port } else { Get-FreeTcpPort } +$profileRoot = Join-Path (Resolve-Path (Join-Path $PSScriptRoot '..')).Path '.cache\cdp-profiles' +New-Item -ItemType Directory -Force $profileRoot | Out-Null +$profileDir = Join-Path $profileRoot "pw-profile-cdp-smoke-win-$Port" +$browserReadyUrl = "http://127.0.0.1:$Port/json/version" +$browserCdpBaseUrl = "http://127.0.0.1:$Port" +$browserHandle = $null + +try { + for ($attempt = 1; $attempt -le 2; $attempt++) { + Remove-Item -Recurse -Force $profileDir -ErrorAction SilentlyContinue + $browserHandle = Start-BrowserProcess -BrowserPath $browserPath -Port $Port -ProfileDir $profileDir + + try { + Wait-UrlReady -Url $browserReadyUrl -Label "browser CDP endpoint (attempt $attempt)" + Write-Host "CDP endpoint ready: $browserReadyUrl" + break + } catch { + Show-BrowserLogs $browserHandle + Stop-BrowserProcess $browserHandle + Remove-BrowserLogs $browserHandle + $browserHandle = $null + + if ($attempt -eq 2) { + throw + } + } + } + + if (-not $env:E2E_COMMAND_TIMEOUT_MS) { + $env:E2E_COMMAND_TIMEOUT_MS = '120000' + } + + $env:E2E_SKIP_BROWSER_LAUNCH = '1' + $env:E2E_CDP_PORT = "$Port" + $env:E2E_CDP_BASE_URL = $browserCdpBaseUrl + $env:E2E_PLAYWRIGHT_CDP_URL = $browserCdpBaseUrl + $env:E2E_EXTERNAL_CDP = '1' + + $commandName = $Command[0] + $commandArgs = @() + if ($Command.Count -gt 1) { + $commandArgs = $Command[1..($Command.Count - 1)] + } + + Write-Host "Launching command: $commandName $($commandArgs -join ' ')" + & $commandName @commandArgs + if ($LASTEXITCODE -ne 0) { + throw "command failed with exit code $LASTEXITCODE" + } +} finally { + Stop-BrowserProcess $browserHandle + Remove-BrowserLogs $browserHandle + Remove-Item Env:E2E_SKIP_BROWSER_LAUNCH -ErrorAction SilentlyContinue + Remove-Item Env:E2E_CDP_PORT -ErrorAction SilentlyContinue + Remove-Item Env:E2E_CDP_BASE_URL -ErrorAction SilentlyContinue + Remove-Item Env:E2E_PLAYWRIGHT_CDP_URL -ErrorAction SilentlyContinue + Remove-Item Env:E2E_EXTERNAL_CDP -ErrorAction SilentlyContinue + Remove-Item -Recurse -Force $profileDir -ErrorAction SilentlyContinue +} diff --git a/frontend/admin/scripts/run-playwright-auth-e2e.ps1 b/frontend/admin/scripts/run-playwright-auth-e2e.ps1 new file mode 100644 index 0000000..1ed5034 --- /dev/null +++ b/frontend/admin/scripts/run-playwright-auth-e2e.ps1 @@ -0,0 +1,297 @@ +param( + [string]$AdminUsername = 'e2e_admin', + [string]$AdminPassword = 'E2EAdmin@123456', + [string]$AdminEmail = 'e2e_admin@example.com', + [int]$BrowserPort = 0, + [int]$BackendPort = 0, + [int]$FrontendPort = 0 +) + +$ErrorActionPreference = 'Stop' + +$projectRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..\..')).Path +$frontendRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +$tempCacheRoot = Join-Path $env:TEMP 'ums-e2e-cache' +$goCacheDir = Join-Path $tempCacheRoot 'go-build' +$goModCacheDir = Join-Path $tempCacheRoot 'gomod' +$goPathDir = Join-Path $tempCacheRoot 'gopath' +$serverExePath = Join-Path $env:TEMP ("ums-server-playwright-e2e-" + [guid]::NewGuid().ToString('N') + '.exe') +$e2eRunRoot = Join-Path $env:TEMP ("ums-playwright-e2e-" + [guid]::NewGuid().ToString('N')) +$e2eDataRoot = Join-Path $e2eRunRoot 'data' +$e2eDbPath = Join-Path $e2eDataRoot 'user_management.e2e.db' +$smtpCaptureFile = Join-Path $e2eRunRoot 'smtp-capture.jsonl' + +New-Item -ItemType Directory -Force $goCacheDir, $goModCacheDir, $goPathDir, $e2eDataRoot | Out-Null + +function Get-FreeTcpPort { + $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0) + $listener.Start() + try { + return ([System.Net.IPEndPoint]$listener.LocalEndpoint).Port + } finally { + $listener.Stop() + } +} + +function Test-UrlReady { + param( + [Parameter(Mandatory = $true)][string]$Url + ) + + try { + $response = Invoke-WebRequest $Url -UseBasicParsing -TimeoutSec 2 + return $response.StatusCode -ge 200 -and $response.StatusCode -lt 500 + } catch { + return $false + } +} + +function Wait-UrlReady { + param( + [Parameter(Mandatory = $true)][string]$Url, + [Parameter(Mandatory = $true)][string]$Label, + [int]$RetryCount = 120, + [int]$DelayMs = 500 + ) + + for ($i = 0; $i -lt $RetryCount; $i++) { + if (Test-UrlReady -Url $Url) { + return + } + Start-Sleep -Milliseconds $DelayMs + } + + throw "$Label did not become ready: $Url" +} + +function Start-ManagedProcess { + param( + [Parameter(Mandatory = $true)][string]$Name, + [Parameter(Mandatory = $true)][string]$FilePath, + [string[]]$ArgumentList = @(), + [Parameter(Mandatory = $true)][string]$WorkingDirectory + ) + + $stdoutPath = Join-Path $env:TEMP "$Name-stdout.log" + $stderrPath = Join-Path $env:TEMP "$Name-stderr.log" + Remove-Item $stdoutPath, $stderrPath -Force -ErrorAction SilentlyContinue + + if ($ArgumentList -and $ArgumentList.Count -gt 0) { + $process = Start-Process ` + -FilePath $FilePath ` + -ArgumentList $ArgumentList ` + -WorkingDirectory $WorkingDirectory ` + -PassThru ` + -WindowStyle Hidden ` + -RedirectStandardOutput $stdoutPath ` + -RedirectStandardError $stderrPath + } else { + $process = Start-Process ` + -FilePath $FilePath ` + -WorkingDirectory $WorkingDirectory ` + -PassThru ` + -WindowStyle Hidden ` + -RedirectStandardOutput $stdoutPath ` + -RedirectStandardError $stderrPath + } + + return [pscustomobject]@{ + Name = $Name + Process = $process + StdOut = $stdoutPath + StdErr = $stderrPath + } +} + +function Stop-ManagedProcess { + param( + [Parameter(Mandatory = $false)]$Handle + ) + + if (-not $Handle) { + return + } + + if ($Handle.Process -and -not $Handle.Process.HasExited) { + try { + taskkill /PID $Handle.Process.Id /T /F *> $null + } catch { + Stop-Process -Id $Handle.Process.Id -Force -ErrorAction SilentlyContinue + } + } +} + +function Show-ManagedProcessLogs { + param( + [Parameter(Mandatory = $false)]$Handle + ) + + if (-not $Handle) { + return + } + + if (Test-Path $Handle.StdOut) { + Get-Content $Handle.StdOut -ErrorAction SilentlyContinue + } + if (Test-Path $Handle.StdErr) { + Get-Content $Handle.StdErr -ErrorAction SilentlyContinue + } +} + +function Remove-ManagedProcessLogs { + param( + [Parameter(Mandatory = $false)]$Handle + ) + + if (-not $Handle) { + return + } + + Remove-Item $Handle.StdOut, $Handle.StdErr -Force -ErrorAction SilentlyContinue +} + +$backendHandle = $null +$frontendHandle = $null +$smtpHandle = $null +$selectedBackendPort = if ($BackendPort -gt 0) { $BackendPort } else { Get-FreeTcpPort } +$selectedFrontendPort = if ($FrontendPort -gt 0) { $FrontendPort } else { Get-FreeTcpPort } +$selectedSMTPPort = Get-FreeTcpPort +$backendBaseUrl = "http://127.0.0.1:$selectedBackendPort" +$frontendBaseUrl = "http://127.0.0.1:$selectedFrontendPort" + +try { + Push-Location $projectRoot + try { + $env:GOCACHE = $goCacheDir + $env:GOMODCACHE = $goModCacheDir + $env:GOPATH = $goPathDir + go build -o $serverExePath .\cmd\server\main.go + if ($LASTEXITCODE -ne 0) { + throw 'server build failed' + } + } finally { + Pop-Location + Remove-Item Env:GOCACHE -ErrorAction SilentlyContinue + Remove-Item Env:GOMODCACHE -ErrorAction SilentlyContinue + Remove-Item Env:GOPATH -ErrorAction SilentlyContinue + } + + $env:UMS_SERVER_PORT = "$selectedBackendPort" + $env:UMS_DATABASE_SQLITE_PATH = $e2eDbPath +$env:UMS_SERVER_MODE = 'debug' +$env:UMS_PASSWORD_RESET_SITE_URL = $frontendBaseUrl +$env:UMS_CORS_ALLOWED_ORIGINS = "$frontendBaseUrl,http://localhost:$selectedFrontendPort" + $env:UMS_LOGGING_OUTPUT = 'stdout' + $env:UMS_EMAIL_HOST = '127.0.0.1' + $env:UMS_EMAIL_PORT = "$selectedSMTPPort" + $env:UMS_EMAIL_FROM_EMAIL = 'noreply@test.local' + $env:UMS_EMAIL_FROM_NAME = 'UMS E2E' + + Write-Host "playwright e2e backend: $backendBaseUrl" + Write-Host "playwright e2e frontend: $frontendBaseUrl" + Write-Host "playwright e2e smtp: 127.0.0.1:$selectedSMTPPort" + Write-Host "playwright e2e sqlite: $e2eDbPath" + + $smtpHandle = Start-ManagedProcess ` + -Name 'ums-smtp-capture' ` + -FilePath 'node' ` + -ArgumentList @((Join-Path $PSScriptRoot 'mock-smtp-capture.mjs'), '--port', "$selectedSMTPPort", '--output', $smtpCaptureFile) ` + -WorkingDirectory $frontendRoot + + Start-Sleep -Milliseconds 500 + if ($smtpHandle.Process -and $smtpHandle.Process.HasExited) { + Show-ManagedProcessLogs $smtpHandle + throw 'smtp capture server failed to start' + } + + $backendHandle = Start-ManagedProcess ` + -Name 'ums-backend-playwright' ` + -FilePath $serverExePath ` + -WorkingDirectory $projectRoot + + try { + Wait-UrlReady -Url "$backendBaseUrl/health" -Label 'backend' + } catch { + Show-ManagedProcessLogs $backendHandle + throw + } + + $env:VITE_API_PROXY_TARGET = $backendBaseUrl + $env:VITE_API_BASE_URL = '/api/v1' + $frontendHandle = Start-ManagedProcess ` + -Name 'ums-frontend-playwright' ` + -FilePath 'npm.cmd' ` + -ArgumentList @('run', 'dev', '--', '--host', '127.0.0.1', '--port', "$selectedFrontendPort") ` + -WorkingDirectory $frontendRoot + + try { + Wait-UrlReady -Url $frontendBaseUrl -Label 'frontend' + } catch { + Show-ManagedProcessLogs $frontendHandle + throw + } + + $env:E2E_LOGIN_USERNAME = $AdminUsername + $env:E2E_LOGIN_PASSWORD = $AdminPassword + $env:E2E_LOGIN_EMAIL = $AdminEmail + $env:E2E_EXPECT_ADMIN_BOOTSTRAP = '1' + $env:E2E_EXTERNAL_WEB_SERVER = '1' + $env:E2E_BASE_URL = $frontendBaseUrl + $env:E2E_SMTP_CAPTURE_FILE = $smtpCaptureFile + + Push-Location $frontendRoot + try { + $lastError = $null + for ($attempt = 1; $attempt -le 2; $attempt++) { + try { + & (Join-Path $PSScriptRoot 'run-cdp-smoke.ps1') ` + -Port $BrowserPort ` + -Command @('node', './scripts/run-playwright-cdp-e2e.mjs') + $lastError = $null + break + } catch { + $lastError = $_ + if ($attempt -ge 2) { + throw + } + $retryReason = if ($_.Exception -and $_.Exception.Message) { $_.Exception.Message } else { $_ | Out-String } + Write-Host "playwright-cdp suite retry: restarting browser and rerunning attempt $($attempt + 1) :: $retryReason" + Start-Sleep -Seconds 1 + } + } + + if ($lastError) { + throw $lastError + } + } finally { + Pop-Location + Remove-Item Env:E2E_LOGIN_USERNAME -ErrorAction SilentlyContinue + Remove-Item Env:E2E_LOGIN_PASSWORD -ErrorAction SilentlyContinue + Remove-Item Env:E2E_LOGIN_EMAIL -ErrorAction SilentlyContinue + Remove-Item Env:E2E_EXPECT_ADMIN_BOOTSTRAP -ErrorAction SilentlyContinue + Remove-Item Env:E2E_EXTERNAL_WEB_SERVER -ErrorAction SilentlyContinue + Remove-Item Env:E2E_BASE_URL -ErrorAction SilentlyContinue + Remove-Item Env:E2E_SMTP_CAPTURE_FILE -ErrorAction SilentlyContinue + } +} finally { + Stop-ManagedProcess $frontendHandle + Remove-ManagedProcessLogs $frontendHandle + Stop-ManagedProcess $backendHandle + Remove-ManagedProcessLogs $backendHandle + Stop-ManagedProcess $smtpHandle + Remove-ManagedProcessLogs $smtpHandle + Remove-Item Env:UMS_SERVER_PORT -ErrorAction SilentlyContinue + Remove-Item Env:UMS_DATABASE_SQLITE_PATH -ErrorAction SilentlyContinue + Remove-Item Env:UMS_SERVER_MODE -ErrorAction SilentlyContinue + Remove-Item Env:UMS_PASSWORD_RESET_SITE_URL -ErrorAction SilentlyContinue + Remove-Item Env:UMS_CORS_ALLOWED_ORIGINS -ErrorAction SilentlyContinue + Remove-Item Env:UMS_LOGGING_OUTPUT -ErrorAction SilentlyContinue + Remove-Item Env:UMS_EMAIL_HOST -ErrorAction SilentlyContinue + Remove-Item Env:UMS_EMAIL_PORT -ErrorAction SilentlyContinue + Remove-Item Env:UMS_EMAIL_FROM_EMAIL -ErrorAction SilentlyContinue + Remove-Item Env:UMS_EMAIL_FROM_NAME -ErrorAction SilentlyContinue + Remove-Item Env:VITE_API_PROXY_TARGET -ErrorAction SilentlyContinue + Remove-Item Env:VITE_API_BASE_URL -ErrorAction SilentlyContinue + Remove-Item $serverExePath -Force -ErrorAction SilentlyContinue + Remove-Item $e2eRunRoot -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/frontend/admin/scripts/run-playwright-cdp-e2e.mjs b/frontend/admin/scripts/run-playwright-cdp-e2e.mjs new file mode 100644 index 0000000..74c5031 --- /dev/null +++ b/frontend/admin/scripts/run-playwright-cdp-e2e.mjs @@ -0,0 +1,1176 @@ +import process from 'node:process' +import { access, mkdir, mkdtemp, readFile, readdir, rm } from 'node:fs/promises' +import { constants as fsConstants } from 'node:fs' +import { spawn } from 'node:child_process' +import net from 'node:net' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { chromium, expect } from '@playwright/test' + +const TEXT = { + accessControl: '\u8bbf\u95ee\u63a7\u5236', + adminBootstrapTitle: '\u7cfb\u7edf\u5c1a\u672a\u521d\u59cb\u5316\u9996\u4e2a\u7ba1\u7406\u5458\u8d26\u53f7', + adminRoleName: '\u7ba1\u7406\u5458', + adminBootstrapAction: '\u521d\u59cb\u5316\u7ba1\u7406\u5458', + adminBootstrapPageTitle: '\u521d\u59cb\u5316\u9996\u4e2a\u7ba1\u7406\u5458\u8d26\u53f7', + appTitle: '\u7528\u6237\u7ba1\u7406\u7cfb\u7edf', + assignPermissions: '\u5206\u914d\u6743\u9650', + assignRoles: '\u5206\u914d\u89d2\u8272', + assignRolesAction: '\u89d2\u8272', + backToLogin: '\u8fd4\u56de\u767b\u5f55', + bootstrapAdminConfirmPasswordPlaceholder: '\u786e\u8ba4\u7ba1\u7406\u5458\u5bc6\u7801', + bootstrapAdminEmailPlaceholder: '\u7ba1\u7406\u5458\u90ae\u7bb1\uff08\u9009\u586b\uff09', + bootstrapAdminPasswordPlaceholder: '\u7ba1\u7406\u5458\u5bc6\u7801', + bootstrapAdminSubmit: '\u5b8c\u6210\u521d\u59cb\u5316\u5e76\u8fdb\u5165\u7cfb\u7edf', + bootstrapAdminUsernamePlaceholder: '\u7ba1\u7406\u5458\u7528\u6237\u540d', + confirmPasswordPlaceholder: '\u786e\u8ba4\u5bc6\u7801', + createAccount: '\u521b\u5efa\u8d26\u53f7', + createUser: '\u521b\u5efa\u7528\u6237', + createUserEmailPlaceholder: '\u90ae\u7bb1\u5730\u5740', + createUserPasswordPlaceholder: '\u8bf7\u8f93\u5165\u521d\u59cb\u5bc6\u7801', + createUserUsernamePlaceholder: '\u8bf7\u8f93\u5165\u7528\u6237\u540d', + createRole: '\u521b\u5efa\u89d2\u8272', + dashboard: '\u603b\u89c8', + emailCodeLogin: '\u90ae\u7bb1\u9a8c\u8bc1\u7801', + emailActivationSuccess: '\u90ae\u7bb1\u9a8c\u8bc1\u6210\u529f', + forgotPassword: '\u5fd8\u8bb0\u5bc6\u7801\uff1f', + loginAction: '\u767b\u5f55', + loginNow: '\u7acb\u5373\u767b\u5f55', + logout: '\u9000\u51fa\u767b\u5f55', + passwordPlaceholder: '\u5bc6\u7801', + permissionsAction: '\u6743\u9650', + permissionsHint: '\u9009\u62e9\u8981\u5206\u914d\u7ed9\u8be5\u89d2\u8272\u7684\u6743\u9650', + profile: '\u4e2a\u4eba\u8d44\u6599', + registerEmailPlaceholder: '\u90ae\u7bb1\u5730\u5740\uff08\u9009\u586b\uff09', + registerSuccess: '\u6ce8\u518c\u6210\u529f', + roleFilter: '\u89d2\u8272\u540d\u79f0/\u4ee3\u7801', + roles: '\u89d2\u8272\u7ba1\u7406', + smsCodeLogin: '\u77ed\u4fe1\u9a8c\u8bc1\u7801', + todaySuccessLogins: '\u4eca\u65e5\u6210\u529f\u767b\u5f55', + totalUsers: '\u7528\u6237\u603b\u6570', + userDetail: '\u7528\u6237\u8be6\u60c5', + userDetailAction: '\u8be6\u60c5', + userId: '\u7528\u6237 ID', + usernamePlaceholder: '\u7528\u6237\u540d', + users: '\u7528\u6237\u7ba1\u7406', + usersFilter: '\u7528\u6237\u540d/\u90ae\u7bb1/\u624b\u673a\u53f7', + welcomeLogin: '\u6b22\u8fce\u767b\u5f55', +} + +const BASE_URL = (process.env.E2E_BASE_URL ?? 'http://127.0.0.1:3000').replace(/\/$/, '') +const VIEWPORTS = [ + { name: 'desktop', width: 1440, height: 960 }, + { name: 'tablet', width: 820, height: 1180 }, + { name: 'mobile', width: 390, height: 844 }, +] +const IGNORED_CONSOLE_ERRORS = [ + 'Static function can not consume context like dynamic theme', +] +const IGNORED_REQUEST_FAILURES = new Set([ + 'net::ERR_ABORTED', + 'net::ERR_FAILED', +]) +const DEBUG = process.env.E2E_DEBUG === '1' +const STARTUP_TIMEOUT_MS = Number(process.env.E2E_STARTUP_TIMEOUT_MS ?? 30000) +const SMTP_CAPTURE_FILE = (process.env.E2E_SMTP_CAPTURE_FILE ?? '').trim() +const SESSION_PRESENCE_COOKIE_NAME = 'ums_session_present' + +let managedCdpUrl = null + +function appUrl(pathname) { + return new URL(pathname, `${BASE_URL}/`).toString() +} + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +function escapeRegex(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function requireEnv(name) { + const value = (process.env[name] ?? '').trim() + if (!value) { + throw new Error(`${name} is required.`) + } + return value +} + +async function readCapturedMessages() { + if (!SMTP_CAPTURE_FILE) { + return [] + } + + try { + const content = await readFile(SMTP_CAPTURE_FILE, 'utf8') + return content + .split(/\r?\n/) + .filter(Boolean) + .flatMap((line) => { + try { + return [JSON.parse(line)] + } catch { + return [] + } + }) + } catch (error) { + if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') { + return [] + } + throw error + } +} + +function normalizeEmail(value) { + return String(value ?? '') + .trim() + .replace(/^<|>$/g, '') + .toLowerCase() +} + +function capturedMessageMatchesRecipient(message, email) { + const target = normalizeEmail(email) + if (!target) { + return false + } + + const recipients = Array.isArray(message?.rcptTo) ? message.rcptTo : [] + return recipients.some((candidate) => normalizeEmail(candidate) === target) +} + +function extractActivationLink(message) { + const body = String(message?.data ?? '') + const match = body.match(/https?:\/\/[^\s"<]+\/activate-account\?token=[^"\s<]+/) + return match?.[0] ?? null +} + +async function waitForActivationLink(email, timeoutMs = 20_000) { + const startedAt = Date.now() + + while (Date.now() - startedAt < timeoutMs) { + const messages = await readCapturedMessages() + const matchedMessages = messages.filter((message) => capturedMessageMatchesRecipient(message, email)) + + for (let index = matchedMessages.length - 1; index >= 0; index -= 1) { + const activationLink = extractActivationLink(matchedMessages[index]) + if (activationLink) { + return activationLink + } + } + + await delay(250) + } + + throw new Error(`Timed out waiting for activation email for ${email}.`) +} + +function resolveCdpUrl() { + if (managedCdpUrl) { + return managedCdpUrl + } + + const baseUrl = (process.env.E2E_PLAYWRIGHT_CDP_URL ?? process.env.E2E_CDP_BASE_URL ?? '').trim() + if (baseUrl) { + return baseUrl + } + + const port = Number(process.env.E2E_CDP_PORT ?? 0) + if (port > 0) { + return `http://127.0.0.1:${port}` + } + + throw new Error('E2E_PLAYWRIGHT_CDP_URL or E2E_CDP_PORT is required.') +} + +function createSignals() { + return { + consoleErrors: [], + dialogs: [], + pageErrors: [], + popups: [], + requestFailures: [], + unauthorizedResponses: [], + windowGuardEvents: [], + } +} + +function logDebug(message) { + if (DEBUG) { + console.log(`[debug] ${message}`) + } +} + +function formatError(error) { + if (!error) { + return 'unknown error' + } + + if (error instanceof Error) { + return error.message || error.name + } + + return String(error) +} + +function isRetryableTargetError(error) { + const message = formatError(error) + return ( + message.includes('Target page, context or browser has been closed') || + message.includes('Target closed') || + message.includes('Browser has been closed') + ) +} + +async function assertFileExists(filePath) { + await access(filePath, fsConstants.F_OK) +} + +function isHeadlessShellBrowser(browserPath) { + return path.basename(browserPath).toLowerCase().includes('headless-shell') +} + +async function resolveManagedBrowserPath() { + const envCandidates = [ + process.env.E2E_BROWSER_PATH, + process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH, + process.env.CHROME_HEADLESS_SHELL_PATH, + ] + .map((value) => (value ?? '').trim()) + .filter(Boolean) + + for (const candidate of envCandidates) { + await assertFileExists(candidate) + return candidate + } + + for (const candidate of [ + 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', + 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe', + 'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe', + 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe', + ]) { + try { + await assertFileExists(candidate) + return candidate + } catch { + continue + } + } + + const baseDir = path.join(process.env.LOCALAPPDATA ?? '', 'ms-playwright') + const candidates = [] + + try { + const entries = await readdir(baseDir, { withFileTypes: true }) + for (const entry of entries) { + if (!entry.isDirectory() || !entry.name.startsWith('chromium_headless_shell-')) { + continue + } + + candidates.push( + path.join(baseDir, entry.name, 'chrome-headless-shell-win64', 'chrome-headless-shell.exe'), + ) + } + } catch { + throw new Error('failed to scan Playwright browser cache under LOCALAPPDATA') + } + + candidates.sort().reverse() + for (const candidate of candidates) { + try { + await assertFileExists(candidate) + return candidate + } catch { + continue + } + } + + throw new Error('No compatible browser found for Playwright CDP E2E.') +} + +async function createManagedBrowserProfileDir(browserPath, port) { + if (!isHeadlessShellBrowser(browserPath)) { + return await mkdtemp(path.join(tmpdir(), 'pw-playwright-cdp-')) + } + + const profileRoot = path.join(process.cwd(), '.cache', 'cdp-profiles') + await mkdir(profileRoot, { recursive: true }) + return path.join(profileRoot, `pw-profile-playwright-cdp-${port}`) +} + +function startManagedBrowser(browserPath, port, profileDir) { + const args = [`--remote-debugging-port=${port}`, `--user-data-dir=${profileDir}`, '--no-sandbox'] + + if (isHeadlessShellBrowser(browserPath)) { + args.push('--single-process') + } else { + args.push( + '--disable-dev-shm-usage', + '--disable-background-networking', + '--disable-background-timer-throttling', + '--disable-renderer-backgrounding', + '--disable-sync', + '--headless=new', + ) + } + + args.push('about:blank') + + const browserProcess = spawn(browserPath, args, { + stdio: ['ignore', 'ignore', 'pipe'], + windowsHide: true, + }) + + let stderr = '' + browserProcess.stderr?.on('data', (chunk) => { + stderr += chunk.toString() + if (stderr.length > 4000) { + stderr = stderr.slice(-4000) + } + }) + + browserProcess.on('exit', (code, signal) => { + if (code !== 0 && signal == null) { + console.error(`managed browser exited unexpectedly with code ${code}`) + const details = stderr.trim() + if (details) { + console.error(details) + } + } + }) + + return browserProcess +} + +async function killManagedBrowser(browserProcess) { + if (!browserProcess || browserProcess.exitCode != null || browserProcess.pid == null) { + return + } + + await new Promise((resolve) => { + const killer = spawn('taskkill', ['/PID', String(browserProcess.pid), '/T', '/F'], { + stdio: 'ignore', + windowsHide: true, + }) + + killer.once('error', () => { + try { + browserProcess.kill('SIGKILL') + } catch { + // ignore + } + resolve() + }) + + killer.once('exit', () => resolve()) + }) +} + +async function getFreePort() { + return await new Promise((resolve, reject) => { + const server = net.createServer() + server.unref() + server.on('error', reject) + server.listen(0, '127.0.0.1', () => { + const address = server.address() + if (address == null || typeof address === 'string') { + server.close(() => reject(new Error('failed to resolve a free port'))) + return + } + + server.close((error) => { + if (error) { + reject(error) + return + } + resolve(address.port) + }) + }) + }) +} + +async function waitForHttp(url, timeoutMs, label) { + const startedAt = Date.now() + let lastError = null + + while (Date.now() - startedAt < timeoutMs) { + try { + const response = await fetch(url) + if (response.ok) { + return response + } + lastError = new Error(`${label} returned ${response.status}`) + } catch (error) { + lastError = error + } + await delay(250) + } + + throw new Error(`timed out waiting for ${label}: ${formatError(lastError)}`) +} + +async function waitForJson(url, timeoutMs, label = url) { + const response = await waitForHttp(url, timeoutMs, label) + return await response.json() +} + +function isIgnoredConsoleError(text) { + if (text.includes('favicon') && text.includes('404')) { + return true + } + + return IGNORED_CONSOLE_ERRORS.some((value) => text.includes(value)) +} + +function formatSignals(signals) { + const lines = [] + + if (signals.windowGuardEvents.length > 0) { + lines.push(`window-guard events:\n${signals.windowGuardEvents.join('\n')}`) + } + if (signals.dialogs.length > 0) { + lines.push(`native dialogs:\n${signals.dialogs.join('\n')}`) + } + if (signals.popups.length > 0) { + lines.push(`popup pages:\n${signals.popups.join('\n')}`) + } + if (signals.pageErrors.length > 0) { + lines.push(`page errors:\n${signals.pageErrors.join('\n\n')}`) + } + if (signals.consoleErrors.length > 0) { + lines.push(`console errors:\n${signals.consoleErrors.join('\n')}`) + } + if (signals.requestFailures.length > 0) { + lines.push(`request failures:\n${signals.requestFailures.join('\n')}`) + } + if (signals.unauthorizedResponses.length > 0) { + lines.push(`unauthorized responses:\n${signals.unauthorizedResponses.join('\n')}`) + } + + return lines.join('\n\n') +} + +function assertCleanSignals(signals) { + const output = formatSignals(signals) + if (output) { + throw new Error(output) + } +} + +function attachSignalCollectors(page, signals) { + const onConsole = (message) => { + if (message.type() !== 'error') { + return + } + + const text = message.text() + if (text.startsWith('[window-guard]')) { + signals.windowGuardEvents.push(text) + return + } + + if (!isIgnoredConsoleError(text)) { + signals.consoleErrors.push(text) + } + } + + const onDialog = (dialog) => { + signals.dialogs.push(`${dialog.type()}: ${dialog.message()}`) + void dialog.dismiss().catch(() => {}) + } + + const onPageError = (error) => { + signals.pageErrors.push(error.stack ?? error.message) + } + + const onPopup = (popup) => { + signals.popups.push(popup.url() || 'about:blank') + void popup.close().catch(() => {}) + } + + const onRequestFailed = (request) => { + const failureText = request.failure()?.errorText ?? 'unknown failure' + if (!IGNORED_REQUEST_FAILURES.has(failureText)) { + signals.requestFailures.push(`${request.method()} ${request.url()} :: ${failureText}`) + } + } + + const onResponse = (response) => { + if (response.status() === 401) { + signals.unauthorizedResponses.push(`${response.request().method()} ${response.url()}`) + } + } + + page.on('console', onConsole) + page.on('dialog', onDialog) + page.on('pageerror', onPageError) + page.on('popup', onPopup) + page.on('requestfailed', onRequestFailed) + page.on('response', onResponse) + + return () => { + page.off('console', onConsole) + page.off('dialog', onDialog) + page.off('pageerror', onPageError) + page.off('popup', onPopup) + page.off('requestfailed', onRequestFailed) + page.off('response', onResponse) + } +} + +async function resetBrowserState(context, page) { + logDebug('resetting browser state') + await context.clearCookies() + await page.goto(appUrl('/login'), { waitUntil: 'domcontentloaded' }) + await page.evaluate(() => { + localStorage.clear() + sessionStorage.clear() + }) + await page.goto('about:blank') +} + +async function openDevToolsPageTarget() { + const endpoints = [ + `${resolveCdpUrl()}/json/new?about:blank`, + `${resolveCdpUrl()}/json/new?url=about:blank`, + ] + + for (const endpoint of endpoints) { + for (const method of ['PUT', 'GET']) { + try { + await fetch(endpoint, { method }) + return + } catch { + // try next variant + } + } + } +} + +async function connectBrowserWithRetry() { + let lastError = null + + for (let attempt = 1; attempt <= 3; attempt += 1) { + try { + return await chromium.connectOverCDP(resolveCdpUrl()) + } catch (error) { + lastError = error + if (attempt >= 3) { + break + } + await delay(500) + } + } + + throw lastError ?? new Error('Failed to connect to the Chromium CDP endpoint.') +} + +async function ensurePersistentPage(browser, context) { + let page = context.pages().find((candidate) => !candidate.isClosed()) + if (page) { + return page + } + + try { + const session = await browser.newBrowserCDPSession() + try { + await session.send('Target.createTarget', { url: 'about:blank' }) + } finally { + await session.detach().catch(() => {}) + } + } catch { + // fall through to DevTools HTTP endpoint fallback + } + + await openDevToolsPageTarget() + + for (let attempt = 0; attempt < 50; attempt += 1) { + page = context.pages().find((candidate) => !candidate.isClosed()) + if (page) { + return page + } + await delay(100) + } + + return null +} + +async function getProtectedRouteRedirect(page) { + return await page.evaluate(() => { + return { + path: window.location.pathname, + redirectFrom: history.state?.usr?.from?.pathname ?? null, + title: document.title, + } + }) +} + +async function clickSidebarMenu(page, label) { + const menuItems = page + .locator('.ant-layout-sider .ant-menu-item, .ant-drawer .ant-menu-item') + .filter({ hasText: label }) + + const count = await menuItems.count() + for (let index = 0; index < count; index += 1) { + const menuItem = menuItems.nth(index) + if (await menuItem.isVisible()) { + await forceClick(menuItem) + return + } + } + + throw new Error(`No visible menu item found for ${label}.`) +} + +async function expandSidebarGroup(page, label) { + const groups = page + .locator('.ant-layout-sider .ant-menu-submenu-title, .ant-drawer .ant-menu-submenu-title') + .filter({ hasText: label }) + + const count = await groups.count() + for (let index = 0; index < count; index += 1) { + const group = groups.nth(index) + if (await group.isVisible()) { + await forceClick(group) + return + } + } + + throw new Error(`No visible menu group found for ${label}.`) +} + +async function forceFillInput(locator, value) { + await expect(locator).toBeVisible() + await locator.evaluate((element, nextValue) => { + if (!(element instanceof HTMLInputElement)) { + throw new Error('Target element is not an input.') + } + + element.focus() + const prototype = Object.getPrototypeOf(element) + const descriptor = Object.getOwnPropertyDescriptor(prototype, 'value') + if (descriptor?.set) { + descriptor.set.call(element, nextValue) + } else { + element.value = nextValue + } + + element.dispatchEvent(new Event('input', { bubbles: true })) + element.dispatchEvent(new Event('change', { bubbles: true })) + }, value) +} + +async function forceClick(locator) { + await expect(locator).toBeVisible() + await locator.evaluate((element) => { + if (!(element instanceof HTMLElement)) { + throw new Error('Target element is not clickable.') + } + + element.scrollIntoView({ block: 'center', inline: 'center' }) + const target = + element.closest( + 'button, a, [role="button"], [role="menuitem"], .ant-btn, .ant-menu-item, .ant-modal-close, .ant-drawer-close', + ) ?? element + + target.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })) + target.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })) + target.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })) + target.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) +} + +async function readRefreshToken(page) { + return await page.evaluate((cookieName) => { + const target = `${cookieName}=` + const matched = document.cookie + .split(';') + .map((cookie) => cookie.trim()) + .find((cookie) => cookie.startsWith(target)) + + return matched ? matched.slice(target.length) : null + }, SESSION_PRESENCE_COOKIE_NAME) +} + +async function assertApiSuccessResponse(response, label) { + const responseBody = await response.text().catch(() => '') + if (!response.ok()) { + throw new Error(`${label} request failed: ${response.status()} ${responseBody}`) + } + + let payload + try { + payload = JSON.parse(responseBody) + } catch (error) { + if (error instanceof SyntaxError) { + throw new Error(`${label} response is not valid JSON: ${responseBody}`) + } + throw error + } + + if (payload?.code !== 0) { + throw new Error(`${label} business response failed: ${responseBody}`) + } + + return payload +} + +async function loginWithPassword(page, username, password, expectedUrlPattern) { + const usernameInput = page + .locator(`input[autocomplete="username"], input[placeholder="${TEXT.usernamePlaceholder}"]`) + .first() + const loginForm = usernameInput.locator('xpath=ancestor::form[1]') + + await forceFillInput(usernameInput, username) + await forceFillInput(loginForm.locator('input[type="password"]').first(), password) + const loginResponsePromise = page.waitForResponse((response) => { + return response.url().includes('/api/v1/auth/login') && response.request().method() === 'POST' + }, { timeout: 5_000 }).catch(() => null) + await forceClick(loginForm.locator('button[type="submit"]').first()) + + const loginResponse = await loginResponsePromise + if (loginResponse) { + await assertApiSuccessResponse(loginResponse, 'password login') + } + + if (expectedUrlPattern) { + await expect(page).toHaveURL(expectedUrlPattern, { timeout: 30 * 1000 }) + } +} + +async function loginFromLoginPage(page) { + const username = requireEnv('E2E_LOGIN_USERNAME') + const password = requireEnv('E2E_LOGIN_PASSWORD') + + await page.goto(appUrl('/login')) + await expect(page).toHaveURL(/\/login$/) + await expect(page.getByRole('heading', { name: TEXT.welcomeLogin })).toBeVisible() + + await loginWithPassword(page, username, password, /\/dashboard$/) + + return { username, password } +} + +async function verifyAdminBootstrapWorkflow(page) { + const username = requireEnv('E2E_LOGIN_USERNAME') + const password = requireEnv('E2E_LOGIN_PASSWORD') + const email = (process.env.E2E_LOGIN_EMAIL ?? `${username}@example.com`).trim() + + const capabilitiesResponse = page.waitForResponse((response) => { + return response.url().includes('/api/v1/auth/capabilities') && response.request().method() === 'GET' + }) + + await page.goto(appUrl('/login')) + const capabilitiesPayload = await (await capabilitiesResponse).json() + expect(Boolean(capabilitiesPayload?.data?.admin_bootstrap_required)).toBe(true) + + await expect(page.getByText(TEXT.adminBootstrapTitle)).toBeVisible() + await forceClick(page.getByRole('button', { name: TEXT.adminBootstrapAction })) + await expect(page).toHaveURL(/\/bootstrap-admin$/) + await expect(page.getByRole('heading', { name: TEXT.adminBootstrapPageTitle })).toBeVisible() + + await forceFillInput(page.locator(`input[placeholder="${TEXT.bootstrapAdminUsernamePlaceholder}"]`).first(), username) + await forceFillInput(page.locator(`input[placeholder="${TEXT.bootstrapAdminEmailPlaceholder}"]`).first(), email) + await forceFillInput(page.locator(`input[placeholder="${TEXT.bootstrapAdminPasswordPlaceholder}"]`).first(), password) + await forceFillInput(page.locator(`input[placeholder="${TEXT.bootstrapAdminConfirmPasswordPlaceholder}"]`).first(), password) + + const [bootstrapResponse] = await Promise.all([ + page.waitForResponse((response) => { + return response.url().includes('/api/v1/auth/bootstrap-admin') && response.request().method() === 'POST' + }), + forceClick(page.getByRole('button', { name: TEXT.bootstrapAdminSubmit })), + ]) + await assertApiSuccessResponse(bootstrapResponse, 'bootstrap admin') + + await expect(page).toHaveURL(/\/dashboard$/, { timeout: 30 * 1000 }) + await expect(page.getByText(TEXT.todaySuccessLogins)).toBeVisible() + + await forceClick(page.locator('[class*="userTrigger"]')) + await forceClick(page.getByText(TEXT.logout, { exact: true })) + await expect(page).toHaveURL(/\/login$/) + await expect(page.getByText(TEXT.adminBootstrapTitle)).toHaveCount(0) +} + +async function verifyPublicRegistration(page) { + const username = `e2e_register_${Date.now()}` + const password = 'Register123!@#' + + await page.goto(appUrl('/login')) + await expect(page.getByRole('link', { name: TEXT.createAccount })).toBeVisible() + await forceClick(page.getByRole('link', { name: TEXT.createAccount })) + await expect(page).toHaveURL(/\/register$/) + await expect(page.getByRole('heading', { name: TEXT.createAccount })).toBeVisible() + + await forceFillInput(page.getByPlaceholder(TEXT.usernamePlaceholder), username) + await forceFillInput(page.locator(`input[placeholder="${TEXT.passwordPlaceholder}"]`).first(), password) + await forceFillInput( + page.locator(`input[placeholder="${TEXT.confirmPasswordPlaceholder}"]`).first(), + password, + ) + const [registerResponse] = await Promise.all([ + page.waitForResponse((response) => { + return response.url().includes('/api/v1/auth/register') && response.request().method() === 'POST' + }), + forceClick(page.getByRole('button', { name: TEXT.createAccount })), + ]) + await assertApiSuccessResponse(registerResponse, 'register') + + await expect(page.locator('.ant-result-title').filter({ hasText: TEXT.registerSuccess }).first()).toBeVisible({ timeout: 20 * 1000 }) + await forceClick(page.getByRole('button', { name: TEXT.backToLogin })) + await expect(page).toHaveURL(/\/login$/) + + await loginWithPassword(page, username, password, /\/profile$/) + await expect(page.locator('body')).toContainText(TEXT.profile) + + await forceClick(page.locator('[class*="userTrigger"]')) + await forceClick(page.getByText(TEXT.logout, { exact: true })) + await expect(page).toHaveURL(/\/login$/) +} + +async function verifyEmailActivationWorkflow(page) { + const username = `e2e_activate_${Date.now()}` + const email = `${username}@example.com` + const password = 'Register123!@#' + + await page.goto(appUrl('/register')) + await expect(page).toHaveURL(/\/register$/) + await expect(page.getByRole('heading', { name: TEXT.createAccount })).toBeVisible() + + await forceFillInput(page.getByPlaceholder(TEXT.usernamePlaceholder), username) + await forceFillInput(page.getByPlaceholder(TEXT.registerEmailPlaceholder), email) + await forceFillInput(page.locator(`input[placeholder="${TEXT.passwordPlaceholder}"]`).first(), password) + await forceFillInput( + page.locator(`input[placeholder="${TEXT.confirmPasswordPlaceholder}"]`).first(), + password, + ) + + const [registerResponse] = await Promise.all([ + page.waitForResponse((response) => { + return response.url().includes('/api/v1/auth/register') && response.request().method() === 'POST' + }), + forceClick(page.getByRole('button', { name: TEXT.createAccount })), + ]) + await assertApiSuccessResponse(registerResponse, 'register email activation') + await expect(page.locator('.ant-result-title').filter({ hasText: TEXT.registerSuccess }).first()).toBeVisible({ timeout: 20 * 1000 }) + + const activationLink = await waitForActivationLink(email) + const [activationResponse] = await Promise.all([ + page.waitForResponse((response) => { + return response.url().includes('/api/v1/auth/activate') && response.request().method() === 'GET' + }), + page.goto(activationLink), + ]) + await assertApiSuccessResponse(activationResponse, 'activate email') + await expect(page.locator('body')).toContainText(TEXT.emailActivationSuccess, { timeout: 20 * 1000 }) + await forceClick(page.getByRole('button', { name: TEXT.loginNow })) + await expect(page).toHaveURL(/\/login$/) + + await loginWithPassword(page, username, password, /\/profile$/) + await expect(page.locator('body')).toContainText(TEXT.profile) + + await forceClick(page.locator('[class*="userTrigger"]')) + await forceClick(page.getByText(TEXT.logout, { exact: true })) + await expect(page).toHaveURL(/\/login$/) +} + +async function runScenario(browser, context, name, fn) { + console.log(`START ${name}`) + let lastError = null + + for (let attempt = 1; attempt <= 2; attempt += 1) { + const activeContext = browser.contexts()[0] ?? context + const page = await ensurePersistentPage(browser, activeContext) + if (!page) { + throw new Error('No persistent page is available in the Chromium CDP context.') + } + + for (const extraPage of activeContext.pages()) { + if (extraPage === page) { + continue + } + await extraPage.close({ runBeforeUnload: false }).catch(() => {}) + } + + const signals = createSignals() + const detachSignals = attachSignalCollectors(page, signals) + const startedAt = Date.now() + + try { + console.log(`STEP ${name} reset-state`) + await resetBrowserState(activeContext, page) + console.log(`STEP ${name} execute`) + await fn(page) + assertCleanSignals(signals) + console.log(`PASS ${name} (${Date.now() - startedAt}ms)`) + return + } catch (error) { + lastError = error + const signalOutput = formatSignals(signals) + if (signalOutput) { + console.error(`SIGNALS ${name}\n${signalOutput}`) + } + + if (attempt >= 2 || !isRetryableTargetError(error)) { + throw error + } + + console.warn(`RETRY ${name} attempt ${attempt + 1}: ${formatError(error)}`) + await delay(500) + } finally { + detachSignals() + if (!page.isClosed()) { + await page.goto('about:blank').catch(() => {}) + } + } + } + + throw lastError ?? new Error(`Scenario ${name} failed`) +} + +async function verifyLoginSurface(page) { + console.log('STEP login-surface wait-capabilities') + const capabilitiesResponse = page.waitForResponse((response) => { + return response.url().includes('/api/v1/auth/capabilities') && response.request().method() === 'GET' + }) + + console.log('STEP login-surface goto-login') + await page.goto(appUrl('/login')) + console.log('STEP login-surface capabilities-response') + const capabilitiesPayload = await (await capabilitiesResponse).json() + const capabilities = capabilitiesPayload?.data ?? {} + + await expect(page).toHaveTitle(new RegExp(TEXT.appTitle)) + await expect(page.locator('html')).toHaveAttribute('lang', 'zh-CN') + await expect(page.getByRole('heading', { name: TEXT.welcomeLogin })).toBeVisible() + await expect(page.getByPlaceholder(TEXT.usernamePlaceholder)).toBeVisible() + await expect(page.getByPlaceholder(TEXT.passwordPlaceholder)).toBeVisible() + + if (capabilities.email_code) { + await expect(page.getByRole('tab', { name: TEXT.emailCodeLogin })).toBeVisible() + } else { + await expect(page.getByRole('tab', { name: TEXT.emailCodeLogin })).toHaveCount(0) + } + + if (capabilities.sms_code) { + await expect(page.getByRole('tab', { name: TEXT.smsCodeLogin })).toBeVisible() + } else { + await expect(page.getByRole('tab', { name: TEXT.smsCodeLogin })).toHaveCount(0) + } + + if (capabilities.password_reset) { + await expect(page.getByRole('link', { name: TEXT.forgotPassword })).toBeVisible() + } else { + await expect(page.getByRole('link', { name: TEXT.forgotPassword })).toHaveCount(0) + } + + await page.goto(appUrl('/dashboard')) + await expect(page).toHaveURL(/\/login$/, { timeout: 10 * 1000 }) + const dashboardRedirect = await getProtectedRouteRedirect(page) + expect(dashboardRedirect.path).toBe('/login') + expect(dashboardRedirect.redirectFrom).toBe('/dashboard') + + await page.goto(appUrl('/users')) + await expect(page).toHaveURL(/\/login$/, { timeout: 10 * 1000 }) + const usersRedirect = await getProtectedRouteRedirect(page) + expect(usersRedirect.path).toBe('/login') + expect(usersRedirect.redirectFrom).toBe('/users') +} + +async function verifyAuthWorkflow(page) { + logDebug('verifyAuthWorkflow: login /login') + const credentials = await loginFromLoginPage(page) + const createdUsername = `e2e_user_${Date.now()}` + await page.goto(appUrl('/users')) + await expect(page).toHaveURL(/\/users$/) + + expect(await readRefreshToken(page)).toBeTruthy() + + const userRow = page.locator('tbody tr').filter({ hasText: credentials.username }).first() + await expect(userRow).toBeVisible({ timeout: 20 * 1000 }) + await expect(page.getByPlaceholder(TEXT.usersFilter)).toBeVisible() + + await forceClick(userRow.getByRole('button', { name: TEXT.userDetailAction })) + const userDetailTitle = page.locator('.ant-drawer-title') + await expect(userDetailTitle).toHaveText(TEXT.userDetail) + await expect(page.locator('.ant-drawer')).toContainText(TEXT.userId) + await expect(page.locator('.ant-drawer')).toContainText(credentials.username) + await page.goto(appUrl('/users')) + await expect(page.locator('tbody tr').filter({ hasText: credentials.username }).first()).toBeVisible({ timeout: 20 * 1000 }) + + await forceClick(userRow.getByRole('button', { name: TEXT.assignRolesAction })) + const assignRolesTitle = page.locator('.ant-modal-title') + await expect(assignRolesTitle).toContainText(TEXT.assignRoles) + await expect(page.locator('.ant-modal')).toContainText(credentials.username) + await page.goto(appUrl('/users')) + await expect(page.locator('tbody tr').filter({ hasText: credentials.username }).first()).toBeVisible({ timeout: 20 * 1000 }) + + await forceClick(page.getByRole('button', { name: TEXT.createUser }).first()) + await expect(page.locator('.ant-modal-title')).toContainText(TEXT.createUser) + const createUserModal = page.locator('.ant-modal').last() + const createUserResponsePromise = page.waitForResponse((response) => { + return response.url().includes('/api/v1/users') && response.request().method() === 'POST' + }) + await forceFillInput( + createUserModal.locator(`input[placeholder="${TEXT.createUserUsernamePlaceholder}"]`).first(), + createdUsername, + ) + await forceFillInput( + createUserModal.locator(`input[placeholder="${TEXT.createUserPasswordPlaceholder}"]`).first(), + 'Pass123!@#', + ) + await forceFillInput( + createUserModal.locator(`input[placeholder="${TEXT.createUserEmailPlaceholder}"]`).first(), + `${createdUsername}@example.com`, + ) + await forceClick(createUserModal.locator('.ant-btn-primary').last()) + const createUserResponse = await createUserResponsePromise + await assertApiSuccessResponse(createUserResponse, 'create user') + await expect(createUserModal).toHaveClass(/ant-zoom-leave/, { timeout: 20 * 1000 }) + await page.goto(appUrl('/users')) + await expect(page).toHaveURL(/\/users$/) + await forceFillInput(page.getByPlaceholder(TEXT.usersFilter), createdUsername) + await expect(page.locator('tbody tr').filter({ hasText: createdUsername }).first()).toBeVisible({ timeout: 20 * 1000 }) + + await page.goto(appUrl('/roles')) + await expect(page).toHaveURL(/\/roles$/) + await expect(page.getByPlaceholder(TEXT.roleFilter)).toBeVisible() + await expect(page.getByRole('button', { name: TEXT.createRole })).toBeVisible() + + const adminRoleRow = page.locator('tbody tr').filter({ hasText: TEXT.adminRoleName }).first() + await expect(adminRoleRow).toBeVisible({ timeout: 20 * 1000 }) + await forceClick(adminRoleRow.getByRole('button', { name: TEXT.permissionsAction })) + + const assignPermissionsTitle = page.locator('.ant-modal-title') + await expect(assignPermissionsTitle).toContainText(TEXT.assignPermissions) + await expect(page.locator('.ant-modal')).toContainText(TEXT.adminRoleName) + await expect(page.locator('.ant-modal')).toContainText(TEXT.permissionsHint) + await page.goto(appUrl('/roles')) + await expect(page.locator('tbody tr').filter({ hasText: TEXT.adminRoleName }).first()).toBeVisible({ timeout: 20 * 1000 }) + + await page.goto(appUrl('/dashboard')) + await expect(page).toHaveURL(/\/dashboard$/) + await expect(page.getByText(TEXT.todaySuccessLogins)).toBeVisible() + await expect(page.getByText(TEXT.totalUsers)).toBeVisible() + + await forceClick(page.locator('[class*="userTrigger"]')) + await forceClick(page.getByText(TEXT.logout, { exact: true })) + await expect(page).toHaveURL(/\/login$/) + await expect(await readRefreshToken(page)).toBeNull() + + await page.goto(appUrl('/dashboard')) + const postLogoutRedirect = await getProtectedRouteRedirect(page) + expect(postLogoutRedirect.path).toBe('/login') + expect(postLogoutRedirect.redirectFrom).toBe('/dashboard') +} + +async function verifyResponsiveLogin(page) { + for (const viewport of VIEWPORTS) { + logDebug(`verifyResponsiveLogin: ${viewport.name}`) + await page.setViewportSize({ width: viewport.width, height: viewport.height }) + await page.goto(appUrl('/login')) + await expect(page).toHaveTitle(new RegExp(TEXT.appTitle)) + await expect(page.getByRole('heading', { name: TEXT.welcomeLogin })).toBeVisible() + + const metrics = await page.evaluate(() => { + return { + innerWidth: window.innerWidth, + bodyScrollWidth: document.body.scrollWidth, + documentScrollWidth: document.documentElement.scrollWidth, + } + }) + + expect(metrics.bodyScrollWidth).toBeLessThanOrEqual(metrics.innerWidth + 24) + expect(metrics.documentScrollWidth).toBeLessThanOrEqual(metrics.innerWidth + 24) + } +} + +async function verifyDesktopAndMobileNavigation(page) { + logDebug('verifyDesktopAndMobileNavigation: login /login') + const credentials = requireEnv('E2E_LOGIN_USERNAME') + + await page.setViewportSize({ width: 1440, height: 960 }) + await loginFromLoginPage(page) + await expect(page.getByText(TEXT.todaySuccessLogins)).toBeVisible() + + await expandSidebarGroup(page, TEXT.accessControl) + await clickSidebarMenu(page, TEXT.users) + await expect(page).toHaveURL(/\/users$/) + await expect(page.locator('tbody tr').filter({ hasText: credentials }).first()).toBeVisible({ timeout: 20 * 1000 }) + + await expandSidebarGroup(page, TEXT.accessControl) + await clickSidebarMenu(page, TEXT.roles) + await expect(page).toHaveURL(/\/roles$/) + await expect(page.locator('tbody tr').filter({ hasText: TEXT.adminRoleName }).first()).toBeVisible({ timeout: 20 * 1000 }) + + await page.setViewportSize({ width: 390, height: 844 }) + await expect + .poll(async () => await page.evaluate(() => window.innerWidth < 768)) + .toBe(true) + await page.evaluate(() => window.dispatchEvent(new Event('resize'))) + await expect + .poll(async () => await page.locator('.ant-layout-header .ant-btn').count()) + .toBeGreaterThan(0) + + const mobileMenuButton = page.locator('.ant-layout-header .ant-btn').first() + await expect(mobileMenuButton).toBeVisible() + await forceClick(mobileMenuButton) + + await expect(page.locator('.ant-drawer-content')).toBeVisible({ timeout: 10 * 1000 }) + const mobileDashboardItem = page.locator('.ant-drawer .ant-menu-item').filter({ hasText: TEXT.dashboard }).first() + await expect(mobileDashboardItem).toBeVisible() + await forceClick(mobileDashboardItem) + await expect(page).toHaveURL(/\/dashboard$/) + await page.goto(appUrl('/dashboard')) + await expect(page.locator('body')).toContainText(TEXT.todaySuccessLogins, { timeout: 10 * 1000 }) +} + +async function main() { + let browser = null + let managedBrowser = null + let managedProfileDir = null + + if (process.env.E2E_MANAGED_BROWSER === '1') { + const browserPath = await resolveManagedBrowserPath() + const port = await getFreePort() + managedProfileDir = await createManagedBrowserProfileDir(browserPath, port) + managedBrowser = startManagedBrowser(browserPath, port, managedProfileDir) + managedCdpUrl = `http://127.0.0.1:${port}` + console.log(`LAUNCH playwright-cdp ${browserPath}`) + await waitForJson(`${managedCdpUrl}/json/version`, STARTUP_TIMEOUT_MS, 'managed browser CDP endpoint') + } + + console.log('CONNECT playwright-cdp') + browser = await connectBrowserWithRetry() + + try { + console.log('CONNECTED playwright-cdp') + const context = browser.contexts()[0] + if (!context) { + throw new Error('No persistent Chromium context is available through CDP.') + } + + if (process.env.E2E_EXPECT_ADMIN_BOOTSTRAP === '1') { + await runScenario(browser, context, 'admin-bootstrap', verifyAdminBootstrapWorkflow) + } + await runScenario(browser, context, 'public-registration', verifyPublicRegistration) + await runScenario(browser, context, 'email-activation', verifyEmailActivationWorkflow) + await runScenario(browser, context, 'login-surface', verifyLoginSurface) + await runScenario(browser, context, 'auth-workflow', verifyAuthWorkflow) + await runScenario(browser, context, 'responsive-login', verifyResponsiveLogin) + await runScenario(browser, context, 'desktop-mobile-navigation', verifyDesktopAndMobileNavigation) + console.log('Playwright CDP E2E completed successfully') + } finally { + await browser?.close().catch(() => {}) + await killManagedBrowser(managedBrowser) + if (managedProfileDir) { + await rm(managedProfileDir, { recursive: true, force: true }).catch(() => {}) + } + managedCdpUrl = null + } +} + +await main().catch((error) => { + console.error(error && error.stack ? error.stack : error) + process.exitCode = 1 +}) diff --git a/frontend/admin/scripts/run-vitest.mjs b/frontend/admin/scripts/run-vitest.mjs new file mode 100644 index 0000000..64a11a8 --- /dev/null +++ b/frontend/admin/scripts/run-vitest.mjs @@ -0,0 +1,79 @@ +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import react from '@vitejs/plugin-react' +import { parseCLI, startVitest } from 'vitest/node' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const root = path.resolve(__dirname, '..') + +const { filter, options } = parseCLI(['vitest', ...process.argv.slice(2)]) +const { coverage: coverageOptions, ...cliOptions } = options + +const baseCoverage = { + provider: 'v8', + reporter: ['text', 'json', 'html'], + include: ['src/**/*.{ts,tsx}'], + exclude: [ + 'src/**/*.d.ts', + 'src/**/*.interface.ts', + 'src/test/**', + 'src/main.tsx', + 'src/vite-env.d.ts', + ], +} + +function resolveCoverageConfig(option) { + if (!option) { + return { + ...baseCoverage, + enabled: false, + } + } + + if (option === true) { + return { + ...baseCoverage, + enabled: true, + } + } + + return { + ...baseCoverage, + ...option, + enabled: option.enabled ?? true, + } +} + +const ctx = await startVitest( + 'test', + filter, + { + ...cliOptions, + root, + config: false, + globals: true, + environment: 'jsdom', + setupFiles: ['./src/test/setup.ts'], + include: ['src/**/*.{test,spec}.{js,ts,jsx,tsx}'], + coverage: resolveCoverageConfig(coverageOptions), + pool: cliOptions.pool ?? 'threads', + fileParallelism: cliOptions.fileParallelism ?? false, + maxWorkers: cliOptions.maxWorkers ?? 1, + testTimeout: cliOptions.testTimeout ?? 10000, + hookTimeout: cliOptions.hookTimeout ?? 10000, + clearMocks: true, + }, + { + plugins: [react()], + resolve: { + preserveSymlinks: true, + alias: { + '@': path.resolve(root, 'src'), + }, + }, + }, +) + +if (!ctx?.shouldKeepServer()) { + await ctx?.exit() +} diff --git a/frontend/admin/src/app/App.test.tsx b/frontend/admin/src/app/App.test.tsx new file mode 100644 index 0000000..324b0ce --- /dev/null +++ b/frontend/admin/src/app/App.test.tsx @@ -0,0 +1,40 @@ +import type { ReactNode } from 'react' +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +import App from './App' + +const routerProviderMock = vi.fn((props: unknown) => { + void props + return
+}) +const errorBoundaryMock = vi.fn(({ children }: { children: ReactNode }) => ( +
{children}
+)) + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom') + return { + ...actual, + RouterProvider: (props: unknown) => routerProviderMock(props), + } +}) + +vi.mock('@/components/common', () => ({ + ErrorBoundary: (props: { children: React.ReactNode }) => errorBoundaryMock(props), +})) + +describe('App', () => { + it('renders the router provider inside the error boundary shell', () => { + render() + + expect(screen.getByTestId('error-boundary')).toBeInTheDocument() + expect(screen.getByTestId('router-provider')).toBeInTheDocument() + expect(errorBoundaryMock).toHaveBeenCalledTimes(1) + expect(routerProviderMock).toHaveBeenCalledWith( + expect.objectContaining({ + router: expect.any(Object), + }), + ) + }) +}) diff --git a/frontend/admin/src/app/App.tsx b/frontend/admin/src/app/App.tsx new file mode 100644 index 0000000..acf3a91 --- /dev/null +++ b/frontend/admin/src/app/App.tsx @@ -0,0 +1,38 @@ +/** + * Admin Frontend App Shell + * + * 项目:用户管理系统 Admin 后台 + * 技术栈:React 18 + TypeScript + Vite + Ant Design 5 + */ + +import { Suspense } from 'react' +import { RouterProvider } from 'react-router-dom' +import { Spin } from 'antd' +import { ErrorBoundary } from '@/components/common' +import { router } from './router' + +const routeFallback = ( +
+ +
+) + +function App() { + return ( + + + + + + ) +} + +export default App diff --git a/frontend/admin/src/app/RootLayout.test.tsx b/frontend/admin/src/app/RootLayout.test.tsx new file mode 100644 index 0000000..50fcc74 --- /dev/null +++ b/frontend/admin/src/app/RootLayout.test.tsx @@ -0,0 +1,27 @@ +import type { ReactNode } from 'react' +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +import { RootLayout } from './RootLayout' + +const authProviderMock = vi.fn(({ children }: { children: ReactNode }) => ( +
{children}
+)) + +vi.mock('react-router-dom', () => ({ + Outlet: () =>
, +})) + +vi.mock('./providers/AuthProvider', () => ({ + AuthProvider: (props: { children: ReactNode }) => authProviderMock(props), +})) + +describe('RootLayout', () => { + it('wraps the route outlet with the auth provider', () => { + render() + + expect(screen.getByTestId('auth-provider')).toBeInTheDocument() + expect(screen.getByTestId('root-outlet')).toBeInTheDocument() + expect(authProviderMock).toHaveBeenCalledTimes(1) + }) +}) diff --git a/frontend/admin/src/app/RootLayout.tsx b/frontend/admin/src/app/RootLayout.tsx new file mode 100644 index 0000000..3bde131 --- /dev/null +++ b/frontend/admin/src/app/RootLayout.tsx @@ -0,0 +1,10 @@ +import { Outlet } from 'react-router-dom' +import { AuthProvider } from './providers/AuthProvider' + +export function RootLayout() { + return ( + + + + ) +} diff --git a/frontend/admin/src/app/bootstrap/installWindowGuards.test.ts b/frontend/admin/src/app/bootstrap/installWindowGuards.test.ts new file mode 100644 index 0000000..a2285ab --- /dev/null +++ b/frontend/admin/src/app/bootstrap/installWindowGuards.test.ts @@ -0,0 +1,67 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { installWindowGuards, restoreWindowGuardsForTest } from './installWindowGuards' + +describe('installWindowGuards', () => { + const logger = vi.fn() + + beforeEach(() => { + logger.mockReset() + restoreWindowGuardsForTest() + }) + + afterEach(() => { + restoreWindowGuardsForTest() + }) + + it('blocks native dialogs and popup windows with structured logs', () => { + installWindowGuards(logger) + + expect(window.alert('danger')).toBeUndefined() + expect(window.confirm('continue?')).toBe(false) + expect(window.prompt('name?', 'admin')).toBeNull() + expect(window.open('https://example.com', '_blank')).toBeNull() + + expect(logger).toHaveBeenCalledTimes(4) + expect(logger.mock.calls[0][0]).toContain('native-alert-blocked') + expect(logger.mock.calls[1][0]).toContain('native-confirm-blocked') + expect(logger.mock.calls[2][0]).toContain('native-prompt-blocked') + expect(logger.mock.calls[3][0]).toContain('popup-blocked') + }) + + it('logs window errors and unhandled promise rejections', () => { + installWindowGuards(logger) + + const runtimeError = new Error('boom') + window.dispatchEvent(new ErrorEvent('error', { + message: runtimeError.message, + filename: '/app.js', + lineno: 12, + colno: 34, + error: runtimeError, + })) + + const rejectionEvent = new Event('unhandledrejection') as PromiseRejectionEvent + Object.defineProperty(rejectionEvent, 'promise', { + value: Promise.resolve(), + configurable: true, + }) + Object.defineProperty(rejectionEvent, 'reason', { + value: new Error('rejected'), + configurable: true, + }) + window.dispatchEvent(rejectionEvent) + + expect(logger).toHaveBeenCalledTimes(2) + expect(logger.mock.calls[0][0]).toContain('[window-guard] error') + expect(logger.mock.calls[1][0]).toContain('[window-guard] unhandledrejection') + }) + + it('does not install twice', () => { + installWindowGuards(logger) + installWindowGuards(logger) + + window.alert('once') + expect(logger).toHaveBeenCalledTimes(1) + }) +}) diff --git a/frontend/admin/src/app/bootstrap/installWindowGuards.ts b/frontend/admin/src/app/bootstrap/installWindowGuards.ts new file mode 100644 index 0000000..27259de --- /dev/null +++ b/frontend/admin/src/app/bootstrap/installWindowGuards.ts @@ -0,0 +1,144 @@ +type GuardLogger = (message?: unknown, ...optionalParams: unknown[]) => void + +type WindowGuardOriginals = { + alert: typeof window.alert + confirm: typeof window.confirm + prompt: typeof window.prompt + open: typeof window.open +} + +type WindowGuardListeners = { + error: (event: ErrorEvent) => void + unhandledrejection: (event: PromiseRejectionEvent) => void +} + +declare global { + interface Window { + __UMS_WINDOW_GUARDS_INSTALLED__?: boolean + __UMS_WINDOW_GUARDS_ORIGINALS__?: WindowGuardOriginals + __UMS_WINDOW_GUARDS_LISTENERS__?: WindowGuardListeners + } +} + +function formatValue(value: unknown): string { + if (typeof value === 'string') { + return value + } + + if (value instanceof Error) { + return value.stack || value.message + } + + try { + return JSON.stringify(value) + } catch { + return String(value) + } +} + +function reportWindowEvent( + logger: GuardLogger, + category: string, + payload: Record, +) { + logger(`[window-guard] ${category}`, payload) +} + +export function installWindowGuards(logger: GuardLogger = console.error) { + if (typeof window === 'undefined' || window.__UMS_WINDOW_GUARDS_INSTALLED__) { + return + } + + window.__UMS_WINDOW_GUARDS_ORIGINALS__ = { + alert: window.alert.bind(window), + confirm: window.confirm.bind(window), + prompt: window.prompt.bind(window), + open: window.open.bind(window), + } + + const onError = (event: ErrorEvent) => { + reportWindowEvent(logger, 'error', { + message: event.message, + source: event.filename, + line: event.lineno, + column: event.colno, + error: formatValue(event.error), + }) + } + + const onUnhandledRejection = (event: PromiseRejectionEvent) => { + reportWindowEvent(logger, 'unhandledrejection', { + reason: formatValue(event.reason), + }) + } + + window.__UMS_WINDOW_GUARDS_LISTENERS__ = { + error: onError, + unhandledrejection: onUnhandledRejection, + } + + window.addEventListener('error', onError) + window.addEventListener('unhandledrejection', onUnhandledRejection) + + window.alert = (message?: unknown) => { + reportWindowEvent(logger, 'native-alert-blocked', { + message: formatValue(message), + }) + } + + window.confirm = (message?: string) => { + reportWindowEvent(logger, 'native-confirm-blocked', { + message: formatValue(message), + fallback: false, + }) + return false + } + + window.prompt = (message?: string, defaultValue?: string) => { + reportWindowEvent(logger, 'native-prompt-blocked', { + message: formatValue(message), + defaultValue: formatValue(defaultValue ?? ''), + fallback: null, + }) + return null + } + + window.open = (url?: string | URL, target?: string, features?: string) => { + reportWindowEvent(logger, 'popup-blocked', { + url: typeof url === 'string' ? url : url?.toString() ?? '', + target: target ?? '', + features: features ?? '', + fallback: null, + }) + return null + } + + window.__UMS_WINDOW_GUARDS_INSTALLED__ = true +} + +export function restoreWindowGuardsForTest() { + if (typeof window === 'undefined') { + return + } + + const originals = window.__UMS_WINDOW_GUARDS_ORIGINALS__ + const listeners = window.__UMS_WINDOW_GUARDS_LISTENERS__ + if (!originals) { + window.__UMS_WINDOW_GUARDS_INSTALLED__ = false + return + } + + if (listeners) { + window.removeEventListener('error', listeners.error) + window.removeEventListener('unhandledrejection', listeners.unhandledrejection) + delete window.__UMS_WINDOW_GUARDS_LISTENERS__ + } + + window.alert = originals.alert + window.confirm = originals.confirm + window.prompt = originals.prompt + window.open = originals.open + + delete window.__UMS_WINDOW_GUARDS_ORIGINALS__ + window.__UMS_WINDOW_GUARDS_INSTALLED__ = false +} diff --git a/frontend/admin/src/app/providers/AuthProvider.test.tsx b/frontend/admin/src/app/providers/AuthProvider.test.tsx new file mode 100644 index 0000000..a96121b --- /dev/null +++ b/frontend/admin/src/app/providers/AuthProvider.test.tsx @@ -0,0 +1,442 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import type { Role, SessionUser, TokenBundle } from '@/types' +import { useAuth } from './auth-context' +import { AuthProvider } from './AuthProvider' + +let storedAccessToken: string | null = null +let storedUser: SessionUser | null = null +let storedRoles: Role[] = [] + +const navigateMock = vi.fn() +const getMock = vi.fn() +const setRefreshTokenMock = vi.fn() +const clearRefreshTokenMock = vi.fn() +const hasSessionPresenceCookieMock = vi.fn() +const setAccessTokenMock = vi.fn((token: string, expiresIn: number) => { + void expiresIn + storedAccessToken = token +}) +const getCurrentUserMock = vi.fn(() => storedUser) +const setCurrentUserMock = vi.fn((user: SessionUser) => { + storedUser = user +}) +const getCurrentRolesMock = vi.fn(() => storedRoles) +const setCurrentRolesMock = vi.fn((roles: Role[]) => { + storedRoles = roles +}) +const clearSessionMock = vi.fn(() => { + storedAccessToken = null + storedUser = null + storedRoles = [] +}) +const isAuthenticatedMock = vi.fn(() => storedAccessToken !== null && storedUser !== null) +const isAccessTokenExpiredMock = vi.fn() +const initCSRFTokenMock = vi.fn() +const clearCSRFTokenMock = vi.fn() +const logoutRequestMock = vi.fn() +const refreshSessionMock = vi.fn() + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom') + return { + ...actual, + useNavigate: () => navigateMock, + } +}) + +vi.mock('@/lib/http', () => ({ + get: (path: string) => getMock(path), +})) + +vi.mock('@/lib/storage', () => ({ + setRefreshToken: (token: string | null | undefined) => setRefreshTokenMock(token), + clearRefreshToken: () => clearRefreshTokenMock(), + hasSessionPresenceCookie: () => hasSessionPresenceCookieMock(), +})) + +vi.mock('@/lib/http/auth-session', () => ({ + setAccessToken: (token: string, expiresIn: number) => setAccessTokenMock(token, expiresIn), + getCurrentUser: () => getCurrentUserMock(), + setCurrentUser: (user: SessionUser) => setCurrentUserMock(user), + getCurrentRoles: () => getCurrentRolesMock(), + setCurrentRoles: (roles: Role[]) => setCurrentRolesMock(roles), + clearSession: () => clearSessionMock(), + isAuthenticated: () => isAuthenticatedMock(), + isAccessTokenExpired: () => isAccessTokenExpiredMock(), +})) + +vi.mock('@/lib/http/csrf', () => ({ + initCSRFToken: () => initCSRFTokenMock(), + clearCSRFToken: () => clearCSRFTokenMock(), +})) + +vi.mock('@/services/auth', () => ({ + logout: () => logoutRequestMock(), + refreshSession: () => refreshSessionMock(), +})) + +const adminUser: SessionUser = { + id: 1, + username: 'admin', + email: 'admin@example.com', + phone: '13800138000', + nickname: 'Admin', + avatar: '', + status: 1, +} + +const adminRoles: Role[] = [ + { + id: 1, + name: 'Administrator', + code: 'admin', + description: 'System administrator', + is_system: true, + is_default: false, + status: 1, + }, +] + +const refreshedSession: TokenBundle = { + access_token: 'access-token', + refresh_token: 'refresh-token', + expires_in: 7200, + user: adminUser, +} + +const loginSession: TokenBundle = { + access_token: 'login-access-token', + refresh_token: 'login-refresh-token', + expires_in: 3600, + user: adminUser, +} + +const operatorUser: SessionUser = { + id: 2, + username: 'operator', + email: 'operator@example.com', + phone: '13900139000', + nickname: 'Operator', + avatar: '', + status: 1, +} + +const operatorRoles: Role[] = [ + { + id: 2, + name: 'Operator', + code: 'operator', + description: 'Operations user', + is_system: false, + is_default: false, + status: 1, + }, +] + +function Probe() { + const auth = useAuth() + + return ( +
+ {String(auth.isLoading)} + {String(auth.isAuthenticated)} + {auth.user?.username ?? ''} + {auth.roles.map((role) => role.code).join(',')} + + + +
+ ) +} + +function renderAuthProvider() { + return render( + + + , + ) +} + +async function waitForProviderIdle() { + await waitFor(() => { + expect(screen.getByTestId('loading')).toHaveTextContent('false') + }) +} + +describe('AuthProvider', () => { + beforeEach(() => { + window.history.pushState({}, '', '/') + storedAccessToken = null + storedUser = null + storedRoles = [] + + navigateMock.mockReset() + getMock.mockReset() + setRefreshTokenMock.mockReset() + clearRefreshTokenMock.mockReset() + hasSessionPresenceCookieMock.mockReset() + setAccessTokenMock.mockReset() + getCurrentUserMock.mockReset() + setCurrentUserMock.mockReset() + getCurrentRolesMock.mockReset() + setCurrentRolesMock.mockReset() + clearSessionMock.mockReset() + isAuthenticatedMock.mockReset() + isAccessTokenExpiredMock.mockReset() + initCSRFTokenMock.mockReset() + clearCSRFTokenMock.mockReset() + logoutRequestMock.mockReset() + isAccessTokenExpiredMock.mockReturnValue(true) + isAuthenticatedMock.mockImplementation(() => storedAccessToken !== null && storedUser !== null) + getCurrentUserMock.mockImplementation(() => storedUser) + setCurrentUserMock.mockImplementation((user: SessionUser) => { + storedUser = user + }) + getCurrentRolesMock.mockImplementation(() => storedRoles) + setCurrentRolesMock.mockImplementation((roles: Role[]) => { + storedRoles = roles + }) + setAccessTokenMock.mockImplementation((token: string, expiresIn: number) => { + void expiresIn + storedAccessToken = token + }) + clearSessionMock.mockImplementation(() => { + storedAccessToken = null + storedUser = null + storedRoles = [] + }) + hasSessionPresenceCookieMock.mockReturnValue(false) + initCSRFTokenMock.mockResolvedValue(undefined) + clearCSRFTokenMock.mockReturnValue(undefined) + logoutRequestMock.mockResolvedValue(undefined) + refreshSessionMock.mockReset() + }) + + it('reuses an in-memory authenticated session when the access token is still valid', async () => { + storedAccessToken = 'cached-access-token' + storedUser = adminUser + storedRoles = adminRoles + isAccessTokenExpiredMock.mockReturnValue(false) + + renderAuthProvider() + await waitForProviderIdle() + + expect(refreshSessionMock).not.toHaveBeenCalled() + expect(navigateMock).not.toHaveBeenCalled() + expect(clearRefreshTokenMock).not.toHaveBeenCalled() + expect(clearSessionMock).not.toHaveBeenCalled() + expect(initCSRFTokenMock).toHaveBeenCalledTimes(1) + expect(screen.getByTestId('authenticated')).toHaveTextContent('true') + expect(screen.getByTestId('username')).toHaveTextContent('admin') + expect(screen.getByTestId('roles')).toHaveTextContent('admin') + }) + + it('clears the local session when auth state has no current user and no backend session cookie exists', async () => { + storedAccessToken = 'dangling-access-token' + isAuthenticatedMock.mockReturnValue(true) + isAccessTokenExpiredMock.mockReturnValue(false) + getCurrentUserMock.mockReturnValue(null) + + renderAuthProvider() + await waitForProviderIdle() + + expect(refreshSessionMock).not.toHaveBeenCalled() + expect(clearRefreshTokenMock).toHaveBeenCalledTimes(1) + expect(clearSessionMock).toHaveBeenCalledTimes(1) + expect(screen.getByTestId('authenticated')).toHaveTextContent('false') + expect(screen.getByTestId('username').textContent).toBe('') + }) + + it('restores the session by refreshing through the backend cookie flow', async () => { + hasSessionPresenceCookieMock.mockReturnValue(true) + refreshSessionMock.mockResolvedValue(refreshedSession) + getMock.mockResolvedValue(adminRoles) + + renderAuthProvider() + + await waitFor(() => { + expect(refreshSessionMock).toHaveBeenCalledTimes(1) + }) + + await waitForProviderIdle() + + expect(setAccessTokenMock).toHaveBeenCalledWith('access-token', 7200) + expect(setRefreshTokenMock).toHaveBeenCalledWith('refresh-token') + expect(setCurrentUserMock).toHaveBeenCalledWith(adminUser) + expect(setCurrentRolesMock).toHaveBeenCalledWith(adminRoles) + expect(getMock).toHaveBeenCalledWith('/users/1/roles') + expect(initCSRFTokenMock).toHaveBeenCalledTimes(1) + expect(navigateMock).not.toHaveBeenCalled() + expect(screen.getByTestId('username')).toHaveTextContent('admin') + expect(screen.getByTestId('roles')).toHaveTextContent('admin') + }) + + it('restores the session with empty roles when the role lookup fails after refresh', async () => { + hasSessionPresenceCookieMock.mockReturnValue(true) + refreshSessionMock.mockResolvedValue(refreshedSession) + getMock.mockRejectedValue(new Error('roles lookup failed')) + + renderAuthProvider() + await waitForProviderIdle() + + expect(setAccessTokenMock).toHaveBeenCalledWith('access-token', 7200) + expect(setRefreshTokenMock).toHaveBeenCalledWith('refresh-token') + expect(setCurrentUserMock).toHaveBeenCalledWith(adminUser) + expect(setCurrentRolesMock).toHaveBeenCalledWith([]) + expect(initCSRFTokenMock).toHaveBeenCalledTimes(1) + expect(screen.getByTestId('authenticated')).toHaveTextContent('true') + expect(screen.getByTestId('username')).toHaveTextContent('admin') + expect(screen.getByTestId('roles').textContent).toBe('') + }) + + it('clears local state when refresh fails against the backend cookie flow', async () => { + hasSessionPresenceCookieMock.mockReturnValue(true) + refreshSessionMock.mockRejectedValue(new Error('missing refresh cookie')) + + renderAuthProvider() + + await waitFor(() => { + expect(refreshSessionMock).toHaveBeenCalledTimes(1) + }) + + await waitForProviderIdle() + + expect(clearRefreshTokenMock).toHaveBeenCalledTimes(1) + expect(clearSessionMock).toHaveBeenCalledTimes(1) + expect(navigateMock).not.toHaveBeenCalled() + expect(screen.getByTestId('authenticated')).toHaveTextContent('false') + }) + + it('persists tokens, user, roles, and csrf state after login success', async () => { + renderAuthProvider() + await waitForProviderIdle() + vi.clearAllMocks() + + getMock.mockResolvedValue(adminRoles) + fireEvent.click(screen.getByRole('button', { name: 'login-success' })) + + await waitFor(() => { + expect(setAccessTokenMock).toHaveBeenCalledWith('login-access-token', 3600) + }) + + expect(setRefreshTokenMock).toHaveBeenCalledWith('login-refresh-token') + expect(setCurrentUserMock).toHaveBeenCalledWith(adminUser) + expect(setCurrentRolesMock).toHaveBeenCalledWith(adminRoles) + expect(getMock).toHaveBeenCalledWith('/users/1/roles') + expect(initCSRFTokenMock).toHaveBeenCalledTimes(1) + expect(screen.getByTestId('authenticated')).toHaveTextContent('true') + expect(screen.getByTestId('username')).toHaveTextContent('admin') + expect(screen.getByTestId('roles')).toHaveTextContent('admin') + }) + + it('refreshes the current user and roles from the backend', async () => { + renderAuthProvider() + await waitForProviderIdle() + vi.clearAllMocks() + + getMock.mockImplementation((path: string) => { + if (path === '/auth/userinfo') { + return Promise.resolve(operatorUser) + } + if (path === '/users/2/roles') { + return Promise.resolve(operatorRoles) + } + return Promise.reject(new Error(`Unexpected path: ${path}`)) + }) + + fireEvent.click(screen.getByRole('button', { name: 'refresh-user' })) + + await waitFor(() => { + expect(setCurrentUserMock).toHaveBeenCalledWith(operatorUser) + }) + + expect(setCurrentRolesMock).toHaveBeenCalledWith(operatorRoles) + expect(screen.getByTestId('username')).toHaveTextContent('operator') + expect(screen.getByTestId('roles')).toHaveTextContent('operator') + }) + + it('logs refreshUser failures without corrupting the current auth state', async () => { + storedAccessToken = 'cached-access-token' + storedUser = adminUser + storedRoles = adminRoles + isAccessTokenExpiredMock.mockReturnValue(false) + + renderAuthProvider() + await waitForProviderIdle() + vi.clearAllMocks() + + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + getMock.mockRejectedValue(new Error('userinfo failed')) + + fireEvent.click(screen.getByRole('button', { name: 'refresh-user' })) + + await waitFor(() => { + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Failed to refresh user info:', + expect.any(Error), + ) + }) + + expect(screen.getByTestId('username')).toHaveTextContent('admin') + expect(screen.getByTestId('roles')).toHaveTextContent('admin') + consoleErrorSpy.mockRestore() + }) + + it('clears the local session and navigates to login when logout succeeds', async () => { + storedAccessToken = 'cached-access-token' + storedUser = adminUser + storedRoles = adminRoles + isAccessTokenExpiredMock.mockReturnValue(false) + + renderAuthProvider() + await waitForProviderIdle() + vi.clearAllMocks() + + fireEvent.click(screen.getByRole('button', { name: 'logout' })) + + await waitFor(() => { + expect(logoutRequestMock).toHaveBeenCalledTimes(1) + }) + + expect(clearRefreshTokenMock).toHaveBeenCalledTimes(1) + expect(clearSessionMock).toHaveBeenCalledTimes(1) + expect(clearCSRFTokenMock).toHaveBeenCalledTimes(1) + expect(navigateMock).toHaveBeenCalledWith('/login') + expect(screen.getByTestId('authenticated')).toHaveTextContent('false') + expect(screen.getByTestId('username').textContent).toBe('') + }) + + it('clears the local session and navigates to login when logout fails', async () => { + storedAccessToken = 'cached-access-token' + storedUser = adminUser + storedRoles = adminRoles + isAccessTokenExpiredMock.mockReturnValue(false) + logoutRequestMock.mockRejectedValue(new Error('logout failed')) + + renderAuthProvider() + await waitForProviderIdle() + vi.clearAllMocks() + + logoutRequestMock.mockRejectedValue(new Error('logout failed')) + fireEvent.click(screen.getByRole('button', { name: 'logout' })) + + await waitFor(() => { + expect(logoutRequestMock).toHaveBeenCalledTimes(1) + }) + + expect(clearRefreshTokenMock).toHaveBeenCalledTimes(1) + expect(clearSessionMock).toHaveBeenCalledTimes(1) + expect(clearCSRFTokenMock).toHaveBeenCalledTimes(1) + expect(navigateMock).toHaveBeenCalledWith('/login') + expect(screen.getByTestId('authenticated')).toHaveTextContent('false') + expect(screen.getByTestId('username').textContent).toBe('') + }) +}) diff --git a/frontend/admin/src/app/providers/AuthProvider.tsx b/frontend/admin/src/app/providers/AuthProvider.tsx new file mode 100644 index 0000000..64a57f1 --- /dev/null +++ b/frontend/admin/src/app/providers/AuthProvider.tsx @@ -0,0 +1,201 @@ +/** + * AuthProvider - 全局会话上下文 + * + * 提供: + * - 会话状态(user, roles, isAdmin) + * - 登录/登出方法 + * - 会话恢复(启动时自动刷新) + */ + +import { + useEffect, + useState, + useCallback, + type ReactNode, +} from 'react' +import { useNavigate } from 'react-router-dom' +import type { SessionUser, Role, TokenBundle } from '@/types' +import { get } from '@/lib/http' +import { + setRefreshToken, + clearRefreshToken, + hasSessionPresenceCookie, +} from '@/lib/storage' +import { + setAccessToken, + getCurrentUser, + setCurrentUser, + getCurrentRoles, + setCurrentRoles, + clearSession, + isAuthenticated, + isAccessTokenExpired, +} from '@/lib/http/auth-session' +import { initCSRFToken, clearCSRFToken } from '@/lib/http/csrf' +import { logout as logoutRequest, refreshSession } from '@/services/auth' +import { AuthContext, type AuthContextValue } from './auth-context' + +// ==================== Provider ==================== + +interface AuthProviderProps { + children: ReactNode +} + +export function AuthProvider({ children }: AuthProviderProps) { + const [user, setUser] = useState(getCurrentUser()) + const [roles, setRoles] = useState(getCurrentRoles()) + const [isLoading, setIsLoading] = useState(true) + const navigate = useNavigate() + const effectiveUser = user ?? getCurrentUser() + const effectiveRoles = roles.length > 0 ? roles : getCurrentRoles() + + // 判断是否为管理员 + const isAdmin = effectiveRoles.some((role) => role.code === 'admin') + + /** + * 获取用户角色 + */ + const fetchUserRoles = useCallback(async (userId: number): Promise => { + try { + const result = await get(`/users/${userId}/roles`) + return result + } catch { + return [] + } + }, []) + + /** + * 登录成功回调 + */ + const onLoginSuccess = useCallback(async (tokenBundle: TokenBundle) => { + // 保存 tokens + setAccessToken(tokenBundle.access_token, tokenBundle.expires_in) + setRefreshToken(tokenBundle.refresh_token) + + // 保存用户信息 + setCurrentUser(tokenBundle.user) + setUser(tokenBundle.user) + + // 获取角色 + const userRoles = await fetchUserRoles(tokenBundle.user.id) + setCurrentRoles(userRoles) + setRoles(userRoles) + + // 初始化 CSRF Token + await initCSRFToken() + }, [fetchUserRoles]) + + /** + * 刷新用户信息 + */ + const refreshUser = useCallback(async () => { + try { + const userInfo = await get('/auth/userinfo') + setCurrentUser(userInfo) + setUser(userInfo) + + const userRoles = await fetchUserRoles(userInfo.id) + setCurrentRoles(userRoles) + setRoles(userRoles) + } catch { + // 刷新失败,清除会话 + setUser(null) + setRoles([]) + } + }, [fetchUserRoles]) + + /** + * 登出 + */ + const logout = useCallback(async () => { + try { + await logoutRequest() + } catch { + // 忽略登出请求错误 + } finally { + // 无论请求成功与否,都清除本地会话和 CSRF Token + clearRefreshToken() + clearSession() + clearCSRFToken() + setUser(null) + setRoles([]) + navigate('/login') + } + }, [navigate]) + + /** + * 会话恢复(应用启动时,只运行一次) + */ + useEffect(() => { + const restoreSession = async () => { + // 如果已有 access_token 且未过期,直接使用 + if (isAuthenticated() && !isAccessTokenExpired()) { + const currentUser = getCurrentUser() + const currentRoles = getCurrentRoles() + + if (currentUser) { + setUser(currentUser) + setRoles(currentRoles) + await initCSRFToken() + setIsLoading(false) + return + } + } + + if (!hasSessionPresenceCookie()) { + clearRefreshToken() + clearSession() + setUser(null) + setRoles([]) + setIsLoading(false) + return + } + + try { + const result = await refreshSession() + + // 保存 tokens + setAccessToken(result.access_token, result.expires_in) + setRefreshToken(result.refresh_token) + + // 保存用户信息 + setCurrentUser(result.user) + setUser(result.user) + + // 获取角色 + const userRoles = await fetchUserRoles(result.user.id) + setCurrentRoles(userRoles) + setRoles(userRoles) + await initCSRFToken() + } catch { + // 刷新失败,清除会话 + clearRefreshToken() + clearSession() + setUser(null) + setRoles([]) + } + + setIsLoading(false) + } + + restoreSession() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) // 只在挂载时运行一次,不依赖 location.pathname + + const value: AuthContextValue = { + user: effectiveUser, + roles: effectiveRoles, + isAdmin, + isAuthenticated: effectiveUser !== null && isAuthenticated(), + isLoading, + onLoginSuccess, + logout, + refreshUser, + } + + return ( + + {children} + + ) +} diff --git a/frontend/admin/src/app/providers/ThemeProvider.test.tsx b/frontend/admin/src/app/providers/ThemeProvider.test.tsx new file mode 100644 index 0000000..0521236 --- /dev/null +++ b/frontend/admin/src/app/providers/ThemeProvider.test.tsx @@ -0,0 +1,65 @@ +import type { ReactNode } from 'react' +import { render, screen } from '@testing-library/react' +import zhCN from 'antd/locale/zh_CN' +import { describe, expect, it, vi } from 'vitest' + +import { ThemeProvider } from './ThemeProvider' + +const configProviderMock = vi.fn( + ({ children }: { children: ReactNode }) =>
{children}
, +) + +vi.mock('antd', async () => { + const actual = await vi.importActual('antd') + return { + ...actual, + ConfigProvider: (props: { children: ReactNode; theme: unknown; locale: unknown }) => configProviderMock(props), + } +}) + +describe('ThemeProvider', () => { + it('passes the theme tokens and locale to ConfigProvider', () => { + render( + +
theme child
+
, + ) + + expect(screen.getByTestId('config-provider')).toBeInTheDocument() + expect(screen.getByText('theme child')).toBeInTheDocument() + expect(configProviderMock).toHaveBeenCalledWith( + expect.objectContaining({ + locale: zhCN, + theme: expect.objectContaining({ + token: expect.objectContaining({ + colorPrimary: '#0e5a6a', + colorSuccess: '#217a5b', + colorWarning: '#b7791f', + colorError: '#b33a3a', + colorInfo: '#2d6a9f', + fontFamily: '"IBM Plex Sans", "PingFang SC", "Microsoft YaHei", sans-serif', + borderRadius: 10, + }), + components: expect.objectContaining({ + Table: expect.objectContaining({ + headerBg: '#f8f5ef', + borderColor: '#d6d0c3', + }), + Card: expect.objectContaining({ + borderRadiusLG: 16, + }), + Button: expect.objectContaining({ + controlHeightLG: 44, + }), + Input: expect.objectContaining({ + controlHeight: 36, + }), + Select: expect.objectContaining({ + controlHeight: 36, + }), + }), + }), + }), + ) + }) +}) diff --git a/frontend/admin/src/app/providers/ThemeProvider.tsx b/frontend/admin/src/app/providers/ThemeProvider.tsx new file mode 100644 index 0000000..4027e68 --- /dev/null +++ b/frontend/admin/src/app/providers/ThemeProvider.tsx @@ -0,0 +1,89 @@ +/** + * 主题配置 Provider + * 使用 Ant Design 5 的 ConfigProvider 覆盖主题 Token + */ + +import { ConfigProvider, type ThemeConfig } from 'antd' +import zhCN from 'antd/locale/zh_CN' +import type { ReactNode } from 'react' + +/** + * Ant Design 主题配置 + * 基于 Mineral Console 视觉方向 + */ +const themeConfig: ThemeConfig = { + token: { + // 主色 + colorPrimary: '#0e5a6a', + colorPrimaryHover: '#0a4b59', + colorPrimaryActive: '#083d4a', + + // 成功色 + colorSuccess: '#217a5b', + + // 警告色 + colorWarning: '#b7791f', + + // 错误色 + colorError: '#b33a3a', + + // 信息色 + colorInfo: '#2d6a9f', + + // 字体 + fontFamily: '"IBM Plex Sans", "PingFang SC", "Microsoft YaHei", sans-serif', + fontSize: 14, + + // 圆角 + borderRadius: 10, + borderRadiusLG: 16, + borderRadiusSM: 6, + + // 链接 + colorLink: '#0e5a6a', + colorLinkHover: '#0a4b59', + colorLinkActive: '#083d4a', + }, + components: { + // 表格组件定制 + Table: { + headerBg: '#f8f5ef', + borderColor: '#d6d0c3', + rowHoverBg: 'rgba(14, 90, 106, 0.04)', + }, + // 卡片组件定制 + Card: { + borderRadiusLG: 16, + boxShadowTertiary: '0 10px 30px rgba(23, 33, 43, 0.06)', + }, + // 按钮组件定制 + Button: { + borderRadius: 10, + controlHeight: 36, + controlHeightLG: 44, + controlHeightSM: 28, + }, + // 输入框组件定制 + Input: { + borderRadius: 10, + controlHeight: 36, + }, + // 选择器组件定制 + Select: { + borderRadius: 10, + controlHeight: 36, + }, + }, +} + +interface ThemeProviderProps { + children: ReactNode +} + +export function ThemeProvider({ children }: ThemeProviderProps) { + return ( + + {children} + + ) +} diff --git a/frontend/admin/src/app/providers/auth-context.ts b/frontend/admin/src/app/providers/auth-context.ts new file mode 100644 index 0000000..6237751 --- /dev/null +++ b/frontend/admin/src/app/providers/auth-context.ts @@ -0,0 +1,24 @@ +import { createContext, useContext } from 'react' +import type { Role, SessionUser, TokenBundle } from '@/types' + +export interface AuthContextValue { + user: SessionUser | null + roles: Role[] + isAdmin: boolean + isAuthenticated: boolean + isLoading: boolean + onLoginSuccess: (tokenBundle: TokenBundle) => Promise + logout: () => Promise + refreshUser: () => Promise +} + +export const AuthContext = createContext(null) + +export function useAuth(): AuthContextValue { + const context = useContext(AuthContext) + if (!context) { + throw new Error('useAuth must be used within an AuthProvider') + } + + return context +} diff --git a/frontend/admin/src/app/router.test.tsx b/frontend/admin/src/app/router.test.tsx new file mode 100644 index 0000000..e81bad3 --- /dev/null +++ b/frontend/admin/src/app/router.test.tsx @@ -0,0 +1,210 @@ +import { createElement, type ComponentType, type ReactElement, type ReactNode } from 'react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Navigate, type RouteObject } from 'react-router-dom' + +type PageDefinition = { + exportName: string + routePath: string + requireAdmin?: boolean +} + +type RouterFixture = { + lazyPage: typeof import('./router').lazyPage + router: { routes: RouteObject[] } +} + +type LazyType = { + _init: (payload: unknown) => unknown + _payload: unknown +} + +const publicPages: PageDefinition[] = [ + { routePath: '/login', exportName: 'LoginPage' }, + { routePath: '/register', exportName: 'RegisterPage' }, + { routePath: '/bootstrap-admin', exportName: 'BootstrapAdminPage' }, + { routePath: '/activate-account', exportName: 'ActivateAccountPage' }, + { routePath: '/login/oauth/callback', exportName: 'OAuthCallbackPage' }, + { routePath: '/forgot-password', exportName: 'ForgotPasswordPage' }, + { routePath: '/reset-password', exportName: 'ResetPasswordPage' }, +] + +const protectedPages: PageDefinition[] = [ + { routePath: 'dashboard', exportName: 'DashboardPage', requireAdmin: true }, + { routePath: 'users', exportName: 'UsersPage', requireAdmin: true }, + { routePath: 'roles', exportName: 'RolesPage', requireAdmin: true }, + { routePath: 'permissions', exportName: 'PermissionsPage', requireAdmin: true }, + { routePath: 'logs/login', exportName: 'LoginLogsPage', requireAdmin: true }, + { routePath: 'logs/operation', exportName: 'OperationLogsPage', requireAdmin: true }, + { routePath: 'webhooks', exportName: 'WebhooksPage' }, + { routePath: 'import-export', exportName: 'ImportExportPage', requireAdmin: true }, + { routePath: 'profile', exportName: 'ProfilePage' }, + { routePath: 'profile/security', exportName: 'ProfileSecurityPage' }, +] + +const resetModulePaths = ['./router', 'react-router-dom'] + +afterEach(() => { + vi.clearAllMocks() + for (const modulePath of resetModulePaths) { + vi.doUnmock(modulePath) + } + vi.resetModules() +}) + +function asRouteObject(route: unknown): RouteObject { + return route as RouteObject +} + +function asElement(node: ReactNode | undefined): ReactElement<{ children?: ReactNode }> | null { + return node && typeof node === 'object' && 'type' in node ? (node as ReactElement<{ children?: ReactNode }>) : null +} + +function asLazyType(type: unknown): LazyType { + return type as LazyType +} + +function getComponentName(type: unknown): string | undefined { + return typeof type === 'function' ? type.name : undefined +} + +function getRouteByPath(routes: RouteObject[], path: string): RouteObject { + const route = routes.find((candidate) => candidate.path === path) + expect(route).toBeDefined() + return route as RouteObject +} + +function expectLazyElement(node: ReactNode | undefined): LazyType { + const element = asElement(node) + expect(element).not.toBeNull() + + const lazyType = asLazyType(element?.type) + expect(typeof lazyType).toBe('object') + expect(typeof lazyType._init).toBe('function') + + return lazyType +} + +async function resolveLazyType(lazyType: LazyType): Promise> { + for (;;) { + try { + return lazyType._init(lazyType._payload) as ComponentType + } catch (thrown) { + if (thrown && typeof (thrown as PromiseLike).then === 'function') { + await thrown + continue + } + + throw thrown + } + } +} + +async function expectResolvedLazyName(node: ReactNode | undefined, expectedName: string) { + const resolved = await resolveLazyType(expectLazyElement(node)) + expect(resolved.name).toBe(expectedName) +} + +async function loadRouterFixture(): Promise { + for (const modulePath of resetModulePaths) { + vi.doUnmock(modulePath) + } + vi.resetModules() + + const module = await import('./router') + + return { + lazyPage: module.lazyPage, + router: module.router as { routes: RouteObject[] }, + } +} + +describe('router', () => { + it('maps each route to the expected shell and lazy page component shape', async () => { + const { router } = await loadRouterFixture() + const rootRoute = asRouteObject(router.routes[0]) + const rootChildren = (rootRoute.children ?? []).map(asRouteObject) + + expect(getComponentName(asElement(rootRoute.element)?.type)).toBe('RootLayout') + expect( + rootChildren + .map((route) => route.path) + .filter((path): path is string => Boolean(path)), + ).toEqual([ + '/login', + '/register', + '/bootstrap-admin', + '/activate-account', + '/login/oauth/callback', + '/forgot-password', + '/reset-password', + '/', + '*', + ]) + + for (const page of publicPages) { + const route = getRouteByPath(rootChildren, page.routePath) + await expectResolvedLazyName(route.element, page.exportName) + } + + const protectedRoute = getRouteByPath(rootChildren, '/') + const protectedElement = asElement(protectedRoute.element) + const protectedChildren = (protectedRoute.children ?? []).map(asRouteObject) + + expect(getComponentName(protectedElement?.type)).toBe('RequireAuth') + expect(getComponentName(asElement(protectedElement?.props.children)?.type)).toBe('AdminLayout') + expect( + protectedChildren + .map((route) => route.path) + .filter((path): path is string => Boolean(path)), + ).toEqual([ + 'dashboard', + 'users', + 'roles', + 'permissions', + 'logs/login', + 'logs/operation', + 'webhooks', + 'import-export', + 'profile', + 'profile/security', + ]) + + const indexRoute = protectedChildren.find((route) => route.index) + const indexElement = asElement(indexRoute?.element) + + expect(indexRoute).toBeDefined() + expect(indexElement?.type).toBe(Navigate) + expect(indexElement?.props).toEqual(expect.objectContaining({ to: '/dashboard', replace: true })) + + for (const page of protectedPages.filter((candidate) => candidate.requireAdmin)) { + const route = getRouteByPath(protectedChildren, page.routePath) + const element = asElement(route.element) + + expect(getComponentName(element?.type)).toBe('RequireAdmin') + await expectResolvedLazyName(element?.props.children, page.exportName) + } + + for (const page of protectedPages.filter((candidate) => !candidate.requireAdmin)) { + const route = getRouteByPath(protectedChildren, page.routePath) + + expect(getComponentName(asElement(route.element)?.type)).not.toBe('RequireAdmin') + await expectResolvedLazyName(route.element, page.exportName) + } + + const notFoundRoute = getRouteByPath(rootChildren, '*') + await expectResolvedLazyName(notFoundRoute.element, 'NotFoundPage') + }) + + it('resolves valid lazy exports and rejects invalid lazy exports clearly', async () => { + const { lazyPage } = await loadRouterFixture() + + const LoginPage: ComponentType = () => null + const validLazyPage = lazyPage(async () => ({ LoginPage }), 'LoginPage') + const invalidLazyPage = lazyPage(async () => ({ LoginPage: 'not-a-component' }), 'LoginPage') + + await expect(resolveLazyType(expectLazyElement(createElement(validLazyPage)))).resolves.toBe(LoginPage) + await expect(resolveLazyType(expectLazyElement(createElement(invalidLazyPage)))).rejects.toThrow( + 'lazy route export "LoginPage" is not a React component', + ) + }) +}) diff --git a/frontend/admin/src/app/router.tsx b/frontend/admin/src/app/router.tsx new file mode 100644 index 0000000..46b6736 --- /dev/null +++ b/frontend/admin/src/app/router.tsx @@ -0,0 +1,223 @@ +/** + * 应用路由配置 + * + * 路由结构: + * - /login - 登录页(公开) + * - /forgot-password - 忘记密码(公开) + * - /reset-password - 重置密码(公开) + * - /dashboard - 总览(需登录) + * - /users - 用户管理(需管理员) + * - /roles - 角色管理(需管理员) + * - /permissions - 权限管理(需管理员) + * - /logs/login - 登录日志(需管理员) + * - /logs/operation - 操作日志(需管理员) + * - /webhooks - Webhooks(需登录) + * - /import-export - 导入导出(需管理员) + * - /profile - 个人资料(需登录) + * - /profile/security - 安全设置(需登录) + */ + +import { createElement, lazy, type ComponentType, type LazyExoticComponent } from 'react' +import { createBrowserRouter, Navigate } from 'react-router-dom' +import { AdminLayout } from '@/layouts' +import { RootLayout } from './RootLayout' +// 路由守卫 +import { RequireAuth, RequireAdmin } from '@/components/guards' + +export function lazyPage>( + loader: () => Promise>, + exportName: string, +): LazyExoticComponent { + return lazy(async () => { + const module = await loader() + const component = module[exportName] + if (typeof component !== 'function') { + throw new Error(`lazy route export "${exportName}" is not a React component`) + } + return { default: component as T } + }) +} + +function renderLazy>(Component: LazyExoticComponent) { + return createElement(Component) +} + +const LoginPage = lazyPage(() => import('@/pages/auth/LoginPage'), 'LoginPage') +const RegisterPage = lazyPage(() => import('@/pages/auth/RegisterPage'), 'RegisterPage') +const BootstrapAdminPage = lazyPage(() => import('@/pages/auth/BootstrapAdminPage'), 'BootstrapAdminPage') +const ActivateAccountPage = lazyPage(() => import('@/pages/auth/ActivateAccountPage'), 'ActivateAccountPage') +const OAuthCallbackPage = lazyPage(() => import('@/pages/auth/OAuthCallbackPage'), 'OAuthCallbackPage') +const ForgotPasswordPage = lazyPage(() => import('@/pages/auth/ForgotPasswordPage'), 'ForgotPasswordPage') +const ResetPasswordPage = lazyPage(() => import('@/pages/auth/ResetPasswordPage'), 'ResetPasswordPage') + +const DashboardPage = lazyPage(() => import('@/pages/admin/DashboardPage'), 'DashboardPage') +const UsersPage = lazyPage(() => import('@/pages/admin/UsersPage'), 'UsersPage') +const DevicesPage = lazyPage(() => import('@/pages/admin/DevicesPage'), 'DevicesPage') +const RolesPage = lazyPage(() => import('@/pages/admin/RolesPage'), 'RolesPage') +const PermissionsPage = lazyPage(() => import('@/pages/admin/PermissionsPage'), 'PermissionsPage') +const LoginLogsPage = lazyPage(() => import('@/pages/admin/LoginLogsPage'), 'LoginLogsPage') +const OperationLogsPage = lazyPage(() => import('@/pages/admin/OperationLogsPage'), 'OperationLogsPage') +const WebhooksPage = lazyPage(() => import('@/pages/admin/WebhooksPage'), 'WebhooksPage') +const ImportExportPage = lazyPage(() => import('@/pages/admin/ImportExportPage'), 'ImportExportPage') +const SettingsPage = lazyPage(() => import('@/pages/admin/SettingsPage'), 'SettingsPage') +const ProfilePage = lazyPage(() => import('@/pages/admin/ProfilePage'), 'ProfilePage') +const ProfileSecurityPage = lazyPage(() => import('@/pages/admin/ProfileSecurityPage'), 'ProfileSecurityPage') +const NotFoundPage = lazyPage(() => import('@/pages/NotFoundPage'), 'NotFoundPage') + +export const router = createBrowserRouter( + [ + // 根布局 - 提供 AuthProvider 上下文 + { + element: , + children: [ + // 公开路由 - 认证页 + { + path: '/login', + element: renderLazy(LoginPage), + }, + { + path: '/register', + element: renderLazy(RegisterPage), + }, + { + path: '/bootstrap-admin', + element: renderLazy(BootstrapAdminPage), + }, + { + path: '/activate-account', + element: renderLazy(ActivateAccountPage), + }, + { + path: '/login/oauth/callback', + element: renderLazy(OAuthCallbackPage), + }, + { + path: '/forgot-password', + element: renderLazy(ForgotPasswordPage), + }, + { + path: '/reset-password', + element: renderLazy(ResetPasswordPage), + }, + + // 受保护路由 - 管理后台 + { + path: '/', + element: ( + + + + ), + children: [ + // 默认跳转到 Dashboard + { + index: true, + element: , + }, + + // Dashboard - 需要登录 + { + path: 'dashboard', + element: ( + + {renderLazy(DashboardPage)} + + ), + }, + + // 管理功能 - 需要管理员权限 + { + path: 'users', + element: ( + + {renderLazy(UsersPage)} + + ), + }, + { + path: 'devices', + element: ( + + {renderLazy(DevicesPage)} + + ), + }, + { + path: 'roles', + element: ( + + {renderLazy(RolesPage)} + + ), + }, + { + path: 'permissions', + element: ( + + {renderLazy(PermissionsPage)} + + ), + }, + + // 日志 - 需要管理员权限 + { + path: 'logs/login', + element: ( + + {renderLazy(LoginLogsPage)} + + ), + }, + { + path: 'logs/operation', + element: ( + + {renderLazy(OperationLogsPage)} + + ), + }, + + // 集成能力 + { + path: 'webhooks', + element: renderLazy(WebhooksPage), + }, + { + path: 'import-export', + element: ( + + {renderLazy(ImportExportPage)} + + ), + }, + { + path: 'settings', + element: ( + + {renderLazy(SettingsPage)} + + ), + }, + + // 个人中心 + { + path: 'profile', + element: renderLazy(ProfilePage), + }, + { + path: 'profile/security', + element: renderLazy(ProfileSecurityPage), + }, + ], + }, + + // 404 + { + path: '*', + element: renderLazy(NotFoundPage), + }, + ], + }, + ], +) + diff --git a/frontend/admin/src/assets/hero.png b/frontend/admin/src/assets/hero.png new file mode 100644 index 0000000..cc51a3d Binary files /dev/null and b/frontend/admin/src/assets/hero.png differ diff --git a/frontend/admin/src/assets/react.svg b/frontend/admin/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/frontend/admin/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/admin/src/assets/vite.svg b/frontend/admin/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/frontend/admin/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/frontend/admin/src/components/common/ErrorBoundary/ErrorBoundary.test.tsx b/frontend/admin/src/components/common/ErrorBoundary/ErrorBoundary.test.tsx new file mode 100644 index 0000000..9137483 --- /dev/null +++ b/frontend/admin/src/components/common/ErrorBoundary/ErrorBoundary.test.tsx @@ -0,0 +1,80 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { ErrorBoundary } from './ErrorBoundary' + +function ThrowingChild(): never { + throw new Error('boom') +} + +function suppressBoundaryError() { + const handler = (event: ErrorEvent) => { + if (event.error instanceof Error && event.error.message === 'boom') { + event.preventDefault() + } + } + + window.addEventListener('error', handler) + return () => window.removeEventListener('error', handler) +} + +describe('ErrorBoundary', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('renders children when no error is thrown', () => { + render( + +
safe child
+
, + ) + + expect(screen.getByText('safe child')).toBeInTheDocument() + }) + + it('renders the provided fallback when a child throws', () => { + vi.spyOn(console, 'error').mockImplementation(() => undefined) + const cleanupErrorHandler = suppressBoundaryError() + + render( + custom fallback}> + + , + ) + + expect(screen.getByText('custom fallback')).toBeInTheDocument() + cleanupErrorHandler() + }) + + it('renders the default error state and resets to the root path', async () => { + const user = userEvent.setup() + vi.spyOn(console, 'error').mockImplementation(() => undefined) + const cleanupErrorHandler = suppressBoundaryError() + + const locationDescriptor = Object.getOwnPropertyDescriptor(window, 'location') + Object.defineProperty(window, 'location', { + configurable: true, + value: { href: '/current' }, + }) + + render( + + + , + ) + + expect(screen.getByText('页面出错了')).toBeInTheDocument() + expect(screen.getByText('boom')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: '刷新页面' })) + + expect(window.location.href).toBe('/') + cleanupErrorHandler() + + if (locationDescriptor) { + Object.defineProperty(window, 'location', locationDescriptor) + } + }) +}) diff --git a/frontend/admin/src/components/common/ErrorBoundary/ErrorBoundary.tsx b/frontend/admin/src/components/common/ErrorBoundary/ErrorBoundary.tsx new file mode 100644 index 0000000..09c7616 --- /dev/null +++ b/frontend/admin/src/components/common/ErrorBoundary/ErrorBoundary.tsx @@ -0,0 +1,70 @@ +/** + * ErrorBoundary - React 错误边界组件 + * 捕获子组件树中的 JavaScript 错误,记录错误并显示备用 UI + */ + +import { Component, type ReactNode, type ErrorInfo } from 'react' +import { Result, Button } from 'antd' + +interface Props { + children: ReactNode + fallback?: ReactNode +} + +interface State { + hasError: boolean + error: Error | null +} + +export class ErrorBoundary extends Component { + constructor(props: Props) { + super(props) + this.state = { hasError: false, error: null } + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error } + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + // 可以将错误日志上报给服务器 + console.error('ErrorBoundary caught an error:', error, errorInfo) + } + + handleReset = () => { + this.setState({ hasError: false, error: null }) + // 刷新页面 + window.location.href = '/' + } + + render() { + if (this.state.hasError) { + if (this.props.fallback) { + return this.props.fallback + } + + return ( +
+ + 刷新页面 + + } + /> +
+ ) + } + + return this.props.children + } +} diff --git a/frontend/admin/src/components/common/ErrorBoundary/index.ts b/frontend/admin/src/components/common/ErrorBoundary/index.ts new file mode 100644 index 0000000..97d788f --- /dev/null +++ b/frontend/admin/src/components/common/ErrorBoundary/index.ts @@ -0,0 +1 @@ +export { ErrorBoundary } from './ErrorBoundary' diff --git a/frontend/admin/src/components/common/PageHeader/PageHeader.module.css b/frontend/admin/src/components/common/PageHeader/PageHeader.module.css new file mode 100644 index 0000000..234793e --- /dev/null +++ b/frontend/admin/src/components/common/PageHeader/PageHeader.module.css @@ -0,0 +1,55 @@ +/** + * PageHeader 样式 + */ + +.container { + margin-bottom: 24px; +} + +.breadcrumb { + margin-bottom: 12px; +} + +.header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.titleArea { + flex: 1; + min-width: 0; +} + +.title { + margin: 0 !important; + font-size: 20px !important; + font-weight: 600 !important; + color: var(--color-text-strong) !important; +} + +.description { + margin: 4px 0 0 0 !important; + font-size: 14px; +} + +.actions { + flex-shrink: 0; +} + +.footer { + margin-top: 16px; +} + +/* 响应式 */ +@media (max-width: 768px) { + .header { + flex-direction: column; + align-items: stretch; + } + + .actions { + margin-top: 12px; + } +} diff --git a/frontend/admin/src/components/common/PageHeader/PageHeader.tsx b/frontend/admin/src/components/common/PageHeader/PageHeader.tsx new file mode 100644 index 0000000..13be8fd --- /dev/null +++ b/frontend/admin/src/components/common/PageHeader/PageHeader.tsx @@ -0,0 +1,65 @@ +/** + * PageHeader - 页面头部组件 + * + * 包含面包屑导航、页面标题、描述、操作按钮 + */ + +import { Breadcrumb, Typography, Space, type BreadcrumbProps } from 'antd' +import type { ReactNode } from 'react' +import styles from './PageHeader.module.css' + +const { Title, Paragraph } = Typography + +interface PageHeaderProps { + /** 面包屑项 */ + breadcrumb?: BreadcrumbProps['items'] + /** 页面标题 */ + title: string + /** 页面描述 */ + description?: string + /** 操作按钮区 */ + actions?: ReactNode + /** 底部额外内容 */ + footer?: ReactNode +} + +export function PageHeader({ + breadcrumb, + title, + description, + actions, + footer, +}: PageHeaderProps) { + return ( +
+ {breadcrumb && breadcrumb.length > 0 && ( + + )} + +
+
+ + {title} + + {description && ( + + {description} + + )} +
+ + {actions && ( + + {actions} + + )} +
+ + {footer && ( +
+ {footer} +
+ )} +
+ ) +} diff --git a/frontend/admin/src/components/common/PageHeader/index.ts b/frontend/admin/src/components/common/PageHeader/index.ts new file mode 100644 index 0000000..87250b5 --- /dev/null +++ b/frontend/admin/src/components/common/PageHeader/index.ts @@ -0,0 +1 @@ +export { PageHeader } from './PageHeader' diff --git a/frontend/admin/src/components/common/index.ts b/frontend/admin/src/components/common/index.ts new file mode 100644 index 0000000..991cf05 --- /dev/null +++ b/frontend/admin/src/components/common/index.ts @@ -0,0 +1,2 @@ +export { ErrorBoundary } from './ErrorBoundary' +export { PageHeader } from './PageHeader' diff --git a/frontend/admin/src/components/feedback/PageState/PageState.module.css b/frontend/admin/src/components/feedback/PageState/PageState.module.css new file mode 100644 index 0000000..df1d944 --- /dev/null +++ b/frontend/admin/src/components/feedback/PageState/PageState.module.css @@ -0,0 +1,45 @@ +/** + * PageState 样式 + */ + +.container { + display: flex; + align-items: center; + justify-content: center; + min-height: 200px; + padding: 48px 24px; +} + +.spinContent { + padding: 24px; +} + +.emptyContainer { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 200px; + padding: 48px 24px; +} + +.emptyIcon { + margin-bottom: 16px; + color: var(--color-text-muted); + font-size: 48px; +} + +.emptyText { + color: var(--color-text-muted); + font-size: 14px; + margin-bottom: 16px; +} + +.errorContainer { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 200px; + padding: 48px 24px; +} diff --git a/frontend/admin/src/components/feedback/PageState/PageState.test.tsx b/frontend/admin/src/components/feedback/PageState/PageState.test.tsx new file mode 100644 index 0000000..1add1f2 --- /dev/null +++ b/frontend/admin/src/components/feedback/PageState/PageState.test.tsx @@ -0,0 +1,151 @@ +import type { ReactNode } from 'react' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' + +import { PageEmpty, PageError, PageLoading } from './PageState' + +vi.mock('antd', () => ({ + Button: ({ + children, + onClick, + icon, + htmlType, + ...props + }: { + children?: ReactNode + onClick?: () => void + icon?: ReactNode + htmlType?: 'button' | 'submit' | 'reset' + [key: string]: unknown + }) => { + void icon + + return ( + + ) + }, + Empty: ({ + description, + children, + }: { + description?: ReactNode + children?: ReactNode + }) => ( +
+
{description}
+ {children} +
+ ), + Result: ({ + status, + title, + subTitle, + extra, + }: { + status?: string + title?: ReactNode + subTitle?: ReactNode + extra?: ReactNode | ReactNode[] + }) => ( +
+
{title}
+
{subTitle}
+
{extra}
+
+ ), + Spin: ({ + size, + tip, + children, + }: { + size?: string + tip?: ReactNode + children?: ReactNode + }) => ( +
+ {tip} + {children} +
+ ), +})) + +vi.mock('@ant-design/icons', () => ({ + PlusOutlined: () => plus-icon, + ReloadOutlined: () => reload-icon, +})) + +describe('PageState', () => { + it('renders PageLoading with both default and custom tips', () => { + render( + <> + + + , + ) + + expect(screen.getAllByTestId('spin')).toHaveLength(2) + expect(screen.getByText('loading-dashboard')).toBeInTheDocument() + }) + + it('renders PageEmpty without an action when the action handler is incomplete', () => { + render() + + expect(screen.getByTestId('empty-description')).toHaveTextContent('no data') + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) + + it('renders PageEmpty action button and invokes the handler when clicked', async () => { + const user = userEvent.setup() + const onAction = vi.fn() + + render( + , + ) + + const button = screen.getByRole('button', { name: 'add first item' }) + + expect(button).toHaveAttribute('data-action', 'create') + + await user.click(button) + + expect(onAction).toHaveBeenCalledTimes(1) + }) + + it('renders PageError defaults without a retry button when onRetry is absent', () => { + render() + + expect(screen.getByTestId('result')).toHaveAttribute('data-status', 'error') + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) + + it('renders PageError retry and extra actions when provided', async () => { + const user = userEvent.setup() + const onRetry = vi.fn() + + render( + contact support} + />, + ) + + expect(screen.getByText('load failed')).toBeInTheDocument() + expect(screen.getByText('service unavailable')).toBeInTheDocument() + expect(screen.getByText('contact support')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'retry now' })) + + expect(onRetry).toHaveBeenCalledTimes(1) + }) +}) diff --git a/frontend/admin/src/components/feedback/PageState/PageState.tsx b/frontend/admin/src/components/feedback/PageState/PageState.tsx new file mode 100644 index 0000000..a99061f --- /dev/null +++ b/frontend/admin/src/components/feedback/PageState/PageState.tsx @@ -0,0 +1,113 @@ +/** + * 页面状态组件 + * + * 提供: + * - PageLoading: 页面级加载状态 + * - PageEmpty: 页面级空状态 + * - PageError: 页面级错误状态 + */ + +import { Spin, Button, Result, Empty, type ButtonProps } from 'antd' +import { ReloadOutlined, PlusOutlined } from '@ant-design/icons' +import type { ReactNode } from 'react' +import styles from './PageState.module.css' + +// ==================== PageLoading ==================== + +interface PageLoadingProps { + /** 加载提示文字 */ + tip?: string +} + +export function PageLoading({ tip = '加载中...' }: PageLoadingProps) { + return ( +
+ +
+ +
+ ) +} + +// ==================== PageEmpty ==================== + +interface PageEmptyProps { + /** 空状态描述 */ + description?: string | ReactNode + /** 主操作按钮文字 */ + actionText?: string + /** 主操作按钮点击 */ + onAction?: () => void + /** 主操作按钮属性 */ + actionProps?: ButtonProps +} + +export function PageEmpty({ + description = '暂无数据', + actionText, + onAction, + actionProps, +}: PageEmptyProps) { + return ( +
+ + {actionText && onAction && ( + + )} + +
+ ) +} + +// ==================== PageError ==================== + +interface PageErrorProps { + /** 错误标题 */ + title?: string + /** 错误描述 */ + description?: string | ReactNode + /** 重试按钮文字 */ + retryText?: string + /** 重试按钮点击 */ + onRetry?: () => void + /** 额外操作 */ + extra?: ReactNode +} + +export function PageError({ + title = '加载失败', + description = '数据加载失败,请稍后重试', + retryText = '重新加载', + onRetry, + extra, +}: PageErrorProps) { + return ( +
+ } + onClick={onRetry} + > + {retryText} + + ), + extra, + ].filter(Boolean)} + /> +
+ ) +} diff --git a/frontend/admin/src/components/feedback/PageState/index.ts b/frontend/admin/src/components/feedback/PageState/index.ts new file mode 100644 index 0000000..fbb1504 --- /dev/null +++ b/frontend/admin/src/components/feedback/PageState/index.ts @@ -0,0 +1 @@ +export { PageLoading, PageEmpty, PageError } from './PageState' diff --git a/frontend/admin/src/components/feedback/index.ts b/frontend/admin/src/components/feedback/index.ts new file mode 100644 index 0000000..fbb1504 --- /dev/null +++ b/frontend/admin/src/components/feedback/index.ts @@ -0,0 +1 @@ +export { PageLoading, PageEmpty, PageError } from './PageState' diff --git a/frontend/admin/src/components/guards/RequireAdmin.tsx b/frontend/admin/src/components/guards/RequireAdmin.tsx new file mode 100644 index 0000000..2eda7c7 --- /dev/null +++ b/frontend/admin/src/components/guards/RequireAdmin.tsx @@ -0,0 +1,30 @@ +/** + * RequireAdmin - 管理员守卫 + * + * 非管理员时跳转到个人资料页。 + * 修复:加入 isLoading 检查,避免会话恢复期间误跳转。 + */ + +import { Navigate } from 'react-router-dom' +import { useAuth } from '@/app/providers/auth-context' +import type { ReactNode } from 'react' + +interface RequireAdminProps { + children: ReactNode +} + +export function RequireAdmin({ children }: RequireAdminProps) { + const { isAdmin, isLoading } = useAuth() + + // 会话恢复中,等待完成再判断 + if (isLoading) { + return null + } + + // 非管理员,跳转到个人资料页 + if (!isAdmin) { + return + } + + return children +} diff --git a/frontend/admin/src/components/guards/RequireAuth.tsx b/frontend/admin/src/components/guards/RequireAuth.tsx new file mode 100644 index 0000000..b351f53 --- /dev/null +++ b/frontend/admin/src/components/guards/RequireAuth.tsx @@ -0,0 +1,40 @@ +/** + * RequireAuth - 登录守卫 + * + * 未登录时跳转到登录页 + */ + +import { Navigate, useLocation } from 'react-router-dom' +import { useAuth } from '@/app/providers/auth-context' +import { Spin } from 'antd' +import type { ReactNode } from 'react' + +interface RequireAuthProps { + children: ReactNode +} + +export function RequireAuth({ children }: RequireAuthProps) { + const { isAuthenticated, isLoading } = useAuth() + const location = useLocation() + + // 加载中显示 loading + if (isLoading) { + return ( +
+ +
+ ) + } + + // 未登录,跳转到登录页 + if (!isAuthenticated) { + return + } + + return children +} diff --git a/frontend/admin/src/components/guards/guards.test.tsx b/frontend/admin/src/components/guards/guards.test.tsx new file mode 100644 index 0000000..7f1e00b --- /dev/null +++ b/frontend/admin/src/components/guards/guards.test.tsx @@ -0,0 +1,188 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom' +import type { ReactNode } from 'react' + +import { AuthContext, type AuthContextValue } from '@/app/providers/auth-context' +import { RequireAdmin } from './RequireAdmin' +import { RequireAuth } from './RequireAuth' + +const baseAuthContextValue: AuthContextValue = { + user: null, + roles: [], + isAdmin: false, + isAuthenticated: false, + isLoading: false, + onLoginSuccess: async () => {}, + logout: async () => {}, + refreshUser: async () => {}, +} + +function LocationProbe() { + const location = useLocation() + const fromPath = + (location.state as { from?: { pathname?: string } } | null)?.from?.pathname ?? 'none' + + return ( +
+ {location.pathname} + {fromPath} +
+ ) +} + +function renderWithAuth( + authContextValue: Partial, + router: ReactNode, +) { + const value: AuthContextValue = { + ...baseAuthContextValue, + ...authContextValue, + } + + return render( + + {router} + , + ) +} + +describe('RequireAuth', () => { + it('shows a loading indicator while auth state is being restored', () => { + const { container } = renderWithAuth( + { isLoading: true }, + + + +
private content
+ + )} + /> +
+
, + ) + + expect(container.querySelector('[aria-busy="true"]')).toBeInTheDocument() + expect(screen.queryByText('private content')).not.toBeInTheDocument() + }) + + it('redirects unauthenticated users to login and preserves the original route', async () => { + renderWithAuth( + { isAuthenticated: false, isLoading: false }, + + + +
private content
+ + )} + /> + } /> +
+
, + ) + + expect(await screen.findByTestId('pathname')).toHaveTextContent('/login') + expect(screen.getByTestId('from-path')).toHaveTextContent('/users') + }) + + it('renders protected content when authenticated', () => { + renderWithAuth( + { + isAuthenticated: true, + isLoading: false, + user: { + id: 1, + username: 'admin', + email: 'admin@example.com', + phone: '', + nickname: 'Admin', + avatar: '', + status: 1, + }, + }, + + + +
private content
+ + )} + /> +
+
, + ) + + expect(screen.getByText('private content')).toBeInTheDocument() + }) +}) + +describe('RequireAdmin', () => { + it('waits silently while auth state is still loading', () => { + const { container } = renderWithAuth( + { isLoading: true, isAdmin: false }, + + + +
admin dashboard
+ + )} + /> +
+
, + ) + + expect(container).toBeEmptyDOMElement() + }) + + it('redirects non-admin users to profile', async () => { + renderWithAuth( + { isLoading: false, isAdmin: false, isAuthenticated: true }, + + + +
admin dashboard
+ + )} + /> + } /> +
+
, + ) + + expect(await screen.findByTestId('pathname')).toHaveTextContent('/profile') + }) + + it('renders admin-only content for admins', () => { + renderWithAuth( + { isLoading: false, isAdmin: true, isAuthenticated: true }, + + + +
admin dashboard
+ + )} + /> +
+
, + ) + + expect(screen.getByText('admin dashboard')).toBeInTheDocument() + }) +}) diff --git a/frontend/admin/src/components/guards/index.ts b/frontend/admin/src/components/guards/index.ts new file mode 100644 index 0000000..5992ee4 --- /dev/null +++ b/frontend/admin/src/components/guards/index.ts @@ -0,0 +1,2 @@ +export { RequireAuth } from './RequireAuth' +export { RequireAdmin } from './RequireAdmin' diff --git a/frontend/admin/src/components/layout/PageLayout/ContentCard.tsx b/frontend/admin/src/components/layout/PageLayout/ContentCard.tsx new file mode 100644 index 0000000..ce8b002 --- /dev/null +++ b/frontend/admin/src/components/layout/PageLayout/ContentCard.tsx @@ -0,0 +1,29 @@ +/** + * 统一内容卡片组件 + * + * 功能: + * - 提供统一的内容展示区域样式 + * - 遵循 warm-elegant 设计主题 + */ + +import { Card } from 'antd' +import styles from './PageLayout.module.css' + +interface ContentCardProps { + children: React.ReactNode + className?: string + style?: React.CSSProperties + title?: React.ReactNode +} + +export function ContentCard({ children, className, style, title }: ContentCardProps) { + return ( + + {children} + + ) +} diff --git a/frontend/admin/src/components/layout/PageLayout/FilterCard.tsx b/frontend/admin/src/components/layout/PageLayout/FilterCard.tsx new file mode 100644 index 0000000..ccd0905 --- /dev/null +++ b/frontend/admin/src/components/layout/PageLayout/FilterCard.tsx @@ -0,0 +1,23 @@ +/** + * 统一筛选卡片组件 + * + * 功能: + * - 提供统一的筛选区域样式 + * - 遵循 warm-elegant 设计主题 + */ + +import { Card } from 'antd' +import styles from './PageLayout.module.css' + +interface FilterCardProps { + children: React.ReactNode + className?: string +} + +export function FilterCard({ children, className }: FilterCardProps) { + return ( + + {children} + + ) +} diff --git a/frontend/admin/src/components/layout/PageLayout/PageLayout.module.css b/frontend/admin/src/components/layout/PageLayout/PageLayout.module.css new file mode 100644 index 0000000..4065978 --- /dev/null +++ b/frontend/admin/src/components/layout/PageLayout/PageLayout.module.css @@ -0,0 +1,123 @@ +/** + * 统一页面布局样式 + * 遵循 warm-elegant 设计主题 + */ + +.pageLayout { + padding: var(--space-5, 24px); + max-width: var(--page-max-width, 1440px); + margin: 0 auto; + min-height: calc(100vh - 64px - 48px); /* 减去header和padding */ +} + +/* 筛选卡片样式 */ +.filterCard { + margin-bottom: var(--space-4, 16px); + border-radius: var(--radius-md, 16px) !important; + box-shadow: var(--shadow-card, 0 10px 30px rgba(23, 33, 43, 0.06)) !important; + background: var(--color-surface, #ffffff) !important; + border: none !important; +} + +.filterCard :global(.ant-card-body) { + padding: var(--space-4, 16px) var(--space-5, 24px) !important; +} + +/* 表格卡片样式 */ +.tableCard { + border-radius: var(--radius-md, 16px) !important; + box-shadow: var(--shadow-card, 0 10px 30px rgba(23, 33, 43, 0.06)) !important; + background: var(--color-surface, #ffffff) !important; + border: none !important; +} + +.tableCard :global(.ant-card-body) { + padding: 0 !important; +} + +.tableCard :global(.ant-table-wrapper) { + border-radius: var(--radius-md, 16px); + overflow: hidden; +} + +/* 树形卡片样式 */ +.treeCard { + border-radius: var(--radius-md, 16px) !important; + box-shadow: var(--shadow-card, 0 10px 30px rgba(23, 33, 43, 0.06)) !important; + background: var(--color-surface, #ffffff) !important; + border: none !important; +} + +.treeCard :global(.ant-card-body) { + padding: var(--space-5, 24px) !important; +} + +/* 内容卡片样式 */ +.contentCard { + border-radius: var(--radius-md, 16px) !important; + box-shadow: var(--shadow-card, 0 10px 30px rgba(23, 33, 43, 0.06)) !important; + background: var(--color-surface, #ffffff) !important; + border: none !important; +} + +.contentCard :global(.ant-card-body) { + padding: var(--space-5, 24px) !important; +} + +/* 操作栏样式 */ +.actionBar { + display: flex; + gap: var(--space-2, 8px); + align-items: center; +} + +/* 页面头部样式 */ +.pageHeader { + margin-bottom: var(--space-5, 24px); +} + +.pageHeaderTitle { + font-size: 24px; + font-weight: 600; + color: var(--color-text-strong, #17212b); + margin: 0 0 var(--space-2, 8px) 0; +} + +.pageHeaderDescription { + font-size: 14px; + color: var(--color-text-muted, #677380); + margin: 0; +} + +.pageHeaderActions { + margin-left: auto; +} + +/* 筛选表单样式 */ +.filterForm { + display: flex; + flex-wrap: wrap; + gap: var(--space-3, 12px); + align-items: center; +} + +/* 表格操作按钮统一样式 */ +.tableActionButton { + padding: 0 var(--space-1, 4px) !important; +} + +/* 响应式适配 */ +@media (max-width: 768px) { + .pageLayout { + padding: var(--space-3, 12px); + } + + .filterForm { + flex-direction: column; + align-items: stretch; + } + + .filterForm > * { + width: 100% !important; + } +} diff --git a/frontend/admin/src/components/layout/PageLayout/PageLayout.tsx b/frontend/admin/src/components/layout/PageLayout/PageLayout.tsx new file mode 100644 index 0000000..04d9720 --- /dev/null +++ b/frontend/admin/src/components/layout/PageLayout/PageLayout.tsx @@ -0,0 +1,22 @@ +/** + * 统一页面布局容器 + * + * 功能: + * - 提供统一的页面布局结构 + * - 遵循 warm-elegant 设计主题 + */ + +import styles from './PageLayout.module.css' + +interface PageLayoutProps { + children: React.ReactNode + className?: string +} + +export function PageLayout({ children, className }: PageLayoutProps) { + return ( +
+ {children} +
+ ) +} diff --git a/frontend/admin/src/components/layout/PageLayout/TableCard.tsx b/frontend/admin/src/components/layout/PageLayout/TableCard.tsx new file mode 100644 index 0000000..358c58e --- /dev/null +++ b/frontend/admin/src/components/layout/PageLayout/TableCard.tsx @@ -0,0 +1,23 @@ +/** + * 统一表格卡片组件 + * + * 功能: + * - 提供统一的表格区域样式 + * - 遵循 warm-elegant 设计主题 + */ + +import { Card } from 'antd' +import styles from './PageLayout.module.css' + +interface TableCardProps { + children: React.ReactNode + className?: string +} + +export function TableCard({ children, className }: TableCardProps) { + return ( + + {children} + + ) +} diff --git a/frontend/admin/src/components/layout/PageLayout/TreeCard.tsx b/frontend/admin/src/components/layout/PageLayout/TreeCard.tsx new file mode 100644 index 0000000..edffaa8 --- /dev/null +++ b/frontend/admin/src/components/layout/PageLayout/TreeCard.tsx @@ -0,0 +1,23 @@ +/** + * 统一树形卡片组件 + * + * 功能: + * - 提供统一的树形展示区域样式 + * - 遵循 warm-elegant 设计主题 + */ + +import { Card } from 'antd' +import styles from './PageLayout.module.css' + +interface TreeCardProps { + children: React.ReactNode + className?: string +} + +export function TreeCard({ children, className }: TreeCardProps) { + return ( + + {children} + + ) +} diff --git a/frontend/admin/src/components/layout/PageLayout/index.ts b/frontend/admin/src/components/layout/PageLayout/index.ts new file mode 100644 index 0000000..c7a3f1d --- /dev/null +++ b/frontend/admin/src/components/layout/PageLayout/index.ts @@ -0,0 +1,9 @@ +/** + * 统一页面布局组件导出 + */ + +export { PageLayout } from './PageLayout' +export { FilterCard } from './FilterCard' +export { TableCard } from './TableCard' +export { TreeCard } from './TreeCard' +export { ContentCard } from './ContentCard' diff --git a/frontend/admin/src/components/layout/index.ts b/frontend/admin/src/components/layout/index.ts new file mode 100644 index 0000000..0e38e9a --- /dev/null +++ b/frontend/admin/src/components/layout/index.ts @@ -0,0 +1,11 @@ +/** + * 布局组件导出 + */ + +export { + PageLayout, + FilterCard, + TableCard, + TreeCard, + ContentCard, +} from './PageLayout' diff --git a/frontend/admin/src/features/README.md b/frontend/admin/src/features/README.md new file mode 100644 index 0000000..f9975f6 --- /dev/null +++ b/frontend/admin/src/features/README.md @@ -0,0 +1,3 @@ +`src/features` 保留为业务复用层目录。 + +当前已补齐目录骨架,后续需要将页面内可复用的业务交互逐步下沉到对应子目录。 diff --git a/frontend/admin/src/features/auth/.gitkeep b/frontend/admin/src/features/auth/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/frontend/admin/src/features/auth/.gitkeep @@ -0,0 +1 @@ + diff --git a/frontend/admin/src/features/devices/.gitkeep b/frontend/admin/src/features/devices/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/frontend/admin/src/features/devices/.gitkeep @@ -0,0 +1 @@ + diff --git a/frontend/admin/src/features/import-export/.gitkeep b/frontend/admin/src/features/import-export/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/frontend/admin/src/features/import-export/.gitkeep @@ -0,0 +1 @@ + diff --git a/frontend/admin/src/features/permissions/.gitkeep b/frontend/admin/src/features/permissions/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/frontend/admin/src/features/permissions/.gitkeep @@ -0,0 +1 @@ + diff --git a/frontend/admin/src/features/profile/.gitkeep b/frontend/admin/src/features/profile/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/frontend/admin/src/features/profile/.gitkeep @@ -0,0 +1 @@ + diff --git a/frontend/admin/src/features/roles/.gitkeep b/frontend/admin/src/features/roles/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/frontend/admin/src/features/roles/.gitkeep @@ -0,0 +1 @@ + diff --git a/frontend/admin/src/features/totp/.gitkeep b/frontend/admin/src/features/totp/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/frontend/admin/src/features/totp/.gitkeep @@ -0,0 +1 @@ + diff --git a/frontend/admin/src/features/users/.gitkeep b/frontend/admin/src/features/users/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/frontend/admin/src/features/users/.gitkeep @@ -0,0 +1 @@ + diff --git a/frontend/admin/src/features/webhooks/.gitkeep b/frontend/admin/src/features/webhooks/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/frontend/admin/src/features/webhooks/.gitkeep @@ -0,0 +1 @@ + diff --git a/frontend/admin/src/index.css b/frontend/admin/src/index.css new file mode 100644 index 0000000..5fb3313 --- /dev/null +++ b/frontend/admin/src/index.css @@ -0,0 +1,111 @@ +:root { + --text: #6b6375; + --text-h: #08060d; + --bg: #fff; + --border: #e5e4e7; + --code-bg: #f4f3ec; + --accent: #aa3bff; + --accent-bg: rgba(170, 59, 255, 0.1); + --accent-border: rgba(170, 59, 255, 0.5); + --social-bg: rgba(244, 243, 236, 0.5); + --shadow: + rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; + + --sans: system-ui, 'Segoe UI', Roboto, sans-serif; + --heading: system-ui, 'Segoe UI', Roboto, sans-serif; + --mono: ui-monospace, Consolas, monospace; + + font: 18px/145% var(--sans); + letter-spacing: 0.18px; + color-scheme: light dark; + color: var(--text); + background: var(--bg); + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + + @media (max-width: 1024px) { + font-size: 16px; + } +} + +@media (prefers-color-scheme: dark) { + :root { + --text: #9ca3af; + --text-h: #f3f4f6; + --bg: #16171d; + --border: #2e303a; + --code-bg: #1f2028; + --accent: #c084fc; + --accent-bg: rgba(192, 132, 252, 0.15); + --accent-border: rgba(192, 132, 252, 0.5); + --social-bg: rgba(47, 48, 58, 0.5); + --shadow: + rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px; + } + + #social .button-icon { + filter: invert(1) brightness(2); + } +} + +#root { + width: 1126px; + max-width: 100%; + margin: 0 auto; + text-align: center; + border-inline: 1px solid var(--border); + min-height: 100svh; + display: flex; + flex-direction: column; + box-sizing: border-box; +} + +body { + margin: 0; +} + +h1, +h2 { + font-family: var(--heading); + font-weight: 500; + color: var(--text-h); +} + +h1 { + font-size: 56px; + letter-spacing: -1.68px; + margin: 32px 0; + @media (max-width: 1024px) { + font-size: 36px; + margin: 20px 0; + } +} +h2 { + font-size: 24px; + line-height: 118%; + letter-spacing: -0.24px; + margin: 0 0 8px; + @media (max-width: 1024px) { + font-size: 20px; + } +} +p { + margin: 0; +} + +code, +.counter { + font-family: var(--mono); + display: inline-flex; + border-radius: 4px; + color: var(--text-h); +} + +code { + font-size: 15px; + line-height: 135%; + padding: 4px 8px; + background: var(--code-bg); +} diff --git a/frontend/admin/src/layouts/AdminLayout/AdminLayout.module.css b/frontend/admin/src/layouts/AdminLayout/AdminLayout.module.css new file mode 100644 index 0000000..ee45df3 --- /dev/null +++ b/frontend/admin/src/layouts/AdminLayout/AdminLayout.module.css @@ -0,0 +1,209 @@ +/** + * AdminLayout 样式 + */ + +.layout { + min-height: 100vh; + background: var(--color-canvas); +} + +/* 加载状态 */ +.loadingContainer { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: var(--color-canvas); +} + +/* 侧边栏 */ +.sider { + background: var(--color-layout) !important; + border-right: 1px solid var(--color-border-soft); +} + +.logo { + height: 64px; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + font-weight: 600; + color: var(--color-text-strong); + border-bottom: 1px solid var(--color-border-soft); + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + padding: 0 16px; +} + +.menu { + background: transparent !important; + border: none !important; +} + +.menu :global(.ant-menu-item), +.menu :global(.ant-menu-submenu-title) { + margin: 4px 8px !important; + border-radius: var(--radius-sm) !important; + pointer-events: auto !important; + cursor: pointer !important; +} + +.menu :global(.ant-menu-item-selected) { + background: var(--color-primary) !important; + color: var(--color-text-inverse) !important; +} + +/* 确保子菜单可展开 */ +.menu :global(.ant-menu-submenu-arrow) { + pointer-events: none !important; +} + +/* 顶栏 */ +.header { + height: 64px; + background: var(--color-surface); + border-bottom: 1px solid var(--color-border-soft); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 24px; + position: sticky; + top: 0; + z-index: 10; +} + +.headerLeft { + display: flex; + align-items: center; + gap: 16px; +} + +.collapseBtn { + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + border: none; + background: transparent; + color: var(--color-text-base); + font-size: 18px; + cursor: pointer; + border-radius: var(--radius-sm); + transition: background var(--motion-fast); +} + +.collapseBtn:hover { + background: var(--color-surface-muted); +} + +.breadcrumb { + font-size: 14px; + color: var(--color-text-muted); + display: flex; + align-items: center; + gap: 0; +} + +.breadcrumbLink { + color: var(--color-text-muted); + cursor: pointer; + transition: color var(--motion-fast); +} + +.breadcrumbLink:hover { + color: var(--color-primary); +} + +.breadcrumbCurrent { + color: var(--color-text-base); + font-weight: 500; +} + +.breadcrumbSeparator { + margin: 0 8px; + color: var(--color-text-muted); +} + +.headerRight { + display: flex; + align-items: center; + gap: 16px; +} + +.userTrigger { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + padding: 4px 8px; + border-radius: var(--radius-sm); + transition: background var(--motion-fast); +} + +.userTrigger:hover { + background: var(--color-surface-muted); +} + +.userName { + font-size: 14px; + color: var(--color-text-base); +} + +/* 内容区 */ +.content { + padding: 24px; + min-height: calc(100vh - 64px); + max-width: var(--page-max-width); + width: 100%; + margin: 0 auto; +} + +/* 响应式 */ +@media (max-width: 1024px) { + .content { + padding: 16px; + } +} + +/* 跳过链接 - 可访问性 */ +.skipLink { + position: absolute; + top: -40px; + left: 0; + background: var(--color-primary); + color: var(--color-text-inverse); + padding: 8px 16px; + z-index: 1000; + transition: top 0.2s; + text-decoration: none; + border-radius: 0 0 4px 0; + pointer-events: auto; +} + +.skipLink:focus { + top: 0; + outline: 3px solid var(--color-primary); + outline-offset: 2px; +} + +/* 侧边栏层级 */ +.sider { + z-index: 100; +} + +/* 确保布局不被遮挡 */ +.layout { + position: relative; +} + +/* 移动端抽屉样式 */ +.mobileDrawer :global(.ant-drawer-header) { + border-bottom: 1px solid var(--color-border-soft); +} + +.mobileDrawer :global(.ant-drawer-body) { + padding: 0; +} diff --git a/frontend/admin/src/layouts/AdminLayout/AdminLayout.test.tsx b/frontend/admin/src/layouts/AdminLayout/AdminLayout.test.tsx new file mode 100644 index 0000000..c517577 --- /dev/null +++ b/frontend/admin/src/layouts/AdminLayout/AdminLayout.test.tsx @@ -0,0 +1,469 @@ +import type { ReactNode } from 'react' +import { act, render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { MemoryRouter, Route, Routes } from 'react-router-dom' + +import { AuthContext, type AuthContextValue } from '@/app/providers/auth-context' +import { AdminLayout } from './AdminLayout' +import styles from './AdminLayout.module.css' + +const logoutMock = vi.fn(async () => {}) + +function flattenChildren(children: ReactNode): string { + if (children === null || children === undefined || typeof children === 'boolean') { + return '' + } + + if (typeof children === 'string' || typeof children === 'number') { + return String(children) + } + + if (Array.isArray(children)) { + return children.map(flattenChildren).join(' ').trim() + } + + if (typeof children === 'object' && 'props' in children) { + return flattenChildren((children as { props?: { children?: ReactNode } }).props?.children) + } + + return '' +} + +vi.mock('antd', async () => { + const React = await import('react') + + type MenuItem = { + key?: string + label?: ReactNode + children?: MenuItem[] + type?: string + onClick?: () => void + } + + const Layout = Object.assign( + ({ + children, + className, + }: { + children?: ReactNode + className?: string + }) => ( +
+ {children} +
+ ), + { + Sider: ({ + children, + className, + }: { + children?: ReactNode + className?: string + }) => ( + + ), + Header: ({ + children, + className, + }: { + children?: ReactNode + className?: string + }) => ( +
+ {children} +
+ ), + Content: ({ + children, + className, + }: { + children?: ReactNode + className?: string + }) => ( +
+ {children} +
+ ), + }, + ) + + function Menu({ + items = [], + onClick, + selectedKeys = [], + defaultOpenKeys = [], + }: { + items?: MenuItem[] + onClick?: (info: { key: string }) => void + selectedKeys?: string[] + defaultOpenKeys?: string[] + }) { + const [openKeys, setOpenKeys] = React.useState((defaultOpenKeys ?? []).map(String)) + + React.useEffect(() => { + setOpenKeys((defaultOpenKeys ?? []).map(String)) + }, [defaultOpenKeys]) + + const renderItem = (item: MenuItem): ReactNode => { + if (item.type === 'divider') { + return
+ } + + const key = String(item.key ?? flattenChildren(item.label)) + const label = flattenChildren(item.label) + const hasChildren = Boolean(item.children?.length) + + return ( +
+ + {hasChildren && openKeys.includes(key) ? item.children?.map(renderItem) : null} +
+ ) + } + + return ( +
+ {items.map((item) => renderItem(item as MenuItem))} +
+ ) + } + + function Dropdown({ + children, + menu, + }: { + children?: ReactNode + menu?: { items?: MenuItem[] } + }) { + const [open, setOpen] = React.useState(false) + + return ( +
+ + {open ? ( +
+ {menu?.items?.map((item, index) => { + if (!item || item.type === 'divider') { + return
+ } + + const key = String(item.key ?? index) + return ( + + ) + })} +
+ ) : null} +
+ ) + } + + return { + Avatar: ({ + src, + style, + icon, + size, + }: { + src?: string | null + style?: { backgroundColor?: string } + icon?: ReactNode + size?: number + }) => ( +
+ {src ? avatar : icon} +
+ ), + Button: ({ + children, + icon, + onClick, + htmlType, + ...props + }: { + children?: ReactNode + icon?: ReactNode + onClick?: () => void + htmlType?: 'button' | 'submit' | 'reset' + [key: string]: unknown + }) => ( + + ), + Drawer: ({ + open, + title, + children, + onClose, + }: { + open?: boolean + title?: ReactNode + children?: ReactNode + onClose?: () => void + }) => ( + open ? ( +
+
{title}
+ + {children} +
+ ) : null + ), + Dropdown, + Layout, + Menu, + Spin: ({ + tip, + size, + children, + }: { + tip?: ReactNode + size?: string + children?: ReactNode + }) => ( +
+ {children} +
+ ), + } +}) + +vi.mock('@ant-design/icons', () => ({ + ApiOutlined: () => api-icon, + DashboardOutlined: () => dashboard-icon, + FileTextOutlined: () => file-text-icon, + LogoutOutlined: () => logout-icon, + MenuFoldOutlined: () => menu-fold-icon, + MenuOutlined: () => menu-icon, + MenuUnfoldOutlined: () => menu-unfold-icon, + SafetyOutlined: () => safety-icon, + SettingOutlined: () => setting-icon, + UserOutlined: () => user-icon, +})) + +const baseAuthContextValue: AuthContextValue = { + user: { + id: 1, + username: 'admin', + email: 'admin@example.com', + phone: '13800138000', + nickname: 'admin-nickname', + avatar: '', + status: 1, + }, + roles: [], + isAdmin: true, + isAuthenticated: true, + isLoading: false, + onLoginSuccess: async () => {}, + logout: () => logoutMock(), + refreshUser: async () => {}, +} + +function setWindowWidth(width: number) { + Object.defineProperty(window, 'innerWidth', { + configurable: true, + writable: true, + value: width, + }) +} + +function renderAdminLayout( + authContextValue: Partial = {}, + initialEntry: string = '/profile/security', + layoutChildren?: ReactNode, +) { + const value: AuthContextValue = { + ...baseAuthContextValue, + ...authContextValue, + } + + return render( + + + + {layoutChildren}}> + Dashboard Page
} /> + Users Page} /> + Roles Page} /> + Permissions Page} /> + Login Logs Page} /> + Operation Logs Page} /> + Webhooks Page} /> + Import Export Page} /> + Profile Page} /> + Security Page} /> + + + + , + ) +} + +describe('AdminLayout', () => { + beforeEach(() => { + logoutMock.mockClear() + setWindowWidth(1280) + }) + + afterEach(() => { + setWindowWidth(1280) + vi.restoreAllMocks() + }) + + it('shows a loading state while the session is restoring', () => { + renderAdminLayout({ isLoading: true }) + + expect(screen.getByTestId('spin')).toHaveAttribute('data-tip') + expect(screen.queryByText('Security Page')).not.toBeInTheDocument() + }) + + it('renders desktop admin navigation, breadcrumbs, collapse state, dropdown actions, and mobile drawer navigation', async () => { + const user = userEvent.setup() + const { container } = renderAdminLayout({ isAdmin: true }, '/profile/security') + + expect(container.querySelector(`.${styles.logo}`)).toHaveTextContent('用户管理系统') + expect(container.querySelector(`.${styles.userName}`)).toHaveTextContent('admin-nickname') + expect(screen.getAllByTestId('menu')[0]).toHaveAttribute('data-open-keys', 'profile') + expect(screen.getByText('Security Page')).toBeInTheDocument() + + const breadcrumbLink = container.querySelector(`.${styles.breadcrumbLink}`) + expect(breadcrumbLink).not.toBeNull() + await user.click(breadcrumbLink as HTMLElement) + await waitFor(() => expect(screen.getByText('Profile Page')).toBeInTheDocument()) + + await user.click(screen.getByTestId('menu-item-access-control')) + await user.click(screen.getByTestId('menu-item-/users')) + await waitFor(() => expect(screen.getByText('Users Page')).toBeInTheDocument()) + + await user.click(screen.getByTestId('dropdown-trigger')) + await user.click(screen.getByTestId('dropdown-item-security')) + await waitFor(() => expect(screen.getByText('Security Page')).toBeInTheDocument()) + + await user.click(screen.getByTestId('dropdown-trigger')) + await user.click(screen.getByTestId('dropdown-item-profile')) + await waitFor(() => expect(screen.getByText('Profile Page')).toBeInTheDocument()) + + await user.click(screen.getByTestId('dropdown-trigger')) + await user.click(screen.getByTestId('dropdown-item-logout')) + await waitFor(() => expect(logoutMock).toHaveBeenCalledTimes(1)) + + const collapseButton = screen.getByText('menu-fold-icon').closest('button') + expect(collapseButton).not.toBeNull() + await user.click(collapseButton as HTMLButtonElement) + + expect(container.querySelector(`.${styles.logo}`)).toHaveTextContent('UMS') + expect(screen.getAllByTestId('menu')[0]).toHaveAttribute('data-open-keys', '') + expect(screen.getByText('menu-unfold-icon')).toBeInTheDocument() + + await act(async () => { + setWindowWidth(375) + window.dispatchEvent(new Event('resize')) + }) + + await waitFor(() => expect(screen.getByRole('button', { name: 'menu-icon' })).toBeInTheDocument()) + await user.click(screen.getByRole('button', { name: 'menu-icon' })) + + const drawer = screen.getByTestId('drawer') + expect(within(drawer).getByTestId('drawer-title')).toHaveTextContent('UMS') + + await user.click(within(drawer).getByTestId('menu-item-/dashboard')) + await waitFor(() => expect(screen.getByText('Dashboard Page')).toBeInTheDocument()) + expect(screen.queryByTestId('drawer')).not.toBeInTheDocument() + }) + + it('renders the reduced mobile menu for non-admin users and uses avatar and username fallbacks correctly', async () => { + const user = userEvent.setup() + + setWindowWidth(375) + + const { container } = renderAdminLayout( + { + isAdmin: false, + user: { + id: 2, + username: 'operator-name', + email: 'operator@example.com', + phone: '', + nickname: '', + avatar: 'https://example.com/avatar.png', + status: 1, + }, + }, + '/profile', + ) + + expect(screen.queryByTestId('menu-item-access-control')).not.toBeInTheDocument() + expect(screen.queryByTestId('menu-item-logs')).not.toBeInTheDocument() + expect(screen.getAllByTestId('menu')[0]).toHaveAttribute('data-open-keys', 'profile') + expect(screen.getByTestId('avatar')).toHaveAttribute('data-src', 'https://example.com/avatar.png') + expect(screen.getByTestId('avatar')).toHaveAttribute('data-background', '') + expect(container.querySelector(`.${styles.userName}`)).toHaveTextContent('operator-name') + + await user.click(screen.getByRole('button', { name: 'menu-icon' })) + const drawer = screen.getByTestId('drawer') + await user.click(within(drawer).getByTestId('menu-item-/webhooks')) + + await waitFor(() => expect(screen.getByText('Webhooks Page')).toBeInTheDocument()) + expect(screen.queryByTestId('drawer')).not.toBeInTheDocument() + }) + + it('opens the logs group for audit routes and prefers explicit children over the outlet while keeping the default user fallback', async () => { + const { container } = renderAdminLayout( + { + user: null, + }, + '/logs/login', +
Injected Layout Content
, + ) + + expect(screen.getByText('Injected Layout Content')).toBeInTheDocument() + expect(screen.queryByText('Login Logs Page')).not.toBeInTheDocument() + expect(container.querySelector(`.${styles.userName}`)?.textContent?.trim().length).toBeGreaterThan(0) + + expect(screen.getAllByTestId('menu')[0]).toHaveAttribute('data-selected-keys', '/logs/login') + expect(container.querySelector(`.${styles.breadcrumb}`)).toHaveTextContent('审计日志') + }) +}) diff --git a/frontend/admin/src/layouts/AdminLayout/AdminLayout.tsx b/frontend/admin/src/layouts/AdminLayout/AdminLayout.tsx new file mode 100644 index 0000000..f60f020 --- /dev/null +++ b/frontend/admin/src/layouts/AdminLayout/AdminLayout.tsx @@ -0,0 +1,329 @@ +/** + * AdminLayout - 管理后台布局 + * + * 布局:侧栏 248px + 顶栏 64px + 内容区 + */ + +import { useState, useEffect } from 'react' +import { Layout, Menu, Avatar, Dropdown, Spin, Drawer, Button, type MenuProps } from 'antd' +import { + DashboardOutlined, + SafetyOutlined, + FileTextOutlined, + ApiOutlined, + UserOutlined, + MenuFoldOutlined, + MenuUnfoldOutlined, + MenuOutlined, + LogoutOutlined, + SettingOutlined, +} from '@ant-design/icons' +import type { ReactNode } from 'react' +import { Outlet, useLocation, useNavigate } from 'react-router-dom' +import { useAuth } from '@/app/providers/auth-context' +import { useBreadcrumbs } from '@/lib/hooks/useBreadcrumbs' +import styles from './AdminLayout.module.css' + +const { Sider, Header, Content } = Layout + +// 管理员菜单配置 +const adminMenuItems: MenuProps['items'] = [ + { + key: '/dashboard', + icon: , + label: '总览', + }, + { + key: 'access-control', + icon: , + label: '访问控制', + children: [ + { key: '/users', label: '用户管理' }, + { key: '/roles', label: '角色管理' }, + { key: '/permissions', label: '权限管理' }, + ], + }, + { + key: 'logs', + icon: , + label: '审计日志', + children: [ + { key: '/logs/login', label: '登录日志' }, + { key: '/logs/operation', label: '操作日志' }, + ], + }, + { + key: 'integration', + icon: , + label: '集成能力', + children: [ + { key: '/webhooks', label: 'Webhooks' }, + { key: '/import-export', label: '导入导出' }, + ], + }, + { + key: 'profile', + icon: , + label: '我的账户', + children: [ + { key: '/profile', label: '个人资料' }, + { key: '/profile/security', label: '安全设置' }, + ], + }, +] + +// 非管理员菜单配置(只有 Webhooks 和个人中心) +const userMenuItems: MenuProps['items'] = [ + { + key: '/webhooks', + icon: , + label: 'Webhooks', + }, + { + key: 'profile', + icon: , + label: '我的账户', + children: [ + { key: '/profile', label: '个人资料' }, + { key: '/profile/security', label: '安全设置' }, + ], + }, +] + +interface AdminLayoutProps { + children?: ReactNode +} + +export function AdminLayout({ children }: AdminLayoutProps) { + const [collapsed, setCollapsed] = useState(false) + const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false) + const [isMobile, setIsMobile] = useState(false) + const location = useLocation() + const navigate = useNavigate() + const { user, isAdmin, logout, isLoading } = useAuth() + const breadcrumbItems = useBreadcrumbs() + + // 检测移动端 + useEffect(() => { + const checkMobile = () => { + setIsMobile(window.innerWidth < 768) + } + checkMobile() + window.addEventListener('resize', checkMobile) + return () => window.removeEventListener('resize', checkMobile) + }, []) + + // 移动端切换侧边栏 + const toggleMobileDrawer = () => { + setMobileDrawerOpen(!mobileDrawerOpen) + } + + // 移动端菜单点击后关闭抽屉 + const handleMobileMenuClick: MenuProps['onClick'] = (info) => { + navigate(info.key) + setMobileDrawerOpen(false) + } + + // 根据是否为管理员选择菜单 + const menuItems = isAdmin ? adminMenuItems : userMenuItems + + // 当前选中的菜单 + const selectedKeys = [location.pathname] + + // 当前展开的菜单组(根据路径决定哪个分组展开) + const openKeys = collapsed + ? [] + : [ + ...(location.pathname.startsWith('/users') || + location.pathname.startsWith('/roles') || + location.pathname.startsWith('/permissions') + ? ['access-control'] + : []), + ...(location.pathname.startsWith('/logs') ? ['logs'] : []), + ...(location.pathname.startsWith('/webhooks') || + location.pathname.startsWith('/import-export') + ? ['integration'] + : []), + ...(location.pathname.startsWith('/profile') ? ['profile'] : []), + ] + + const handleMenuClick: MenuProps['onClick'] = (info) => { + navigate(info.key) + } + + // 处理面包屑点击 + const handleBreadcrumbClick = (path: string) => { + navigate(path) + } + + // 处理登出 + const handleLogout = () => { + void logout() + } + + // 用户下拉菜单 + const userDropdownItems: MenuProps['items'] = [ + { + key: 'profile', + icon: , + label: '个人资料', + onClick: () => navigate('/profile'), + }, + { + key: 'security', + icon: , + label: '安全设置', + onClick: () => navigate('/profile/security'), + }, + { type: 'divider' }, + { + key: 'logout', + icon: , + label: '退出登录', + danger: true, + onClick: handleLogout, + }, + ] + + // 加载中状态 + if (isLoading) { + return ( +
+ +
+ ) + } + + return ( + + {/* 跳过链接 - 便于键盘用户快速跳转到主要内容 */} + + 跳转到主要内容 + + + {/* 侧边栏 */} + + {/* Logo 区域 */} +
+ {collapsed ? 'UMS' : '用户管理系统'} +
+ + {/* 导航菜单 */} + + + + {/* 右侧主体 */} + + {/* 顶栏 */} +
+
+ {/* 折叠/菜单按钮 - 移动端显示菜单图标,桌面端显示折叠图标 */} + {isMobile ? ( + + )} + + {/* 面包屑 */} + {breadcrumbItems && breadcrumbItems.length > 0 && ( +
+ {breadcrumbItems.map((item, index) => ( + + {item.path ? ( + handleBreadcrumbClick(item.path as string)} + > + {item.title} + + ) : ( + + {item.title} + + )} + {index < breadcrumbItems.length - 1 && ( + / + )} + + ))} +
+ )} +
+ +
+ {/* 用户信息 */} + +
+ } + src={user?.avatar || null} + style={{ backgroundColor: user?.avatar ? undefined : 'var(--color-primary)' }} + /> + + {user?.nickname || user?.username || '用户'} + +
+
+
+
+ + {/* 内容区 */} + + {children || } + +
+ + {/* 移动端抽屉式导航 */} + + {collapsed ? 'UMS' : '用户管理系统'} + + } + placement="left" + onClose={toggleMobileDrawer} + open={mobileDrawerOpen} + size="default" + className={styles.mobileDrawer} + styles={{ body: { padding: 0 } }} + > + + + + ) +} diff --git a/frontend/admin/src/layouts/AdminLayout/index.ts b/frontend/admin/src/layouts/AdminLayout/index.ts new file mode 100644 index 0000000..a9d3fcd --- /dev/null +++ b/frontend/admin/src/layouts/AdminLayout/index.ts @@ -0,0 +1 @@ +export { AdminLayout } from './AdminLayout' diff --git a/frontend/admin/src/layouts/AuthLayout/AuthLayout.module.css b/frontend/admin/src/layouts/AuthLayout/AuthLayout.module.css new file mode 100644 index 0000000..1043022 --- /dev/null +++ b/frontend/admin/src/layouts/AuthLayout/AuthLayout.module.css @@ -0,0 +1,105 @@ +/** + * AuthLayout 样式 + */ + +.container { + display: flex; + min-height: 100vh; +} + +/* 左侧品牌区 */ +.brand { + width: 480px; + min-width: 400px; + background: var(--gradient-shell); + padding: 48px; + display: flex; + align-items: flex-end; + position: relative; + overflow: hidden; +} + +.brand::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: + radial-gradient(circle at 20% 20%, rgba(14, 90, 106, 0.1) 0%, transparent 50%), + radial-gradient(circle at 80% 80%, rgba(194, 109, 58, 0.08) 0%, transparent 50%); + pointer-events: none; +} + +.brandContent { + position: relative; + z-index: 1; +} + +.brandTitle { + font-size: 32px; + font-weight: 600; + color: var(--color-text-strong); + margin-bottom: 12px; +} + +.brandDesc { + font-size: 16px; + color: var(--color-text-muted); + margin-bottom: 32px; +} + +.features { + list-style: none; + padding: 0; +} + +.features li { + font-size: 14px; + color: var(--color-text-base); + padding: 8px 0; + padding-left: 24px; + position: relative; +} + +.features li::before { + content: '✓'; + position: absolute; + left: 0; + color: var(--color-success); + font-weight: 600; +} + +/* 右侧表单区 */ +.main { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 48px; + background: var(--color-canvas); +} + +.formContainer { + width: 100%; + max-width: 420px; +} + +/* 响应式 */ +@media (max-width: 1024px) { + .brand { + width: 360px; + min-width: 320px; + } +} + +@media (max-width: 768px) { + .brand { + display: none; + } + + .main { + padding: 24px; + } +} diff --git a/frontend/admin/src/layouts/AuthLayout/AuthLayout.tsx b/frontend/admin/src/layouts/AuthLayout/AuthLayout.tsx new file mode 100644 index 0000000..f2e708b --- /dev/null +++ b/frontend/admin/src/layouts/AuthLayout/AuthLayout.tsx @@ -0,0 +1,42 @@ +/** + * AuthLayout - 认证页面布局 + * 用于登录、忘记密码、重置密码等页面 + * + * 布局:左侧品牌区 + 右侧表单区 + */ + +import type { ReactNode } from 'react' +import styles from './AuthLayout.module.css' + +interface AuthLayoutProps { + children: ReactNode +} + +export function AuthLayout({ children }: AuthLayoutProps) { + return ( +
+ {/* 左侧品牌区 */} + + + {/* 右侧表单区 */} +
+
+ {children} +
+
+
+ ) +} diff --git a/frontend/admin/src/layouts/AuthLayout/index.ts b/frontend/admin/src/layouts/AuthLayout/index.ts new file mode 100644 index 0000000..4b0e7c9 --- /dev/null +++ b/frontend/admin/src/layouts/AuthLayout/index.ts @@ -0,0 +1 @@ +export { AuthLayout } from './AuthLayout' diff --git a/frontend/admin/src/layouts/index.ts b/frontend/admin/src/layouts/index.ts new file mode 100644 index 0000000..54d49aa --- /dev/null +++ b/frontend/admin/src/layouts/index.ts @@ -0,0 +1,2 @@ +export { AuthLayout } from './AuthLayout' +export { AdminLayout } from './AdminLayout' diff --git a/frontend/admin/src/lib/auth/oauth.test.ts b/frontend/admin/src/lib/auth/oauth.test.ts new file mode 100644 index 0000000..1711568 --- /dev/null +++ b/frontend/admin/src/lib/auth/oauth.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' + +import { + buildOAuthCallbackReturnTo, + parseOAuthCallbackHash, + sanitizeAuthRedirect, +} from './oauth' + +describe('oauth auth helpers', () => { + it('sanitizes redirect paths to internal routes only', () => { + expect(sanitizeAuthRedirect('/users')).toBe('/users') + expect(sanitizeAuthRedirect('https://evil.example.com')).toBe('/dashboard') + expect(sanitizeAuthRedirect('//evil.example.com')).toBe('/dashboard') + expect(sanitizeAuthRedirect('users')).toBe('/dashboard') + }) + + it('builds oauth callback return url on current origin', () => { + expect(buildOAuthCallbackReturnTo('/users')).toBe('http://localhost:3000/login/oauth/callback?redirect=%2Fusers') + }) + + it('parses oauth callback hash payload', () => { + expect(parseOAuthCallbackHash('#status=success&code=abc&provider=github')).toEqual({ + status: 'success', + code: 'abc', + provider: 'github', + message: '', + }) + }) +}) diff --git a/frontend/admin/src/lib/auth/oauth.ts b/frontend/admin/src/lib/auth/oauth.ts new file mode 100644 index 0000000..7ff91df --- /dev/null +++ b/frontend/admin/src/lib/auth/oauth.ts @@ -0,0 +1,27 @@ +export function sanitizeAuthRedirect(target: string | null | undefined, fallback: string = '/dashboard'): string { + const value = (target || '').trim() + if (!value.startsWith('/') || value.startsWith('//')) { + return fallback + } + return value +} + +export function buildOAuthCallbackReturnTo(redirectPath: string): string { + const callbackUrl = new URL('/login/oauth/callback', window.location.origin) + if (redirectPath && redirectPath !== '/dashboard') { + callbackUrl.searchParams.set('redirect', redirectPath) + } + return callbackUrl.toString() +} + +export function parseOAuthCallbackHash(hash: string): Record { + const normalized = hash.startsWith('#') ? hash.slice(1) : hash + const values = new URLSearchParams(normalized) + + return { + status: values.get('status') || '', + code: values.get('code') || '', + provider: values.get('provider') || '', + message: values.get('message') || '', + } +} diff --git a/frontend/admin/src/lib/config.ts b/frontend/admin/src/lib/config.ts new file mode 100644 index 0000000..c7d49e4 --- /dev/null +++ b/frontend/admin/src/lib/config.ts @@ -0,0 +1,11 @@ +/** + * 应用配置 + * 从环境变量中读取配置项 + */ + +export const config = { + /** + * API 基础地址 + */ + apiBaseUrl: import.meta.env.VITE_API_BASE_URL || '/api/v1', +} as const diff --git a/frontend/admin/src/lib/errors/AppError.test.ts b/frontend/admin/src/lib/errors/AppError.test.ts new file mode 100644 index 0000000..ec9a657 --- /dev/null +++ b/frontend/admin/src/lib/errors/AppError.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from 'vitest' + +import { AppError, ErrorType, isAppError } from './AppError' +import { getErrorMessage, isFormValidationError } from './index' + +describe('AppError', () => { + it('uses the default status and type when options are omitted', () => { + const error = new AppError(1001, 'business failed') + + expect(error).toBeInstanceOf(AppError) + expect(error).toBeInstanceOf(Error) + expect(error.name).toBe('AppError') + expect(error.code).toBe(1001) + expect(error.status).toBe(500) + expect(error.type).toBe(ErrorType.BUSINESS) + expect(error.cause).toBeUndefined() + }) + + it('keeps explicit options including cause and exposes type guards', () => { + const cause = new Error('root cause') + const authByStatus = new AppError(2001, 'status-auth', { + status: 401, + type: ErrorType.BUSINESS, + cause, + }) + const forbiddenByStatus = new AppError(2002, 'status-forbidden', { + status: 403, + type: ErrorType.BUSINESS, + }) + const networkError = AppError.network('network failed', cause) + + expect(authByStatus.cause).toBe(cause) + expect(authByStatus.isAuthError()).toBe(true) + expect(forbiddenByStatus.isForbidden()).toBe(true) + expect(networkError.isNetworkError()).toBe(true) + }) + + it('maps backend responses to the expected error type for each status family', () => { + const unauthorized = AppError.fromResponse({ code: 40101, message: 'unauthorized' }, 401) + const forbidden = AppError.fromResponse({ code: 40301, message: 'forbidden' }, 403) + const notFound = AppError.fromResponse({ code: 40401, message: 'missing' }, 404) + const network = AppError.fromResponse({ code: 50001, message: 'server error' }, 500) + const business = AppError.fromResponse({ code: 40001, message: 'business error' }, 400) + + expect(unauthorized.type).toBe(ErrorType.AUTH) + expect(forbidden.type).toBe(ErrorType.FORBIDDEN) + expect(notFound.type).toBe(ErrorType.NOT_FOUND) + expect(network.type).toBe(ErrorType.NETWORK) + expect(business.type).toBe(ErrorType.BUSINESS) + }) + + it('creates auth, forbidden, and validation errors with the expected defaults', () => { + const auth = AppError.auth() + const forbidden = AppError.forbidden() + const validation = AppError.validation('validation failed') + + expect(auth.code).toBe(401) + expect(auth.status).toBe(401) + expect(auth.type).toBe(ErrorType.AUTH) + expect(auth.message.length).toBeGreaterThan(0) + + expect(forbidden.code).toBe(403) + expect(forbidden.status).toBe(403) + expect(forbidden.type).toBe(ErrorType.FORBIDDEN) + expect(forbidden.message.length).toBeGreaterThan(0) + + expect(validation.code).toBe(400) + expect(validation.status).toBe(400) + expect(validation.type).toBe(ErrorType.VALIDATION) + expect(validation.message).toBe('validation failed') + }) + + it('returns user-facing messages for each supported error type', () => { + const networkMessage = AppError.network('network failed').getUserMessage() + const authMessage = AppError.auth('custom auth').getUserMessage() + const forbiddenMessage = AppError.forbidden('custom forbidden').getUserMessage() + const notFoundMessage = new AppError(40401, 'missing', { + status: 404, + type: ErrorType.NOT_FOUND, + }).getUserMessage() + const validationMessage = AppError.validation('validation failed').getUserMessage() + const customUnknownMessage = new AppError(9001, 'custom unknown', { + type: ErrorType.UNKNOWN, + }).getUserMessage() + const fallbackUnknownMessage = new AppError(9002, '', { + type: ErrorType.UNKNOWN, + }).getUserMessage() + + expect(networkMessage.length).toBeGreaterThan(0) + expect(networkMessage).not.toBe('network failed') + expect(authMessage.length).toBeGreaterThan(0) + expect(authMessage).not.toBe('custom auth') + expect(forbiddenMessage.length).toBeGreaterThan(0) + expect(forbiddenMessage).not.toBe('custom forbidden') + expect(notFoundMessage.length).toBeGreaterThan(0) + expect(notFoundMessage).not.toBe('missing') + expect(validationMessage).toBe('validation failed') + expect(customUnknownMessage).toBe('custom unknown') + expect(fallbackUnknownMessage.length).toBeGreaterThan(0) + }) + + it('identifies AppError instances correctly', () => { + expect(isAppError(new AppError(1, 'boom'))).toBe(true) + expect(isAppError(new Error('boom'))).toBe(false) + expect(isAppError('boom')).toBe(false) + }) +}) + +describe('error helpers', () => { + it('uses the AppError user message when available', () => { + const error = AppError.validation('invalid form') + + expect(getErrorMessage(error, 'fallback')).toBe('invalid form') + }) + + it('falls back to generic Error messages and finally to the provided fallback', () => { + expect(getErrorMessage(new Error('plain error'), 'fallback')).toBe('plain error') + expect(getErrorMessage({ foo: 'bar' }, 'fallback')).toBe('fallback') + }) + + it('detects form validation errors only for objects with an errorFields array', () => { + expect(isFormValidationError({ errorFields: [] })).toBe(true) + expect(isFormValidationError({ errorFields: 'nope' })).toBe(false) + expect(isFormValidationError(null)).toBe(false) + }) +}) diff --git a/frontend/admin/src/lib/errors/AppError.ts b/frontend/admin/src/lib/errors/AppError.ts new file mode 100644 index 0000000..20bce2e --- /dev/null +++ b/frontend/admin/src/lib/errors/AppError.ts @@ -0,0 +1,172 @@ +/** + * AppError - 应用统一错误模型 + * + * 用于统一处理后端业务错误和前端运行时错误 + */ + +/** + * 错误类型常量 + */ +export const ErrorType = { + /** 业务错误 - 后端返回的业务逻辑错误 */ + BUSINESS: 'BUSINESS', + /** 网络错误 - 请求失败、超时等 */ + NETWORK: 'NETWORK', + /** 认证错误 - 401 未登录或 token 过期 */ + AUTH: 'AUTH', + /** 权限错误 - 403 无权限访问 */ + FORBIDDEN: 'FORBIDDEN', + /** 资源不存在 - 404 */ + NOT_FOUND: 'NOT_FOUND', + /** 验证错误 - 表单校验失败 */ + VALIDATION: 'VALIDATION', + /** 未知错误 */ + UNKNOWN: 'UNKNOWN', +} as const + +export type ErrorTypeValue = typeof ErrorType[keyof typeof ErrorType] + +/** + * 应用错误类 + */ +export class AppError extends Error { + /** 错误码 */ + readonly code: number + + /** HTTP 状态码 */ + readonly status: number + + /** 错误类型 */ + readonly type: ErrorTypeValue + + /** 原始错误 */ + readonly cause?: Error + + constructor( + code: number, + message: string, + options?: { + status?: number + type?: ErrorTypeValue + cause?: Error + } + ) { + super(message) + this.name = 'AppError' + this.code = code + this.status = options?.status ?? 500 + this.type = options?.type ?? ErrorType.BUSINESS + this.cause = options?.cause + + // 确保 instanceof 正常工作 + Object.setPrototypeOf(this, AppError.prototype) + } + + /** + * 从后端响应创建错误 + */ + static fromResponse(response: { code: number; message: string }, status: number): AppError { + let type: ErrorTypeValue = ErrorType.BUSINESS + + if (status === 401) { + type = ErrorType.AUTH + } else if (status === 403) { + type = ErrorType.FORBIDDEN + } else if (status === 404) { + type = ErrorType.NOT_FOUND + } else if (status >= 500) { + type = ErrorType.NETWORK + } + + return new AppError(response.code, response.message, { status, type }) + } + + /** + * 创建网络错误 + */ + static network(message: string, cause?: Error): AppError { + return new AppError(0, message, { + status: 0, + type: ErrorType.NETWORK, + cause, + }) + } + + /** + * 创建认证错误 + */ + static auth(message: string = '请先登录'): AppError { + return new AppError(401, message, { + status: 401, + type: ErrorType.AUTH, + }) + } + + /** + * 创建权限错误 + */ + static forbidden(message: string = '无权限访问'): AppError { + return new AppError(403, message, { + status: 403, + type: ErrorType.FORBIDDEN, + }) + } + + /** + * 创建验证错误 + */ + static validation(message: string): AppError { + return new AppError(400, message, { + status: 400, + type: ErrorType.VALIDATION, + }) + } + + /** + * 判断是否为认证错误 + */ + isAuthError(): boolean { + return this.type === ErrorType.AUTH || this.status === 401 + } + + /** + * 判断是否为权限错误 + */ + isForbidden(): boolean { + return this.type === ErrorType.FORBIDDEN || this.status === 403 + } + + /** + * 判断是否为网络错误 + */ + isNetworkError(): boolean { + return this.type === ErrorType.NETWORK + } + + /** + * 获取用户友好的错误消息 + */ + getUserMessage(): string { + switch (this.type) { + case ErrorType.NETWORK: + return '网络连接失败,请检查网络后重试' + case ErrorType.AUTH: + return '登录已过期,请重新登录' + case ErrorType.FORBIDDEN: + return '您没有权限执行此操作' + case ErrorType.NOT_FOUND: + return '请求的资源不存在' + case ErrorType.VALIDATION: + return this.message + default: + return this.message || '操作失败,请稍后重试' + } + } +} + +/** + * 判断是否为 AppError + */ +export function isAppError(error: unknown): error is AppError { + return error instanceof AppError +} diff --git a/frontend/admin/src/lib/errors/index.ts b/frontend/admin/src/lib/errors/index.ts new file mode 100644 index 0000000..99d249a --- /dev/null +++ b/frontend/admin/src/lib/errors/index.ts @@ -0,0 +1,26 @@ +import { AppError, ErrorType, isAppError } from './AppError' + +export { AppError, ErrorType, isAppError } + +export function getErrorMessage(error: unknown, fallback: string): string { + if (isAppError(error)) { + return error.getUserMessage() + } + + if (error instanceof Error && error.message) { + return error.message + } + + return fallback +} + +export function isFormValidationError( + error: unknown, +): error is { errorFields: unknown[] } { + return ( + typeof error === 'object' && + error !== null && + 'errorFields' in error && + Array.isArray((error as { errorFields?: unknown[] }).errorFields) + ) +} diff --git a/frontend/admin/src/lib/hooks/useBreadcrumbs.test.tsx b/frontend/admin/src/lib/hooks/useBreadcrumbs.test.tsx new file mode 100644 index 0000000..a830712 --- /dev/null +++ b/frontend/admin/src/lib/hooks/useBreadcrumbs.test.tsx @@ -0,0 +1,82 @@ +import type { ReactNode } from 'react' +import { renderHook } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { describe, expect, it } from 'vitest' + +import { useBreadcrumbs } from './useBreadcrumbs' + +function createWrapper(pathname: string) { + return function Wrapper({ children }: { children: ReactNode }) { + return {children} + } +} + +describe('useBreadcrumbs', () => { + it('returns an empty breadcrumb list at the root path', () => { + const { result } = renderHook(() => useBreadcrumbs(), { + wrapper: createWrapper('/'), + }) + + expect(result.current).toEqual([]) + }) + + it('maps known single-segment routes to a terminal breadcrumb item', () => { + const { result } = renderHook(() => useBreadcrumbs(), { + wrapper: createWrapper('/dashboard'), + }) + + expect(result.current).toEqual([ + { + title: '概览', + path: undefined, + }, + ]) + }) + + it('builds nested breadcrumbs for supported child routes', () => { + const { logsResult } = { + logsResult: renderHook(() => useBreadcrumbs(), { + wrapper: createWrapper('/logs/login'), + }), + } + + expect(logsResult.result.current).toEqual([ + { + title: '审计日志', + path: '/logs', + }, + { + title: '登录日志', + path: undefined, + }, + ]) + + const profileResult = renderHook(() => useBreadcrumbs(), { + wrapper: createWrapper('/profile/security'), + }) + + expect(profileResult.result.current).toEqual([ + { + title: '个人资料', + path: '/profile', + }, + { + title: '安全设置', + path: undefined, + }, + ]) + }) + + it('skips unknown route segments while keeping known ancestors', () => { + const { result } = renderHook(() => useBreadcrumbs(), { + wrapper: createWrapper('/logs/unknown'), + }) + + expect(result.current).toEqual([ + { + title: '审计日志', + path: '/logs', + }, + ]) + }) +}) diff --git a/frontend/admin/src/lib/hooks/useBreadcrumbs.ts b/frontend/admin/src/lib/hooks/useBreadcrumbs.ts new file mode 100644 index 0000000..a566008 --- /dev/null +++ b/frontend/admin/src/lib/hooks/useBreadcrumbs.ts @@ -0,0 +1,48 @@ +import { useMemo } from 'react' +import { useLocation } from 'react-router-dom' +import type { BreadcrumbProps } from 'antd' + +const breadcrumbNameMap: Record = { + '/dashboard': '概览', + '/users': '用户管理', + '/roles': '角色管理', + '/permissions': '权限管理', + '/logs': '审计日志', + '/logs/login': '登录日志', + '/logs/operation': '操作日志', + '/webhooks': 'Webhooks', + '/import-export': '导入导出', + '/profile': '个人资料', + '/profile/security': '安全设置', +} + +export function useBreadcrumbs(): BreadcrumbProps['items'] { + const location = useLocation() + + return useMemo(() => { + const pathSnippets = location.pathname.split('/').filter(Boolean) + + if (pathSnippets.length === 0) { + return [] + } + + const items: BreadcrumbProps['items'] = [] + let currentPath = '' + + pathSnippets.forEach((snippet, index) => { + currentPath += `/${snippet}` + + const name = breadcrumbNameMap[currentPath] + if (!name) { + return + } + + items.push({ + title: name, + path: index === pathSnippets.length - 1 ? undefined : currentPath, + }) + }) + + return items + }, [location.pathname]) +} diff --git a/frontend/admin/src/lib/http/auth-session.test.ts b/frontend/admin/src/lib/http/auth-session.test.ts new file mode 100644 index 0000000..41c6fa0 --- /dev/null +++ b/frontend/admin/src/lib/http/auth-session.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const user = { + id: 1, + username: 'admin', + email: 'admin@example.com', + phone: '13800138000', + nickname: 'Admin', + avatar: '', + status: 1 as const, +} + +const roles = [ + { + id: 1, + name: 'Administrator', + code: 'admin', + description: 'System administrator', + is_system: true, + is_default: false, + status: 1 as const, + }, +] + +describe('auth-session', () => { + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + }) + + it('stores and clears the session state in memory', async () => { + const session = await import('@/lib/http/auth-session') + + session.setAccessToken('access-token', 60) + session.setCurrentUser(user) + session.setCurrentRoles(roles) + + expect(session.getAccessToken()).toBe('access-token') + expect(session.getCurrentUser()).toEqual(user) + expect(session.getCurrentRoles()).toEqual(roles) + expect(session.getRoleCodes()).toEqual(['admin']) + expect(session.isAdmin()).toBe(true) + expect(session.isAuthenticated()).toBe(true) + + session.clearSession() + + expect(session.getAccessToken()).toBeNull() + expect(session.getCurrentUser()).toBeNull() + expect(session.getCurrentRoles()).toEqual([]) + expect(session.isAuthenticated()).toBe(false) + }) + + it('starts empty after a module reload because the session is memory-only', async () => { + let session = await import('@/lib/http/auth-session') + session.setAccessToken('access-token', 60) + session.setCurrentUser(user) + session.setCurrentRoles(roles) + + vi.resetModules() + session = await import('@/lib/http/auth-session') + + expect(session.getAccessToken()).toBeNull() + expect(session.getCurrentUser()).toBeNull() + expect(session.getCurrentRoles()).toEqual([]) + expect(session.isAuthenticated()).toBe(false) + }) + + it('marks the token as expired before the hard expiry time', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-03-21T00:00:00Z')) + + const session = await import('@/lib/http/auth-session') + + session.setAccessToken('access-token', 60) + expect(session.isAccessTokenExpired()).toBe(false) + + vi.advanceTimersByTime(31_000) + expect(session.isAccessTokenExpired()).toBe(true) + + session.clearSession() + vi.useRealTimers() + }) +}) diff --git a/frontend/admin/src/lib/http/auth-session.ts b/frontend/admin/src/lib/http/auth-session.ts new file mode 100644 index 0000000..3b75846 --- /dev/null +++ b/frontend/admin/src/lib/http/auth-session.ts @@ -0,0 +1,101 @@ +import type { SessionUser, Role } from '@/types' + +interface SessionState { + accessToken: string | null + expiresAt: number | null + user: SessionUser | null + roles: Role[] + isRefreshing: boolean + refreshPromise: Promise | null +} + +const sessionState: SessionState = { + accessToken: null, + expiresAt: null, + user: null, + roles: [], + isRefreshing: false, + refreshPromise: null, +} + +export function getAccessToken(): string | null { + return sessionState.accessToken +} + +export function setAccessToken(token: string, expiresIn: number): void { + sessionState.accessToken = token + sessionState.expiresAt = Date.now() + expiresIn * 1000 +} + +export function clearAccessToken(): void { + sessionState.accessToken = null + sessionState.expiresAt = null +} + +export function isAccessTokenExpired(): boolean { + if (!sessionState.expiresAt) { + return true + } + return Date.now() > sessionState.expiresAt - 30_000 +} + +export function getCurrentUser(): SessionUser | null { + return sessionState.user +} + +export function setCurrentUser(user: SessionUser): void { + sessionState.user = user +} + +export function getCurrentRoles(): Role[] { + return sessionState.roles +} + +export function setCurrentRoles(roles: Role[]): void { + sessionState.roles = roles +} + +export function isAdmin(): boolean { + return sessionState.roles.some((role) => role.code === 'admin') +} + +export function getRoleCodes(): string[] { + return sessionState.roles.map((role) => role.code) +} + +export function isAuthenticated(): boolean { + return sessionState.accessToken !== null && sessionState.user !== null +} + +export function clearSession(): void { + sessionState.accessToken = null + sessionState.expiresAt = null + sessionState.user = null + sessionState.roles = [] + sessionState.isRefreshing = false + sessionState.refreshPromise = null +} + +export function isRefreshing(): boolean { + return sessionState.isRefreshing +} + +export function startRefreshing(): void { + sessionState.isRefreshing = true +} + +export function endRefreshing(): void { + sessionState.isRefreshing = false +} + +export function getRefreshPromise(): Promise | null { + return sessionState.refreshPromise +} + +export function setRefreshPromise(promise: Promise): void { + sessionState.refreshPromise = promise +} + +export function clearRefreshPromise(): void { + sessionState.refreshPromise = null +} diff --git a/frontend/admin/src/lib/http/client.test.ts b/frontend/admin/src/lib/http/client.test.ts new file mode 100644 index 0000000..2f91602 --- /dev/null +++ b/frontend/admin/src/lib/http/client.test.ts @@ -0,0 +1,785 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +type JsonResponseInit = ResponseInit & { + status?: number +} + +function jsonResponse(data: unknown, init: JsonResponseInit = {}) { + return new Response(JSON.stringify(data), { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + ...init, + }) +} + +async function loadModules() { + vi.resetModules() + + const session = await import('@/lib/http/auth-session') + const storage = await import('@/lib/storage') + const csrf = await import('@/lib/http/csrf') + const errors = await import('@/lib/errors') + const client = await import('@/lib/http/client') + + return { + ...session, + ...storage, + ...csrf, + ...errors, + ...client, + } +} + +describe('http client', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useRealTimers() + vi.unstubAllEnvs() + vi.unstubAllGlobals() + vi.stubGlobal('fetch', vi.fn()) + }) + + afterEach(() => { + vi.useRealTimers() + vi.unstubAllEnvs() + vi.unstubAllGlobals() + }) + + it('builds query-string urls and skips undefined params without auth headers', async () => { + const fetchMock = vi.mocked(fetch) + fetchMock.mockResolvedValueOnce( + jsonResponse({ + code: 0, + message: 'ok', + data: { ok: true }, + }), + ) + + const { get } = await loadModules() + const result = await get( + '/users', + { page: 2, active: true, keyword: undefined }, + { auth: false }, + ) + + expect(result).toEqual({ ok: true }) + expect(fetchMock).toHaveBeenCalledTimes(1) + + const [requestUrl, requestInit] = fetchMock.mock.calls[0] + + expect(String(requestUrl)).toBe(`${window.location.origin}/api/v1/users?page=2&active=true`) + expect(requestInit?.headers).not.toMatchObject({ + Authorization: expect.any(String), + }) + }) + + it('supports relative api base urls without a leading slash', async () => { + vi.stubEnv('VITE_API_BASE_URL', 'api/custom') + const fetchMock = vi.mocked(fetch) + fetchMock.mockResolvedValueOnce( + jsonResponse({ + code: 0, + message: 'ok', + data: { ok: true }, + }), + ) + + const { get } = await loadModules() + await get('/status', undefined, { auth: false }) + + expect(fetchMock).toHaveBeenCalledWith( + `${window.location.origin}/api/custom/status`, + expect.any(Object), + ) + }) + + it('supports absolute api base urls', async () => { + vi.stubEnv('VITE_API_BASE_URL', 'https://api.example.com/base') + const fetchMock = vi.mocked(fetch) + fetchMock.mockResolvedValueOnce( + jsonResponse({ + code: 0, + message: 'ok', + data: { ok: true }, + }), + ) + + const { get } = await loadModules() + await get('/status', undefined, { auth: false }) + + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.example.com/base/status', + expect.any(Object), + ) + }) + + it('sends FormData without forcing a JSON content type', async () => { + const fetchMock = vi.mocked(fetch) + fetchMock.mockResolvedValueOnce( + jsonResponse({ + code: 0, + message: 'ok', + data: { uploaded: true }, + }), + ) + + const { post } = await loadModules() + const formData = new FormData() + formData.append('file', new Blob(['demo'], { type: 'text/plain' }), 'demo.txt') + + const result = await post('/upload', formData, { auth: false }) + + expect(result).toEqual({ uploaded: true }) + expect(fetchMock).toHaveBeenCalledTimes(1) + + const [requestUrl, requestInit] = fetchMock.mock.calls[0] + const headers = requestInit?.headers as Record | undefined + + expect(String(requestUrl)).toContain('/api/v1/upload') + expect(requestInit?.body).toBe(formData) + expect(requestInit?.credentials).toBe('include') + expect(headers?.['Content-Type']).toBeUndefined() + }) + + it('adds csrf and json headers for protected write requests', async () => { + const fetchMock = vi.mocked(fetch) + fetchMock.mockResolvedValueOnce( + jsonResponse({ + code: 0, + message: 'ok', + data: { saved: true }, + }), + ) + + const { CSRF_HEADER_NAME, put, setCSRFToken } = await loadModules() + setCSRFToken('csrf-token') + + const result = await put('/users/1', { nickname: 'Demo' }, { auth: false }) + + expect(result).toEqual({ saved: true }) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock.mock.calls[0][1]).toMatchObject({ + method: 'PUT', + body: JSON.stringify({ nickname: 'Demo' }), + headers: { + 'Content-Type': 'application/json', + [CSRF_HEADER_NAME]: 'csrf-token', + }, + }) + }) + + it('adds csrf headers to delete requests', async () => { + const fetchMock = vi.mocked(fetch) + fetchMock.mockResolvedValueOnce( + jsonResponse({ + code: 0, + message: 'ok', + data: { deleted: true }, + }), + ) + + const { CSRF_HEADER_NAME, del, setCSRFToken } = await loadModules() + setCSRFToken('csrf-token') + + const result = await del('/users/1', { auth: false }) + + expect(result).toEqual({ deleted: true }) + expect(fetchMock.mock.calls[0][1]).toMatchObject({ + method: 'DELETE', + headers: { + [CSRF_HEADER_NAME]: 'csrf-token', + }, + }) + }) + + it('refreshes an expired access token before sending the business request', async () => { + const fetchMock = vi.mocked(fetch) + + fetchMock + .mockResolvedValueOnce( + jsonResponse({ + code: 0, + message: 'ok', + data: { + access_token: 'access-token-new', + refresh_token: 'refresh-token-new', + expires_in: 3600, + user: { + id: 1, + username: 'admin', + email: 'admin@example.com', + phone: '13800138000', + nickname: 'Admin', + avatar: '', + status: 1, + }, + }, + }), + ) + .mockResolvedValueOnce( + jsonResponse({ + code: 0, + message: 'ok', + data: { ok: true }, + }), + ) + + const { get, setAccessToken, setRefreshToken } = await loadModules() + setAccessToken('access-token-old', -1) + setRefreshToken('refresh-token-old') + + const data = await get('/protected') + + expect(data).toEqual({ ok: true }) + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(String(fetchMock.mock.calls[0][0])).toContain('/api/v1/auth/refresh') + expect(fetchMock.mock.calls[0][1]).toMatchObject({ + credentials: 'include', + method: 'POST', + body: JSON.stringify({ refresh_token: 'refresh-token-old' }), + }) + expect(fetchMock.mock.calls[1][1]?.headers).toMatchObject({ + Authorization: 'Bearer access-token-new', + }) + }) + + it('waits for an in-flight refresh promise before sending the request', async () => { + const fetchMock = vi.mocked(fetch) + fetchMock.mockResolvedValueOnce( + jsonResponse({ + code: 0, + message: 'ok', + data: { ok: true }, + }), + ) + + const { get, setAccessToken, setRefreshPromise, startRefreshing } = await loadModules() + setAccessToken('queued-access-token', 3600) + startRefreshing() + setRefreshPromise(Promise.resolve()) + + const result = await get('/protected') + + expect(result).toEqual({ ok: true }) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock.mock.calls[0][1]?.headers).toMatchObject({ + Authorization: 'Bearer queued-access-token', + }) + }) + + it('clears the local session when refresh fails before the business request is sent', async () => { + const fetchMock = vi.mocked(fetch) + fetchMock.mockResolvedValueOnce(new Response(null, { status: 401 })) + + const { + ErrorType, + get, + getAccessToken, + getRefreshToken, + setAccessToken, + setRefreshToken, + } = await loadModules() + setAccessToken('expired-access-token', -1) + setRefreshToken('refresh-token-old') + + await expect(get('/protected')).rejects.toMatchObject({ + status: 401, + type: ErrorType.AUTH, + }) + + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(getAccessToken()).toBeNull() + expect(getRefreshToken()).toBeNull() + }) + + it('clears the local session when refresh returns a business error payload', async () => { + const fetchMock = vi.mocked(fetch) + fetchMock.mockResolvedValueOnce( + jsonResponse({ + code: 10001, + message: 'refresh failed', + data: null, + }), + ) + + const { + ErrorType, + get, + getAccessToken, + getRefreshToken, + setAccessToken, + setRefreshToken, + } = await loadModules() + setAccessToken('expired-access-token', -1) + setRefreshToken('refresh-token-old') + + await expect(get('/protected')).rejects.toMatchObject({ + status: 401, + type: ErrorType.AUTH, + }) + + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(getAccessToken()).toBeNull() + expect(getRefreshToken()).toBeNull() + }) + + it('retries once after a 401 response and rotates the in-memory refresh token', async () => { + const fetchMock = vi.mocked(fetch) + const capturedHeaders: Array | undefined> = [] + + fetchMock + .mockImplementationOnce(async (_url, requestInit) => { + capturedHeaders.push( + requestInit?.headers + ? { ...(requestInit.headers as Record) } + : undefined, + ) + return new Response(null, { status: 401 }) + }) + .mockResolvedValueOnce( + jsonResponse({ + code: 0, + message: 'ok', + data: { + access_token: 'access-token-retried', + refresh_token: 'refresh-token-retried', + expires_in: 3600, + user: { + id: 1, + username: 'admin', + email: 'admin@example.com', + phone: '13800138000', + nickname: 'Admin', + avatar: '', + status: 1, + }, + }, + }), + ) + .mockImplementationOnce(async (_url, requestInit) => { + capturedHeaders.push( + requestInit?.headers + ? { ...(requestInit.headers as Record) } + : undefined, + ) + + return jsonResponse({ + code: 0, + message: 'ok', + data: { retried: true }, + }) + }) + + const { get, getRefreshToken, setAccessToken, setRefreshToken } = await loadModules() + setAccessToken('access-token-old', 3600) + setRefreshToken('refresh-token-old') + + const data = await get('/protected') + + expect(data).toEqual({ retried: true }) + expect(fetchMock).toHaveBeenCalledTimes(3) + expect(capturedHeaders[0]).toMatchObject({ + Authorization: 'Bearer access-token-old', + }) + expect(String(fetchMock.mock.calls[1][0])).toContain('/api/v1/auth/refresh') + expect(fetchMock.mock.calls[1][1]).toMatchObject({ + credentials: 'include', + method: 'POST', + body: JSON.stringify({ refresh_token: 'refresh-token-old' }), + }) + expect(capturedHeaders[1]).toMatchObject({ + Authorization: 'Bearer access-token-retried', + }) + expect(getRefreshToken()).toBe('refresh-token-retried') + }) + + it('reuses an in-flight refresh token when a 401 retry happens during another refresh', async () => { + const fetchMock = vi.mocked(fetch) + const { + get, + setAccessToken, + setRefreshPromise, + startRefreshing, + } = await loadModules() + + fetchMock + .mockImplementationOnce(async () => { + startRefreshing() + setAccessToken('shared-refresh-token', 3600) + setRefreshPromise(Promise.resolve()) + return new Response(null, { status: 401 }) + }) + .mockResolvedValueOnce( + jsonResponse({ + code: 0, + message: 'ok', + data: { retried: true }, + }), + ) + + setAccessToken('access-token-old', 3600) + + const data = await get('/protected') + + expect(data).toEqual({ retried: true }) + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(fetchMock.mock.calls[1][1]?.headers).toMatchObject({ + Authorization: 'Bearer shared-refresh-token', + }) + }) + + it('fails the 401 retry when the shared refresh finishes without an access token', async () => { + const fetchMock = vi.mocked(fetch) + const { + clearAccessToken, + ErrorType, + get, + getAccessToken, + getRefreshToken, + setAccessToken, + setRefreshPromise, + setRefreshToken, + startRefreshing, + } = await loadModules() + + fetchMock.mockImplementationOnce(async () => { + startRefreshing() + clearAccessToken() + setRefreshPromise(Promise.resolve()) + return new Response(null, { status: 401 }) + }) + + setAccessToken('access-token-old', 3600) + setRefreshToken('refresh-token-old') + + await expect(get('/protected')).rejects.toMatchObject({ + status: 401, + type: ErrorType.AUTH, + }) + + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(getAccessToken()).toBeNull() + expect(getRefreshToken()).toBeNull() + }) + + it('clears the local session when the retried request still returns 401', async () => { + const fetchMock = vi.mocked(fetch) + fetchMock + .mockResolvedValueOnce(new Response(null, { status: 401 })) + .mockResolvedValueOnce( + jsonResponse({ + code: 0, + message: 'ok', + data: { + access_token: 'access-token-retried', + refresh_token: 'refresh-token-retried', + expires_in: 3600, + user: { + id: 1, + username: 'admin', + email: 'admin@example.com', + phone: '13800138000', + nickname: 'Admin', + avatar: '', + status: 1, + }, + }, + }), + ) + .mockResolvedValueOnce(new Response(null, { status: 401 })) + + const { + ErrorType, + get, + getAccessToken, + getRefreshToken, + setAccessToken, + setRefreshToken, + } = await loadModules() + setAccessToken('access-token-old', 3600) + setRefreshToken('refresh-token-old') + + await expect(get('/protected')).rejects.toMatchObject({ + status: 401, + type: ErrorType.AUTH, + }) + + expect(fetchMock).toHaveBeenCalledTimes(3) + expect(getAccessToken()).toBeNull() + expect(getRefreshToken()).toBeNull() + }) + + it('maps 403 responses to forbidden errors', async () => { + const fetchMock = vi.mocked(fetch) + fetchMock.mockResolvedValueOnce(new Response(null, { status: 403 })) + + const { ErrorType, get } = await loadModules() + + await expect(get('/forbidden', undefined, { auth: false })).rejects.toMatchObject({ + status: 403, + type: ErrorType.FORBIDDEN, + }) + }) + + it('maps 404 responses to not-found errors', async () => { + const fetchMock = vi.mocked(fetch) + fetchMock.mockResolvedValueOnce(new Response(null, { status: 404 })) + + const { ErrorType, get } = await loadModules() + + await expect(get('/missing', undefined, { auth: false })).rejects.toMatchObject({ + status: 404, + type: ErrorType.NOT_FOUND, + }) + }) + + it('maps other non-ok responses to network errors', async () => { + const fetchMock = vi.mocked(fetch) + fetchMock.mockResolvedValueOnce(new Response(null, { status: 500 })) + + const { ErrorType, get } = await loadModules() + + await expect(get('/broken', undefined, { auth: false })).rejects.toMatchObject({ + status: 0, + type: ErrorType.NETWORK, + }) + }) + + it('maps non-zero business responses to AppError.fromResponse', async () => { + const fetchMock = vi.mocked(fetch) + fetchMock.mockResolvedValueOnce( + jsonResponse({ + code: 10001, + message: 'business failure', + data: null, + }), + ) + + const { ErrorType, get } = await loadModules() + + await expect(get('/business', undefined, { auth: false })).rejects.toMatchObject({ + code: 10001, + status: 200, + type: ErrorType.BUSINESS, + }) + }) + + it('converts aborted requests into timeout AppErrors', async () => { + vi.useFakeTimers() + const fetchMock = vi.mocked(fetch) + fetchMock.mockImplementation( + (_url, requestInit) => + new Promise((_, reject) => { + ;(requestInit?.signal as AbortSignal).addEventListener( + 'abort', + () => reject(new DOMException('Aborted', 'AbortError')), + { once: true }, + ) + }), + ) + + const { ErrorType, request } = await loadModules() + const requestPromise = expect(request('/slow', { auth: false })).rejects.toMatchObject({ + status: 0, + type: ErrorType.NETWORK, + }) + + await vi.advanceTimersByTimeAsync(30_000) + await requestPromise + }) + + it('propagates a caller abort signal into the request timeout controller', async () => { + const fetchMock = vi.mocked(fetch) + fetchMock.mockImplementation( + (_url, requestInit) => + new Promise((_, reject) => { + ;(requestInit?.signal as AbortSignal).addEventListener( + 'abort', + () => reject(new DOMException('Aborted', 'AbortError')), + { once: true }, + ) + }), + ) + + const controller = new AbortController() + const { ErrorType, request } = await loadModules() + const requestPromise = expect( + request('/slow', { auth: false, signal: controller.signal }), + ).rejects.toMatchObject({ + status: 0, + type: ErrorType.NETWORK, + }) + + await Promise.resolve() + controller.abort() + await requestPromise + }) + + it('retries downloads after a 401 and returns the blob payload', async () => { + const fetchMock = vi.mocked(fetch) + const downloadedBlob = { kind: 'downloaded-blob' } as unknown as Blob + const successResponse = { + ok: true, + status: 200, + blob: vi.fn().mockResolvedValue(downloadedBlob), + } as unknown as Response + + fetchMock + .mockResolvedValueOnce(new Response(null, { status: 401 })) + .mockResolvedValueOnce( + jsonResponse({ + code: 0, + message: 'ok', + data: { + access_token: 'download-access-token', + refresh_token: 'download-refresh-token', + expires_in: 3600, + user: { + id: 1, + username: 'admin', + email: 'admin@example.com', + phone: '13800138000', + nickname: 'Admin', + avatar: '', + status: 1, + }, + }, + }), + ) + .mockResolvedValueOnce(successResponse) + + const { download, getRefreshToken, setAccessToken, setRefreshToken } = await loadModules() + setAccessToken('access-token-old', 3600) + setRefreshToken('refresh-token-old') + + const blob = await download('/export') + + expect(blob).toBe(downloadedBlob) + expect(fetchMock).toHaveBeenCalledTimes(3) + expect(String(fetchMock.mock.calls[1][0])).toContain('/api/v1/auth/refresh') + expect(fetchMock.mock.calls[2][1]?.headers).toMatchObject({ + Authorization: 'Bearer download-access-token', + }) + expect(getRefreshToken()).toBe('download-refresh-token') + }) + + it('maps failed downloads to network AppErrors', async () => { + const fetchMock = vi.mocked(fetch) + fetchMock.mockResolvedValueOnce(new Response(null, { status: 500 })) + + const { ErrorType, download } = await loadModules() + + await expect(download('/export', undefined, { auth: false })).rejects.toMatchObject({ + status: 0, + type: ErrorType.NETWORK, + }) + }) + + it('clears the local session when a download retry still returns 401', async () => { + const fetchMock = vi.mocked(fetch) + fetchMock + .mockResolvedValueOnce(new Response(null, { status: 401 })) + .mockResolvedValueOnce( + jsonResponse({ + code: 0, + message: 'ok', + data: { + access_token: 'download-access-token', + refresh_token: 'download-refresh-token', + expires_in: 3600, + user: { + id: 1, + username: 'admin', + email: 'admin@example.com', + phone: '13800138000', + nickname: 'Admin', + avatar: '', + status: 1, + }, + }, + }), + ) + .mockResolvedValueOnce(new Response(null, { status: 401 })) + + const { + ErrorType, + download, + getAccessToken, + getRefreshToken, + setAccessToken, + setRefreshToken, + } = await loadModules() + setAccessToken('access-token-old', 3600) + setRefreshToken('refresh-token-old') + + await expect(download('/export')).rejects.toMatchObject({ + status: 401, + type: ErrorType.AUTH, + }) + + expect(fetchMock).toHaveBeenCalledTimes(3) + expect(getAccessToken()).toBeNull() + expect(getRefreshToken()).toBeNull() + }) + + it('converts aborted downloads into timeout AppErrors', async () => { + vi.useFakeTimers() + const fetchMock = vi.mocked(fetch) + fetchMock.mockImplementation( + (_url, requestInit) => + new Promise((_, reject) => { + ;(requestInit?.signal as AbortSignal).addEventListener( + 'abort', + () => reject(new DOMException('Aborted', 'AbortError')), + { once: true }, + ) + }), + ) + + const { ErrorType, download } = await loadModules() + const downloadPromise = expect( + download('/export', undefined, { auth: false }), + ).rejects.toMatchObject({ + status: 0, + type: ErrorType.NETWORK, + }) + + await vi.advanceTimersByTimeAsync(30_000) + await downloadPromise + }) + + it('builds upload form data with additional fields', async () => { + const fetchMock = vi.mocked(fetch) + fetchMock.mockResolvedValueOnce( + jsonResponse({ + code: 0, + message: 'ok', + data: { uploaded: true }, + }), + ) + + const { upload } = await loadModules() + const file = new File(['demo'], 'avatar.png', { type: 'image/png' }) + + const result = await upload( + '/upload', + file, + 'asset', + { folder: 'avatars' }, + { auth: false }, + ) + + expect(result).toEqual({ uploaded: true }) + expect(fetchMock).toHaveBeenCalledTimes(1) + + const requestInit = fetchMock.mock.calls[0][1] + const body = requestInit?.body as FormData + + expect(requestInit?.method).toBe('POST') + expect(body.get('folder')).toBe('avatars') + expect(body.get('asset')).toBeInstanceOf(File) + expect((body.get('asset') as File).name).toBe('avatar.png') + }) +}) diff --git a/frontend/admin/src/lib/http/client.ts b/frontend/admin/src/lib/http/client.ts new file mode 100644 index 0000000..297b138 --- /dev/null +++ b/frontend/admin/src/lib/http/client.ts @@ -0,0 +1,367 @@ +import { config } from '@/lib/config' +import { AppError, ErrorType } from '@/lib/errors' +import type { ApiResponse, RequestOptions } from '@/types' +import { + clearRefreshPromise, + clearSession, + endRefreshing, + getAccessToken, + getRefreshPromise, + isAccessTokenExpired, + isRefreshing, + setAccessToken, + setRefreshPromise, + startRefreshing, +} from './auth-session' +import { clearRefreshToken, getRefreshToken, setRefreshToken } from '../storage' +import { CSRF_PROTECTED_METHODS, getCSRFHeaders } from './csrf' +import type { TokenBundle } from '@/types' + +const DEFAULT_TIMEOUT = 30_000 + +function isFormDataBody(body: unknown): body is FormData { + return typeof FormData !== 'undefined' && body instanceof FormData +} + +function serializeBody(body: unknown): BodyInit | undefined { + if (body === undefined || body === null) { + return undefined + } + + if (isFormDataBody(body)) { + return body + } + + return JSON.stringify(body) +} + +function resolveApiBaseUrl(): URL { + const origin = typeof window !== 'undefined' ? window.location.origin : 'http://localhost' + const rawBaseUrl = /^https?:\/\//i.test(config.apiBaseUrl) + ? config.apiBaseUrl + : config.apiBaseUrl.startsWith('/') + ? config.apiBaseUrl + : `/${config.apiBaseUrl}` + + const baseUrl = new URL(rawBaseUrl, origin) + if (!baseUrl.pathname.endsWith('/')) { + baseUrl.pathname = `${baseUrl.pathname}/` + } + return baseUrl +} + +function buildUrl(path: string, params?: Record): string { + const url = new URL(path.replace(/^\/+/, ''), resolveApiBaseUrl()) + + if (params) { + for (const [key, value] of Object.entries(params)) { + if (value !== undefined) { + url.searchParams.append(key, String(value)) + } + } + } + + return url.toString() +} + +function cleanupSessionOnAuthFailure(): never { + clearRefreshToken() + clearSession() + throw AppError.auth('会话已过期,请重新登录') +} + +function createTimeoutSignal(signal?: AbortSignal): { signal: AbortSignal; cleanup: () => void } { + const controller = new AbortController() + const timeoutId = window.setTimeout(() => controller.abort(), DEFAULT_TIMEOUT) + + if (signal) { + signal.addEventListener('abort', () => controller.abort(), { once: true }) + } + + return { + signal: controller.signal, + cleanup: () => window.clearTimeout(timeoutId), + } +} + +async function parseJsonResponse(response: Response): Promise> { + return response.json() as Promise> +} + +async function refreshAccessToken(): Promise { + const refreshToken = getRefreshToken() + const body = refreshToken ? JSON.stringify({ refresh_token: refreshToken }) : undefined + + const response = await fetch(buildUrl('/auth/refresh'), { + method: 'POST', + credentials: 'include', + headers: body ? { 'Content-Type': 'application/json' } : undefined, + body, + }) + + if (!response.ok) { + return cleanupSessionOnAuthFailure() + } + + const result = await parseJsonResponse(response) + if (result.code !== 0) { + return cleanupSessionOnAuthFailure() + } + + return result.data +} + +async function performRefresh(): Promise { + if (isRefreshing()) { + const promise = getRefreshPromise() + if (promise) { + await promise + } + + const token = getAccessToken() + if (!token) { + return cleanupSessionOnAuthFailure() + } + + return token + } + + startRefreshing() + const promise = (async () => { + try { + const tokenBundle = await refreshAccessToken() + setAccessToken(tokenBundle.access_token, tokenBundle.expires_in) + setRefreshToken(tokenBundle.refresh_token) + return tokenBundle.access_token + } finally { + endRefreshing() + clearRefreshPromise() + } + })() + + setRefreshPromise( + promise.then( + () => undefined, + () => undefined, + ), + ) + return promise +} + +async function resolveAuthorizationHeader(auth: boolean): Promise { + if (!auth) { + return null + } + + let token = getAccessToken() + if (isRefreshing()) { + const promise = getRefreshPromise() + if (promise) { + await promise + token = getAccessToken() + } + } + + if (token && isAccessTokenExpired()) { + token = await performRefresh() + } + + return token +} + +async function request(path: string, options: RequestOptions = {}): Promise { + const { + method = 'GET', + headers = {}, + body, + params, + auth = true, + credentials = 'include', + signal, + } = options + + const url = buildUrl(path, params) + const requestHeaders: Record = { ...headers } + + if (body !== undefined && body !== null && !isFormDataBody(body) && !requestHeaders['Content-Type']) { + requestHeaders['Content-Type'] = 'application/json' + } + + if (CSRF_PROTECTED_METHODS.includes(method)) { + Object.assign(requestHeaders, getCSRFHeaders()) + } + + const authToken = await resolveAuthorizationHeader(auth) + if (authToken) { + requestHeaders.Authorization = `Bearer ${authToken}` + } + + const timeout = createTimeoutSignal(signal) + + try { + let response = await fetch(url, { + method, + headers: requestHeaders, + body: serializeBody(body), + credentials, + signal: timeout.signal, + }) + + if (response.status === 401 && auth) { + const refreshedToken = await performRefresh() + requestHeaders.Authorization = `Bearer ${refreshedToken}` + + response = await fetch(url, { + method, + headers: requestHeaders, + body: serializeBody(body), + credentials, + signal: timeout.signal, + }) + } + + if (response.status === 401) { + return cleanupSessionOnAuthFailure() + } + + if (!response.ok) { + if (response.status === 403) { + throw AppError.forbidden() + } + if (response.status === 404) { + throw new AppError(404, '请求的资源不存在', { + status: 404, + type: ErrorType.NOT_FOUND, + }) + } + throw AppError.network(`请求失败: ${response.status}`) + } + + const result = await parseJsonResponse(response) + if (result.code !== 0) { + throw AppError.fromResponse(result, response.status) + } + + return result.data + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') { + throw AppError.network('请求超时,请稍后重试') + } + throw error + } finally { + timeout.cleanup() + } +} + +export function get( + path: string, + params?: Record, + options?: Omit, +): Promise { + return request(path, { ...options, method: 'GET', params }) +} + +export function post( + path: string, + body?: unknown, + options?: Omit, +): Promise { + return request(path, { ...options, method: 'POST', body }) +} + +export function put( + path: string, + body?: unknown, + options?: Omit, +): Promise { + return request(path, { ...options, method: 'PUT', body }) +} + +export function del( + path: string, + options?: Omit, +): Promise { + return request(path, { ...options, method: 'DELETE' }) +} + +async function resolveAuthorizedHeaders(options?: Omit): Promise> { + const headers: Record = { ...(options?.headers ?? {}) } + + if (options?.auth !== false) { + const token = await resolveAuthorizationHeader(true) + if (token) { + headers.Authorization = `Bearer ${token}` + } + } + + return headers +} + +export async function download( + path: string, + params?: Record, + options?: Omit, +): Promise { + const url = buildUrl(path, params) + const headers = await resolveAuthorizedHeaders(options) + const timeout = createTimeoutSignal(options?.signal) + + try { + let response = await fetch(url, { + headers, + credentials: options?.credentials ?? 'include', + signal: timeout.signal, + }) + + if (response.status === 401 && options?.auth !== false) { + const refreshedToken = await performRefresh() + headers.Authorization = `Bearer ${refreshedToken}` + response = await fetch(url, { + headers, + credentials: options?.credentials ?? 'include', + signal: timeout.signal, + }) + } + + if (response.status === 401) { + return cleanupSessionOnAuthFailure() + } + if (!response.ok) { + throw AppError.network(`下载失败: ${response.status}`) + } + + return response.blob() + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') { + throw AppError.network('下载超时,请稍后重试') + } + throw error + } finally { + timeout.cleanup() + } +} + +export async function upload( + path: string, + file: File, + fieldName: string = 'file', + additionalData?: Record, + options?: Omit, +): Promise { + const formData = new FormData() + formData.append(fieldName, file) + + if (additionalData) { + for (const [key, value] of Object.entries(additionalData)) { + formData.append(key, value) + } + } + + return request(path, { + ...options, + method: 'POST', + body: formData, + }) +} + +export { request } diff --git a/frontend/admin/src/lib/http/csrf.test.ts b/frontend/admin/src/lib/http/csrf.test.ts new file mode 100644 index 0000000..f7eaea9 --- /dev/null +++ b/frontend/admin/src/lib/http/csrf.test.ts @@ -0,0 +1,192 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +function jsonResponse(data: unknown, init: ResponseInit = {}) { + return new Response(JSON.stringify(data), { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + ...init, + }) +} + +async function loadCsrfModule() { + vi.resetModules() + return import('./csrf') +} + +function clearCsrfCookie() { + if (typeof document === 'undefined') { + return + } + document.cookie = 'csrftoken=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/' +} + +describe('csrf helpers', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.unstubAllGlobals() + vi.unstubAllEnvs() + clearCsrfCookie() + vi.stubGlobal('fetch', vi.fn()) + }) + + afterEach(() => { + clearCsrfCookie() + vi.restoreAllMocks() + vi.unstubAllGlobals() + vi.unstubAllEnvs() + }) + + it('returns null when cookie lookup runs without a document', async () => { + vi.stubGlobal('document', undefined) + + const { getCSRFTokenFromCookie, getCSRFHeaders } = await loadCsrfModule() + + expect(getCSRFTokenFromCookie()).toBeNull() + expect(getCSRFHeaders()).toEqual({}) + }) + + it('stores csrf tokens in memory and falls back to the cookie for headers', async () => { + const { + CSRF_HEADER_NAME, + clearCSRFToken, + getCSRFHeaders, + getCSRFToken, + setCSRFToken, + } = await loadCsrfModule() + + setCSRFToken('memory-token') + expect(getCSRFToken()).toBe('memory-token') + expect(getCSRFHeaders()).toEqual({ + [CSRF_HEADER_NAME]: 'memory-token', + }) + + clearCSRFToken() + document.cookie = 'csrftoken=cookie-token; path=/' + + expect(getCSRFToken()).toBeNull() + expect(getCSRFHeaders()).toEqual({ + [CSRF_HEADER_NAME]: 'cookie-token', + }) + }) + + it('prefers an existing csrf cookie and skips the network bootstrap', async () => { + const fetchMock = vi.mocked(fetch) + document.cookie = 'csrftoken=cookie-token; path=/' + + const { getCSRFToken, initCSRFToken } = await loadCsrfModule() + const token = await initCSRFToken() + + expect(token).toBe('cookie-token') + expect(getCSRFToken()).toBe('cookie-token') + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('fetches and stores a csrf token from the default relative api base', async () => { + const fetchMock = vi.mocked(fetch) + fetchMock.mockResolvedValueOnce( + jsonResponse({ + code: 0, + data: { + csrf_token: 'api-token', + }, + }), + ) + + const { getCSRFToken, initCSRFToken } = await loadCsrfModule() + const token = await initCSRFToken() + + expect(token).toBe('api-token') + expect(getCSRFToken()).toBe('api-token') + expect(fetchMock).toHaveBeenCalledWith( + `${window.location.origin}/api/v1/auth/csrf-token`, + { + method: 'GET', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + }, + ) + }) + + it('supports api base urls without a leading slash', async () => { + vi.stubEnv('VITE_API_BASE_URL', 'api/custom') + const fetchMock = vi.mocked(fetch) + fetchMock.mockResolvedValueOnce( + jsonResponse({ + code: 0, + data: { + csrf_token: 'custom-token', + }, + }), + ) + + const { initCSRFToken } = await loadCsrfModule() + + await initCSRFToken() + + expect(fetchMock).toHaveBeenCalledWith( + `${window.location.origin}/api/custom/auth/csrf-token`, + expect.any(Object), + ) + }) + + it('supports absolute api base urls', async () => { + vi.stubEnv('VITE_API_BASE_URL', 'https://api.example.com/base') + const fetchMock = vi.mocked(fetch) + fetchMock.mockResolvedValueOnce( + jsonResponse({ + code: 0, + data: { + csrf_token: 'absolute-token', + }, + }), + ) + + const { initCSRFToken } = await loadCsrfModule() + + await initCSRFToken() + + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.example.com/base/auth/csrf-token', + expect.any(Object), + ) + }) + + it('falls back to a cookie exposed after the csrf bootstrap request fails', async () => { + const fetchMock = vi.mocked(fetch) + fetchMock.mockRejectedValueOnce(new Error('network failed')) + + const cookieSpy = vi + .spyOn(document, 'cookie', 'get') + .mockReturnValueOnce('') + .mockReturnValueOnce('csrftoken=fallback-token') + + const { getCSRFToken, initCSRFToken } = await loadCsrfModule() + const token = await initCSRFToken() + + expect(token).toBe('fallback-token') + expect(getCSRFToken()).toBe('fallback-token') + expect(fetchMock).toHaveBeenCalledTimes(1) + cookieSpy.mockRestore() + }) + + it('returns null when the bootstrap response does not contain a csrf token', async () => { + const fetchMock = vi.mocked(fetch) + fetchMock.mockResolvedValueOnce( + jsonResponse({ + code: 1, + data: {}, + }), + ) + + const { getCSRFHeaders, getCSRFToken, initCSRFToken } = await loadCsrfModule() + const token = await initCSRFToken() + + expect(token).toBeNull() + expect(getCSRFToken()).toBeNull() + expect(getCSRFHeaders()).toEqual({}) + }) +}) diff --git a/frontend/admin/src/lib/http/csrf.ts b/frontend/admin/src/lib/http/csrf.ts new file mode 100644 index 0000000..7fd6daf --- /dev/null +++ b/frontend/admin/src/lib/http/csrf.ts @@ -0,0 +1,145 @@ +/** + * CSRF Token 管理 + * + * CSRF 保护机制: + * 1. GET 请求获取 CSRF Token(从 cookie 或 API) + * 2. POST/PUT/DELETE 请求将 Token 添加到 X-CSRF-Token 头 + * + * 注意:由于使用 Bearer Token 认证(存储在内存中), + * CSRF 风险相对较低,但为增强安全性仍建议对关键操作启用。 + */ + +// 注意:避免从 './client' 导入,防止循环依赖 +// 使用原生 fetch 获取 CSRF Token + +import { config } from '@/lib/config' + +// CSRF Token 存储 +let csrfToken: string | null = null + +/** + * 获取 CSRF Token + */ +export function getCSRFToken(): string | null { + return csrfToken +} + +/** + * 设置 CSRF Token + */ +export function setCSRFToken(token: string): void { + csrfToken = token +} + +/** + * 从 cookie 中读取 CSRF Token + * Django/Laravel 等框架通常在 cookie 中设置 csrftoken + */ +export function getCSRFTokenFromCookie(): string | null { + if (typeof document === 'undefined') { + return null + } + + const match = document.cookie.match(/csrftoken=([^;]+)/) + return match ? match[1] : null +} + +/** + * 解析 API 基础 URL + * 注意:此函数复制自 client.ts 以避免循环依赖 + */ +function resolveApiBaseUrl(): URL { + const origin = typeof window !== 'undefined' ? window.location.origin : 'http://localhost' + const rawBaseUrl = /^https?:\/\//i.test(config.apiBaseUrl) + ? config.apiBaseUrl + : config.apiBaseUrl.startsWith('/') + ? config.apiBaseUrl + : `/${config.apiBaseUrl}` + + const baseUrl = new URL(rawBaseUrl, origin) + + if (!baseUrl.pathname.endsWith('/')) { + baseUrl.pathname = `${baseUrl.pathname}/` + } + + return baseUrl +} + +/** + * 构建完整 URL + */ +function buildUrl(path: string): string { + const normalizedPath = path.replace(/^\/+/, '') + const url = new URL(normalizedPath, resolveApiBaseUrl()) + return url.toString() +} + +/** + * 初始化 CSRF Token + * 从 cookie 或 API 获取 Token 并存储 + */ +export async function initCSRFToken(): Promise { + // 优先从 cookie 获取 + let token = getCSRFTokenFromCookie() + + if (!token) { + try { + // 使用原生 fetch 避免循环依赖 + const response = await fetch(buildUrl('/auth/csrf-token'), { + method: 'GET', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + }) + + if (response.ok) { + const result = await response.json() + // 后端返回字段名为 csrf_token + if (result.code === 0 && result.data?.csrf_token) { + token = result.data.csrf_token + } + } + } catch { + // API 不支持,使用 cookie 中的 token(如果有) + token = getCSRFTokenFromCookie() + } + } + + if (token) { + setCSRFToken(token) + } + + return token +} + +/** + * 清除 CSRF Token(登出时调用) + */ +export function clearCSRFToken(): void { + csrfToken = null +} + +/** + * CSRF Token 头名称 + */ +export const CSRF_HEADER_NAME = 'X-CSRF-Token' + +/** + * 获取带 CSRF Token 的请求头 + * 用于 POST/PUT/DELETE 请求 + */ +export function getCSRFHeaders(): Record { + const token = csrfToken || getCSRFTokenFromCookie() + if (!token) { + return {} + } + return { + [CSRF_HEADER_NAME]: token + } +} + +/** + * 需要 CSRF 保护的方法列表 + */ +export const CSRF_PROTECTED_METHODS = ['POST', 'PUT', 'DELETE', 'PATCH'] diff --git a/frontend/admin/src/lib/http/index.ts b/frontend/admin/src/lib/http/index.ts new file mode 100644 index 0000000..bf3df54 --- /dev/null +++ b/frontend/admin/src/lib/http/index.ts @@ -0,0 +1,32 @@ +export { + get, + post, + put, + del, + download, + upload, + request, +} from './client' + +export { + getAccessToken, + setAccessToken, + clearAccessToken, + isAccessTokenExpired, + getCurrentUser, + setCurrentUser, + getCurrentRoles, + setCurrentRoles, + isAdmin, + getRoleCodes, + isAuthenticated, + clearSession, + isRefreshing, + startRefreshing, + endRefreshing, + getRefreshPromise, + setRefreshPromise, + clearRefreshPromise, +} from './auth-session' + +export { AppError, ErrorType, isAppError } from '@/lib/errors' diff --git a/frontend/admin/src/lib/index.ts b/frontend/admin/src/lib/index.ts new file mode 100644 index 0000000..6ca251e --- /dev/null +++ b/frontend/admin/src/lib/index.ts @@ -0,0 +1,4 @@ +export * from './config' +export * from './errors' +export * from './http' +export * from './storage' diff --git a/frontend/admin/src/lib/storage/index.ts b/frontend/admin/src/lib/storage/index.ts new file mode 100644 index 0000000..42d1b72 --- /dev/null +++ b/frontend/admin/src/lib/storage/index.ts @@ -0,0 +1,7 @@ +export { + getRefreshToken, + setRefreshToken, + clearRefreshToken, + hasRefreshToken, + hasSessionPresenceCookie, +} from './token-storage' diff --git a/frontend/admin/src/lib/storage/token-storage.test.ts b/frontend/admin/src/lib/storage/token-storage.test.ts new file mode 100644 index 0000000..a65f067 --- /dev/null +++ b/frontend/admin/src/lib/storage/token-storage.test.ts @@ -0,0 +1,68 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { + clearRefreshToken, + getRefreshToken, + hasRefreshToken, + hasSessionPresenceCookie, + setRefreshToken, +} from './token-storage' + +const originalDocument = globalThis.document + +describe('token-storage', () => { + afterEach(() => { + clearRefreshToken() + vi.restoreAllMocks() + + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: originalDocument, + }) + }) + + it('stores refresh tokens in memory and normalizes empty values to null', () => { + setRefreshToken(' refresh-token ') + + expect(getRefreshToken()).toBe('refresh-token') + expect(hasRefreshToken()).toBe(true) + + setRefreshToken(' ') + expect(getRefreshToken()).toBeNull() + expect(hasRefreshToken()).toBe(false) + + setRefreshToken(undefined) + expect(getRefreshToken()).toBeNull() + }) + + it('clears the in-memory refresh token explicitly', () => { + setRefreshToken('token-to-clear') + expect(hasRefreshToken()).toBe(true) + + clearRefreshToken() + + expect(getRefreshToken()).toBeNull() + expect(hasRefreshToken()).toBe(false) + }) + + it('detects the session presence cookie when it is present among other cookies', () => { + vi.spyOn(document, 'cookie', 'get').mockReturnValue('foo=bar; ums_session_present=1; theme=dark') + + expect(hasSessionPresenceCookie()).toBe(true) + }) + + it('returns false when the session presence cookie is absent', () => { + vi.spyOn(document, 'cookie', 'get').mockReturnValue('foo=bar; theme=dark') + + expect(hasSessionPresenceCookie()).toBe(false) + }) + + it('returns false when document is unavailable', () => { + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: undefined, + }) + + expect(hasSessionPresenceCookie()).toBe(false) + }) +}) diff --git a/frontend/admin/src/lib/storage/token-storage.ts b/frontend/admin/src/lib/storage/token-storage.ts new file mode 100644 index 0000000..5a7f80b --- /dev/null +++ b/frontend/admin/src/lib/storage/token-storage.ts @@ -0,0 +1,38 @@ +/** + * In-memory refresh token storage. + * + * The authoritative session continuity mechanism is now the backend-managed + * HttpOnly refresh cookie. This module only keeps a process-local copy so the + * current tab can still send an explicit logout payload when available. + */ + +let refreshToken: string | null = null +const SESSION_PRESENCE_COOKIE_NAME = 'ums_session_present' + +export function getRefreshToken(): string | null { + return refreshToken +} + +export function setRefreshToken(token: string | null | undefined): void { + const value = (token || '').trim() + refreshToken = value || null +} + +export function clearRefreshToken(): void { + refreshToken = null +} + +export function hasRefreshToken(): boolean { + return refreshToken !== null +} + +export function hasSessionPresenceCookie(): boolean { + if (typeof document === 'undefined') { + return false + } + + return document.cookie + .split(';') + .map((cookie) => cookie.trim()) + .some((cookie) => cookie.startsWith(`${SESSION_PRESENCE_COOKIE_NAME}=`)) +} diff --git a/frontend/admin/src/main.tsx b/frontend/admin/src/main.tsx new file mode 100644 index 0000000..3b0f8b3 --- /dev/null +++ b/frontend/admin/src/main.tsx @@ -0,0 +1,18 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { installWindowGuards } from '@/app/bootstrap/installWindowGuards' +import { ThemeProvider } from '@/app/providers/ThemeProvider' +// 使用 @/ 别名导入 App +import App from '@/app/App' +// 全局样式 +import '@/styles/global.css' + +installWindowGuards() + +createRoot(document.getElementById('root')!).render( + + + + + , +) diff --git a/frontend/admin/src/pages/NotFoundPage/NotFoundPage.test.tsx b/frontend/admin/src/pages/NotFoundPage/NotFoundPage.test.tsx new file mode 100644 index 0000000..f487a2e --- /dev/null +++ b/frontend/admin/src/pages/NotFoundPage/NotFoundPage.test.tsx @@ -0,0 +1,30 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' + +import { NotFoundPage } from './NotFoundPage' + +const navigateMock = vi.fn() + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom') + return { + ...actual, + useNavigate: () => navigateMock, + } +}) + +describe('NotFoundPage', () => { + it('renders the 404 state and routes users back to the dashboard', async () => { + const user = userEvent.setup() + + render() + + expect(screen.getByText('404')).toBeInTheDocument() + expect(screen.getByText('抱歉,您访问的页面不存在')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: '返回首页' })) + + expect(navigateMock).toHaveBeenCalledWith('/dashboard') + }) +}) diff --git a/frontend/admin/src/pages/NotFoundPage/NotFoundPage.tsx b/frontend/admin/src/pages/NotFoundPage/NotFoundPage.tsx new file mode 100644 index 0000000..3316a20 --- /dev/null +++ b/frontend/admin/src/pages/NotFoundPage/NotFoundPage.tsx @@ -0,0 +1,31 @@ +/** + * 404 页面 + */ + +import { Result, Button } from 'antd' +import { useNavigate } from 'react-router-dom' + +export function NotFoundPage() { + const navigate = useNavigate() + + return ( +
+ navigate('/dashboard')}> + 返回首页 + + } + /> +
+ ) +} diff --git a/frontend/admin/src/pages/NotFoundPage/index.ts b/frontend/admin/src/pages/NotFoundPage/index.ts new file mode 100644 index 0000000..53534be --- /dev/null +++ b/frontend/admin/src/pages/NotFoundPage/index.ts @@ -0,0 +1 @@ +export { NotFoundPage } from './NotFoundPage' diff --git a/frontend/admin/src/pages/admin/DashboardPage/DashboardPage.module.css b/frontend/admin/src/pages/admin/DashboardPage/DashboardPage.module.css new file mode 100644 index 0000000..41a426f --- /dev/null +++ b/frontend/admin/src/pages/admin/DashboardPage/DashboardPage.module.css @@ -0,0 +1,47 @@ +/** + * Dashboard 页面样式 + */ + +.section { + margin-bottom: 32px; +} + +.sectionTitle { + display: block; + margin-bottom: 16px; + font-size: 14px; + font-weight: 500; +} + +.statCard { + border-radius: var(--radius-md); + border: 1px solid var(--color-border-soft); + transition: box-shadow var(--motion-fast); +} + +.statCard:hover { + box-shadow: var(--shadow-sm); +} + +.statCard :global(.ant-statistic-title) { + color: var(--color-text-muted); + font-size: 13px; +} + +.statCard :global(.ant-statistic-content) { + font-size: 28px; +} + +.infoCard { + margin-top: 24px; + border-radius: var(--radius-md); + background: var(--color-surface-muted); + border: none; +} + +/* 响应式 */ +@media (max-width: 768px) { + .statCard :global(.ant-statistic-content) { + font-size: 24px; + } +} diff --git a/frontend/admin/src/pages/admin/DashboardPage/DashboardPage.test.tsx b/frontend/admin/src/pages/admin/DashboardPage/DashboardPage.test.tsx new file mode 100644 index 0000000..292368a --- /dev/null +++ b/frontend/admin/src/pages/admin/DashboardPage/DashboardPage.test.tsx @@ -0,0 +1,218 @@ +import type { ReactNode } from 'react' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import type { DashboardStats } from '@/types/stats' +import { DashboardPage } from './DashboardPage' + +const getDashboardStatsMock = vi.fn<() => Promise>() +const getErrorMessageMock = vi.fn<(error: unknown, fallback: string) => string>() + +function flattenChildren(children: ReactNode): string { + if (children === null || children === undefined || typeof children === 'boolean') { + return '' + } + + if (typeof children === 'string' || typeof children === 'number') { + return String(children) + } + + if (Array.isArray(children)) { + return children.map(flattenChildren).join(' ').trim() + } + + if (typeof children === 'object' && 'props' in children) { + return flattenChildren((children as { props?: { children?: ReactNode } }).props?.children) + } + + return '' +} + +function createDeferred() { + let resolve!: (value: T) => void + let reject!: (error?: unknown) => void + + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + + return { promise, resolve, reject } +} + +const sampleStats: DashboardStats = { + total_users: 101, + active_users: 102, + inactive_users: 103, + locked_users: 104, + disabled_users: 105, + today_new_users: 106, + week_new_users: 107, + month_new_users: 108, + today_success_logins: 109, + today_failed_logins: 110, + week_success_logins: 111, +} + +vi.mock('antd', () => ({ + Col: ({ children }: { children?: ReactNode }) =>
{children}
, + Row: ({ children }: { children?: ReactNode }) =>
{children}
, + Statistic: ({ + title, + value, + }: { + title?: ReactNode + value?: ReactNode + }) => ( +
+ {flattenChildren(title)} + {String(value)} +
+ ), + Tooltip: ({ + children, + title, + }: { + children?: ReactNode + title?: ReactNode + }) => {children}, + Typography: { + Text: ({ + children, + className, + type, + }: { + children?: ReactNode + className?: string + type?: string + }) => { + void className + void type + return {children} + }, + }, +})) + +vi.mock('@ant-design/icons', () => ({ + CloseCircleOutlined: () => close-icon, + InfoCircleOutlined: () => info-icon, + LockOutlined: () => lock-icon, + LoginOutlined: () => login-icon, + StopOutlined: () => stop-icon, + TeamOutlined: () => team-icon, + UserAddOutlined: () => user-add-icon, + UserOutlined: () => user-icon, +})) + +vi.mock('@/components/common', () => ({ + PageHeader: ({ + title, + description, + }: { + title: ReactNode + description?: ReactNode + }) => ( +
+

{flattenChildren(title)}

+

{flattenChildren(description)}

+
+ ), +})) + +vi.mock('@/components/feedback', () => ({ + PageLoading: ({ tip }: { tip?: ReactNode }) => ( +
{flattenChildren(tip)}
+ ), + PageError: ({ + description, + onRetry, + }: { + description?: ReactNode + onRetry?: () => void + }) => ( +
+ {flattenChildren(description)} + {onRetry ? ( + + ) : null} +
+ ), +})) + +vi.mock('@/components/layout', () => ({ + ContentCard: ({ children }: { children?: ReactNode }) => ( +
{children}
+ ), + PageLayout: ({ children }: { children?: ReactNode }) => ( +
{children}
+ ), +})) + +vi.mock('@/lib/errors', () => ({ + getErrorMessage: (error: unknown, fallback: string) => getErrorMessageMock(error, fallback), +})) + +vi.mock('@/services/stats', () => ({ + getDashboardStats: () => getDashboardStatsMock(), +})) + +describe('DashboardPage', () => { + beforeEach(() => { + getDashboardStatsMock.mockReset() + getErrorMessageMock.mockReset() + getErrorMessageMock.mockImplementation((_, fallback) => fallback) + }) + + it('shows loading first and then renders the dashboard cards after stats load', async () => { + const deferred = createDeferred() + getDashboardStatsMock.mockReturnValueOnce(deferred.promise) + + render() + + expect(screen.getByTestId('page-loading')).toBeInTheDocument() + expect(getDashboardStatsMock).toHaveBeenCalledTimes(1) + + deferred.resolve(sampleStats) + + expect(await screen.findByTestId('page-layout')).toBeInTheDocument() + expect(screen.getByTestId('page-header')).toBeInTheDocument() + expect(screen.getAllByTestId('content-card')).toHaveLength(12) + + for (const value of Object.values(sampleStats)) { + expect(screen.getByText(String(value))).toBeInTheDocument() + } + }) + + it('renders a retriable error state and reloads data successfully on retry', async () => { + const user = userEvent.setup() + + getDashboardStatsMock + .mockRejectedValueOnce(new Error('network down')) + .mockResolvedValueOnce(sampleStats) + getErrorMessageMock.mockReturnValue('dashboard load failed') + + render() + + expect(await screen.findByTestId('page-error')).toHaveAttribute('data-has-retry', 'true') + expect(screen.getByText('dashboard load failed')).toBeInTheDocument() + expect(getErrorMessageMock).toHaveBeenCalledWith(expect.any(Error), expect.any(String)) + + await user.click(screen.getByRole('button', { name: 'retry' })) + + expect(await screen.findByTestId('page-layout')).toBeInTheDocument() + expect(getDashboardStatsMock).toHaveBeenCalledTimes(2) + }) + + it('renders a non-retriable empty-state error when the stats payload is missing', async () => { + getDashboardStatsMock.mockResolvedValueOnce(null as unknown as DashboardStats) + + render() + + expect(await screen.findByTestId('page-error')).toHaveAttribute('data-has-retry', 'false') + expect(screen.queryByRole('button', { name: 'retry' })).not.toBeInTheDocument() + expect(screen.queryByTestId('page-layout')).not.toBeInTheDocument() + }) +}) diff --git a/frontend/admin/src/pages/admin/DashboardPage/DashboardPage.tsx b/frontend/admin/src/pages/admin/DashboardPage/DashboardPage.tsx new file mode 100644 index 0000000..d7d8209 --- /dev/null +++ b/frontend/admin/src/pages/admin/DashboardPage/DashboardPage.tsx @@ -0,0 +1,224 @@ +/** + * Dashboard 页面 + * + * 展示系统统计信息: + * - 用户统计卡片 + * - 登录统计卡片 + */ + +import { useState, useEffect, useCallback } from 'react' +import { Row, Col, Statistic, Typography, Tooltip } from 'antd' +import { + UserOutlined, + TeamOutlined, + LockOutlined, + StopOutlined, + UserAddOutlined, + LoginOutlined, + CloseCircleOutlined, + InfoCircleOutlined, +} from '@ant-design/icons' +import { PageHeader } from '@/components/common' +import { PageLoading, PageError } from '@/components/feedback' +import { PageLayout, ContentCard } from '@/components/layout' +import { getErrorMessage } from '@/lib/errors' +import { getDashboardStats } from '@/services/stats' +import type { DashboardStats } from '@/types/stats' +import styles from './DashboardPage.module.css' + +const { Text } = Typography + +export function DashboardPage() { + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [stats, setStats] = useState(null) + + const fetchStats = useCallback(async () => { + setLoading(true) + setError(null) + try { + const data = await getDashboardStats() + setStats(data) + } catch (err) { + setError(getErrorMessage(err, '获取统计数据失败')) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + fetchStats() + }, [fetchStats]) + + if (loading) { + return + } + + if (error) { + return + } + + if (!stats) { + return + } + + return ( + + + + {/* 用户状态统计 */} +
+ + 用户状态分布 + + + + + } + valueStyle={{ color: 'var(--color-text-strong)' }} + /> + + + + + } + valueStyle={{ color: 'var(--color-success)' }} + /> + + + + + } + valueStyle={{ color: 'var(--color-text-muted)' }} + /> + + + + + } + valueStyle={{ color: 'var(--color-warning)' }} + /> + + + + + } + valueStyle={{ color: 'var(--color-danger)' }} + /> + + + +
+ + {/* 新增用户统计 */} +
+ + 新增用户 + + + + + } + valueStyle={{ color: 'var(--color-primary)' }} + /> + + + + + } + valueStyle={{ color: 'var(--color-primary)' }} + /> + + + + + } + valueStyle={{ color: 'var(--color-primary)' }} + /> + + + +
+ + {/* 登录统计 */} +
+ + 登录统计 + + + + + } + valueStyle={{ color: 'var(--color-success)' }} + /> + + + + + + 今日失败登录 + + + + + } + value={stats.today_failed_logins} + prefix={} + valueStyle={{ color: 'var(--color-danger)' }} + /> + + + + + } + valueStyle={{ color: 'var(--color-success)' }} + /> + + + +
+ + {/* 说明卡片 */} + + + 当前版本展示基础统计信息。趋势图、地域分布、在线用户等高级功能将在后续版本中提供。 + + +
+ ) +} diff --git a/frontend/admin/src/pages/admin/DashboardPage/index.ts b/frontend/admin/src/pages/admin/DashboardPage/index.ts new file mode 100644 index 0000000..3c08e53 --- /dev/null +++ b/frontend/admin/src/pages/admin/DashboardPage/index.ts @@ -0,0 +1 @@ +export { DashboardPage } from './DashboardPage' diff --git a/frontend/admin/src/pages/admin/DevicesPage/DevicesPage.tsx b/frontend/admin/src/pages/admin/DevicesPage/DevicesPage.tsx new file mode 100644 index 0000000..78fc6f7 --- /dev/null +++ b/frontend/admin/src/pages/admin/DevicesPage/DevicesPage.tsx @@ -0,0 +1,352 @@ +/** + * 设备管理页 + * + * 功能: + * - 全局设备列表、筛选(用户、状态、信任状态)、分页 + * - 设备详情、信任/取消信任、删除 + */ + +import { useState, useEffect, useCallback } from 'react' +import { + Table, + Button, + Space, + Tag, + Input, + Select, + Popconfirm, + message, + type TableColumnsType, + type TablePaginationConfig, +} from 'antd' +import { + SearchOutlined, + ReloadOutlined, + DeleteOutlined, +} from '@ant-design/icons' +import dayjs from 'dayjs' +import { PageHeader } from '@/components/common' +import { PageEmpty, PageError } from '@/components/feedback' +import { PageLayout, FilterCard, TableCard } from '@/components/layout' +import { getErrorMessage } from '@/lib/errors' +import { + listAllDevices, + deleteDevice, + trustDevice, + untrustDevice, +} from '@/services/devices' +import type { Device, AdminDeviceListParams, DeviceStatus, DeviceType } from '@/types/device' +import { DeviceTypeText, DeviceStatusText, DeviceStatusColor, DeviceTrustText, DeviceTrustColor } from '@/types/device' + +export function DevicesPage() { + // 列表数据 + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [devices, setDevices] = useState([]) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) + + // 筛选条件 + const [keyword, setKeyword] = useState('') + const [userIdFilter, setUserIdFilter] = useState() + const [statusFilter, setStatusFilter] = useState() + const [trustFilter, setTrustFilter] = useState() + + // 加载设备列表 + const fetchDevices = useCallback(async () => { + setLoading(true) + setError(null) + try { + const params: AdminDeviceListParams = { + page, + page_size: pageSize, + keyword: keyword || undefined, + user_id: userIdFilter, + status: statusFilter, + is_trusted: trustFilter, + } + const result = await listAllDevices(params) + setDevices(result.items) + setTotal(result.total) + } catch (err) { + setError(getErrorMessage(err, '获取设备列表失败')) + } finally { + setLoading(false) + } + }, [page, pageSize, keyword, userIdFilter, statusFilter, trustFilter]) + + useEffect(() => { + void fetchDevices() + }, [fetchDevices]) + + // 筛选条件变化时重置到第一页 + useEffect(() => { + setPage(1) + }, [keyword, userIdFilter, statusFilter, trustFilter]) + + // 重置筛选 + const handleReset = () => { + setKeyword('') + setUserIdFilter(undefined) + setStatusFilter(undefined) + setTrustFilter(undefined) + setPage(1) + } + + // 删除设备 + const handleDelete = async (device: Device) => { + try { + await deleteDevice(device.id) + message.success(`设备 ${device.device_name} 已删除`) + void fetchDevices() + } catch (err) { + message.error(getErrorMessage(err, '删除失败')) + } + } + + // 信任设备 + const handleTrust = async (device: Device) => { + try { + await trustDevice(device.id, '30d') + message.success(`设备 ${device.device_name} 已设为信任`) + void fetchDevices() + } catch (err) { + message.error(getErrorMessage(err, '操作失败')) + } + } + + // 取消信任 + const handleUntrust = async (device: Device) => { + try { + await untrustDevice(device.id) + message.success(`设备 ${device.device_name} 已取消信任`) + void fetchDevices() + } catch (err) { + message.error(getErrorMessage(err, '操作失败')) + } + } + + // 表格列定义 + const columns: TableColumnsType = [ + { + title: 'ID', + dataIndex: 'id', + key: 'id', + width: 80, + }, + { + title: '用户ID', + dataIndex: 'user_id', + key: 'user_id', + width: 80, + }, + { + title: '设备名称', + dataIndex: 'device_name', + key: 'device_name', + width: 120, + render: (text) => text || '-', + }, + { + title: '设备类型', + dataIndex: 'device_type', + key: 'device_type', + width: 80, + render: (type: DeviceType) => DeviceTypeText[type] || '未知', + }, + { + title: '操作系统', + dataIndex: 'device_os', + key: 'device_os', + width: 80, + render: (text) => text || '-', + }, + { + title: '浏览器', + dataIndex: 'device_browser', + key: 'device_browser', + width: 80, + render: (text) => text || '-', + }, + { + title: 'IP地址', + dataIndex: 'ip', + key: 'ip', + width: 120, + }, + { + title: '位置', + dataIndex: 'location', + key: 'location', + width: 120, + render: (text) => text || '-', + }, + { + title: '状态', + dataIndex: 'status', + key: 'status', + width: 80, + render: (status: DeviceStatus) => ( + {DeviceStatusText[status]} + ), + }, + { + title: '信任状态', + dataIndex: 'is_trusted', + key: 'is_trusted', + width: 80, + render: (isTrusted: boolean) => ( + {DeviceTrustText[String(isTrusted)]} + ), + }, + { + title: '最后活跃', + dataIndex: 'last_active_time', + key: 'last_active_time', + width: 160, + render: (text) => text ? dayjs(text).format('YYYY-MM-DD HH:mm') : '-', + }, + { + title: '创建时间', + dataIndex: 'created_at', + key: 'created_at', + width: 160, + render: (text) => dayjs(text).format('YYYY-MM-DD HH:mm'), + }, + { + title: '操作', + key: 'action', + width: 180, + fixed: 'right', + render: (_, record) => ( + + {record.is_trusted ? ( + + ) : ( + + )} + void handleDelete(record)} + > + + + + ), + }, + ] + + // 分页配置 + const paginationConfig: TablePaginationConfig = { + current: page, + pageSize, + total, + showSizeChanger: true, + showQuickJumper: true, + showTotal: (total) => `共 ${total} 条`, + onChange: (p, ps) => { + setPage(p) + setPageSize(ps) + }, + } + + if (error) { + return void fetchDevices()} /> + } + + return ( + + + + + } + /> + + {/* 筛选区域 */} + + + } + value={keyword} + onChange={(e) => setKeyword(e.target.value)} + onPressEnter={() => void fetchDevices()} + style={{ width: 200 }} + allowClear + /> + setUserIdFilter(e.target.value ? Number(e.target.value) : undefined)} + style={{ width: 100 }} + allowClear + /> + + + + + + + {/* 设备列表 */} + + + ), + }} + /> + + + ) +} diff --git a/frontend/admin/src/pages/admin/DevicesPage/index.ts b/frontend/admin/src/pages/admin/DevicesPage/index.ts new file mode 100644 index 0000000..86b8ff7 --- /dev/null +++ b/frontend/admin/src/pages/admin/DevicesPage/index.ts @@ -0,0 +1 @@ +export { DevicesPage } from './DevicesPage' diff --git a/frontend/admin/src/pages/admin/ImportExportPage/ImportExportPage.test.tsx b/frontend/admin/src/pages/admin/ImportExportPage/ImportExportPage.test.tsx new file mode 100644 index 0000000..f8ddbc8 --- /dev/null +++ b/frontend/admin/src/pages/admin/ImportExportPage/ImportExportPage.test.tsx @@ -0,0 +1,194 @@ +import { render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { message } from 'antd' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { ImportUsersResult } from '@/types/import-export' +import { ImportExportPage } from './ImportExportPage' + +const downloadImportTemplateMock = vi.fn<(format: 'csv' | 'xlsx') => Promise>() +const exportUsersMock = vi.fn<(payload: unknown) => Promise>() +const importUsersMock = vi.fn<(file: File) => Promise>() +const realGetComputedStyle = window.getComputedStyle.bind(window) + +vi.mock('@/services/import-export', () => ({ + downloadImportTemplate: (format: 'csv' | 'xlsx') => downloadImportTemplateMock(format), + exportUsers: (payload: unknown) => exportUsersMock(payload), + importUsers: (file: File) => importUsersMock(file), +})) + +function getUploadInput() { + const uploadInput = document.querySelector('input[type="file"]') + + expect(uploadInput).not.toBeNull() + + return uploadInput as HTMLInputElement +} + +describe('ImportExportPage', () => { + beforeEach(() => { + downloadImportTemplateMock.mockReset() + exportUsersMock.mockReset() + importUsersMock.mockReset() + + vi.spyOn(message, 'success').mockImplementation(() => undefined as never) + vi.spyOn(message, 'error').mockImplementation(() => undefined as never) + vi.spyOn(window, 'getComputedStyle').mockImplementation((element) => realGetComputedStyle(element)) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('downloads the default import template format', async () => { + const user = userEvent.setup() + downloadImportTemplateMock.mockResolvedValue(undefined) + + render() + const importPanel = screen.getByRole('tabpanel', { name: /导入用户/ }) + + await user.click(within(importPanel).getByRole('button', { name: /下载模板/ })) + + await waitFor(() => expect(downloadImportTemplateMock).toHaveBeenCalledWith('csv')) + expect(message.success).toHaveBeenCalledTimes(1) + }) + + it('switches the import format and surfaces template download failures', async () => { + const user = userEvent.setup() + downloadImportTemplateMock.mockRejectedValue(new Error('template failed')) + + render() + const importPanel = screen.getByRole('tabpanel', { name: /导入用户/ }) + + await user.click(within(importPanel).getByRole('radio', { name: 'Excel (.xlsx)' })) + await user.click(within(importPanel).getByRole('button', { name: /下载模板/ })) + + await waitFor(() => expect(downloadImportTemplateMock).toHaveBeenCalledWith('xlsx')) + await waitFor(() => expect(message.error).toHaveBeenCalledWith('template failed')) + }) + + it('rejects unsupported and oversized import files before hitting the service', async () => { + const user = userEvent.setup({ applyAccept: false }) + + render() + + await user.upload(getUploadInput(), new File(['hello'], 'users.txt', { type: 'text/plain' })) + await waitFor(() => expect(message.error).toHaveBeenCalledTimes(1)) + + const oversizedFile = new File([new Uint8Array(11 * 1024 * 1024)], 'users.csv', { type: 'text/csv' }) + await user.upload(getUploadInput(), oversizedFile) + + await waitFor(() => expect(message.error).toHaveBeenCalledTimes(2)) + expect(importUsersMock).not.toHaveBeenCalled() + }) + + it('surfaces backend import failures for supported files', async () => { + const user = userEvent.setup() + importUsersMock.mockRejectedValue(new Error('import failed')) + + render() + + const csvFile = new File(['username,email'], 'users.csv', { type: 'text/csv' }) + await user.upload(getUploadInput(), csvFile) + + await waitFor(() => expect(importUsersMock).toHaveBeenCalledTimes(1)) + await waitFor(() => expect(message.error).toHaveBeenCalledWith('import failed')) + }) + + it('submits a csv import and renders a success result summary', async () => { + const user = userEvent.setup() + importUsersMock.mockResolvedValue({ + success_count: 2, + fail_count: 0, + errors: [], + message: '导入完成', + }) + + render() + + const csvFile = new File(['username,email'], 'users.csv', { type: 'text/csv' }) + await user.upload(getUploadInput(), csvFile) + + await waitFor(() => expect(importUsersMock).toHaveBeenCalledTimes(1)) + expect(await screen.findByText('成功 2 条,失败 0 条')).toBeInTheDocument() + expect(document.querySelector('.ant-alert-success')).not.toBeNull() + }) + + it('submits an xlsx import, renders warning details, and resets the import flow', async () => { + const user = userEvent.setup() + importUsersMock.mockResolvedValue({ + success_count: 1, + fail_count: 2, + errors: ['row 2 invalid email', 'row 3 duplicate username'], + message: '', + }) + + render() + + const xlsxFile = new File(['binary'], 'users.xlsx', { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }) + await user.upload(getUploadInput(), xlsxFile) + + await waitFor(() => expect(importUsersMock).toHaveBeenCalledTimes(1)) + expect(await screen.findByText('row 2 invalid email')).toBeInTheDocument() + expect(screen.getByText('row 3 duplicate username')).toBeInTheDocument() + expect(document.querySelector('.ant-alert-warning')).not.toBeNull() + expect(message.success).toHaveBeenCalledTimes(1) + + await user.click(screen.getByRole('button', { name: '继续导入' })) + + expect(screen.queryByText('row 2 invalid email')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: /下载模板/ })).toBeInTheDocument() + }) + + it('requires at least one export field and submits the selected export payload', async () => { + const user = userEvent.setup() + exportUsersMock.mockResolvedValue(undefined) + + render() + + await user.click(screen.getByRole('tab', { name: /导出用户/ })) + const exportPanel = screen.getByRole('tabpanel', { name: /导出用户/ }) + + await user.click(within(exportPanel).getByRole('button', { name: '清空' })) + await user.click(within(exportPanel).getByRole('button', { name: /导出用户数据/ })) + + expect(exportUsersMock).not.toHaveBeenCalled() + expect(message.error).toHaveBeenCalledTimes(1) + + await user.click(within(exportPanel).getByRole('button', { name: '全选' })) + await user.type(within(exportPanel).getByRole('textbox'), 'alice') + await user.click(within(exportPanel).getByRole('radio', { name: /CSV \(\.csv\)/ })) + await user.click(within(exportPanel).getByRole('button', { name: /导出用户数据/ })) + + await waitFor(() => expect(exportUsersMock).toHaveBeenCalledWith({ + format: 'csv', + fields: ['id', 'username', 'email', 'phone', 'nickname', 'status', 'totp_enabled', 'last_login_time', 'created_at'], + keyword: 'alice', + status: undefined, + })) + expect(message.success).toHaveBeenCalledTimes(1) + }) + + it('updates export field selection and surfaces export failures with an empty keyword', async () => { + const user = userEvent.setup() + exportUsersMock.mockRejectedValue(new Error('export failed')) + + render() + + await user.click(screen.getByRole('tab', { name: /导出用户/ })) + const exportPanel = screen.getByRole('tabpanel', { name: /导出用户/ }) + + await user.click(within(exportPanel).getByRole('checkbox', { name: /ID/ })) + await user.click(within(exportPanel).getByRole('button', { name: /导出用户数据/ })) + + await waitFor(() => expect(exportUsersMock).toHaveBeenCalledWith({ + format: 'xlsx', + fields: ['username', 'email', 'phone', 'status', 'created_at'], + keyword: undefined, + status: undefined, + })) + await waitFor(() => expect(message.error).toHaveBeenCalledWith('export failed')) + }) +}) diff --git a/frontend/admin/src/pages/admin/ImportExportPage/ImportExportPage.tsx b/frontend/admin/src/pages/admin/ImportExportPage/ImportExportPage.tsx new file mode 100644 index 0000000..1d77801 --- /dev/null +++ b/frontend/admin/src/pages/admin/ImportExportPage/ImportExportPage.tsx @@ -0,0 +1,361 @@ +import { useState, type ChangeEvent } from 'react' +import { + Alert, + Button, + Card, + Checkbox, + Col, + Input, + Radio, + Row, + Select, + Space, + Steps, + Table, + Tabs, + Typography, + Upload, + message, +} from 'antd' +import { + DownloadOutlined, + FileExcelOutlined, + InboxOutlined, + UploadOutlined, +} from '@ant-design/icons' +import type { RcFile } from 'antd/es/upload' +import { getErrorMessage } from '@/lib/errors' +import { PageLayout, ContentCard } from '@/components/layout' +import { PageHeader } from '@/components/common' +import { + downloadImportTemplate, + exportUsers, + importUsers, +} from '@/services/import-export' +import type { ImportExportFormat, ImportUsersResult } from '@/types/import-export' + +const { Text } = Typography +const { Dragger } = Upload + +const exportableFields = [ + { key: 'id', label: '用户 ID' }, + { key: 'username', label: '用户名' }, + { key: 'email', label: '邮箱' }, + { key: 'phone', label: '手机号' }, + { key: 'nickname', label: '昵称' }, + { key: 'status', label: '状态' }, + { key: 'totp_enabled', label: 'TOTP 状态' }, + { key: 'last_login_time', label: '最后登录时间' }, + { key: 'created_at', label: '注册时间' }, +] + +export function ImportExportPage() { + const [activeTab, setActiveTab] = useState('import') + const [importLoading, setImportLoading] = useState(false) + const [importStep, setImportStep] = useState(0) + const [importFormat, setImportFormat] = useState('csv') + const [importResult, setImportResult] = useState(null) + const [exportLoading, setExportLoading] = useState(false) + const [exportFormat, setExportFormat] = useState('xlsx') + const [exportFields, setExportFields] = useState([ + 'id', + 'username', + 'email', + 'phone', + 'status', + 'created_at', + ]) + const [exportKeyword, setExportKeyword] = useState('') + const [exportStatus, setExportStatus] = useState() + + const handleImport = async (file: RcFile) => { + const lowerName = file.name.toLowerCase() + const isSupported = lowerName.endsWith('.csv') || lowerName.endsWith('.xlsx') + if (!isSupported) { + message.error('只能上传 CSV 或 XLSX 文件') + return false + } + + const isLt10M = file.size / 1024 / 1024 < 10 + if (!isLt10M) { + message.error('文件大小不能超过 10MB') + return false + } + + try { + setImportLoading(true) + setImportStep(1) + const result = await importUsers(file) + setImportResult(result) + setImportStep(2) + message.success(result.message || '导入完成') + } catch (error) { + setImportStep(0) + message.error(getErrorMessage(error, '导入失败')) + } finally { + setImportLoading(false) + } + + return false + } + + const handleDownloadTemplate = async () => { + try { + await downloadImportTemplate(importFormat) + message.success('模板下载已开始') + } catch (error) { + message.error(getErrorMessage(error, '下载模板失败')) + } + } + + const handleExport = async () => { + if (exportFields.length === 0) { + message.error('请至少选择一个导出字段') + return + } + + try { + setExportLoading(true) + await exportUsers({ + format: exportFormat, + fields: exportFields, + keyword: exportKeyword || undefined, + status: exportStatus, + }) + message.success('导出任务已开始') + } catch (error) { + message.error(getErrorMessage(error, '导出失败')) + } finally { + setExportLoading(false) + } + } + + const handleResetImport = () => { + setImportStep(0) + setImportResult(null) + } + + const importErrorRows = (importResult?.errors || []).map((error, index) => ({ + key: `${index}`, + index: index + 1, + message: error, + })) + + const tabItems = [ + { + key: 'import', + label: ( + + + 导入用户 + + ), + children: ( +
+ + + {importStep !== 2 && ( + <> + +

1. 仅支持 `.csv` 和 `.xlsx`。

+

2. 后端对单次上传限制为 10MB。

+

3. 导入结果返回成功数、失败数和错误列表。

+
+ )} + style={{ marginBottom: 16 }} + /> + + + + setImportFormat(event.target.value)}> + CSV (.csv) + Excel (.xlsx) + + + + + + { + void handleImport(file) + return false + }} + disabled={importLoading} + > +

+ +

+

点击或拖拽文件到此区域上传

+

+ 支持单次上传 CSV / XLSX 文件,文件大小不超过 10MB +

+
+ + )} + + {importStep === 2 && importResult && ( + + + 0 ? 'warning' : 'success'} + showIcon + message={importResult.message} + description={`成功 ${importResult.success_count} 条,失败 ${importResult.fail_count} 条`} + /> + {importErrorRows.length > 0 && ( +
+ )} + + + + )} + + ), + }, + { + key: 'export', + label: ( + + + 导出用户 + + ), + children: ( +
+ + + + +
+ ) => setExportKeyword(event.target.value)} + /> + + + { + const nextStart = event.target.value + setStart(nextStart) + onChange?.(null, [nextStart, end]) + }} + /> + { + const nextEnd = event.target.value + setEnd(nextEnd) + onChange?.(null, [start, nextEnd]) + }} + /> + + ) + } + + return { + Button: ({ + children, + onClick, + htmlType, + type: buttonType, + icon, + ...props + }: { + children?: ReactNode + onClick?: () => void + htmlType?: 'button' | 'submit' | 'reset' + [key: string]: unknown + }) => { + void buttonType + void icon + + return ( + + ) + }, + DatePicker: { RangePicker }, + Input: ({ + onPressEnter, + prefix, + allowClear, + ...props + }: { + onPressEnter?: () => void + prefix?: ReactNode + allowClear?: boolean + [key: string]: unknown + }) => { + void prefix + void allowClear + + return ( + { + if (event.key === 'Enter') { + onPressEnter?.() + } + }} + /> + ) + }, + Select: ({ + value, + onChange, + options = [], + placeholder, + allowClear, + ...props + }: { + value?: string | number + onChange?: (value: unknown) => void + options?: Array<{ value: string | number, label: ReactNode }> + placeholder?: string + allowClear?: boolean + [key: string]: unknown + }) => { + void allowClear + + return ( + + ) + }, + Space: ({ children }: { children?: ReactNode }) =>
{children}
, + Table: ({ + columns, + dataSource, + rowKey, + locale, + pagination, + }: { + columns: Array<{ + key?: string + title?: ReactNode + dataIndex?: string + render?: (value: unknown, record: Record, index: number) => ReactNode + }> + dataSource?: Array> + rowKey?: string | ((row: Record) => string | number) + locale?: { emptyText?: ReactNode } + pagination?: { + current?: number + pageSize?: number + total?: number + onChange?: (page: number, pageSize: number) => void + } + }) => { + const rows = dataSource ?? [] + + if (rows.length === 0) { + return
{locale?.emptyText ?? null}
+ } + + return ( +
+
+ + + {columns.map((column, index) => ( + + ))} + + + + {rows.map((record, rowIndex) => ( + + {columns.map((column, columnIndex) => { + const value = column.dataIndex ? record[column.dataIndex] : undefined + const content = column.render ? column.render(value, record, rowIndex) : value + return ( + + ) + })} + + ))} + +
{column.title}
+ {content as ReactNode} +
+ + {`${pagination?.current ?? 1}-${pagination?.pageSize ?? 20}-${pagination?.total ?? rows.length}`} + + ) + }, + Tag: ({ children }: { children?: ReactNode }) => {children}, + } +}) + +vi.mock('@ant-design/icons', () => ({ + EyeOutlined: () => eye, + ReloadOutlined: () => reload, + SearchOutlined: () => search, +})) + +vi.mock('@/components/common', () => ({ + PageHeader: ({ + title, + description, + actions, + }: { + title: ReactNode + description?: ReactNode + actions?: ReactNode + }) => ( +
+

{title}

+

{description}

+ {actions} +
+ ), +})) + +vi.mock('@/components/feedback', () => ({ + PageEmpty: ({ description }: { description?: ReactNode }) =>
{description ?? 'empty'}
, + PageError: ({ + description, + onRetry, + }: { + description?: ReactNode + onRetry?: () => void + }) => ( +
+

{description}

+ +
+ ), +})) + +vi.mock('@/components/layout', () => ({ + PageLayout: ({ children }: { children?: ReactNode }) =>
{children}
, + FilterCard: ({ children }: { children?: ReactNode }) =>
{children}
, + TableCard: ({ children }: { children?: ReactNode }) =>
{children}
, +})) + +vi.mock('@/services/login-logs', () => ({ + listLoginLogs: (params?: LoginLogListParams) => listLoginLogsMock(params), +})) + +vi.mock('./LoginLogDetailDrawer', () => ({ + LoginLogDetailDrawer: ({ + open, + log, + onClose, + }: { + open: boolean + log: LoginLog | null + onClose: () => void + }) => ( + open ? ( +
+ {`detail:${log?.id ?? 'none'}`} + +
+ ) : null + ), +})) + +function buildLog(id: number, userId: number, status: 0 | 1): LoginLog { + return { + id, + user_id: userId, + login_type: 1, + device_id: `device-${id}`, + ip: `10.0.0.${id}`, + location: 'Shanghai', + status, + fail_reason: status === 0 ? 'bad password' : undefined, + created_at: `2026-03-27 0${id}:00:00`, + } +} + +describe('LoginLogsPage', () => { + let currentLogs: LoginLog[] + + beforeEach(() => { + currentLogs = [ + buildLog(1, 7, 1), + buildLog(2, 8, 0), + buildLog(3, 7, 0), + ] + + listLoginLogsMock.mockReset() + listLoginLogsMock.mockImplementation(async (params?: LoginLogListParams) => { + const page = params?.page ?? 1 + const pageSize = params?.page_size ?? 20 + + let items = currentLogs + + if (params?.user_id !== undefined) { + items = items.filter((log) => log.user_id === params.user_id) + } + + if (params?.status !== undefined) { + items = items.filter((log) => log.status === params.status) + } + + const start = (page - 1) * pageSize + const pagedItems = items.slice(start, start + pageSize) + + return { + items: pagedItems.map((log) => ({ ...log })), + total: items.length, + page, + page_size: pageSize, + } + }) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('loads, filters, resets, paginates, refreshes, and opens detail drawer', async () => { + const user = userEvent.setup() + + render() + + expect(await screen.findByText('10.0.0.1')).toBeInTheDocument() + expect(screen.getByText('10.0.0.2')).toBeInTheDocument() + expect(listLoginLogsMock).toHaveBeenLastCalledWith(expect.objectContaining({ + page: 1, + page_size: 20, + user_id: undefined, + status: undefined, + })) + + const [refreshButton, searchButton, resetButton] = screen.getAllByRole('button').slice(0, 3) + const [userIdInput] = screen.getAllByRole('textbox') + const statusSelect = screen.getByRole('combobox') + + await user.clear(userIdInput) + await user.type(userIdInput, '7abc') + await user.selectOptions(statusSelect, '0') + await user.click(searchButton) + + await waitFor(() => expect(screen.queryByText('10.0.0.1')).not.toBeInTheDocument()) + expect(screen.getByText('10.0.0.3')).toBeInTheDocument() + expect(listLoginLogsMock).toHaveBeenLastCalledWith(expect.objectContaining({ + user_id: 7, + status: 0, + })) + + await user.click(resetButton) + + await waitFor(() => expect(screen.getByText('10.0.0.1')).toBeInTheDocument()) + expect(screen.getByText('10.0.0.2')).toBeInTheDocument() + expect(listLoginLogsMock).toHaveBeenLastCalledWith(expect.objectContaining({ + user_id: undefined, + status: undefined, + })) + + const callCountBeforeRefresh = listLoginLogsMock.mock.calls.length + await user.click(refreshButton) + await waitFor(() => expect(listLoginLogsMock.mock.calls.length).toBeGreaterThan(callCountBeforeRefresh)) + + await user.click(screen.getByRole('button', { name: 'paginate' })) + await waitFor(() => expect(listLoginLogsMock).toHaveBeenLastCalledWith(expect.objectContaining({ + page: 1, + page_size: 50, + }))) + + const firstRow = screen.getByTestId('table-row-1') + await user.click(within(firstRow).getByRole('button')) + expect(screen.getByTestId('login-log-detail-drawer')).toHaveTextContent('detail:1') + + await user.click(screen.getByRole('button', { name: 'close detail' })) + await waitFor(() => expect(screen.queryByTestId('login-log-detail-drawer')).not.toBeInTheDocument()) + }) + + it('renders the page error state and retries fetching', async () => { + const user = userEvent.setup() + + listLoginLogsMock.mockReset() + listLoginLogsMock.mockRejectedValueOnce(new Error('login logs failed')) + listLoginLogsMock.mockResolvedValue({ + items: currentLogs.map((log) => ({ ...log })), + total: currentLogs.length, + page: 1, + page_size: 20, + }) + + render() + + expect(await screen.findByText('login logs failed')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'retry' })) + + await waitFor(() => expect(screen.getByText('10.0.0.1')).toBeInTheDocument()) + expect(listLoginLogsMock).toHaveBeenCalledTimes(2) + }) +}) diff --git a/frontend/admin/src/pages/admin/LoginLogsPage/LoginLogsPage.tsx b/frontend/admin/src/pages/admin/LoginLogsPage/LoginLogsPage.tsx new file mode 100644 index 0000000..1697e65 --- /dev/null +++ b/frontend/admin/src/pages/admin/LoginLogsPage/LoginLogsPage.tsx @@ -0,0 +1,272 @@ +import { useCallback, useEffect, useState } from 'react' +import { + Button, + DatePicker, + Input, + message, + Select, + Space, + Table, + Tag, + type TableColumnsType, + type TablePaginationConfig, +} from 'antd' +import { DownloadOutlined, EyeOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons' +import dayjs from 'dayjs' +import { PageHeader } from '@/components/common' +import { PageEmpty, PageError } from '@/components/feedback' +import { PageLayout, FilterCard, TableCard } from '@/components/layout' +import { getErrorMessage } from '@/lib/errors' +import { exportLoginLogs, listLoginLogs } from '@/services/login-logs' +import { + LoginStatusColor, + LoginStatusText, + LoginTypeText, + type LoginLog, + type LoginLogListParams, + type LoginStatus, +} from '@/types/login-log' +import { LoginLogDetailDrawer } from './LoginLogDetailDrawer' + +const { RangePicker } = DatePicker + +export function LoginLogsPage() { + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [logs, setLogs] = useState([]) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) + const [userId, setUserId] = useState('') + const [statusFilter, setStatusFilter] = useState() + const [startAt, setStartAt] = useState() + const [endAt, setEndAt] = useState() + const [detailVisible, setDetailVisible] = useState(false) + const [selectedLog, setSelectedLog] = useState(null) + + const fetchLogs = useCallback(async () => { + setLoading(true) + setError(null) + + try { + const params: LoginLogListParams = { + page, + page_size: pageSize, + user_id: userId ? Number(userId) : undefined, + status: statusFilter, + start_at: startAt, + end_at: endAt, + } + const result = await listLoginLogs(params) + setLogs(result.items) + setTotal(result.total) + } catch (err) { + setError(getErrorMessage(err, '获取登录日志失败')) + } finally { + setLoading(false) + } + }, [endAt, page, pageSize, startAt, statusFilter, userId]) + + useEffect(() => { + void fetchLogs() + }, [fetchLogs]) + + // 筛选条件变化时重置到第一页 + useEffect(() => { + setPage(1) + }, [userId, statusFilter, startAt, endAt]) + + const handleReset = () => { + setUserId('') + setStatusFilter(undefined) + setStartAt(undefined) + setEndAt(undefined) + setPage(1) + } + + const handleExport = async () => { + try { + await exportLoginLogs({ + user_id: userId ? Number(userId) : undefined, + status: statusFilter, + format: 'csv', + start_at: startAt, + end_at: endAt, + }) + message.success('导出成功') + } catch (err) { + message.error(getErrorMessage(err, '导出失败')) + } + } + + const columns: TableColumnsType = [ + { + title: 'ID', + dataIndex: 'id', + key: 'id', + width: 80, + }, + { + title: '用户 ID', + dataIndex: 'user_id', + key: 'user_id', + width: 100, + render: (value) => value ?? '-', + }, + { + title: '登录类型', + dataIndex: 'login_type', + key: 'login_type', + width: 140, + render: (value: LoginLog['login_type']) => LoginTypeText[value] || value, + }, + { + title: '设备 ID', + dataIndex: 'device_id', + key: 'device_id', + width: 180, + render: (value) => value || '-', + }, + { + title: 'IP', + dataIndex: 'ip', + key: 'ip', + width: 140, + }, + { + title: '位置', + dataIndex: 'location', + key: 'location', + width: 180, + render: (value) => value || '-', + }, + { + title: '状态', + dataIndex: 'status', + key: 'status', + width: 100, + render: (status: LoginLog['status']) => ( + {LoginStatusText[status]} + ), + }, + { + title: '失败原因', + dataIndex: 'fail_reason', + key: 'fail_reason', + width: 220, + render: (value) => value || '-', + }, + { + title: '登录时间', + dataIndex: 'created_at', + key: 'created_at', + width: 180, + render: (value) => dayjs(value).format('YYYY-MM-DD HH:mm:ss'), + }, + { + title: '操作', + key: 'action', + width: 100, + fixed: 'right', + render: (_, record) => ( + + ), + }, + ] + + const paginationConfig: TablePaginationConfig = { + current: page, + pageSize, + total, + showSizeChanger: true, + showQuickJumper: true, + showTotal: (value) => `共 ${value} 条`, + onChange: (current, size) => { + setPage(current) + setPageSize(size) + }, + } + + if (error) { + return + } + + return ( + + + + + + )} + /> + + + + } + value={userId} + onChange={(event) => setUserId(event.target.value.replace(/[^\d]/g, ''))} + onPressEnter={() => void fetchLogs()} + style={{ width: 160 }} + allowClear + /> + { + const nextStart = event.target.value + setStart(nextStart) + onChange?.(null, [nextStart, end]) + }} + /> + { + const nextEnd = event.target.value + setEnd(nextEnd) + onChange?.(null, [start, nextEnd]) + }} + /> + + ) + } + + return { + Button: ({ + children, + onClick, + htmlType, + type: buttonType, + icon, + ...props + }: { + children?: ReactNode + onClick?: () => void + htmlType?: 'button' | 'submit' | 'reset' + [key: string]: unknown + }) => { + void buttonType + void icon + + return ( + + ) + }, + DatePicker: { RangePicker }, + Input: ({ + onPressEnter, + prefix, + allowClear, + ...props + }: { + onPressEnter?: () => void + prefix?: ReactNode + allowClear?: boolean + [key: string]: unknown + }) => { + void prefix + void allowClear + + return ( + { + if (event.key === 'Enter') { + onPressEnter?.() + } + }} + /> + ) + }, + Select: ({ + value, + onChange, + options = [], + placeholder, + allowClear, + ...props + }: { + value?: string | number + onChange?: (value: unknown) => void + options?: Array<{ value: string | number, label: ReactNode }> + placeholder?: string + allowClear?: boolean + [key: string]: unknown + }) => { + void allowClear + + return ( + + ) + }, + Space: ({ children }: { children?: ReactNode }) =>
{children}
, + Table: ({ + columns, + dataSource, + rowKey, + locale, + pagination, + }: { + columns: Array<{ + key?: string + title?: ReactNode + dataIndex?: string + render?: (value: unknown, record: Record, index: number) => ReactNode + }> + dataSource?: Array> + rowKey?: string | ((row: Record) => string | number) + locale?: { emptyText?: ReactNode } + pagination?: { + current?: number + pageSize?: number + total?: number + onChange?: (page: number, pageSize: number) => void + } + }) => { + const rows = dataSource ?? [] + + if (rows.length === 0) { + return
{locale?.emptyText ?? null}
+ } + + return ( +
+ + + + {columns.map((column, index) => ( + + ))} + + + + {rows.map((record, rowIndex) => ( + + {columns.map((column, columnIndex) => { + const value = column.dataIndex ? record[column.dataIndex] : undefined + const content = column.render ? column.render(value, record, rowIndex) : value + return ( + + ) + })} + + ))} + +
{column.title}
+ {content as ReactNode} +
+ + {`${pagination?.current ?? 1}-${pagination?.pageSize ?? 20}-${pagination?.total ?? rows.length}`} +
+ ) + }, + Tag: ({ children }: { children?: ReactNode }) => {children}, + } +}) + +vi.mock('@ant-design/icons', () => ({ + EyeOutlined: () => eye, + ReloadOutlined: () => reload, + SearchOutlined: () => search, +})) + +vi.mock('@/components/common', () => ({ + PageHeader: ({ + title, + description, + actions, + }: { + title: ReactNode + description?: ReactNode + actions?: ReactNode + }) => ( +
+

{title}

+

{description}

+ {actions} +
+ ), +})) + +vi.mock('@/components/feedback', () => ({ + PageEmpty: ({ description }: { description?: ReactNode }) =>
{description ?? 'empty'}
, + PageError: ({ + description, + onRetry, + }: { + description?: ReactNode + onRetry?: () => void + }) => ( +
+

{description}

+ +
+ ), +})) + +vi.mock('@/components/layout', () => ({ + PageLayout: ({ children }: { children?: ReactNode }) =>
{children}
, + FilterCard: ({ children }: { children?: ReactNode }) =>
{children}
, + TableCard: ({ children }: { children?: ReactNode }) =>
{children}
, +})) + +vi.mock('@/services/operation-logs', () => ({ + listOperationLogs: (params?: OperationLogListParams) => listOperationLogsMock(params), +})) + +vi.mock('./OperationLogDetailDrawer', () => ({ + OperationLogDetailDrawer: ({ + open, + log, + onClose, + }: { + open: boolean + log: OperationLog | null + onClose: () => void + }) => ( + open ? ( +
+ {`detail:${log?.id ?? 'none'}`} + +
+ ) : null + ), +})) + +function buildLog(id: number, userId: number, requestMethod: string, operationName: string): OperationLog { + return { + id, + user_id: userId, + operation_type: 'user', + operation_name: operationName, + request_method: requestMethod, + request_path: `/api/v1/logs/${id}`, + request_params: `{"id":${id}}`, + response_status: requestMethod === 'GET' ? 200 : 500, + ip: `10.0.1.${id}`, + user_agent: 'Chrome', + created_at: `2026-03-27 1${id}:00:00`, + } +} + +describe('OperationLogsPage', () => { + let currentLogs: OperationLog[] + + beforeEach(() => { + currentLogs = [ + buildLog(1, 9, 'GET', 'fetch users'), + buildLog(2, 9, 'POST', 'create user'), + buildLog(3, 12, 'DELETE', 'delete user'), + ] + + listOperationLogsMock.mockReset() + listOperationLogsMock.mockImplementation(async (params?: OperationLogListParams) => { + const page = params?.page ?? 1 + const pageSize = params?.page_size ?? 20 + + let items = currentLogs + + if (params?.user_id !== undefined) { + items = items.filter((log) => log.user_id === params.user_id) + } + + if (params?.method) { + items = items.filter((log) => log.request_method === params.method) + } + + if (params?.keyword) { + const keyword = params.keyword.toLowerCase() + items = items.filter((log) => ( + log.operation_name.toLowerCase().includes(keyword) || + log.request_path.toLowerCase().includes(keyword) + )) + } + + const start = (page - 1) * pageSize + const pagedItems = items.slice(start, start + pageSize) + + return { + items: pagedItems.map((log) => ({ ...log })), + total: items.length, + page, + page_size: pageSize, + } + }) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('loads, filters, resets, paginates, refreshes, and opens detail drawer', async () => { + const user = userEvent.setup() + + render() + + expect(await screen.findByText('fetch users')).toBeInTheDocument() + expect(screen.getByText('create user')).toBeInTheDocument() + expect(listOperationLogsMock).toHaveBeenLastCalledWith(expect.objectContaining({ + page: 1, + page_size: 20, + user_id: undefined, + method: undefined, + keyword: undefined, + })) + + const [refreshButton, searchButton, resetButton] = screen.getAllByRole('button').slice(0, 3) + const [userIdInput, keywordInput] = screen.getAllByRole('textbox') + const methodSelect = screen.getByRole('combobox') + + await user.type(userIdInput, '9') + await user.selectOptions(methodSelect, 'POST') + await user.type(keywordInput, 'create') + await user.click(searchButton) + + await waitFor(() => expect(screen.queryByText('fetch users')).not.toBeInTheDocument()) + expect(screen.getByText('create user')).toBeInTheDocument() + expect(listOperationLogsMock).toHaveBeenLastCalledWith(expect.objectContaining({ + user_id: 9, + method: 'POST', + keyword: 'create', + })) + + await user.click(resetButton) + + await waitFor(() => expect(screen.getByText('fetch users')).toBeInTheDocument()) + expect(screen.getByText('delete user')).toBeInTheDocument() + expect(listOperationLogsMock).toHaveBeenLastCalledWith(expect.objectContaining({ + user_id: undefined, + method: undefined, + keyword: undefined, + })) + + const callCountBeforeRefresh = listOperationLogsMock.mock.calls.length + await user.click(refreshButton) + await waitFor(() => expect(listOperationLogsMock.mock.calls.length).toBeGreaterThan(callCountBeforeRefresh)) + + await user.click(screen.getByRole('button', { name: 'paginate' })) + await waitFor(() => expect(listOperationLogsMock).toHaveBeenLastCalledWith(expect.objectContaining({ + page: 1, + page_size: 50, + }))) + + const firstRow = screen.getByTestId('table-row-1') + await user.click(within(firstRow).getByRole('button')) + expect(screen.getByTestId('operation-log-detail-drawer')).toHaveTextContent('detail:1') + + await user.click(screen.getByRole('button', { name: 'close detail' })) + await waitFor(() => expect(screen.queryByTestId('operation-log-detail-drawer')).not.toBeInTheDocument()) + }) + + it('renders the page error state and retries fetching', async () => { + const user = userEvent.setup() + + listOperationLogsMock.mockReset() + listOperationLogsMock.mockRejectedValueOnce(new Error('operation logs failed')) + listOperationLogsMock.mockResolvedValue({ + items: currentLogs.map((log) => ({ ...log })), + total: currentLogs.length, + page: 1, + page_size: 20, + }) + + render() + + expect(await screen.findByText('operation logs failed')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'retry' })) + + await waitFor(() => expect(screen.getByText('fetch users')).toBeInTheDocument()) + expect(listOperationLogsMock).toHaveBeenCalledTimes(2) + }) +}) diff --git a/frontend/admin/src/pages/admin/OperationLogsPage/OperationLogsPage.tsx b/frontend/admin/src/pages/admin/OperationLogsPage/OperationLogsPage.tsx new file mode 100644 index 0000000..5f39ae0 --- /dev/null +++ b/frontend/admin/src/pages/admin/OperationLogsPage/OperationLogsPage.tsx @@ -0,0 +1,256 @@ +import { useCallback, useEffect, useState } from 'react' +import { + Button, + DatePicker, + Input, + Select, + Space, + Table, + Tag, + type TableColumnsType, + type TablePaginationConfig, +} from 'antd' +import { EyeOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons' +import dayjs from 'dayjs' +import { PageHeader } from '@/components/common' +import { PageEmpty, PageError } from '@/components/feedback' +import { PageLayout, FilterCard, TableCard } from '@/components/layout' +import { getErrorMessage } from '@/lib/errors' +import { listOperationLogs } from '@/services/operation-logs' +import type { OperationLog, OperationLogListParams } from '@/types/operation-log' +import { OperationLogDetailDrawer } from './OperationLogDetailDrawer' + +const { RangePicker } = DatePicker + +export function OperationLogsPage() { + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [logs, setLogs] = useState([]) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) + const [userId, setUserId] = useState('') + const [method, setMethod] = useState() + const [keyword, setKeyword] = useState('') + const [startAt, setStartAt] = useState() + const [endAt, setEndAt] = useState() + const [detailVisible, setDetailVisible] = useState(false) + const [selectedLog, setSelectedLog] = useState(null) + + const fetchLogs = useCallback(async () => { + setLoading(true) + setError(null) + + try { + const params: OperationLogListParams = { + page, + page_size: pageSize, + user_id: userId ? Number(userId) : undefined, + method, + keyword: keyword || undefined, + start_at: startAt, + end_at: endAt, + } + const result = await listOperationLogs(params) + setLogs(result.items) + setTotal(result.total) + } catch (err) { + setError(getErrorMessage(err, '获取操作日志失败')) + } finally { + setLoading(false) + } + }, [endAt, keyword, method, page, pageSize, startAt, userId]) + + useEffect(() => { + void fetchLogs() + }, [fetchLogs]) + + // 筛选条件变化时重置到第一页 + useEffect(() => { + setPage(1) + }, [userId, method, keyword, startAt, endAt]) + + const handleReset = () => { + setUserId('') + setMethod(undefined) + setKeyword('') + setStartAt(undefined) + setEndAt(undefined) + setPage(1) + } + + const columns: TableColumnsType = [ + { + title: 'ID', + dataIndex: 'id', + key: 'id', + width: 80, + }, + { + title: '用户 ID', + dataIndex: 'user_id', + key: 'user_id', + width: 100, + render: (value) => value ?? '-', + }, + { + title: '操作类型', + dataIndex: 'operation_type', + key: 'operation_type', + width: 140, + }, + { + title: '操作名称', + dataIndex: 'operation_name', + key: 'operation_name', + width: 180, + }, + { + title: '请求方法', + dataIndex: 'request_method', + key: 'request_method', + width: 100, + render: (value) => {value}, + }, + { + title: '请求路径', + dataIndex: 'request_path', + key: 'request_path', + width: 260, + ellipsis: true, + }, + { + title: '状态码', + dataIndex: 'response_status', + key: 'response_status', + width: 100, + render: (value) => ( + = 200 && value < 300 ? 'success' : 'error'}>{value} + ), + }, + { + title: 'IP', + dataIndex: 'ip', + key: 'ip', + width: 140, + }, + { + title: '操作时间', + dataIndex: 'created_at', + key: 'created_at', + width: 180, + render: (value) => dayjs(value).format('YYYY-MM-DD HH:mm:ss'), + }, + { + title: '操作', + key: 'action', + width: 100, + fixed: 'right', + render: (_, record) => ( + + ), + }, + ] + + const paginationConfig: TablePaginationConfig = { + current: page, + pageSize, + total, + showSizeChanger: true, + showQuickJumper: true, + showTotal: (value) => `共 ${value} 条`, + onChange: (current, size) => { + setPage(current) + setPageSize(size) + }, + } + + if (error) { + return + } + + return ( + + } onClick={() => void fetchLogs()}> + 刷新 + + )} + /> + + + + } + value={userId} + onChange={(event) => setUserId(event.target.value.replace(/[^\d]/g, ''))} + onPressEnter={() => void fetchLogs()} + style={{ width: 160 }} + allowClear + /> + setKeyword(event.target.value)} + onPressEnter={() => void fetchLogs()} + style={{ width: 200 }} + allowClear + /> + { + setStartAt(dateStrings[0] || undefined) + setEndAt(dateStrings[1] || undefined) + }} + /> + + + + + + + }} + /> + + + setDetailVisible(false)} + /> + + ) +} diff --git a/frontend/admin/src/pages/admin/OperationLogsPage/index.ts b/frontend/admin/src/pages/admin/OperationLogsPage/index.ts new file mode 100644 index 0000000..a65b990 --- /dev/null +++ b/frontend/admin/src/pages/admin/OperationLogsPage/index.ts @@ -0,0 +1 @@ +export { OperationLogsPage } from './OperationLogsPage' diff --git a/frontend/admin/src/pages/admin/PermissionsPage/PermissionFormModal.test.tsx b/frontend/admin/src/pages/admin/PermissionsPage/PermissionFormModal.test.tsx new file mode 100644 index 0000000..42f0eca --- /dev/null +++ b/frontend/admin/src/pages/admin/PermissionsPage/PermissionFormModal.test.tsx @@ -0,0 +1,295 @@ +import type { ReactNode } from 'react' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { Permission } from '@/types/permission' +import { PermissionFormModal } from './PermissionFormModal' + +const createPermissionMock = vi.fn() +const updatePermissionMock = vi.fn() +const messageSuccessMock = vi.fn() +const messageErrorMock = vi.fn() +const formApi = { + validateFields: vi.fn(), + setFieldsValue: vi.fn(), + resetFields: vi.fn(), +} + +vi.mock('antd', () => { + const Form = ({ + children, + }: { + children?: ReactNode + }) =>
{children} + + const Select = Object.assign(({ + options = [], + children, + }: { + options?: Array<{ value: number | string, label: ReactNode }> + children?: ReactNode + }) => ( + + ), { + Option: ({ + value, + children, + }: { + value: string + children?: ReactNode + }) => , + }) + + return { + Modal: ({ + open, + title, + onOk, + onCancel, + children, + }: { + open: boolean + title: ReactNode + onOk?: () => void + onCancel?: () => void + children?: ReactNode + }) => ( + open ? ( +
+

{title}

+ {children} + + +
+ ) : null + ), + Form: Object.assign(Form, { + useForm: () => [formApi], + Item: ({ children }: { children?: ReactNode }) =>
{children}
, + }), + Input: ({ + placeholder, + disabled, + }: { + placeholder?: string + disabled?: boolean + }) => , + InputNumber: () => , + Select, + message: { + success: (content: string) => messageSuccessMock(content), + error: (content: string) => messageErrorMock(content), + }, + } +}) + +vi.mock('@/services/permissions', () => ({ + createPermission: (payload: unknown) => createPermissionMock(payload), + updatePermission: (id: number, payload: unknown) => updatePermissionMock(id, payload), +})) + +function buildPermission( + id: number, + name: string, + code: string, + parentId: number | null = null, + children?: Permission[], +): Permission { + return { + id, + name, + code, + type: 'menu', + parent_id: parentId, + path: `/admin/${code}`, + icon: `${code}-icon`, + sort: id, + status: 1, + children, + } +} + +describe('PermissionFormModal', () => { + beforeEach(() => { + createPermissionMock.mockReset() + updatePermissionMock.mockReset() + messageSuccessMock.mockReset() + messageErrorMock.mockReset() + formApi.validateFields.mockReset() + formApi.setFieldsValue.mockReset() + formApi.resetFields.mockReset() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('creates permissions, applies parent defaults, and resets on cancel', async () => { + const user = userEvent.setup() + const parentPermission = buildPermission(1, 'Dashboard', 'dashboard') + const permissions = [ + parentPermission, + buildPermission(2, 'Users', 'user:manage', 1), + ] + const onSuccess = vi.fn() + const onClose = vi.fn() + + formApi.validateFields.mockResolvedValue({ + name: 'Audit API', + code: 'audit:read', + type: 'api', + parent_id: 1, + path: '/admin/audit', + icon: 'AuditOutlined', + sort: undefined, + }) + createPermissionMock.mockResolvedValue(undefined) + + render( + , + ) + + expect(formApi.resetFields).toHaveBeenCalled() + expect(formApi.setFieldsValue).toHaveBeenCalledWith({ parent_id: 1 }) + expect(screen.getByText('创建子权限 - Dashboard')).toBeInTheDocument() + expect(screen.getByText('Dashboard')).toBeInTheDocument() + expect(screen.getByText('Users')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'modal ok' })) + + await waitFor(() => expect(createPermissionMock).toHaveBeenCalledWith({ + name: 'Audit API', + code: 'audit:read', + type: 'api', + parent_id: 1, + path: '/admin/audit', + icon: 'AuditOutlined', + sort: 0, + })) + expect(messageSuccessMock).toHaveBeenCalledWith('权限已创建') + expect(onSuccess).toHaveBeenCalledTimes(1) + + await user.click(screen.getByRole('button', { name: 'modal cancel' })) + expect(formApi.resetFields).toHaveBeenCalledTimes(2) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('prefills edit mode, excludes self from parent options, and updates editable fields only', async () => { + const user = userEvent.setup() + const permission = buildPermission(2, 'Users', 'user:manage', 1) + const permissions = [ + buildPermission(1, 'Dashboard', 'dashboard', null, [permission]), + buildPermission(3, 'Reports', 'reports:view'), + ] + + formApi.validateFields.mockResolvedValue({ + name: 'Users Updated', + code: 'ignored-code', + type: 'menu', + parent_id: 3, + path: '/admin/users', + icon: 'UserOutlined', + sort: 20, + }) + updatePermissionMock.mockResolvedValue(undefined) + + render( + , + ) + + expect(formApi.setFieldsValue).toHaveBeenCalledWith({ + name: 'Users', + code: 'user:manage', + type: 'menu', + parent_id: 1, + path: '/admin/user:manage', + icon: 'user:manage-icon', + sort: 2, + }) + expect(screen.getByText('编辑权限')).toBeInTheDocument() + expect(screen.getByText('Dashboard')).toBeInTheDocument() + expect(screen.getByText('Reports')).toBeInTheDocument() + expect(screen.queryByText('Users')).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'modal ok' })) + + await waitFor(() => expect(updatePermissionMock).toHaveBeenCalledWith(2, { + name: 'Users Updated', + path: '/admin/users', + icon: 'UserOutlined', + sort: 20, + })) + expect(messageSuccessMock).toHaveBeenCalledWith('权限已更新') + }) + + it('swallows validation errors and surfaces service failures', async () => { + const user = userEvent.setup() + + formApi.validateFields.mockRejectedValueOnce({ errorFields: [{ name: ['name'] }] }) + + const { rerender } = render( + , + ) + + await user.click(screen.getByRole('button', { name: 'modal ok' })) + + await waitFor(() => expect(createPermissionMock).not.toHaveBeenCalled()) + expect(updatePermissionMock).not.toHaveBeenCalled() + expect(messageErrorMock).not.toHaveBeenCalled() + + formApi.validateFields.mockResolvedValueOnce({ + name: 'Broken Permission', + code: 'broken:permission', + type: 'api', + parent_id: null, + path: '', + icon: '', + sort: 0, + }) + createPermissionMock.mockRejectedValueOnce(new Error('save failed')) + + rerender( + , + ) + + await user.click(screen.getByRole('button', { name: 'modal ok' })) + + await waitFor(() => expect(messageErrorMock).toHaveBeenCalledWith('save failed')) + }) +}) diff --git a/frontend/admin/src/pages/admin/PermissionsPage/PermissionFormModal.tsx b/frontend/admin/src/pages/admin/PermissionsPage/PermissionFormModal.tsx new file mode 100644 index 0000000..3e1d208 --- /dev/null +++ b/frontend/admin/src/pages/admin/PermissionsPage/PermissionFormModal.tsx @@ -0,0 +1,227 @@ +/** + * 权限创建/编辑弹窗 + */ + +import { useState, useEffect } from 'react' +import { + Modal, + Form, + Input, + Select, + InputNumber, + message, +} from 'antd' +import { getErrorMessage, isFormValidationError } from '@/lib/errors' +import type { Permission, PermissionType } from '@/types/permission' +import { PermissionTypeText } from '@/types/permission' +import { createPermission, updatePermission } from '@/services/permissions' + +interface PermissionFormModalProps { + open: boolean + permission: Permission | null + parentPermission: Permission | null + permissions: Permission[] + onSuccess: () => void + onClose: () => void +} + +export function PermissionFormModal({ + open, + permission, + parentPermission, + permissions, + onSuccess, + onClose, +}: PermissionFormModalProps) { + const [form] = Form.useForm() + const [loading, setLoading] = useState(false) + const isEdit = !!permission + + // 初始化表单 + useEffect(() => { + if (open) { + if (permission) { + form.setFieldsValue({ + name: permission.name, + code: permission.code, + type: permission.type, + parent_id: permission.parent_id, + path: permission.path, + icon: permission.icon, + sort: permission.sort, + }) + } else { + form.resetFields() + // 如果有父权限,设置 parent_id + if (parentPermission) { + form.setFieldsValue({ + parent_id: parentPermission.id, + }) + } + } + } + }, [open, permission, parentPermission, form]) + + // 提交表单 + const handleSubmit = async () => { + try { + const values = await form.validateFields() + setLoading(true) + + if (isEdit && permission) { + await updatePermission(permission.id, { + name: values.name, + path: values.path, + icon: values.icon, + sort: values.sort, + }) + message.success('权限已更新') + } else { + await createPermission({ + name: values.name, + code: values.code, + type: values.type, + parent_id: values.parent_id || null, + path: values.path, + icon: values.icon, + sort: values.sort || 0, + }) + message.success('权限已创建') + } + + onSuccess() + } catch (err) { + if (isFormValidationError(err)) { + return + } + message.error(getErrorMessage(err, '操作失败')) + } finally { + setLoading(false) + } + } + + // 关闭时重置 + const handleClose = () => { + form.resetFields() + onClose() + } + + // 构建父权限选项(树形结构) + const buildParentOptions = (perms: Permission[], level = 0): Array<{ value: number; label: string }> => { + const options: Array<{ value: number; label: string }> = [] + perms.forEach((perm) => { + // 排除当前权限及其子权限(编辑时不能选择自己或子权限作为父级) + if (isEdit && permission && perm.id === permission.id) { + return + } + options.push({ + value: perm.id, + label: `${' '.repeat(level)}${perm.name}`, + }) + if (perm.children && perm.children.length > 0) { + options.push(...buildParentOptions(perm.children, level + 1)) + } + }) + return options + } + + return ( + +
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ ) +} diff --git a/frontend/admin/src/pages/admin/PermissionsPage/PermissionsPage.module.css b/frontend/admin/src/pages/admin/PermissionsPage/PermissionsPage.module.css new file mode 100644 index 0000000..4125aaf --- /dev/null +++ b/frontend/admin/src/pages/admin/PermissionsPage/PermissionsPage.module.css @@ -0,0 +1,31 @@ +.treeCard { + margin-bottom: 16px; +} + +.treeNode { + display: flex; + align-items: center; + gap: 8px; +} + +.nodeInfo { + flex: 1; + display: flex; + align-items: center; + gap: 8px; +} + +.nodeName { + font-weight: 500; +} + +.nodeCode { + color: var(--color-text-muted); + font-family: monospace; + font-size: 12px; +} + +.nodeActions { + display: flex; + gap: 4px; +} diff --git a/frontend/admin/src/pages/admin/PermissionsPage/PermissionsPage.test.tsx b/frontend/admin/src/pages/admin/PermissionsPage/PermissionsPage.test.tsx new file mode 100644 index 0000000..839ae0b --- /dev/null +++ b/frontend/admin/src/pages/admin/PermissionsPage/PermissionsPage.test.tsx @@ -0,0 +1,403 @@ +import type { ReactElement, ReactNode } from 'react' +import { cloneElement, isValidElement } from 'react' +import { render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { Permission, PermissionStatus } from '@/types/permission' +import { PermissionsPage } from './PermissionsPage' + +const getPermissionTreeMock = vi.fn<() => Promise>() +const listPermissionsMock = vi.fn<() => Promise>() +const deletePermissionMock = vi.fn<(id: number) => Promise>() +const updatePermissionStatusMock = vi.fn<(id: number, status: PermissionStatus) => Promise>() +const messageSuccessMock = vi.fn() +const messageErrorMock = vi.fn() + +vi.mock('antd', async () => { + type MockTreeNode = { + key: string + title: ReactNode + children?: MockTreeNode[] + } + + function resolveRowKey>( + record: RecordType, + rowKey: string | ((row: RecordType) => string | number) | undefined, + index: number, + ): string { + if (typeof rowKey === 'function') { + return String(rowKey(record)) + } + if (typeof rowKey === 'string') { + return String(record[rowKey] ?? index) + } + return String(index) + } + + function renderTreeNodes(nodes: MockTreeNode[]): ReactNode { + return nodes.map((node) => ( +
+ {node.title} + {node.children ? renderTreeNodes(node.children) : null} +
+ )) + } + + return { + Button: ({ + children, + onClick, + htmlType, + type: buttonType, + icon, + danger, + ...props + }: { + children?: ReactNode + onClick?: () => void + htmlType?: 'button' | 'submit' | 'reset' + [key: string]: unknown + }) => { + void buttonType + void icon + void danger + + return ( + + ) + }, + Popconfirm: ({ + children, + onConfirm, + }: { + children: ReactElement + onConfirm?: () => void + }) => { + if (!isValidElement(children)) { + return children + } + + const child = children as ReactElement<{ onClick?: () => void }> + + return cloneElement(child, { + onClick: () => onConfirm?.(), + }) + }, + Segmented: ({ + options, + onChange, + }: { + options: Array<{ label: ReactNode, value: string }> + onChange?: (value: string) => void + }) => ( +
+ {options.map((option) => ( + + ))} +
+ ), + Space: ({ children }: { children?: ReactNode }) =>
{children}
, + Spin: ({ children }: { children?: ReactNode }) =>
{children}
, + Table: ({ + columns, + dataSource, + rowKey, + locale, + }: { + columns: Array<{ + key?: string + title?: ReactNode + dataIndex?: string + render?: (value: unknown, record: Record, index: number) => ReactNode + }> + dataSource?: Array> + rowKey?: string | ((row: Record) => string | number) + locale?: { emptyText?: ReactNode } + }) => { + const rows = dataSource ?? [] + + if (rows.length === 0) { + return
{locale?.emptyText ?? null}
+ } + + return ( +
+ + + {columns.map((column, index) => ( + + ))} + + + + {rows.map((record, rowIndex) => ( + + {columns.map((column, columnIndex) => { + const value = column.dataIndex ? record[column.dataIndex] : undefined + const content = column.render ? column.render(value, record, rowIndex) : value + return ( + + ) + })} + + ))} + +
{column.title}
+ {content as ReactNode} +
+ ) + }, + Tag: ({ children }: { children?: ReactNode }) => {children}, + Tree: ({ + treeData = [], + }: { + treeData?: MockTreeNode[] + }) =>
{renderTreeNodes(treeData)}
, + message: { + success: (content: string) => messageSuccessMock(content), + error: (content: string) => messageErrorMock(content), + }, + } +}) + +vi.mock('@ant-design/icons', () => ({ + DeleteOutlined: () => delete, + EditOutlined: () => edit, + PlusOutlined: () => plus, + ReloadOutlined: () => reload, +})) + +vi.mock('@/components/common', () => ({ + PageHeader: ({ + title, + description, + actions, + }: { + title: ReactNode + description?: ReactNode + actions?: ReactNode + }) => ( +
+

{title}

+

{description}

+ {actions} +
+ ), +})) + +vi.mock('@/components/feedback', () => ({ + PageEmpty: ({ description }: { description?: ReactNode }) =>
{description ?? 'empty'}
, + PageError: ({ + description, + onRetry, + }: { + description?: ReactNode + onRetry?: () => void + }) => ( +
+

{description}

+ +
+ ), +})) + +vi.mock('@/components/layout', () => ({ + PageLayout: ({ children }: { children?: ReactNode }) =>
{children}
, + TreeCard: ({ children }: { children?: ReactNode }) =>
{children}
, +})) + +vi.mock('@/services/permissions', () => ({ + getPermissionTree: () => getPermissionTreeMock(), + listPermissions: () => listPermissionsMock(), + deletePermission: (id: number) => deletePermissionMock(id), + updatePermissionStatus: (id: number, status: PermissionStatus) => updatePermissionStatusMock(id, status), +})) + +vi.mock('./PermissionFormModal', () => ({ + PermissionFormModal: ({ + open, + permission, + parentPermission, + onSuccess, + onClose, + }: { + open: boolean + permission: Permission | null + parentPermission: Permission | null + onSuccess: () => void + onClose: () => void + }) => ( + open ? ( +
+ {`permission-form:${permission?.code ?? 'create'}:${parentPermission?.code ?? 'root'}`} + + +
+ ) : null + ), +})) + +function buildPermission( + id: number, + name: string, + code: string, + status: PermissionStatus, + parentId: number | null = null, + children?: Permission[], +): Permission { + return { + id, + name, + code, + type: 'menu', + parent_id: parentId, + path: `/admin/${code}`, + icon: `${code}-icon`, + sort: id, + status, + children, + } +} + +function flattenPermissions(permissions: Permission[]): Permission[] { + return permissions.flatMap((permission) => [ + { ...permission, children: permission.children?.map((child) => ({ ...child })) }, + ...(permission.children ? flattenPermissions(permission.children) : []), + ]) +} + +function togglePermissionStatus(permissions: Permission[], id: number, status: PermissionStatus): Permission[] { + return permissions.map((permission) => ( + permission.id === id + ? { ...permission, status } + : { + ...permission, + children: permission.children ? togglePermissionStatus(permission.children, id, status) : undefined, + } + )) +} + +function deletePermissionFromTree(permissions: Permission[], id: number): Permission[] { + return permissions + .filter((permission) => permission.id !== id) + .map((permission) => ({ + ...permission, + children: permission.children ? deletePermissionFromTree(permission.children, id) : undefined, + })) +} + +describe('PermissionsPage', () => { + let currentTreePermissions: Permission[] + + beforeEach(() => { + currentTreePermissions = [ + buildPermission(1, 'Dashboard', 'dashboard', 1, null, [ + buildPermission(2, 'User Manage', 'user:manage', 1, 1), + ]), + buildPermission(3, 'Audit API', 'audit:read', 0), + ] + + getPermissionTreeMock.mockReset() + listPermissionsMock.mockReset() + deletePermissionMock.mockReset() + updatePermissionStatusMock.mockReset() + messageSuccessMock.mockReset() + messageErrorMock.mockReset() + + getPermissionTreeMock.mockImplementation(async () => currentTreePermissions.map((permission) => ({ ...permission }))) + listPermissionsMock.mockImplementation(async () => flattenPermissions(currentTreePermissions)) + deletePermissionMock.mockImplementation(async (id: number) => { + currentTreePermissions = deletePermissionFromTree(currentTreePermissions, id) + }) + updatePermissionStatusMock.mockImplementation(async (id: number, status: PermissionStatus) => { + currentTreePermissions = togglePermissionStatus(currentTreePermissions, id, status) + }) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('loads tree/list views and handles create/edit/create-child/refresh/toggle/delete flows', async () => { + const user = userEvent.setup() + + render() + + expect(await screen.findByText('Dashboard')).toBeInTheDocument() + expect(screen.getByText('User Manage')).toBeInTheDocument() + expect(getPermissionTreeMock).toHaveBeenCalledTimes(1) + expect(listPermissionsMock).toHaveBeenCalledTimes(1) + + const initialFetchCount = getPermissionTreeMock.mock.calls.length + + await user.click(screen.getByRole('button', { name: '创建权限' })) + expect(screen.getByTestId('permission-form-modal')).toHaveTextContent('permission-form:create:root') + + await user.click(screen.getByRole('button', { name: 'permission form success' })) + await waitFor(() => expect(getPermissionTreeMock.mock.calls.length).toBeGreaterThan(initialFetchCount)) + await waitFor(() => expect(screen.queryByTestId('permission-form-modal')).not.toBeInTheDocument()) + + const dashboardNode = screen.getByTestId('tree-node-1') + await user.click(within(dashboardNode).getAllByRole('button', { name: '添加子权限' })[0]) + expect(screen.getByTestId('permission-form-modal')).toHaveTextContent('permission-form:create:dashboard') + + await user.click(screen.getByRole('button', { name: 'permission form close' })) + await waitFor(() => expect(screen.queryByTestId('permission-form-modal')).not.toBeInTheDocument()) + + const childNode = screen.getByTestId('tree-node-2') + await user.click(within(childNode).getByRole('button', { name: '编辑' })) + expect(screen.getByTestId('permission-form-modal')).toHaveTextContent('permission-form:user:manage:root') + + await user.click(screen.getByRole('button', { name: 'permission form success' })) + await waitFor(() => expect(screen.queryByTestId('permission-form-modal')).not.toBeInTheDocument()) + + await user.click(within(childNode).getByRole('button', { name: '禁用' })) + await waitFor(() => expect(updatePermissionStatusMock).toHaveBeenCalledWith(2, 0)) + expect(messageSuccessMock).toHaveBeenCalledWith('状态已更新') + await waitFor(() => expect(within(screen.getByTestId('tree-node-2')).getByRole('button', { name: '启用' })).toBeInTheDocument()) + + const refreshCallCount = getPermissionTreeMock.mock.calls.length + await user.click(screen.getByRole('button', { name: '刷新' })) + await waitFor(() => expect(getPermissionTreeMock.mock.calls.length).toBeGreaterThan(refreshCallCount)) + + await user.click(screen.getByRole('button', { name: '列表' })) + expect(await screen.findByTestId('table-row-3')).toBeInTheDocument() + + await user.click(within(screen.getByTestId('table-row-3')).getByRole('button', { name: '删除' })) + await waitFor(() => expect(deletePermissionMock).toHaveBeenCalledWith(3)) + expect(messageSuccessMock).toHaveBeenCalledWith('权限已删除') + await waitFor(() => expect(screen.queryByTestId('table-row-3')).not.toBeInTheDocument()) + }) + + it('renders the page error state and retries fetching', async () => { + const user = userEvent.setup() + + getPermissionTreeMock.mockReset() + listPermissionsMock.mockReset() + getPermissionTreeMock.mockRejectedValueOnce(new Error('permissions failed')) + listPermissionsMock.mockResolvedValue([]) + getPermissionTreeMock.mockResolvedValue(currentTreePermissions) + listPermissionsMock.mockResolvedValue(flattenPermissions(currentTreePermissions)) + + render() + + expect(await screen.findByText('permissions failed')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'retry' })) + + await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument()) + expect(getPermissionTreeMock).toHaveBeenCalledTimes(2) + }) +}) diff --git a/frontend/admin/src/pages/admin/PermissionsPage/PermissionsPage.tsx b/frontend/admin/src/pages/admin/PermissionsPage/PermissionsPage.tsx new file mode 100644 index 0000000..3ffe22f --- /dev/null +++ b/frontend/admin/src/pages/admin/PermissionsPage/PermissionsPage.tsx @@ -0,0 +1,299 @@ +import { useCallback, useEffect, useState } from 'react' +import { + Button, + Popconfirm, + Segmented, + Space, + Spin, + Table, + Tag, + Tree, + message, + type TableColumnsType, + type TreeProps, +} from 'antd' +import { DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons' +import { PageHeader } from '@/components/common' +import { PageEmpty, PageError } from '@/components/feedback' +import { PageLayout, TreeCard } from '@/components/layout' +import { getErrorMessage } from '@/lib/errors' +import { + deletePermission, + getPermissionTree, + listPermissions, + updatePermissionStatus, +} from '@/services/permissions' +import type { Permission, PermissionStatus } from '@/types/permission' +import { + PermissionStatusColor, + PermissionStatusText, + PermissionTypeColor, + PermissionTypeText, +} from '@/types/permission' +import { PermissionFormModal } from './PermissionFormModal' +import styles from './PermissionsPage.module.css' + +type ViewMode = 'tree' | 'list' + +export function PermissionsPage() { + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [treePermissions, setTreePermissions] = useState([]) + const [flatPermissions, setFlatPermissions] = useState([]) + const [viewMode, setViewMode] = useState('tree') + const [formVisible, setFormVisible] = useState(false) + const [selectedPermission, setSelectedPermission] = useState(null) + const [parentPermission, setParentPermission] = useState(null) + + const fetchPermissions = useCallback(async () => { + setLoading(true) + setError(null) + + try { + const [treeResult, listResult] = await Promise.all([ + getPermissionTree(), + listPermissions(), + ]) + setTreePermissions(treeResult) + setFlatPermissions(listResult) + } catch (err) { + setError(getErrorMessage(err, '获取权限数据失败')) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + void fetchPermissions() + }, [fetchPermissions]) + + const handleCreate = () => { + setSelectedPermission(null) + setParentPermission(null) + setFormVisible(true) + } + + const handleCreateChild = (permission: Permission) => { + setSelectedPermission(null) + setParentPermission(permission) + setFormVisible(true) + } + + const handleEdit = (permission: Permission) => { + setSelectedPermission(permission) + setParentPermission(null) + setFormVisible(true) + } + + const handleDelete = async (id: number) => { + try { + await deletePermission(id) + message.success('权限已删除') + await fetchPermissions() + } catch (err) { + message.error(getErrorMessage(err, '删除失败')) + } + } + + const handleToggleStatus = async (permission: Permission) => { + const nextStatus: PermissionStatus = permission.status === 1 ? 0 : 1 + + try { + await updatePermissionStatus(permission.id, nextStatus) + message.success('状态已更新') + await fetchPermissions() + } catch (err) { + message.error(getErrorMessage(err, '状态更新失败')) + } + } + + const buildTreeData = (permissions: Permission[]): TreeProps['treeData'] => + permissions.map((permission) => ({ + key: String(permission.id), + title: ( +
+
+ {permission.name} + + {PermissionTypeText[permission.type]} + + {permission.code} + + {PermissionStatusText[permission.status]} + + {permission.path && ( + + {permission.path} + + )} +
+
event.stopPropagation()}> + + + void handleToggleStatus(permission)} + > + + + void handleDelete(permission.id)} + > + + +
+
+ ), + children: permission.children ? buildTreeData(permission.children) : undefined, + })) + + const listColumns: TableColumnsType = [ + { + title: '名称', + dataIndex: 'name', + key: 'name', + width: 180, + }, + { + title: '代码', + dataIndex: 'code', + key: 'code', + width: 220, + }, + { + title: '类型', + dataIndex: 'type', + key: 'type', + width: 120, + render: (value: Permission['type']) => ( + {PermissionTypeText[value]} + ), + }, + { + title: '路径', + dataIndex: 'path', + key: 'path', + width: 220, + render: (value) => value || '-', + }, + { + title: '状态', + dataIndex: 'status', + key: 'status', + width: 100, + render: (value: PermissionStatus) => ( + {PermissionStatusText[value]} + ), + }, + { + title: '操作', + key: 'action', + width: 240, + fixed: 'right', + render: (_, record) => ( + + + + void handleToggleStatus(record)} + > + + + void handleDelete(record.id)} + > + + + + ), + }, + ] + + if (error) { + return + } + + return ( + + + + value={viewMode} + onChange={(value) => setViewMode(value)} + options={[ + { label: '树形', value: 'tree' }, + { label: '列表', value: 'list' }, + ]} + /> + + +
+ )} + /> + + + + {viewMode === 'tree' ? ( + treePermissions.length > 0 ? ( + + ) : ( + + ) + ) : ( + }} + /> + )} + + + + { + setFormVisible(false) + void fetchPermissions() + }} + onClose={() => setFormVisible(false)} + /> + + ) +} diff --git a/frontend/admin/src/pages/admin/PermissionsPage/index.ts b/frontend/admin/src/pages/admin/PermissionsPage/index.ts new file mode 100644 index 0000000..cb7f70c --- /dev/null +++ b/frontend/admin/src/pages/admin/PermissionsPage/index.ts @@ -0,0 +1 @@ +export { PermissionsPage } from './PermissionsPage' diff --git a/frontend/admin/src/pages/admin/ProfilePage/ProfilePage.test.tsx b/frontend/admin/src/pages/admin/ProfilePage/ProfilePage.test.tsx new file mode 100644 index 0000000..bea58d5 --- /dev/null +++ b/frontend/admin/src/pages/admin/ProfilePage/ProfilePage.test.tsx @@ -0,0 +1,330 @@ +import type { ReactNode } from 'react' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import dayjs from 'dayjs' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { Role } from '@/types/auth' +import type { CurrentUserProfile } from '@/services/profile' +import { ProfilePage } from './ProfilePage' + +const useAuthMock = vi.fn() +const getCurrentProfileMock = vi.fn() +const updateProfileMock = vi.fn() +const messageSuccessMock = vi.fn() +const messageErrorMock = vi.fn() +const formApi = { + setFieldsValue: vi.fn(), +} + +let submittedFormValues: Record = {} + +vi.mock('antd', () => { + const Form = ({ + children, + onFinish, + }: { + children?: ReactNode + onFinish?: (values: Record) => void + }) => ( +
{ + event.preventDefault() + onFinish?.(submittedFormValues) + }} + > + {children} + + ) + + return { + Alert: ({ + message, + description, + }: { + message?: ReactNode + description?: ReactNode + }) => ( +
+
{message}
+
{description}
+
+ ), + Button: ({ + children, + htmlType, + onClick, + icon, + type: buttonType, + loading, + ...props + }: { + children?: ReactNode + htmlType?: 'button' | 'submit' | 'reset' + onClick?: () => void + [key: string]: unknown + }) => { + void icon + void buttonType + void loading + + return ( + + ) + }, + Col: ({ children }: { children?: ReactNode }) =>
{children}
, + DatePicker: ({ placeholder }: { placeholder?: string }) => , + Form: Object.assign(Form, { + useForm: () => [formApi], + Item: ({ children }: { children?: ReactNode }) =>
{children}
, + }), + Input: Object.assign(({ + placeholder, + }: { + placeholder?: string + }) => , { + TextArea: ({ placeholder }: { placeholder?: string }) =>