1
0

feat: 实现多主题系统,支持6套主题切换和设置页面

重构 ThemeContext 为多主题模型(themeId + followSystem + systemIsDark),
新增设置页面(主题下拉栏 + 跟随系统开关),移除旧 ThemeToggle 按钮,
引入 antd-style 和 clsx 依赖支持 MUI/shadcn/Bootstrap/玻璃主题。
This commit is contained in:
2026-04-17 00:06:08 +08:00
parent c5b3d9dfc7
commit ddd284c1ca
21 changed files with 1609 additions and 153 deletions

View File

@@ -9,6 +9,8 @@
"@ant-design/icons": "^6.1.1",
"@tanstack/react-query": "^5.80.2",
"antd": "^6.3.5",
"antd-style": "^4.1.0",
"clsx": "^2.1.1",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router": "^7.6.1",
@@ -166,14 +168,32 @@
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
"@emotion/babel-plugin": ["@emotion/babel-plugin@11.13.5", "https://registry.npmmirror.com/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", { "dependencies": { "@babel/helper-module-imports": "^7.16.7", "@babel/runtime": "^7.18.3", "@emotion/hash": "^0.9.2", "@emotion/memoize": "^0.9.0", "@emotion/serialize": "^1.3.3", "babel-plugin-macros": "^3.1.0", "convert-source-map": "^1.5.0", "escape-string-regexp": "^4.0.0", "find-root": "^1.1.0", "source-map": "^0.5.7", "stylis": "4.2.0" } }, "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ=="],
"@emotion/cache": ["@emotion/cache@11.14.0", "https://registry.npmmirror.com/@emotion/cache/-/cache-11.14.0.tgz", { "dependencies": { "@emotion/memoize": "^0.9.0", "@emotion/sheet": "^1.4.0", "@emotion/utils": "^1.4.2", "@emotion/weak-memoize": "^0.4.0", "stylis": "4.2.0" } }, "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA=="],
"@emotion/css": ["@emotion/css@11.13.5", "https://registry.npmmirror.com/@emotion/css/-/css-11.13.5.tgz", { "dependencies": { "@emotion/babel-plugin": "^11.13.5", "@emotion/cache": "^11.13.5", "@emotion/serialize": "^1.3.3", "@emotion/sheet": "^1.4.0", "@emotion/utils": "^1.4.2" } }, "sha512-wQdD0Xhkn3Qy2VNcIzbLP9MR8TafI0MJb7BEAXKp+w4+XqErksWR4OXomuDzPsN4InLdGhVe6EYcn2ZIUCpB8w=="],
"@emotion/hash": ["@emotion/hash@0.8.0", "https://registry.npmmirror.com/@emotion/hash/-/hash-0.8.0.tgz", {}, "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow=="],
"@emotion/is-prop-valid": ["@emotion/is-prop-valid@1.4.0", "https://registry.npmmirror.com/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", { "dependencies": { "@emotion/memoize": "^0.9.0" } }, "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw=="],
"@emotion/memoize": ["@emotion/memoize@0.9.0", "https://registry.npmmirror.com/@emotion/memoize/-/memoize-0.9.0.tgz", {}, "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ=="],
"@emotion/react": ["@emotion/react@11.14.0", "https://registry.npmmirror.com/@emotion/react/-/react-11.14.0.tgz", { "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", "@emotion/cache": "^11.14.0", "@emotion/serialize": "^1.3.3", "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", "@emotion/utils": "^1.4.2", "@emotion/weak-memoize": "^0.4.0", "hoist-non-react-statics": "^3.3.1" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA=="],
"@emotion/serialize": ["@emotion/serialize@1.3.3", "https://registry.npmmirror.com/@emotion/serialize/-/serialize-1.3.3.tgz", { "dependencies": { "@emotion/hash": "^0.9.2", "@emotion/memoize": "^0.9.0", "@emotion/unitless": "^0.10.0", "@emotion/utils": "^1.4.2", "csstype": "^3.0.2" } }, "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA=="],
"@emotion/sheet": ["@emotion/sheet@1.4.0", "https://registry.npmmirror.com/@emotion/sheet/-/sheet-1.4.0.tgz", {}, "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg=="],
"@emotion/unitless": ["@emotion/unitless@0.7.5", "https://registry.npmmirror.com/@emotion/unitless/-/unitless-0.7.5.tgz", {}, "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg=="],
"@emotion/use-insertion-effect-with-fallbacks": ["@emotion/use-insertion-effect-with-fallbacks@1.2.0", "https://registry.npmmirror.com/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", { "peerDependencies": { "react": ">=16.8.0" } }, "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg=="],
"@emotion/utils": ["@emotion/utils@1.4.2", "https://registry.npmmirror.com/@emotion/utils/-/utils-1.4.2.tgz", {}, "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA=="],
"@emotion/weak-memoize": ["@emotion/weak-memoize@0.4.0", "https://registry.npmmirror.com/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", {}, "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.27.7.tgz", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="],
@@ -560,6 +580,8 @@
"@types/node": ["@types/node@24.12.2", "https://registry.npmmirror.com/@types/node/-/node-24.12.2.tgz", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="],
"@types/parse-json": ["@types/parse-json@4.0.2", "https://registry.npmmirror.com/@types/parse-json/-/parse-json-4.0.2.tgz", {}, "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw=="],
"@types/react": ["@types/react@19.2.14", "https://registry.npmmirror.com/@types/react/-/react-19.2.14.tgz", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "https://registry.npmmirror.com/@types/react-dom/-/react-dom-19.2.3.tgz", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
@@ -622,6 +644,8 @@
"antd": ["antd@6.3.5", "https://registry.npmmirror.com/antd/-/antd-6.3.5.tgz", { "dependencies": { "@ant-design/colors": "^8.0.1", "@ant-design/cssinjs": "^2.1.2", "@ant-design/cssinjs-utils": "^2.1.2", "@ant-design/fast-color": "^3.0.1", "@ant-design/icons": "^6.1.1", "@ant-design/react-slick": "~2.0.0", "@babel/runtime": "^7.28.4", "@rc-component/cascader": "~1.14.0", "@rc-component/checkbox": "~2.0.0", "@rc-component/collapse": "~1.2.0", "@rc-component/color-picker": "~3.1.1", "@rc-component/dialog": "~1.8.4", "@rc-component/drawer": "~1.4.2", "@rc-component/dropdown": "~1.0.2", "@rc-component/form": "~1.8.0", "@rc-component/image": "~1.8.0", "@rc-component/input": "~1.1.2", "@rc-component/input-number": "~1.6.2", "@rc-component/mentions": "~1.6.0", "@rc-component/menu": "~1.2.0", "@rc-component/motion": "^1.3.2", "@rc-component/mutate-observer": "^2.0.1", "@rc-component/notification": "~1.2.0", "@rc-component/pagination": "~1.2.0", "@rc-component/picker": "~1.9.1", "@rc-component/progress": "~1.0.2", "@rc-component/qrcode": "~1.1.1", "@rc-component/rate": "~1.0.1", "@rc-component/resize-observer": "^1.1.2", "@rc-component/segmented": "~1.3.0", "@rc-component/select": "~1.6.15", "@rc-component/slider": "~1.0.1", "@rc-component/steps": "~1.2.2", "@rc-component/switch": "~1.0.3", "@rc-component/table": "~1.9.1", "@rc-component/tabs": "~1.7.0", "@rc-component/textarea": "~1.1.2", "@rc-component/tooltip": "~1.4.0", "@rc-component/tour": "~2.3.0", "@rc-component/tree": "~1.2.4", "@rc-component/tree-select": "~1.8.0", "@rc-component/trigger": "^3.9.0", "@rc-component/upload": "~1.1.0", "@rc-component/util": "^1.10.0", "clsx": "^2.1.1", "dayjs": "^1.11.11", "scroll-into-view-if-needed": "^3.1.0", "throttle-debounce": "^5.0.2" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-8BPz9lpZWQm42PTx7yL4KxWAotVuqINiKcoYRcLtdd5BFmAcAZicVyFTnBJyRDlzGZFZeRW3foGu6jXYFnej6Q=="],
"antd-style": ["antd-style@4.1.0", "https://registry.npmmirror.com/antd-style/-/antd-style-4.1.0.tgz", { "dependencies": { "@ant-design/cssinjs": "^2.0.0", "@babel/runtime": "^7.24.1", "@emotion/cache": "^11.11.0", "@emotion/css": "^11.11.2", "@emotion/react": "^11.11.4", "@emotion/serialize": "^1.1.3", "@emotion/utils": "^1.2.1", "use-merge-value": "^1.2.0" }, "peerDependencies": { "antd": ">=6.0.0", "react": ">=18" } }, "sha512-vnPBGg0OVlSz90KRYZhxd89aZiOImTiesF+9MQqN8jsLGZUQTjbP04X9jTdEfsztKUuMbBWg/RmB/wHTakbtMQ=="],
"argparse": ["argparse@2.0.1", "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"aria-query": ["aria-query@5.3.2", "https://registry.npmmirror.com/aria-query/-/aria-query-5.3.2.tgz", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
@@ -646,6 +670,8 @@
"available-typed-arrays": ["available-typed-arrays@1.0.7", "https://registry.npmmirror.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="],
"babel-plugin-macros": ["babel-plugin-macros@3.1.0", "https://registry.npmmirror.com/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", { "dependencies": { "@babel/runtime": "^7.12.5", "cosmiconfig": "^7.0.0", "resolve": "^1.19.0" } }, "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg=="],
"balanced-match": ["balanced-match@1.0.2", "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"base64-arraybuffer": ["base64-arraybuffer@1.0.2", "https://registry.npmmirror.com/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", {}, "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ=="],
@@ -704,6 +730,8 @@
"cookie": ["cookie@1.1.1", "https://registry.npmmirror.com/cookie/-/cookie-1.1.1.tgz", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
"cosmiconfig": ["cosmiconfig@7.1.0", "https://registry.npmmirror.com/cosmiconfig/-/cosmiconfig-7.1.0.tgz", { "dependencies": { "@types/parse-json": "^4.0.0", "import-fresh": "^3.2.1", "parse-json": "^5.0.0", "path-type": "^4.0.0", "yaml": "^1.10.0" } }, "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA=="],
"cross-spawn": ["cross-spawn@7.0.6", "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"css-color-keywords": ["css-color-keywords@1.0.0", "https://registry.npmmirror.com/css-color-keywords/-/css-color-keywords-1.0.0.tgz", {}, "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg=="],
@@ -810,6 +838,8 @@
"entities": ["entities@7.0.1", "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"error-ex": ["error-ex@1.3.4", "https://registry.npmmirror.com/error-ex/-/error-ex-1.3.4.tgz", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="],
"es-abstract": ["es-abstract@1.24.2", "https://registry.npmmirror.com/es-abstract/-/es-abstract-1.24.2.tgz", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg=="],
"es-define-property": ["es-define-property@1.0.1", "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
@@ -876,6 +906,8 @@
"file-entry-cache": ["file-entry-cache@8.0.0", "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
"find-root": ["find-root@1.1.0", "https://registry.npmmirror.com/find-root/-/find-root-1.1.0.tgz", {}, "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng=="],
"find-up": ["find-up@5.0.0", "https://registry.npmmirror.com/find-up/-/find-up-5.0.0.tgz", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
"flat-cache": ["flat-cache@4.0.1", "https://registry.npmmirror.com/flat-cache/-/flat-cache-4.0.1.tgz", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
@@ -946,6 +978,8 @@
"hermes-parser": ["hermes-parser@0.25.1", "https://registry.npmmirror.com/hermes-parser/-/hermes-parser-0.25.1.tgz", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
"hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "https://registry.npmmirror.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="],
"html-encoding-sniffer": ["html-encoding-sniffer@4.0.0", "https://registry.npmmirror.com/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", { "dependencies": { "whatwg-encoding": "^3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="],
"html-escaper": ["html-escaper@2.0.2", "https://registry.npmmirror.com/html-escaper/-/html-escaper-2.0.2.tgz", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="],
@@ -1056,6 +1090,8 @@
"json-buffer": ["json-buffer@3.0.1", "https://registry.npmmirror.com/json-buffer/-/json-buffer-3.0.1.tgz", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
"json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "https://registry.npmmirror.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="],
"json-schema-traverse": ["json-schema-traverse@0.4.1", "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "https://registry.npmmirror.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
@@ -1092,6 +1128,8 @@
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
"lines-and-columns": ["lines-and-columns@1.2.4", "https://registry.npmmirror.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
"locate-path": ["locate-path@6.0.0", "https://registry.npmmirror.com/locate-path/-/locate-path-6.0.0.tgz", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
"lodash": ["lodash@4.18.1", "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="],
@@ -1174,6 +1212,8 @@
"parent-module": ["parent-module@1.0.1", "https://registry.npmmirror.com/parent-module/-/parent-module-1.0.1.tgz", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
"parse-json": ["parse-json@5.2.0", "https://registry.npmmirror.com/parse-json/-/parse-json-5.2.0.tgz", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="],
"parse5": ["parse5@7.3.0", "https://registry.npmmirror.com/parse5/-/parse5-7.3.0.tgz", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
"path-exists": ["path-exists@4.0.0", "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
@@ -1186,6 +1226,8 @@
"path-to-regexp": ["path-to-regexp@6.3.0", "https://registry.npmmirror.com/path-to-regexp/-/path-to-regexp-6.3.0.tgz", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="],
"path-type": ["path-type@4.0.0", "https://registry.npmmirror.com/path-type/-/path-type-4.0.0.tgz", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="],
"pathe": ["pathe@2.0.3", "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"pathval": ["pathval@2.0.1", "https://registry.npmmirror.com/pathval/-/pathval-2.0.1.tgz", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="],
@@ -1288,6 +1330,8 @@
"simple-swizzle": ["simple-swizzle@0.2.4", "https://registry.npmmirror.com/simple-swizzle/-/simple-swizzle-0.2.4.tgz", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw=="],
"source-map": ["source-map@0.5.7", "https://registry.npmmirror.com/source-map/-/source-map-0.5.7.tgz", {}, "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ=="],
"source-map-js": ["source-map-js@1.2.1", "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"stackback": ["stackback@0.0.2", "https://registry.npmmirror.com/stackback/-/stackback-0.0.2.tgz", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
@@ -1396,6 +1440,8 @@
"uri-js": ["uri-js@4.4.1", "https://registry.npmmirror.com/uri-js/-/uri-js-4.4.1.tgz", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
"use-merge-value": ["use-merge-value@1.2.0", "https://registry.npmmirror.com/use-merge-value/-/use-merge-value-1.2.0.tgz", { "peerDependencies": { "react": ">= 16.x" } }, "sha512-DXgG0kkgJN45TcyoXL49vJnn55LehnrmoHc7MbKi+QDBvr8dsesqws8UlyIWGHMR+JXgxc1nvY+jDGMlycsUcw=="],
"utrie": ["utrie@1.0.2", "https://registry.npmmirror.com/utrie/-/utrie-1.0.2.tgz", { "dependencies": { "base64-arraybuffer": "^1.0.2" } }, "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw=="],
"vite": ["vite@8.0.8", "https://registry.npmmirror.com/vite/-/vite-8.0.8.tgz", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.15", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw=="],
@@ -1442,6 +1488,8 @@
"yallist": ["yallist@3.1.1", "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"yaml": ["yaml@1.10.3", "https://registry.npmmirror.com/yaml/-/yaml-1.10.3.tgz", {}, "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA=="],
"yargs": ["yargs@17.7.2", "https://registry.npmmirror.com/yargs/-/yargs-17.7.2.tgz", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
"yargs-parser": ["yargs-parser@21.1.1", "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-21.1.1.tgz", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
@@ -1470,6 +1518,18 @@
"@babel/core/json5": ["json5@2.2.3", "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"@emotion/babel-plugin/@emotion/hash": ["@emotion/hash@0.9.2", "https://registry.npmmirror.com/@emotion/hash/-/hash-0.9.2.tgz", {}, "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g=="],
"@emotion/babel-plugin/convert-source-map": ["convert-source-map@1.9.0", "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-1.9.0.tgz", {}, "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="],
"@emotion/babel-plugin/stylis": ["stylis@4.2.0", "https://registry.npmmirror.com/stylis/-/stylis-4.2.0.tgz", {}, "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw=="],
"@emotion/cache/stylis": ["stylis@4.2.0", "https://registry.npmmirror.com/stylis/-/stylis-4.2.0.tgz", {}, "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw=="],
"@emotion/serialize/@emotion/hash": ["@emotion/hash@0.9.2", "https://registry.npmmirror.com/@emotion/hash/-/hash-0.9.2.tgz", {}, "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g=="],
"@emotion/serialize/@emotion/unitless": ["@emotion/unitless@0.10.0", "https://registry.npmmirror.com/@emotion/unitless/-/unitless-0.10.0.tgz", {}, "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@eslint/eslintrc/globals": ["globals@14.0.0", "https://registry.npmmirror.com/globals/-/globals-14.0.0.tgz", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
@@ -1492,10 +1552,14 @@
"@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
"babel-plugin-macros/resolve": ["resolve@1.22.12", "https://registry.npmmirror.com/resolve/-/resolve-1.22.12.tgz", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="],
"cliui/wrap-ansi": ["wrap-ansi@7.0.0", "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"data-urls/whatwg-mimetype": ["whatwg-mimetype@4.0.0", "https://registry.npmmirror.com/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
"error-ex/is-arrayish": ["is-arrayish@0.2.1", "https://registry.npmmirror.com/is-arrayish/-/is-arrayish-0.2.1.tgz", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="],
"eslint-import-resolver-node/debug": ["debug@3.2.7", "https://registry.npmmirror.com/debug/-/debug-3.2.7.tgz", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
"eslint-module-utils/debug": ["debug@3.2.7", "https://registry.npmmirror.com/debug/-/debug-3.2.7.tgz", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
@@ -1504,6 +1568,8 @@
"glob/minimatch": ["minimatch@9.0.9", "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
"hoist-non-react-statics/react-is": ["react-is@16.13.1", "https://registry.npmmirror.com/react-is/-/react-is-16.13.1.tgz", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"jsdom/whatwg-mimetype": ["whatwg-mimetype@4.0.0", "https://registry.npmmirror.com/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
"make-dir/semver": ["semver@7.7.4", "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],

View File

@@ -18,6 +18,8 @@
"@ant-design/icons": "^6.1.1",
"@tanstack/react-query": "^5.80.2",
"antd": "^6.3.5",
"antd-style": "^4.1.0",
"clsx": "^2.1.1",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router": "^7.6.1"

View File

@@ -1,8 +1,9 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter } from 'react-router';
import { ConfigProvider, theme } from 'antd';
import { ConfigProvider } from 'antd';
import { AppRoutes } from '@/routes';
import { ThemeProvider, useTheme } from '@/contexts/ThemeContext';
import { useThemeConfig } from '@/themes';
const queryClient = new QueryClient({
defaultOptions: {
@@ -15,14 +16,11 @@ const queryClient = new QueryClient({
});
function ThemedApp() {
const { mode } = useTheme();
const { effectiveThemeId } = useTheme();
const configProps = useThemeConfig(effectiveThemeId);
return (
<ConfigProvider
theme={{
algorithm: mode === 'dark' ? theme.darkAlgorithm : theme.defaultAlgorithm,
}}
>
<ConfigProvider {...configProps}>
<BrowserRouter>
<AppRoutes />
</BrowserRouter>

View File

@@ -3,11 +3,8 @@ import { describe, it, expect, vi } from 'vitest';
import { BrowserRouter } from 'react-router';
import { AppLayout } from '@/components/AppLayout';
const mockToggleTheme = vi.fn();
const mockSetTheme = vi.fn();
vi.mock('@/contexts/ThemeContext', () => ({
useTheme: vi.fn(() => ({ mode: 'light', toggleTheme: mockToggleTheme, setTheme: mockSetTheme })),
useTheme: vi.fn(() => ({ effectiveThemeId: 'default', themeId: 'default', followSystem: false, systemIsDark: false, setThemeId: vi.fn(), setFollowSystem: vi.fn() })),
}));
const renderWithRouter = (component: React.ReactNode) => {
@@ -29,27 +26,17 @@ describe('AppLayout', () => {
expect(screen.getByText('用量统计')).toBeInTheDocument();
});
it('renders theme toggle button with visible color in sidebar', () => {
it('renders settings menu item', () => {
renderWithRouter(<AppLayout />);
const themeButton = screen.getByRole('button', { name: 'moon' });
expect(themeButton).toBeInTheDocument();
expect(themeButton.style.color).toBe('rgba(255, 255, 255, 0.85)');
expect(screen.getByText('设置')).toBeInTheDocument();
});
it('renders theme toggle button visible in dark mode', async () => {
const { useTheme } = await import('@/contexts/ThemeContext');
vi.mocked(useTheme).mockReturnValue({
mode: 'dark',
toggleTheme: mockToggleTheme,
setTheme: mockSetTheme,
});
it('does not render theme toggle button', () => {
renderWithRouter(<AppLayout />);
const themeButton = screen.getByRole('button', { name: 'sun' });
expect(themeButton).toBeInTheDocument();
expect(themeButton.style.color).toBe('rgba(255, 255, 255, 0.85)');
expect(screen.queryByRole('button', { name: 'moon' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'sun' })).not.toBeInTheDocument();
});
it('renders content outlet', () => {
@@ -69,4 +56,20 @@ describe('AppLayout', () => {
expect(container.querySelector('.ant-layout-header')).toBeInTheDocument();
});
it('uses effectiveThemeId for header background in dark mode', async () => {
const { useTheme } = await import('@/contexts/ThemeContext');
vi.mocked(useTheme).mockReturnValue({
effectiveThemeId: 'dark',
themeId: 'dark',
followSystem: false,
systemIsDark: false,
setThemeId: vi.fn(),
setFollowSystem: vi.fn(),
});
const { container } = renderWithRouter(<AppLayout />);
const header = container.querySelector('.ant-layout-header') as HTMLElement;
expect(header.style.background).toBe('#141414');
});
});

View File

@@ -0,0 +1,123 @@
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { ThemeProvider, useTheme } from '@/contexts/ThemeContext';
describe('ThemeContext', () => {
beforeEach(() => {
localStorage.clear();
});
it('returns default theme on initial load', () => {
const { result } = renderHook(() => useTheme(), { wrapper: ThemeProvider });
expect(result.current.themeId).toBe('default');
expect(result.current.followSystem).toBe(false);
expect(result.current.systemIsDark).toBe(false);
expect(result.current.effectiveThemeId).toBe('default');
});
it('restores themeId from localStorage', () => {
localStorage.setItem('nex-theme-id', 'mui');
const { result } = renderHook(() => useTheme(), { wrapper: ThemeProvider });
expect(result.current.themeId).toBe('mui');
});
it('restores followSystem from localStorage', () => {
localStorage.setItem('nex-follow-system', 'true');
const { result } = renderHook(() => useTheme(), { wrapper: ThemeProvider });
expect(result.current.followSystem).toBe(true);
});
it('falls back to default for invalid themeId', () => {
localStorage.setItem('nex-theme-id', 'invalid-theme');
const { result } = renderHook(() => useTheme(), { wrapper: ThemeProvider });
expect(result.current.themeId).toBe('default');
});
it('setThemeId updates themeId and persists to localStorage', () => {
const { result } = renderHook(() => useTheme(), { wrapper: ThemeProvider });
act(() => {
result.current.setThemeId('shadcn');
});
expect(result.current.themeId).toBe('shadcn');
expect(localStorage.getItem('nex-theme-id')).toBe('shadcn');
});
it('setFollowSystem updates followSystem and persists to localStorage', () => {
const { result } = renderHook(() => useTheme(), { wrapper: ThemeProvider });
act(() => {
result.current.setFollowSystem(true);
});
expect(result.current.followSystem).toBe(true);
expect(localStorage.getItem('nex-follow-system')).toBe('true');
});
it('computes effectiveThemeId as dark when followSystem and systemIsDark', () => {
localStorage.setItem('nex-theme-id', 'mui');
localStorage.setItem('nex-follow-system', 'true');
const mediaQuerySpy = vi.spyOn(window, 'matchMedia').mockReturnValue({
matches: true,
media: '(prefers-color-scheme: dark)',
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
});
const { result } = renderHook(() => useTheme(), { wrapper: ThemeProvider });
expect(result.current.effectiveThemeId).toBe('dark');
mediaQuerySpy.mockRestore();
});
it('computes effectiveThemeId as themeId when followSystem but system is light', () => {
localStorage.setItem('nex-theme-id', 'mui');
localStorage.setItem('nex-follow-system', 'true');
const { result } = renderHook(() => useTheme(), { wrapper: ThemeProvider });
expect(result.current.effectiveThemeId).toBe('mui');
});
it('computes effectiveThemeId as themeId when followSystem is off', () => {
localStorage.setItem('nex-theme-id', 'glass');
localStorage.setItem('nex-follow-system', 'false');
const mediaQuerySpy = vi.spyOn(window, 'matchMedia').mockReturnValue({
matches: true,
media: '(prefers-color-scheme: dark)',
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
});
const { result } = renderHook(() => useTheme(), { wrapper: ThemeProvider });
expect(result.current.effectiveThemeId).toBe('glass');
mediaQuerySpy.mockRestore();
});
it('throws error when useTheme is used outside ThemeProvider', () => {
expect(() => {
renderHook(() => useTheme());
}).toThrow('useTheme must be used within ThemeProvider');
});
});

View File

@@ -0,0 +1,74 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { BrowserRouter } from 'react-router';
import { SettingsPage } from '@/pages/Settings';
const mockSetThemeId = vi.fn();
const mockSetFollowSystem = vi.fn();
vi.mock('@/contexts/ThemeContext', () => ({
useTheme: vi.fn(() => ({
themeId: 'default',
followSystem: false,
systemIsDark: false,
effectiveThemeId: 'default',
setThemeId: mockSetThemeId,
setFollowSystem: mockSetFollowSystem,
})),
}));
const renderWithRouter = (component: React.ReactNode) => {
return render(<BrowserRouter>{component}</BrowserRouter>);
};
describe('SettingsPage', () => {
it('renders theme card', () => {
renderWithRouter(<SettingsPage />);
expect(screen.getByText('跟随系统')).toBeInTheDocument();
});
it('renders theme select with all 6 options', async () => {
renderWithRouter(<SettingsPage />);
const select = screen.getByRole('combobox');
expect(select).toBeInTheDocument();
fireEvent.mouseDown(select);
expect(screen.getByText('暗黑')).toBeInTheDocument();
expect(screen.getByText('MUI')).toBeInTheDocument();
expect(screen.getByText('shadcn')).toBeInTheDocument();
expect(screen.getByText('Bootstrap')).toBeInTheDocument();
expect(screen.getByText('玻璃')).toBeInTheDocument();
});
it('renders follow system switch', () => {
renderWithRouter(<SettingsPage />);
expect(screen.getByText('跟随系统')).toBeInTheDocument();
const switchEl = screen.getByRole('switch');
expect(switchEl).toBeInTheDocument();
});
it('calls setThemeId when selecting a theme', async () => {
renderWithRouter(<SettingsPage />);
const select = screen.getByRole('combobox');
fireEvent.mouseDown(select);
const option = screen.getByText('MUI');
fireEvent.click(option);
expect(mockSetThemeId).toHaveBeenCalledWith('mui');
});
it('calls setFollowSystem when toggling the switch', () => {
renderWithRouter(<SettingsPage />);
const switchEl = screen.getByRole('switch');
fireEvent.click(switchEl);
expect(mockSetFollowSystem).toHaveBeenCalledWith(true, expect.anything());
});
});

View File

@@ -0,0 +1,61 @@
import { describe, it, expect } from 'vitest';
import { renderHook } from '@testing-library/react';
import { themeOptions, isValidThemeId, useThemeConfig, type ThemeId } from '@/themes';
describe('Theme Registry', () => {
it('contains all 6 theme options', () => {
expect(themeOptions).toHaveLength(6);
});
it('has correct theme IDs and labels', () => {
const expected: Array<{ id: ThemeId; label: string }> = [
{ id: 'default', label: '默认' },
{ id: 'dark', label: '暗黑' },
{ id: 'mui', label: 'MUI' },
{ id: 'shadcn', label: 'shadcn' },
{ id: 'bootstrap', label: 'Bootstrap' },
{ id: 'glass', label: '玻璃' },
];
expected.forEach(({ id, label }) => {
const option = themeOptions.find((o) => o.id === id);
expect(option).toBeDefined();
expect(option!.label).toBe(label);
});
});
it('isValidThemeId returns true for valid IDs', () => {
expect(isValidThemeId('default')).toBe(true);
expect(isValidThemeId('dark')).toBe(true);
expect(isValidThemeId('mui')).toBe(true);
expect(isValidThemeId('shadcn')).toBe(true);
expect(isValidThemeId('bootstrap')).toBe(true);
expect(isValidThemeId('glass')).toBe(true);
});
it('isValidThemeId returns false for invalid IDs', () => {
expect(isValidThemeId('invalid')).toBe(false);
expect(isValidThemeId('')).toBe(false);
expect(isValidThemeId('LIGHT')).toBe(false);
});
it('useThemeConfig returns config for default theme', () => {
const { result } = renderHook(() => useThemeConfig('default'));
expect(result.current).toBeDefined();
expect(result.current.theme).toBeDefined();
});
it('useThemeConfig returns config for dark theme with darkAlgorithm', () => {
const { result } = renderHook(() => useThemeConfig('dark'));
expect(result.current).toBeDefined();
expect(result.current.theme).toBeDefined();
expect(result.current.theme!.algorithm).toBeDefined();
});
it('useThemeConfig returns config for mui theme', () => {
const { result } = renderHook(() => useThemeConfig('mui'));
expect(result.current).toBeDefined();
expect(result.current.theme).toBeDefined();
expect(result.current.theme!.token).toBeDefined();
});
});

View File

@@ -1,24 +1,26 @@
import { useState } from 'react';
import { Layout, Menu } from 'antd';
import { CloudServerOutlined, BarChartOutlined } from '@ant-design/icons';
import { CloudServerOutlined, BarChartOutlined, SettingOutlined } from '@ant-design/icons';
import { Outlet, useLocation, useNavigate } from 'react-router';
import { ThemeToggle } from '@/components/ThemeToggle';
import { useTheme } from '@/contexts/ThemeContext';
const menuItems = [
{ key: '/providers', label: '供应商管理', icon: <CloudServerOutlined /> },
{ key: '/stats', label: '用量统计', icon: <BarChartOutlined /> },
{ type: 'divider' as const },
{ key: '/settings', label: '设置', icon: <SettingOutlined /> },
];
export function AppLayout() {
const location = useLocation();
const navigate = useNavigate();
const { mode } = useTheme();
const { effectiveThemeId } = useTheme();
const [collapsed, setCollapsed] = useState(false);
const getPageTitle = () => {
if (location.pathname === '/providers') return '供应商管理';
if (location.pathname === '/stats') return '用量统计';
if (location.pathname === '/settings') return '设置';
return 'AI Gateway';
};
@@ -62,27 +64,16 @@ export function AppLayout() {
onClick={({ key }) => navigate(key)}
style={{ flex: 1, overflow: 'auto' }}
/>
<div
style={{
padding: '16px 0',
display: 'flex',
justifyContent: 'center',
borderTop: '1px solid rgba(255, 255, 255, 0.1)',
flexShrink: 0,
}}
>
<ThemeToggle />
</div>
</div>
</Layout.Sider>
<Layout style={{ marginLeft: collapsed ? 80 : 200, transition: 'all 0.2s' }}>
<Layout.Header
style={{
padding: '0 2rem',
background: mode === 'dark' ? '#141414' : '#fff',
background: effectiveThemeId === 'dark' ? '#141414' : '#fff',
display: 'flex',
alignItems: 'center',
borderBottom: mode === 'dark' ? '1px solid #303030' : '1px solid #f0f0f0',
borderBottom: effectiveThemeId === 'dark' ? '1px solid #303030' : '1px solid #f0f0f0',
}}
>
<h1 style={{ margin: 0, fontSize: '1.25rem' }}>{getPageTitle()}</h1>

View File

@@ -1,18 +0,0 @@
import { Button, Tooltip } from 'antd';
import { SunOutlined, MoonOutlined } from '@ant-design/icons';
import { useTheme } from '@/contexts/ThemeContext';
export function ThemeToggle() {
const { mode, toggleTheme } = useTheme();
return (
<Tooltip title={mode === 'light' ? '切换到暗色模式' : '切换到亮色模式'}>
<Button
type="text"
icon={mode === 'light' ? <MoonOutlined /> : <SunOutlined />}
onClick={toggleTheme}
style={{ color: 'rgba(255, 255, 255, 0.85)' }}
/>
</Tooltip>
);
}

View File

@@ -1,11 +1,18 @@
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
import { createContext, useContext, useState, useEffect, useMemo, useCallback, type ReactNode } from 'react';
import type { ThemeId } from '@/themes';
import { isValidThemeId } from '@/themes';
type ThemeMode = 'light' | 'dark';
const STORAGE_KEY_THEME = 'nex-theme-id';
const STORAGE_KEY_FOLLOW = 'nex-follow-system';
const DEFAULT_THEME: ThemeId = 'default';
interface ThemeContextValue {
mode: ThemeMode;
toggleTheme: () => void;
setTheme: (mode: ThemeMode) => void;
themeId: ThemeId;
followSystem: boolean;
systemIsDark: boolean;
effectiveThemeId: ThemeId;
setThemeId: (id: ThemeId) => void;
setFollowSystem: (follow: boolean) => void;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
@@ -15,26 +22,59 @@ interface ThemeProviderProps {
}
export function ThemeProvider({ children }: ThemeProviderProps) {
// 从 localStorage 恢复主题
const [mode, setMode] = useState<ThemeMode>(() => {
if (typeof window === 'undefined') return 'light';
const saved = localStorage.getItem('theme-mode');
return (saved as ThemeMode) || 'light';
const [themeId, setThemeIdState] = useState<ThemeId>(() => {
if (typeof window === 'undefined') return DEFAULT_THEME;
const saved = localStorage.getItem(STORAGE_KEY_THEME);
if (saved && isValidThemeId(saved)) return saved;
return DEFAULT_THEME;
});
// 持久化主题
useEffect(() => {
localStorage.setItem('theme-mode', mode);
// 更新 document class方便全局样式判断
document.documentElement.classList.toggle('dark', mode === 'dark');
}, [mode]);
const [followSystem, setFollowSystemState] = useState<boolean>(() => {
if (typeof window === 'undefined') return false;
return localStorage.getItem(STORAGE_KEY_FOLLOW) === 'true';
});
const toggleTheme = () => {
setMode(prev => prev === 'light' ? 'dark' : 'light');
};
const [systemIsDark, setSystemIsDark] = useState<boolean>(() => {
if (typeof window === 'undefined') return false;
return window.matchMedia('(prefers-color-scheme: dark)').matches;
});
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handler = (e: MediaQueryListEvent) => {
setSystemIsDark(e.matches);
};
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
}, []);
useEffect(() => {
localStorage.setItem(STORAGE_KEY_THEME, themeId);
}, [themeId]);
useEffect(() => {
localStorage.setItem(STORAGE_KEY_FOLLOW, String(followSystem));
}, [followSystem]);
const effectiveThemeId = useMemo<ThemeId>(() => {
if (followSystem && systemIsDark) return 'dark';
return themeId;
}, [themeId, followSystem, systemIsDark]);
const setThemeId = useCallback((id: ThemeId) => {
setThemeIdState(id);
}, []);
const setFollowSystem = useCallback((follow: boolean) => {
setFollowSystemState(follow);
}, []);
useEffect(() => {
document.documentElement.classList.toggle('dark', effectiveThemeId === 'dark');
}, [effectiveThemeId]);
return (
<ThemeContext.Provider value={{ mode, toggleTheme, setTheme: setMode }}>
<ThemeContext.Provider value={{ themeId, followSystem, systemIsDark, effectiveThemeId, setThemeId, setFollowSystem }}>
{children}
</ThemeContext.Provider>
);

View File

@@ -0,0 +1,40 @@
import { Card, Select, Switch, Space, Typography } from 'antd';
import { useTheme } from '@/contexts/ThemeContext';
import { themeOptions, type ThemeId } from '@/themes';
const { Text } = Typography;
export function SettingsPage() {
const { themeId, followSystem, setThemeId, setFollowSystem } = useTheme();
return (
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<Card title="主题">
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<Text strong></Text>
</div>
<Select
value={themeId}
onChange={(value: ThemeId) => setThemeId(value)}
style={{ width: 180 }}
options={themeOptions.map((opt) => ({
value: opt.id,
label: opt.label,
}))}
/>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<Text strong></Text>
<br />
<Text type="secondary"></Text>
</div>
<Switch checked={followSystem} onChange={setFollowSystem} />
</div>
</Space>
</Card>
</Space>
);
}

View File

@@ -2,6 +2,7 @@ import { Routes, Route, Navigate } from 'react-router';
import { AppLayout } from '@/components/AppLayout';
import { ProvidersPage } from '@/pages/Providers';
import { StatsPage } from '@/pages/Stats';
import { SettingsPage } from '@/pages/Settings';
import { NotFound } from '@/pages/NotFound';
export function AppRoutes() {
@@ -11,6 +12,7 @@ export function AppRoutes() {
<Route index element={<Navigate to="/providers" replace />} />
<Route path="providers" element={<ProvidersPage />} />
<Route path="stats" element={<StatsPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="*" element={<NotFound />} />
</Route>
</Routes>

View File

@@ -0,0 +1,180 @@
import { useMemo } from 'react';
import { theme } from 'antd';
import type { ConfigProviderProps } from 'antd';
import { createStyles } from 'antd-style';
import clsx from 'clsx';
const useStyles = createStyles(({ css, cssVar }) => {
return {
boxBorder: css({
border: `${cssVar.lineWidth} ${cssVar.lineType} color-mix(in srgb,${cssVar.colorBorder} 80%, #000)`,
}),
alertRoot: css({
color: cssVar.colorInfoText,
textShadow: `0 1px 0 rgba(255, 255, 255, 0.8)`,
}),
modalContainer: css({
padding: 0,
borderRadius: cssVar.borderRadiusLG,
}),
modalHeader: css({
borderBottom: `${cssVar.lineWidth} ${cssVar.lineType} ${cssVar.colorSplit}`,
padding: `${cssVar.padding} ${cssVar.paddingLG}`,
}),
modalBody: css({
padding: `${cssVar.padding} ${cssVar.paddingLG}`,
}),
modalFooter: css({
borderTop: `${cssVar.lineWidth} ${cssVar.lineType} ${cssVar.colorSplit}`,
padding: `${cssVar.padding} ${cssVar.paddingLG}`,
backgroundColor: cssVar.colorBgContainerDisabled,
boxShadow: `inset 0 1px 0 ${cssVar.colorBgContainer}`,
}),
buttonRoot: css({
backgroundImage: `linear-gradient(to bottom, transparent, rgba(0, 0, 0, 0.2))`,
boxShadow: `inset 0 1px 0 rgba(255, 255, 255, 0.15)`,
transition: 'none',
borderColor: `rgba(0, 0, 0, 0.3)`,
textShadow: `0 -1px 0 rgba(0, 0, 0, 0.2)`,
'&:hover, &:active': {
backgroundImage: `linear-gradient(rgba(0, 0, 0, 0.15) 100%)`,
},
'&:active': {
boxShadow: `inset 0 1px 3px rgba(0, 0, 0, 0.15)`,
},
}),
buttonColorDefault: css({
textShadow: 'none',
color: cssVar.colorText,
borderBottomColor: 'rgba(0, 0, 0, 0.5)',
}),
popupBox: css({
borderRadius: cssVar.borderRadiusLG,
backgroundColor: cssVar.colorBgContainer,
ul: {
paddingInline: 0,
},
}),
dropdownItem: css({
borderRadius: 0,
transition: 'none',
paddingBlock: cssVar.paddingXXS,
paddingInline: cssVar.padding,
'&:hover, &:active, &:focus': {
backgroundImage: `linear-gradient(to bottom, ${cssVar.colorPrimaryHover}, ${cssVar.colorPrimary})`,
color: cssVar.colorTextLightSolid,
},
}),
selectPopupRoot: css({
paddingInline: 0,
}),
switchRoot: css({
boxShadow: `inset 0 1px 3px rgba(0, 0, 0, 0.4)`,
}),
progressTrack: css({
backgroundImage: `linear-gradient(to bottom, ${cssVar.colorPrimaryHover}, ${cssVar.colorPrimary})`,
borderRadius: cssVar.borderRadiusSM,
}),
progressRail: css({
borderRadius: cssVar.borderRadiusSM,
}),
};
});
const useBootstrapTheme = () => {
const { styles } = useStyles();
return useMemo<ConfigProviderProps>(
() => ({
theme: {
algorithm: theme.defaultAlgorithm,
token: {
borderRadius: 4,
borderRadiusLG: 6,
colorInfo: '#3a87ad',
},
components: {
Tooltip: {
fontSize: 12,
},
Checkbox: {
colorBorder: '#666',
borderRadius: 2,
algorithm: true,
},
Radio: {
colorBorder: '#666',
borderRadius: 2,
algorithm: true,
},
},
},
wave: {
showEffect: () => {},
},
modal: {
classNames: {
container: clsx(styles.boxBorder, styles.modalContainer),
header: styles.modalHeader,
body: styles.modalBody,
footer: styles.modalFooter,
},
},
button: {
classNames: ({ props }) => ({
root: clsx(styles.buttonRoot, props.color === 'default' && styles.buttonColorDefault),
}),
},
alert: {
className: styles.alertRoot,
},
colorPicker: {
classNames: {
root: styles.boxBorder,
popup: {
root: clsx(styles.boxBorder, styles.popupBox),
},
},
arrow: false,
},
checkbox: {
classNames: {},
},
dropdown: {
classNames: {
root: clsx(styles.boxBorder, styles.popupBox),
item: styles.dropdownItem,
},
},
select: {
classNames: {
root: styles.boxBorder,
popup: {
root: clsx(styles.boxBorder, styles.selectPopupRoot),
listItem: styles.dropdownItem,
},
},
},
switch: {
classNames: {
root: styles.switchRoot,
},
},
progress: {
classNames: {
track: styles.progressTrack,
rail: styles.progressRail,
},
styles: {
rail: {
height: 20,
},
track: { height: 20 },
},
},
}),
[],
);
};
export default useBootstrapTheme;

View File

@@ -0,0 +1,10 @@
import { theme } from 'antd';
import type { ConfigProviderProps } from 'antd';
const useDarkTheme = (): ConfigProviderProps => ({
theme: {
algorithm: theme.darkAlgorithm,
},
});
export default useDarkTheme;

View File

@@ -0,0 +1,10 @@
import { theme } from 'antd';
import type { ConfigProviderProps } from 'antd';
const useDefaultTheme = (): ConfigProviderProps => ({
theme: {
algorithm: theme.defaultAlgorithm,
},
});
export default useDefaultTheme;

View File

@@ -0,0 +1,214 @@
import { useMemo } from 'react';
import { theme } from 'antd';
import type { ConfigProviderProps } from 'antd';
import { createStyles } from 'antd-style';
import clsx from 'clsx';
const useStyles = createStyles(({ css, cssVar }) => {
const glassBorder = {
boxShadow: [
`${cssVar.boxShadowSecondary}`,
`inset 0 0 5px 2px rgba(255, 255, 255, 0.3)`,
`inset 0 5px 2px rgba(255, 255, 255, 0.2)`,
].join(','),
};
const glassBox = {
...glassBorder,
background: `color-mix(in srgb, ${cssVar.colorBgContainer} 15%, transparent)`,
backdropFilter: 'blur(12px)',
};
return {
glassBorder,
glassBox,
notBackdropFilter: css({
backdropFilter: 'none',
}),
app: css({
textShadow: '0 1px rgba(0,0,0,0.1)',
}),
cardRoot: css({
...glassBox,
backgroundColor: `color-mix(in srgb, ${cssVar.colorBgContainer} 40%, transparent)`,
}),
modalContainer: css({
...glassBox,
backdropFilter: 'none',
}),
buttonRoot: css({
...glassBorder,
}),
buttonRootDefaultColor: css({
background: 'transparent',
color: cssVar.colorText,
'&:hover': {
background: 'rgba(255,255,255,0.2)',
color: `color-mix(in srgb, ${cssVar.colorText} 90%, transparent)`,
},
'&:active': {
background: 'rgba(255,255,255,0.1)',
color: `color-mix(in srgb, ${cssVar.colorText} 80%, transparent)`,
},
}),
dropdownRoot: css({
...glassBox,
borderRadius: cssVar.borderRadiusLG,
ul: {
background: 'transparent',
},
}),
switchRoot: css({ ...glassBorder, border: 'none' }),
segmentedRoot: css({
...glassBorder,
background: 'transparent',
backdropFilter: 'none',
'& .ant-segmented-thumb': {
...glassBox,
},
'& .ant-segmented-item-selected': {
...glassBox,
},
}),
radioButtonRoot: css({
'&.ant-radio-button-wrapper': {
...glassBorder,
background: 'transparent',
borderColor: 'rgba(255, 255, 255, 0.2)',
color: cssVar.colorText,
'&:hover': {
borderColor: 'rgba(255, 255, 255, 0.24)',
color: cssVar.colorText,
},
'&.ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled)': {
...glassBox,
borderColor: 'rgba(255, 255, 255, 0.28)',
color: cssVar.colorText,
'&::before': {
backgroundColor: 'rgba(255, 255, 255, 0.18)',
},
'&:hover': {
color: cssVar.colorText,
},
},
},
}),
};
});
const useGlassTheme = () => {
const { styles } = useStyles();
return useMemo<ConfigProviderProps>(
() => ({
theme: {
algorithm: theme.defaultAlgorithm,
token: {
borderRadius: 12,
borderRadiusLG: 12,
borderRadiusSM: 12,
borderRadiusXS: 12,
motionDurationSlow: '0.2s',
motionDurationMid: '0.1s',
motionDurationFast: '0.05s',
},
},
app: {
className: styles.app,
},
card: {
classNames: {
root: styles.cardRoot,
},
},
modal: {
classNames: {
container: styles.modalContainer,
},
},
button: {
classNames: ({ props }) => ({
root: clsx(
styles.buttonRoot,
(props.variant !== 'solid' || props.color === 'default' || props.type === 'default') &&
styles.buttonRootDefaultColor,
),
}),
},
alert: {
className: clsx(styles.glassBox, styles.notBackdropFilter),
},
colorPicker: {
classNames: {
root: clsx(styles.glassBox, styles.notBackdropFilter),
},
arrow: false,
},
dropdown: {
classNames: {
root: styles.dropdownRoot,
},
},
select: {
classNames: {
root: clsx(styles.glassBox, styles.notBackdropFilter),
popup: {
root: styles.glassBox,
},
},
},
datePicker: {
classNames: {
root: clsx(styles.glassBox, styles.notBackdropFilter),
popup: {
container: styles.glassBox,
},
},
},
input: {
classNames: {
root: clsx(styles.glassBox, styles.notBackdropFilter),
},
},
inputNumber: {
classNames: {
root: clsx(styles.glassBox, styles.notBackdropFilter),
},
},
popover: {
classNames: {
container: styles.glassBox,
},
},
switch: {
classNames: {
root: styles.switchRoot,
},
},
radio: {
classNames: {
root: styles.radioButtonRoot,
},
},
segmented: {
className: styles.segmentedRoot,
},
progress: {
classNames: {
track: styles.glassBorder,
},
styles: {
track: {
height: 12,
},
rail: {
height: 12,
},
},
},
}),
[],
);
};
export default useGlassTheme;

View File

@@ -0,0 +1,49 @@
import type { ConfigProviderProps } from 'antd';
import useDefaultTheme from './default';
import useDarkTheme from './dark';
import useMuiTheme from './mui';
import useShadcnTheme from './shadcn';
import useBootstrapTheme from './bootstrap';
import useGlassTheme from './glass';
export type ThemeId = 'default' | 'dark' | 'mui' | 'shadcn' | 'bootstrap' | 'glass';
export interface ThemeOption {
id: ThemeId;
label: string;
}
export const themeOptions: ThemeOption[] = [
{ id: 'default', label: '默认' },
{ id: 'dark', label: '暗黑' },
{ id: 'mui', label: 'MUI' },
{ id: 'shadcn', label: 'shadcn' },
{ id: 'bootstrap', label: 'Bootstrap' },
{ id: 'glass', label: '玻璃' },
];
const themeIdSet = new Set<ThemeId>(themeOptions.map((opt) => opt.id));
export function useThemeConfig(themeId: ThemeId): ConfigProviderProps {
const defaultConfig = useDefaultTheme();
const darkConfig = useDarkTheme();
const muiConfig = useMuiTheme();
const shadcnConfig = useShadcnTheme();
const bootstrapConfig = useBootstrapTheme();
const glassConfig = useGlassTheme();
const configs: Record<ThemeId, ConfigProviderProps> = {
default: defaultConfig,
dark: darkConfig,
mui: muiConfig,
shadcn: shadcnConfig,
bootstrap: bootstrapConfig,
glass: glassConfig,
};
return configs[themeId] ?? configs.default;
}
export function isValidThemeId(value: string): value is ThemeId {
return themeIdSet.has(value as ThemeId);
}

281
frontend/src/themes/mui.ts Normal file
View File

@@ -0,0 +1,281 @@
import { useMemo } from 'react';
import raf from '@rc-component/util/lib/raf';
import { theme } from 'antd';
import type { ConfigProviderProps, GetProp } from 'antd';
import { createStyles } from 'antd-style';
import clsx from 'clsx';
type WaveConfig = GetProp<ConfigProviderProps, 'wave'>;
const createHolder = (node: HTMLElement) => {
const { borderWidth } = getComputedStyle(node);
const borderWidthNum = Number.parseInt(borderWidth, 10);
const div = document.createElement('div');
div.style.position = 'absolute';
div.style.inset = `-${borderWidthNum}px`;
div.style.borderRadius = 'inherit';
div.style.background = 'transparent';
div.style.zIndex = '999';
div.style.pointerEvents = 'none';
div.style.overflow = 'hidden';
node.appendChild(div);
return div;
};
const createDot = (holder: HTMLElement, color: string, left: number, top: number, size = 0) => {
const dot = document.createElement('div');
dot.style.position = 'absolute';
dot.style.left = `${left}px`;
dot.style.top = `${top}px`;
dot.style.width = `${size}px`;
dot.style.height = `${size}px`;
dot.style.borderRadius = '50%';
dot.style.background = color;
dot.style.transform = 'translate3d(-50%, -50%, 0)';
dot.style.transition = 'all 1s ease-out';
holder.appendChild(dot);
return dot;
};
const showInsetEffect: WaveConfig['showEffect'] = (node, { event, component }) => {
if (component !== 'Button') {
return;
}
const holder = createHolder(node);
const rect = holder.getBoundingClientRect();
const left = event.clientX - rect.left;
const top = event.clientY - rect.top;
const dot = createDot(holder, 'rgba(255, 255, 255, 0.65)', left, top);
raf(() => {
dot.ontransitionend = () => {
holder.remove();
};
dot.style.width = '200px';
dot.style.height = '200px';
dot.style.opacity = '0';
});
};
const useStyles = createStyles(({ css }) => {
return {
buttonPrimary: css({
backgroundColor: '#1976d2',
color: '#ffffff',
border: 'none',
fontWeight: 500,
textTransform: 'uppercase',
letterSpacing: '0.02857em',
boxShadow:
'0px 3px 1px -2px rgba(0,0,0,0.2), 0px 2px 2px 0px rgba(0,0,0,0.14), 0px 1px 5px 0px rgba(0,0,0,0.12)',
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
}),
buttonDefault: css({
backgroundColor: '#ffffff',
color: 'rgba(0, 0, 0, 0.87)',
border: '1px solid rgba(0, 0, 0, 0.23)',
fontWeight: 500,
textTransform: 'uppercase',
letterSpacing: '0.02857em',
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
}),
buttonDanger: css({
backgroundColor: '#d32f2f',
color: '#ffffff',
border: 'none',
fontWeight: 500,
textTransform: 'uppercase',
letterSpacing: '0.02857em',
boxShadow:
'0px 3px 1px -2px rgba(0,0,0,0.2), 0px 2px 2px 0px rgba(0,0,0,0.14), 0px 1px 5px 0px rgba(0,0,0,0.12)',
}),
inputRoot: css({
borderColor: 'rgba(0, 0, 0, 0.23)',
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
}),
inputElement: css({
color: 'rgba(0, 0, 0, 0.87)',
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
}),
inputError: css({
borderColor: '#d32f2f',
}),
selectRoot: css({
borderColor: 'rgba(0, 0, 0, 0.23)',
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
}),
selectPopup: css({
borderRadius: '4px',
boxShadow:
'0px 5px 5px -3px rgba(0,0,0,0.2), 0px 8px 10px 1px rgba(0,0,0,0.14), 0px 3px 14px 2px rgba(0,0,0,0.12)',
}),
};
});
const useMuiTheme = () => {
const { styles } = useStyles();
return useMemo<ConfigProviderProps>(
() => ({
theme: {
algorithm: theme.defaultAlgorithm,
token: {
colorPrimary: '#1976d2',
colorSuccess: '#2e7d32',
colorWarning: '#ed6c02',
colorError: '#d32f2f',
colorInfo: '#0288d1',
colorTextBase: '#212121',
colorBgBase: '#fafafa',
colorPrimaryBg: '#e3f2fd',
colorPrimaryBgHover: '#bbdefb',
colorPrimaryBorder: '#90caf9',
colorPrimaryBorderHover: '#64b5f6',
colorPrimaryHover: '#42a5f5',
colorPrimaryActive: '#1565c0',
colorPrimaryText: '#1976d2',
colorPrimaryTextHover: '#42a5f5',
colorPrimaryTextActive: '#1565c0',
colorSuccessBg: '#e8f5e9',
colorSuccessBgHover: '#c8e6c9',
colorSuccessBorder: '#a5d6a7',
colorSuccessBorderHover: '#81c784',
colorSuccessHover: '#4caf50',
colorSuccessActive: '#1b5e20',
colorSuccessText: '#2e7d32',
colorSuccessTextHover: '#4caf50',
colorSuccessTextActive: '#1b5e20',
colorWarningBg: '#fff3e0',
colorWarningBgHover: '#ffe0b2',
colorWarningBorder: '#ffcc02',
colorWarningBorderHover: '#ffb74d',
colorWarningHover: '#ff9800',
colorWarningActive: '#e65100',
colorWarningText: '#ed6c02',
colorWarningTextHover: '#ff9800',
colorWarningTextActive: '#e65100',
colorErrorBg: '#ffebee',
colorErrorBgHover: '#ffcdd2',
colorErrorBorder: '#ef9a9a',
colorErrorBorderHover: '#e57373',
colorErrorHover: '#ef5350',
colorErrorActive: '#c62828',
colorErrorText: '#d32f2f',
colorErrorTextHover: '#ef5350',
colorErrorTextActive: '#c62828',
colorInfoBg: '#e1f5fe',
colorInfoBgHover: '#b3e5fc',
colorInfoBorder: '#81d4fa',
colorInfoBorderHover: '#4fc3f7',
colorInfoHover: '#03a9f4',
colorInfoActive: '#01579b',
colorInfoText: '#0288d1',
colorInfoTextHover: '#03a9f4',
colorInfoTextActive: '#01579b',
colorText: 'rgba(33, 33, 33, 0.87)',
colorTextSecondary: 'rgba(33, 33, 33, 0.6)',
colorTextTertiary: 'rgba(33, 33, 33, 0.38)',
colorTextQuaternary: 'rgba(33, 33, 33, 0.26)',
colorTextDisabled: 'rgba(33, 33, 33, 0.38)',
colorBgContainer: '#ffffff',
colorBgElevated: '#ffffff',
colorBgLayout: '#f5f5f5',
colorBgSpotlight: 'rgba(33, 33, 33, 0.85)',
colorBgMask: 'rgba(33, 33, 33, 0.5)',
colorBorder: '#e0e0e0',
colorBorderSecondary: '#eeeeee',
borderRadius: 4,
borderRadiusXS: 1,
borderRadiusSM: 2,
borderRadiusLG: 6,
padding: 16,
paddingSM: 8,
paddingLG: 24,
margin: 16,
marginSM: 8,
marginLG: 24,
boxShadow:
'0px 2px 1px -1px rgba(0,0,0,0.2),0px 1px 1px 0px rgba(0,0,0,0.14),0px 1px 3px 0px rgba(0,0,0,0.12)',
boxShadowSecondary:
'0px 3px 3px -2px rgba(0,0,0,0.2),0px 3px 4px 0px rgba(0,0,0,0.14),0px 1px 8px 0px rgba(0,0,0,0.12)',
},
components: {
Button: {
primaryShadow:
'0px 3px 1px -2px rgba(0,0,0,0.2), 0px 2px 2px 0px rgba(0,0,0,0.14), 0px 1px 5px 0px rgba(0,0,0,0.12)',
defaultShadow:
'0px 3px 1px -2px rgba(0,0,0,0.2), 0px 2px 2px 0px rgba(0,0,0,0.14), 0px 1px 5px 0px rgba(0,0,0,0.12)',
dangerShadow:
'0px 3px 1px -2px rgba(0,0,0,0.2), 0px 2px 2px 0px rgba(0,0,0,0.14), 0px 1px 5px 0px rgba(0,0,0,0.12)',
fontWeight: 500,
defaultBorderColor: 'rgba(0, 0, 0, 0.23)',
defaultColor: 'rgba(0, 0, 0, 0.87)',
defaultBg: '#ffffff',
defaultHoverBg: 'rgba(25, 118, 210, 0.04)',
defaultHoverBorderColor: 'rgba(0, 0, 0, 0.23)',
paddingInline: 16,
paddingBlock: 6,
contentFontSize: 14,
borderRadius: 4,
},
Alert: {
borderRadiusLG: 4,
},
Modal: {
borderRadiusLG: 4,
},
Progress: {
defaultColor: '#1976d2',
remainingColor: 'rgba(25, 118, 210, 0.12)',
},
Steps: {
iconSize: 24,
},
Checkbox: {
borderRadiusSM: 2,
},
Slider: {
trackBg: 'rgba(25, 118, 210, 0.26)',
trackHoverBg: 'rgba(25, 118, 210, 0.38)',
handleSize: 20,
handleSizeHover: 20,
railSize: 4,
},
ColorPicker: {
borderRadius: 4,
},
},
},
wave: {
showEffect: showInsetEffect,
},
button: {
classNames: ({ props }) => ({
root: clsx(
props.type === 'primary' && styles.buttonPrimary,
props.type === 'default' && styles.buttonDefault,
props.danger && styles.buttonDanger,
),
}),
},
input: {
classNames: ({ props }) => ({
root: clsx(styles.inputRoot, props.status === 'error' && styles.inputError),
input: styles.inputElement,
}),
},
select: {
classNames: {
root: styles.selectRoot,
},
},
}),
[styles],
);
};
export default useMuiTheme;

View File

@@ -0,0 +1,221 @@
import { useMemo } from 'react';
import { theme } from 'antd';
import type { ConfigProviderProps } from 'antd';
import { createStyles } from 'antd-style';
import clsx from 'clsx';
const useStyles = createStyles(({ css }) => {
return {
buttonPrimary: css({
backgroundColor: '#18181b',
color: '#ffffff',
border: '1px solid #18181b',
fontWeight: 500,
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
}),
buttonDefault: css({
backgroundColor: '#ffffff',
color: '#18181b',
border: '1px solid #e4e4e7',
fontWeight: 500,
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
}),
buttonDanger: css({
backgroundColor: '#dc2626',
color: '#ffffff',
border: '1px solid #dc2626',
fontWeight: 500,
}),
inputRoot: css({
borderColor: '#e4e4e7',
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
}),
inputElement: css({
color: '#18181b',
}),
inputError: css({
borderColor: '#dc2626',
}),
selectRoot: css({
borderColor: '#e4e4e7',
}),
selectPopup: css({
borderRadius: '8px',
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)',
}),
};
});
const useShadcnTheme = () => {
const { styles } = useStyles();
return useMemo<ConfigProviderProps>(
() => ({
theme: {
algorithm: theme.defaultAlgorithm,
token: {
colorPrimary: '#262626',
colorSuccess: '#22c55e',
colorWarning: '#f97316',
colorError: '#ef4444',
colorInfo: '#262626',
colorTextBase: '#262626',
colorBgBase: '#ffffff',
colorPrimaryBg: '#f5f5f5',
colorPrimaryBgHover: '#e5e5e5',
colorPrimaryBorder: '#d4d4d4',
colorPrimaryBorderHover: '#a3a3a3',
colorPrimaryHover: '#404040',
colorPrimaryActive: '#171717',
colorPrimaryText: '#262626',
colorPrimaryTextHover: '#404040',
colorPrimaryTextActive: '#171717',
colorSuccessBg: '#f0fdf4',
colorSuccessBgHover: '#dcfce7',
colorSuccessBorder: '#bbf7d0',
colorSuccessBorderHover: '#86efac',
colorSuccessHover: '#16a34a',
colorSuccessActive: '#15803d',
colorSuccessText: '#16a34a',
colorSuccessTextHover: '#16a34a',
colorSuccessTextActive: '#15803d',
colorWarningBg: '#fff7ed',
colorWarningBgHover: '#fed7aa',
colorWarningBorder: '#fdba74',
colorWarningBorderHover: '#fb923c',
colorWarningHover: '#ea580c',
colorWarningActive: '#c2410c',
colorWarningText: '#ea580c',
colorWarningTextHover: '#ea580c',
colorWarningTextActive: '#c2410c',
colorErrorBg: '#fef2f2',
colorErrorBgHover: '#fecaca',
colorErrorBorder: '#fca5a5',
colorErrorBorderHover: '#f87171',
colorErrorHover: '#dc2626',
colorErrorActive: '#b91c1c',
colorErrorText: '#dc2626',
colorErrorTextHover: '#dc2626',
colorErrorTextActive: '#b91c1c',
colorInfoBg: '#f5f5f5',
colorInfoBgHover: '#e5e5e5',
colorInfoBorder: '#d4d4d4',
colorInfoBorderHover: '#a3a3a3',
colorInfoHover: '#404040',
colorInfoActive: '#171717',
colorInfoText: '#262626',
colorInfoTextHover: '#404040',
colorInfoTextActive: '#171717',
colorText: '#262626',
colorTextSecondary: '#525252',
colorTextTertiary: '#737373',
colorTextQuaternary: '#a3a3a3',
colorTextDisabled: '#a3a3a3',
colorBgContainer: '#ffffff',
colorBgElevated: '#ffffff',
colorBgLayout: '#fafafa',
colorBgSpotlight: 'rgba(38, 38, 38, 0.85)',
colorBgMask: 'rgba(38, 38, 38, 0.45)',
colorBorder: '#e5e5e5',
colorBorderSecondary: '#f5f5f5',
borderRadius: 10,
borderRadiusXS: 2,
borderRadiusSM: 6,
borderRadiusLG: 14,
padding: 16,
paddingSM: 12,
paddingLG: 24,
margin: 16,
marginSM: 12,
marginLG: 24,
boxShadow: '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1)',
boxShadowSecondary:
'0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)',
},
components: {
Button: {
primaryShadow: 'none',
defaultShadow: 'none',
dangerShadow: 'none',
defaultBorderColor: '#e4e4e7',
defaultColor: '#18181b',
defaultBg: '#ffffff',
defaultHoverBg: '#f4f4f5',
defaultHoverBorderColor: '#d4d4d8',
defaultHoverColor: '#18181b',
defaultActiveBg: '#e4e4e7',
defaultActiveBorderColor: '#d4d4d8',
borderRadius: 6,
},
Input: {
activeShadow: 'none',
hoverBorderColor: '#a1a1aa',
activeBorderColor: '#18181b',
borderRadius: 6,
},
Select: {
optionSelectedBg: '#f4f4f5',
optionActiveBg: '#fafafa',
optionSelectedFontWeight: 500,
borderRadius: 6,
},
Alert: {
borderRadiusLG: 8,
},
Modal: {
borderRadiusLG: 12,
},
Progress: {
defaultColor: '#18181b',
remainingColor: '#f4f4f5',
},
Steps: {
iconSize: 32,
},
Switch: {
trackHeight: 24,
trackMinWidth: 44,
innerMinMargin: 4,
innerMaxMargin: 24,
},
Checkbox: {
borderRadiusSM: 4,
},
Slider: {
trackBg: '#f4f4f5',
trackHoverBg: '#e4e4e7',
handleSize: 18,
handleSizeHover: 20,
railSize: 6,
},
ColorPicker: {
borderRadius: 6,
},
},
},
button: {
classNames: ({ props }) => ({
root: clsx(
props.type === 'primary' && styles.buttonPrimary,
props.type === 'default' && styles.buttonDefault,
props.danger && styles.buttonDanger,
),
}),
},
input: {
classNames: ({ props }) => ({
root: clsx(styles.inputRoot, props.status === 'error' && styles.inputError),
input: styles.inputElement,
}),
},
select: {
classNames: {
root: styles.selectRoot,
},
},
}),
[styles],
);
};
export default useShadcnTheme;