From 2826a001927b0f70173875fe6050aa222439155e Mon Sep 17 00:00:00 2001 From: zhang1106 <849185023@qq.com> Date: Fri, 8 May 2026 11:17:29 +0800 Subject: [PATCH] =?UTF-8?q?refactor(frontend):=20=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E7=AE=A1=E7=90=86=E4=BD=BF=E7=94=A8Zustand?= =?UTF-8?q?=E6=9B=BF=E4=BB=A3Context=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat(auth): 添加账户锁定功能及自动解锁机制 feat(user): 在用户模型中添加lockedUntil字段 feat(api): 实现账户锁定逻辑和剩余尝试次数提示 perf(3d): 优化3D场景状态管理性能 perf(floorplan): 优化平面图状态管理性能 chore(deps): 添加zustand依赖 chore(config): 更新安全配置锁定时间为3分钟 docs: 更新部分组件注释 style: 调整登录页面样式 --- backend/config/security.js | 2 +- backend/models/User.js | 5 + backend/routes/auth.js | 29 +- backend/scripts/migrate-all.js | 17 + frontend/.env.example | 2 +- frontend/package-lock.json | 304 ++--- frontend/package.json | 3 +- frontend/src/App.jsx | 24 +- frontend/src/components/3d/Scene.jsx | 2 +- frontend/src/components/AuthInitializer.jsx | 44 + frontend/src/components/ProtectedRoute.jsx | 2 +- frontend/src/context/AuthContext.jsx | 167 --- frontend/src/context/ConfigContext.jsx | 98 -- frontend/src/context/FloorPlanContext.jsx | 111 -- frontend/src/context/Scene3DContext.jsx | 121 -- .../hooks/floorplan/useFloorPlanContext.js | 43 +- frontend/src/hooks/useAuth.js | 42 + frontend/src/hooks/useConfig.js | 25 + frontend/src/hooks/useDesignTokens.js | 14 +- frontend/src/hooks/useFloorPlan.js | 12 + frontend/src/hooks/useScene3D.js | 12 + frontend/src/main.jsx | 8 +- frontend/src/pages/Login.css | 1007 +++++++++++++++++ frontend/src/pages/Login.jsx | 824 +++++--------- frontend/src/pages/Rack3DVisualization.jsx | 4 +- frontend/src/pages/RoomFloorPlan.jsx | 184 ++- frontend/src/pages/SystemSettings.jsx | 2 +- frontend/src/stores/authStore.js | 116 ++ frontend/src/stores/configStore.js | 88 ++ frontend/src/stores/floorPlanStore.js | 46 + frontend/src/stores/index.js | 15 + frontend/src/stores/scene3DStore.js | 60 + frontend/src/utils/storageAdapter.js | 35 + 33 files changed, 2026 insertions(+), 1442 deletions(-) create mode 100644 frontend/src/components/AuthInitializer.jsx delete mode 100644 frontend/src/context/AuthContext.jsx delete mode 100644 frontend/src/context/ConfigContext.jsx delete mode 100644 frontend/src/context/FloorPlanContext.jsx delete mode 100644 frontend/src/context/Scene3DContext.jsx create mode 100644 frontend/src/hooks/useAuth.js create mode 100644 frontend/src/hooks/useConfig.js create mode 100644 frontend/src/hooks/useFloorPlan.js create mode 100644 frontend/src/hooks/useScene3D.js create mode 100644 frontend/src/pages/Login.css create mode 100644 frontend/src/stores/authStore.js create mode 100644 frontend/src/stores/configStore.js create mode 100644 frontend/src/stores/floorPlanStore.js create mode 100644 frontend/src/stores/index.js create mode 100644 frontend/src/stores/scene3DStore.js create mode 100644 frontend/src/utils/storageAdapter.js diff --git a/backend/config/security.js b/backend/config/security.js index 6aa9d45..8deef00 100644 --- a/backend/config/security.js +++ b/backend/config/security.js @@ -8,7 +8,7 @@ module.exports = { MAX_LOGIN_ATTEMPTS: parseInt(process.env.MAX_LOGIN_ATTEMPTS, 10) || 5, - LOCK_TIME: (parseInt(process.env.LOCK_TIME_MINUTES, 10) || 30) * 60 * 1000, + LOCK_TIME: (parseInt(process.env.LOCK_TIME_MINUTES, 10) || 3) * 60 * 1000, TOKEN_EXPIRY: process.env.TOKEN_EXPIRY || '24h', diff --git a/backend/models/User.js b/backend/models/User.js index 85e7ce1..93a09e9 100644 --- a/backend/models/User.js +++ b/backend/models/User.js @@ -54,6 +54,11 @@ const User = sequelize.define( type: DataTypes.INTEGER, defaultValue: 0, }, + lockedUntil: { + type: DataTypes.DATE, + allowNull: true, + comment: '账户锁定过期时间,NULL表示未锁定或已解锁', + }, remark: { type: DataTypes.TEXT, allowNull: true, diff --git a/backend/routes/auth.js b/backend/routes/auth.js index 0ce05e6..1daa89b 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -8,6 +8,7 @@ const { generateToken, authMiddleware } = require('../middleware/auth'); const { SALT_ROUNDS, MAX_LOGIN_ATTEMPTS, + LOCK_TIME, PASSWORD_MIN_LENGTH, USERNAME_MIN_LENGTH, USERNAME_MAX_LENGTH, @@ -154,10 +155,18 @@ router.post('/login', async (req, res) => { } if (user.status === 'locked') { - return res.status(403).json({ - success: false, - message: '账户已被锁定,请联系管理员', - }); + const now = new Date(); + if (user.lockedUntil && user.lockedUntil > now) { + const remainingMinutes = Math.ceil((user.lockedUntil - now) / 60000); + return res.status(403).json({ + success: false, + message: `账户已被锁定,请在 ${remainingMinutes} 分钟后重试`, + }); + } + user.status = 'active'; + user.loginCount = 0; + user.lockedUntil = null; + await user.save(); } if (user.status === 'inactive') { @@ -180,12 +189,21 @@ router.post('/login', async (req, res) => { user.loginCount = (user.loginCount || 0) + 1; if (user.loginCount >= MAX_LOGIN_ATTEMPTS) { user.status = 'locked'; + user.lockedUntil = new Date(Date.now() + LOCK_TIME); } await user.save(); + const remainingAttempts = MAX_LOGIN_ATTEMPTS - user.loginCount; + let message = '用户名或密码错误'; + if (remainingAttempts > 0) { + message += `,剩余 ${remainingAttempts} 次尝试机会`; + } else { + message = `账户已被锁定,请在 3 分钟后重试`; + } + return res.status(401).json({ success: false, - message: '用户名或密码错误', + message, }); } @@ -201,6 +219,7 @@ router.post('/login', async (req, res) => { user.lastLoginTime = new Date(); user.lastLoginIp = req.ip || req.connection.remoteAddress; user.loginCount = 0; + user.lockedUntil = null; await user.save(); res.json({ diff --git a/backend/scripts/migrate-all.js b/backend/scripts/migrate-all.js index 9e2109a..c34b836 100644 --- a/backend/scripts/migrate-all.js +++ b/backend/scripts/migrate-all.js @@ -133,6 +133,11 @@ const migrations = [ description: '为 operation_logs 表添加 requestId 字段和复合索引,支持请求追踪', migrate: migrateOperationLogRequestId, }, + { + name: '用户账户锁定时间', + description: '为 users 表添加 lockedUntil 字段,支持账户自动解锁', + migrate: migrateUserLockedUntil, + }, ]; async function runMigrations() { @@ -951,6 +956,18 @@ async function migrateOperationLogRequestId() { console.log(' 操作日志requestId字段和索引迁移完成'); } +async function migrateUserLockedUntil() { + const tableName = 'users'; + + if (!(await tableExists(tableName))) { + console.log(` ${tableName} 表不存在,跳过`); + return; + } + + await addColumnIfNotExists(tableName, 'lockedUntil', 'DATETIME'); + console.log(' users 表 lockedUntil 字段迁移完成'); +} + // 执行迁移 runMigrations().catch(error => { console.error('迁移执行失败:', error); diff --git a/frontend/.env.example b/frontend/.env.example index 9da5d1b..cb70c14 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -18,4 +18,4 @@ VITE_IDLE_TIMEOUT=1800000 # 超时前警告时间(毫秒) # 默认1分钟 = 60 * 1000 = 60000 -VITE_IDLE_WARNING_TIME=60000 +VITE_IDLE_WARNING_TIME=60000 \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 57e2e1f..d27e2c1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -29,7 +29,8 @@ "styled-components": "^6.3.9", "swr": "^2.4.0", "three": "^0.183.2", - "xlsx": "^0.18.5" + "xlsx": "^0.18.5", + "zustand": "^4.5.7" }, "devDependencies": { "@testing-library/jest-dom": "^6.9.1", @@ -280,6 +281,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -628,6 +630,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -671,6 +674,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -692,6 +696,7 @@ "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", "license": "MIT", + "peer": true, "dependencies": { "@emotion/memoize": "^0.9.0" } @@ -1732,11 +1737,41 @@ } } }, + "node_modules/@react-three/drei/node_modules/zustand": { + "version": "5.0.13", + "resolved": "https://registry.npmmirror.com/zustand/-/zustand-5.0.13.tgz", + "integrity": "sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + }, "node_modules/@react-three/fiber": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-8.18.0.tgz", "integrity": "sha512-FYZZqD0UUHUswKz3LQl2Z7H24AhD14XGTsIRw3SJaXUxyfVMi+1yiZGmqTcPt/CkPpdU7rrxqcyQ1zJE5DjvIQ==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.17.8", "@types/react-reconciler": "^0.26.7", @@ -1822,34 +1857,6 @@ "react-dom": ">=17" } }, - "node_modules/@reactflow/background/node_modules/zustand": { - "version": "4.5.7", - "resolved": "https://registry.npmmirror.com/zustand/-/zustand-4.5.7.tgz", - "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", - "license": "MIT", - "dependencies": { - "use-sync-external-store": "^1.2.2" - }, - "engines": { - "node": ">=12.7.0" - }, - "peerDependencies": { - "@types/react": ">=16.8", - "immer": ">=9.0.6", - "react": ">=16.8" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "immer": { - "optional": true - }, - "react": { - "optional": true - } - } - }, "node_modules/@reactflow/controls": { "version": "11.2.14", "resolved": "https://registry.npmmirror.com/@reactflow/controls/-/controls-11.2.14.tgz", @@ -1865,34 +1872,6 @@ "react-dom": ">=17" } }, - "node_modules/@reactflow/controls/node_modules/zustand": { - "version": "4.5.7", - "resolved": "https://registry.npmmirror.com/zustand/-/zustand-4.5.7.tgz", - "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", - "license": "MIT", - "dependencies": { - "use-sync-external-store": "^1.2.2" - }, - "engines": { - "node": ">=12.7.0" - }, - "peerDependencies": { - "@types/react": ">=16.8", - "immer": ">=9.0.6", - "react": ">=16.8" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "immer": { - "optional": true - }, - "react": { - "optional": true - } - } - }, "node_modules/@reactflow/core": { "version": "11.11.4", "resolved": "https://registry.npmmirror.com/@reactflow/core/-/core-11.11.4.tgz", @@ -1914,34 +1893,6 @@ "react-dom": ">=17" } }, - "node_modules/@reactflow/core/node_modules/zustand": { - "version": "4.5.7", - "resolved": "https://registry.npmmirror.com/zustand/-/zustand-4.5.7.tgz", - "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", - "license": "MIT", - "dependencies": { - "use-sync-external-store": "^1.2.2" - }, - "engines": { - "node": ">=12.7.0" - }, - "peerDependencies": { - "@types/react": ">=16.8", - "immer": ">=9.0.6", - "react": ">=16.8" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "immer": { - "optional": true - }, - "react": { - "optional": true - } - } - }, "node_modules/@reactflow/minimap": { "version": "11.7.14", "resolved": "https://registry.npmmirror.com/@reactflow/minimap/-/minimap-11.7.14.tgz", @@ -1961,34 +1912,6 @@ "react-dom": ">=17" } }, - "node_modules/@reactflow/minimap/node_modules/zustand": { - "version": "4.5.7", - "resolved": "https://registry.npmmirror.com/zustand/-/zustand-4.5.7.tgz", - "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", - "license": "MIT", - "dependencies": { - "use-sync-external-store": "^1.2.2" - }, - "engines": { - "node": ">=12.7.0" - }, - "peerDependencies": { - "@types/react": ">=16.8", - "immer": ">=9.0.6", - "react": ">=16.8" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "immer": { - "optional": true - }, - "react": { - "optional": true - } - } - }, "node_modules/@reactflow/node-resizer": { "version": "2.2.14", "resolved": "https://registry.npmmirror.com/@reactflow/node-resizer/-/node-resizer-2.2.14.tgz", @@ -2006,34 +1929,6 @@ "react-dom": ">=17" } }, - "node_modules/@reactflow/node-resizer/node_modules/zustand": { - "version": "4.5.7", - "resolved": "https://registry.npmmirror.com/zustand/-/zustand-4.5.7.tgz", - "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", - "license": "MIT", - "dependencies": { - "use-sync-external-store": "^1.2.2" - }, - "engines": { - "node": ">=12.7.0" - }, - "peerDependencies": { - "@types/react": ">=16.8", - "immer": ">=9.0.6", - "react": ">=16.8" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "immer": { - "optional": true - }, - "react": { - "optional": true - } - } - }, "node_modules/@reactflow/node-toolbar": { "version": "1.3.14", "resolved": "https://registry.npmmirror.com/@reactflow/node-toolbar/-/node-toolbar-1.3.14.tgz", @@ -2049,34 +1944,6 @@ "react-dom": ">=17" } }, - "node_modules/@reactflow/node-toolbar/node_modules/zustand": { - "version": "4.5.7", - "resolved": "https://registry.npmmirror.com/zustand/-/zustand-4.5.7.tgz", - "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", - "license": "MIT", - "dependencies": { - "use-sync-external-store": "^1.2.2" - }, - "engines": { - "node": ">=12.7.0" - }, - "peerDependencies": { - "@types/react": ">=16.8", - "immer": ">=9.0.6", - "react": ">=16.8" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "immer": { - "optional": true - }, - "react": { - "optional": true - } - } - }, "node_modules/@remix-run/router": { "version": "1.23.1", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.1.tgz", @@ -2509,8 +2376,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -2871,6 +2737,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.9.tgz", "integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2901,6 +2768,7 @@ "resolved": "https://registry.npmjs.org/@types/three/-/three-0.182.0.tgz", "integrity": "sha512-WByN9V3Sbwbe2OkWuSGyoqQO8Du6yhYaXtXLoA5FkKTUJorZ+yOHBZ35zUUPQXlAKABZmbYp5oAqpA4RBjtJ/Q==", "license": "MIT", + "peer": true, "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", @@ -3046,7 +2914,6 @@ "integrity": "sha512-cuzPhD8fwRHk8IGfmYaR4eEe4cAyJEL66Ove/WZL7yWNL134nqLddSLwNRIsFlnnW1kK+p8Ck3viFnC0chXCXw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/parser": "^7.29.0", "@vue/shared": "3.5.29", @@ -3061,7 +2928,6 @@ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "engines": { "node": ">=0.12" }, @@ -3074,8 +2940,7 @@ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@vue/compiler-dom": { "version": "3.5.29", @@ -3083,7 +2948,6 @@ "integrity": "sha512-n0G5o7R3uBVmVxjTIYcz7ovr8sy7QObFG8OQJ3xGCDNhbG60biP/P5KnyY8NLd81OuT1WJflG7N4KWYHaeeaIg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-core": "3.5.29", "@vue/shared": "3.5.29" @@ -3113,8 +2977,7 @@ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@vue/compiler-ssr": { "version": "3.5.29", @@ -3122,7 +2985,6 @@ "integrity": "sha512-Y/ARJZE6fpjzL5GH/phJmsFwx3g6t2KmHKHx5q+MLl2kencADKIrhH5MLF6HHpRMmlRAYBRSvv347Mepf1zVNw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.29", "@vue/shared": "3.5.29" @@ -3134,7 +2996,6 @@ "integrity": "sha512-zcrANcrRdcLtmGZETBxWqIkoQei8HaFpZWx/GHKxx79JZsiZ8j1du0VUJtu4eJjgFvU/iKL5lRXFXksVmI+5DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vue/shared": "3.5.29" } @@ -3145,7 +3006,6 @@ "integrity": "sha512-8DpW2QfdwIWOLqtsNcds4s+QgwSaHSJY/SUe04LptianUQ/0xi6KVsu/pYVh+HO3NTVvVJjIPL2t6GdeKbS4Lg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vue/reactivity": "3.5.29", "@vue/shared": "3.5.29" @@ -3157,7 +3017,6 @@ "integrity": "sha512-AHvvJEtcY9tw/uk+s/YRLSlxxQnqnAkjqvK25ZiM4CllCZWzElRAoQnCM42m9AHRLNJ6oe2kC5DCgD4AUdlvXg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vue/reactivity": "3.5.29", "@vue/runtime-core": "3.5.29", @@ -3171,7 +3030,6 @@ "integrity": "sha512-G/1k6WK5MusLlbxSE2YTcqAAezS+VuwHhOvLx2KnQU7G2zCH6KIb+5Wyt6UjMq7a3qPzNEjJXs1hvAxDclQH+g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-ssr": "3.5.29", "@vue/shared": "3.5.29" @@ -3185,8 +3043,7 @@ "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.29.tgz", "integrity": "sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@webgpu/types": { "version": "0.1.69", @@ -3200,6 +3057,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3259,7 +3117,6 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -3659,6 +3516,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4135,6 +3993,7 @@ "resolved": "https://registry.npmmirror.com/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -4265,7 +4124,8 @@ "version": "1.11.19", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/debug": { "version": "4.4.3", @@ -4380,8 +4240,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dom-helpers": { "version": "5.2.1", @@ -4680,6 +4539,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4740,6 +4600,7 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -6103,6 +5964,7 @@ "integrity": "sha512-GtldT42B8+jefDUC4yUKAvsaOrH7PDHmZxZXNgF2xMmymjUbRYJvpAybZAKEmXDGTM0mCsz8duOa4vTm5AY2Kg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@acemir/cssom": "^0.9.28", "@asamuzakjp/dom-selector": "^6.7.6", @@ -6317,7 +6179,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -6859,6 +6720,7 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -6888,7 +6750,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -6904,7 +6765,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -6917,8 +6777,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/promise-worker-transferable": { "version": "1.0.4", @@ -7591,6 +7450,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -7615,6 +7475,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -8562,6 +8423,7 @@ "integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -8588,7 +8450,8 @@ "version": "0.183.2", "resolved": "https://registry.npmjs.org/three/-/three-0.183.2.tgz", "integrity": "sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/three-mesh-bvh": { "version": "0.7.8", @@ -8773,34 +8636,6 @@ "zustand": "^4.3.2" } }, - "node_modules/tunnel-rat/node_modules/zustand": { - "version": "4.5.7", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", - "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", - "license": "MIT", - "dependencies": { - "use-sync-external-store": "^1.2.2" - }, - "engines": { - "node": ">=12.7.0" - }, - "peerDependencies": { - "@types/react": ">=16.8", - "immer": ">=9.0.6", - "react": ">=16.8" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "immer": { - "optional": true - }, - "react": { - "optional": true - } - } - }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -9139,6 +8974,7 @@ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", "license": "MIT", + "peer": true, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } @@ -9167,6 +9003,7 @@ "integrity": "sha512-+v57oAaoYNnO3hIu5Z/tJRZjq5aHM2zDve9YZ8HngVHbhk66RStobhb1sqPMIPEleV6cNKYK4eGrAbE9Ulbl2g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.18.10", "postcss": "^8.4.27", @@ -9786,6 +9623,7 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -10191,6 +10029,7 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -10209,18 +10048,20 @@ } }, "node_modules/zustand": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.10.tgz", - "integrity": "sha512-U1AiltS1O9hSy3rul+Ub82ut2fqIAefiSuwECWt6jlMVUGejvf+5omLcRBSzqbRagSM3hQZbtzdeRc6QVScXTg==", + "version": "4.5.7", + "resolved": "https://registry.npmmirror.com/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, "engines": { - "node": ">=12.20.0" + "node": ">=12.7.0" }, "peerDependencies": { - "@types/react": ">=18.0.0", + "@types/react": ">=16.8", "immer": ">=9.0.6", - "react": ">=18.0.0", - "use-sync-external-store": ">=1.2.0" + "react": ">=16.8" }, "peerDependenciesMeta": { "@types/react": { @@ -10231,9 +10072,6 @@ }, "react": { "optional": true - }, - "use-sync-external-store": { - "optional": true } } } diff --git a/frontend/package.json b/frontend/package.json index f1aba27..598a29a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -34,7 +34,8 @@ "styled-components": "^6.3.9", "swr": "^2.4.0", "three": "^0.183.2", - "xlsx": "^0.18.5" + "xlsx": "^0.18.5", + "zustand": "^4.5.7" }, "devDependencies": { "@testing-library/jest-dom": "^6.9.1", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 68ae4bc..4fdec7d 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -46,9 +46,8 @@ import { useLocation, useNavigate, } from 'react-router-dom'; -import { useAuth } from './context/AuthContext'; -import { ConfigProvider, useConfig } from './context/ConfigContext'; -import { Scene3DProvider } from './context/Scene3DContext'; +import { useAuth } from './hooks/useAuth'; +import { useConfig } from './hooks/useConfig'; import { useDesignTokens } from './hooks/useDesignTokens'; import useIdleTimeout from './hooks/useIdleTimeout'; import { SWRConfig, swrConfig } from './hooks/useSWR'; @@ -143,10 +142,9 @@ const ProtectedRoute = ({ component: Component }) => ( ); -// 默认空闲超时配置 const DEFAULT_IDLE_CONFIG = { - timeout: 30 * 60 * 1000, // 30分钟 - warningTime: 60 * 1000, // 60秒 + timeout: 30 * 60 * 1000, + warningTime: 60 * 1000, }; const AppLayout = ({ children }) => { @@ -159,7 +157,6 @@ const AppLayout = ({ children }) => { const location = useLocation(); const designTokens = useDesignTokens(); - // 获取空闲超时配置 useEffect(() => { const fetchIdleConfig = async () => { try { @@ -176,7 +173,6 @@ const AppLayout = ({ children }) => { fetchIdleConfig(); }, []); - // 启用空闲超时检测 useIdleTimeout({ timeout: idleConfig.timeout, warningTime: idleConfig.warningTime, @@ -674,9 +670,7 @@ const ThemeConfig = () => { title="3D 可视化加载失败" subTitle="3D 场景在加载过程中遇到错误,可能是浏览器不支持 WebGL 或模型文件加载失败" > - - - + } @@ -691,11 +685,7 @@ const ThemeConfig = () => { }; function App() { - return ( - - - - ); + return ; } -export default App; +export default App; \ No newline at end of file diff --git a/frontend/src/components/3d/Scene.jsx b/frontend/src/components/3d/Scene.jsx index 34eb5ee..48b74f4 100644 --- a/frontend/src/components/3d/Scene.jsx +++ b/frontend/src/components/3d/Scene.jsx @@ -10,7 +10,7 @@ import { Canvas, useFrame, useThree } from '@react-three/fiber'; import { OrbitControls, PerspectiveCamera, Environment } from '@react-three/drei'; const envMapUrl = '/assets/3d/env.hdr'; import RackModel from './RackModel'; -import { useScene3D } from '../../context/Scene3DContext'; +import { useScene3D } from '../../hooks/useScene3D'; import * as THREE from 'three'; import ErrorBoundary from '../ErrorBoundary'; diff --git a/frontend/src/components/AuthInitializer.jsx b/frontend/src/components/AuthInitializer.jsx new file mode 100644 index 0000000..af5b76a --- /dev/null +++ b/frontend/src/components/AuthInitializer.jsx @@ -0,0 +1,44 @@ +/** + * Zustand Store 初始化组件 + * 确保所有必要的 Store 在应用渲染前完成初始化 + */ + +import { useEffect, useState } from 'react'; +import { useAuthStore } from '../stores/authStore'; +import { useConfigStore } from '../stores/configStore'; +import Spin from 'antd/es/spin'; + +const AuthInitializer = ({ children }) => { + const [allInitialized, setAllInitialized] = useState(false); + + const { initialize: initializeAuth, initialized: authInitialized } = useAuthStore(); + const { loadConfig, loading: configLoading } = useConfigStore(); + + useEffect(() => { + const initStores = async () => { + await initializeAuth(); + await loadConfig(); + }; + + initStores().then(() => { + setAllInitialized(true); + }); + }, [initializeAuth, loadConfig]); + + if (!allInitialized) { + return ( +
+ +
+ ); + } + + return children; +}; + +export default AuthInitializer; diff --git a/frontend/src/components/ProtectedRoute.jsx b/frontend/src/components/ProtectedRoute.jsx index 75e45b3..c105c54 100644 --- a/frontend/src/components/ProtectedRoute.jsx +++ b/frontend/src/components/ProtectedRoute.jsx @@ -1,7 +1,7 @@ import React from 'react'; import { Navigate, useLocation } from 'react-router-dom'; import { Spin } from 'antd'; -import { useAuth } from '../context/AuthContext'; +import { useAuth } from '../hooks/useAuth'; const ProtectedRoute = ({ children, requiredPermission }) => { const { user, token, loading, initialized } = useAuth(); diff --git a/frontend/src/context/AuthContext.jsx b/frontend/src/context/AuthContext.jsx deleted file mode 100644 index 63c1b9b..0000000 --- a/frontend/src/context/AuthContext.jsx +++ /dev/null @@ -1,167 +0,0 @@ -import React, { createContext, useContext, useState, useEffect, useCallback, useMemo } from 'react'; -import { authAPI, setAuthInitialized } from '../api'; -import secureStorage, { TOKEN_KEY, USER_KEY } from '../utils/secureStorage'; - -const AuthContext = createContext({ - user: null, - token: null, - loading: true, - initialized: false, - login: async () => ({ success: false, message: '认证未初始化' }), - register: async () => ({ success: false, message: '认证未初始化' }), - logout: () => {}, - updateUser: () => {}, - hasPermission: () => false, - checkAdmin: () => Promise.resolve({ success: true, data: { hasAdmin: false, userCount: 0 } }), -}); - -export const useAuth = () => { - return useContext(AuthContext); -}; - -export const AuthProvider = ({ children }) => { - const [user, setUser] = useState(null); - const [token, setToken] = useState(null); - const [loading, setLoading] = useState(true); - const [initialized, setInitialized] = useState(false); - - useEffect(() => { - let cancelled = false; - - const initializeAuth = async () => { - try { - const storedToken = await secureStorage.loadFromStorage(TOKEN_KEY); - const storedUser = await secureStorage.loadFromStorage(USER_KEY); - - if (cancelled) return; - - if (!storedToken) { - setToken(null); - setUser(null); - setLoading(false); - setInitialized(true); - return; - } - - setToken(storedToken); - setUser(storedUser); - - try { - const response = await authAPI.getProfile(); - if (cancelled) return; - - if (response.success) { - setUser(response.data.user); - await secureStorage.set(USER_KEY, response.data.user); - } - } catch (error) { - if (cancelled) return; - - const status = error?.response?.status; - if (status === 401 || status === 403) { - secureStorage.remove(TOKEN_KEY); - secureStorage.remove(USER_KEY); - setToken(null); - setUser(null); - } - } - } catch { - if (cancelled) return; - setToken(null); - setUser(null); - } finally { - if (!cancelled) { - setLoading(false); - setInitialized(true); - setAuthInitialized(true); - } - } - }; - - initializeAuth(); - - return () => { - cancelled = true; - }; - }, []); - - const login = useCallback(async (username, password) => { - try { - const response = await authAPI.login({ username, password }); - if (response.success) { - const { token: newToken, user: userData } = response.data; - await secureStorage.set(TOKEN_KEY, newToken); - await secureStorage.set(USER_KEY, userData); - setToken(newToken); - setUser(userData); - return { success: true }; - } - return { success: false, message: response.message, code: response.code }; - } catch (error) { - const message = error?.response?.data?.message || error?.message || '登录失败,请稍后重试'; - return { success: false, message }; - } - }, []); - - const register = useCallback(async userData => { - try { - const response = await authAPI.register(userData); - if (response.success) { - const { token: newToken, user: newUser, isFirstUser, pendingApproval } = response.data; - if (newToken) { - await secureStorage.set(TOKEN_KEY, newToken); - await secureStorage.set(USER_KEY, newUser); - setToken(newToken); - setUser(newUser); - } - return { success: true, isFirstUser, pendingApproval }; - } - return { success: false, message: response.message }; - } catch (error) { - const message = error?.response?.data?.message || error?.message || '注册失败,请稍后重试'; - return { success: false, message }; - } - }, []); - - const logout = useCallback(() => { - secureStorage.remove(TOKEN_KEY); - secureStorage.remove(USER_KEY); - setToken(null); - setUser(null); - }, []); - - const updateUser = useCallback(newUserData => { - setUser(prev => { - const updated = { ...prev, ...newUserData }; - secureStorage.set(USER_KEY, updated).catch(() => {}); - return updated; - }); - }, []); - - const hasPermission = useCallback(permission => { - if (!user) return false; - const roles = user.roles || []; - if (roles.some(r => r.roleCode === 'admin')) return true; - if (permission === 'admin') return roles.some(r => r.roleCode === 'admin'); - return roles.some(r => r.roleCode === permission); - }, [user]); - - const checkAdmin = useCallback(() => authAPI.checkAdmin(), []); - - const value = useMemo(() => ({ - user, - token, - loading, - initialized, - login, - register, - logout, - updateUser, - hasPermission, - checkAdmin, - }), [user, token, loading, initialized, login, register, logout, updateUser, hasPermission, checkAdmin]); - - return {children}; -}; - -export default AuthContext; \ No newline at end of file diff --git a/frontend/src/context/ConfigContext.jsx b/frontend/src/context/ConfigContext.jsx deleted file mode 100644 index 4dfb32a..0000000 --- a/frontend/src/context/ConfigContext.jsx +++ /dev/null @@ -1,98 +0,0 @@ -import React, { createContext, useState, useEffect, useContext } from 'react'; -import axios from 'axios'; - -const ConfigContext = createContext(); - -const applyThemeColors = (primaryColor, secondaryColor) => { - const root = document.documentElement; - if (primaryColor) { - root.style.setProperty('--primary-color', primaryColor); - root.style.setProperty('--primary-light', `${primaryColor}20`); - root.style.setProperty( - '--primary-gradient', - `linear-gradient(135deg, ${primaryColor} 0%, ${secondaryColor || '#764ba2'} 100%)` - ); - } - if (secondaryColor) { - root.style.setProperty('--secondary-color', secondaryColor); - root.style.setProperty('--secondary-light', `${secondaryColor}20`); - } -}; - -export const ConfigProvider = ({ children }) => { - const [config, setConfig] = useState({ - site_name: '机柜管理系统', - primary_color: '#667eea', - secondary_color: '#764ba2', - sidebar_collapsed: false, - compact_mode: false, - animation_enabled: true, - language: 'zh-CN', - timezone: 'Asia/Shanghai', - date_format: 'YYYY-MM-DD', - session_timeout: 30, - max_login_attempts: 5, - maintenance_mode: false, - }); - const [loading, setLoading] = useState(true); - - const loadConfig = async () => { - try { - const response = await axios.get('/api/system-settings'); - const settings = response.data; - const configValues = {}; - - Object.entries(settings).forEach(([key, value]) => { - configValues[key] = value.value; - }); - - setConfig(prev => ({ - ...prev, - ...configValues, - })); - - if (configValues.primary_color || configValues.secondary_color) { - applyThemeColors(configValues.primary_color, configValues.secondary_color); - } - } catch (error) { - console.error('加载系统配置失败:', error); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - loadConfig(); - }, []); - - useEffect(() => { - if (config.primary_color || config.secondary_color) { - applyThemeColors(config.primary_color, config.secondary_color); - } - }, [config.primary_color, config.secondary_color]); - - const updateConfig = newConfig => { - setConfig(prev => ({ - ...prev, - ...newConfig, - })); - }; - - const reloadConfig = async () => { - await loadConfig(); - }; - - return ( - - {children} - - ); -}; - -export const useConfig = () => { - const context = useContext(ConfigContext); - if (!context) { - throw new Error('useConfig must be used within a ConfigProvider'); - } - return context; -}; diff --git a/frontend/src/context/FloorPlanContext.jsx b/frontend/src/context/FloorPlanContext.jsx deleted file mode 100644 index 0efe8f2..0000000 --- a/frontend/src/context/FloorPlanContext.jsx +++ /dev/null @@ -1,111 +0,0 @@ -import React, { createContext, useReducer, useCallback } from 'react'; - -const initialState = { - selectedRoomId: null, - selectedRack: null, - hoveredRack: null, - zoom: 1, - offsetX: 0, - offsetY: 0, - detailRack: null, - detailVisible: false, -}; - -const actionTypes = { - SET_SELECTED_ROOM: 'SET_SELECTED_ROOM', - SET_SELECTED_RACK: 'SET_SELECTED_RACK', - SET_HOVERED_RACK: 'SET_HOVERED_RACK', - SET_VIEW_CHANGE: 'SET_VIEW_CHANGE', - SHOW_DETAIL: 'SHOW_DETAIL', - HIDE_DETAIL: 'HIDE_DETAIL', - RESET: 'RESET', -}; - -function floorPlanReducer(state, action) { - switch (action.type) { - case actionTypes.SET_SELECTED_ROOM: - return { - ...state, - selectedRoomId: action.payload, - selectedRack: null, - hoveredRack: null, - detailRack: null, - detailVisible: false, - }; - case actionTypes.SET_SELECTED_RACK: - return { ...state, selectedRack: action.payload }; - case actionTypes.SET_HOVERED_RACK: - return { ...state, hoveredRack: action.payload }; - case actionTypes.SET_VIEW_CHANGE: - return { - ...state, - zoom: action.payload.zoom, - offsetX: action.payload.offsetX, - offsetY: action.payload.offsetY, - }; - case actionTypes.SHOW_DETAIL: - return { - ...state, - detailRack: action.payload, - detailVisible: true, - }; - case actionTypes.HIDE_DETAIL: - return { - ...state, - detailRack: null, - detailVisible: false, - }; - case actionTypes.RESET: - return { ...initialState }; - default: - return state; - } -} - -export const FloorPlanContext = createContext(null); - -export const FloorPlanProvider = ({ children }) => { - const [state, dispatch] = useReducer(floorPlanReducer, initialState); - - const setSelectedRoom = useCallback((roomId) => { - dispatch({ type: actionTypes.SET_SELECTED_ROOM, payload: roomId }); - }, []); - - const setSelectedRack = useCallback((rack) => { - dispatch({ type: actionTypes.SET_SELECTED_RACK, payload: rack }); - }, []); - - const setHoveredRack = useCallback((rack) => { - dispatch({ type: actionTypes.SET_HOVERED_RACK, payload: rack }); - }, []); - - const setViewChange = useCallback((viewState) => { - dispatch({ type: actionTypes.SET_VIEW_CHANGE, payload: viewState }); - }, []); - - const showDetail = useCallback((rack) => { - dispatch({ type: actionTypes.SHOW_DETAIL, payload: rack }); - }, []); - - const hideDetail = useCallback(() => { - dispatch({ type: actionTypes.HIDE_DETAIL }); - }, []); - - const value = { - ...state, - setSelectedRoom, - setSelectedRack, - setHoveredRack, - setViewChange, - showDetail, - hideDetail, - }; - - return ( - - {children} - - ); -}; - -export default FloorPlanContext; diff --git a/frontend/src/context/Scene3DContext.jsx b/frontend/src/context/Scene3DContext.jsx deleted file mode 100644 index 2766991..0000000 --- a/frontend/src/context/Scene3DContext.jsx +++ /dev/null @@ -1,121 +0,0 @@ -import React, { createContext, useContext, useState, useMemo, useCallback } from 'react'; - -// 3D场景状态上下文 -// 将3D场景状态与UI状态分离,避免不必要的重渲染 - -const Scene3DContext = createContext(null); - -export const Scene3DProvider = ({ children }) => { - // 3D场景相关状态 - const [devices, setDevices] = useState([]); - const [selectedDevice, setSelectedDevice] = useState(null); - const [hoveredDevice, setHoveredDevice] = useState(null); - const [deviceSlideEnabled, setDeviceSlideEnabled] = useState(false); - const [selectedRack, setSelectedRack] = useState(null); - const [racks, setRacks] = useState([]); - const [deviceCables, setDeviceCables] = useState([]); - const [loadingDevices, setLoadingDevices] = useState(false); - - // 使用 useCallback 稳定回调函数 - const selectDevice = useCallback(device => { - setSelectedDevice(device); - }, []); - - const hoverDevice = useCallback(device => { - setHoveredDevice(device); - }, []); - - const toggleDeviceSlide = useCallback(() => { - setDeviceSlideEnabled(prev => !prev); - }, []); - - const setDeviceSlide = useCallback(enabled => { - setDeviceSlideEnabled(enabled); - }, []); - - const updateDevices = useCallback(newDevices => { - setDevices(newDevices); - }, []); - - const updateRacks = useCallback(newRacks => { - setRacks(newRacks); - }, []); - - const selectRack = useCallback(rack => { - setSelectedRack(rack); - }, []); - - const updateDeviceCables = useCallback(cables => { - setDeviceCables(cables); - }, []); - - const setLoading = useCallback(loading => { - setLoadingDevices(loading); - }, []); - - // 使用 useMemo 缓存 context value,避免不必要的重渲染 - const value = useMemo( - () => ({ - // 状态 - devices, - selectedDevice, - hoveredDevice, - deviceSlideEnabled, - selectedRack, - racks, - deviceCables, - loadingDevices, - // 方法 - selectDevice, - hoverDevice, - toggleDeviceSlide, - setDeviceSlide, - updateDevices, - updateRacks, - selectRack, - updateDeviceCables, - setLoading, - // 直接设置状态的方法(用于兼容现有代码) - setDevices, - setSelectedDevice, - setHoveredDevice, - setDeviceSlideEnabled, - setSelectedRack, - setRacks, - setDeviceCables, - setLoadingDevices, - }), - [ - devices, - selectedDevice, - hoveredDevice, - deviceSlideEnabled, - selectedRack, - racks, - deviceCables, - loadingDevices, - selectDevice, - hoverDevice, - toggleDeviceSlide, - setDeviceSlide, - updateDevices, - updateRacks, - selectRack, - updateDeviceCables, - setLoading, - ] - ); - - return {children}; -}; - -// 自定义 Hook -export const useScene3D = () => { - const context = useContext(Scene3DContext); - if (!context) { - throw new Error('useScene3D must be used within a Scene3DProvider'); - } - return context; -}; - -export default Scene3DContext; diff --git a/frontend/src/hooks/floorplan/useFloorPlanContext.js b/frontend/src/hooks/floorplan/useFloorPlanContext.js index 745565d..6771dcd 100644 --- a/frontend/src/hooks/floorplan/useFloorPlanContext.js +++ b/frontend/src/hooks/floorplan/useFloorPlanContext.js @@ -1,12 +1,39 @@ -import { useContext } from 'react'; -import FloorPlanContext from '../../context/FloorPlanContext'; +import { useFloorPlanStore } from '../../stores/floorPlanStore'; const useFloorPlanContext = () => { - const context = useContext(FloorPlanContext); - if (!context) { - throw new Error('useFloorPlanContext 必须在 FloorPlanProvider 内使用'); - } - return context; + const selectedRoomId = useFloorPlanStore((s) => s.selectedRoomId); + const selectedRack = useFloorPlanStore((s) => s.selectedRack); + const hoveredRack = useFloorPlanStore((s) => s.hoveredRack); + const zoom = useFloorPlanStore((s) => s.zoom); + const offsetX = useFloorPlanStore((s) => s.offsetX); + const offsetY = useFloorPlanStore((s) => s.offsetY); + const detailRack = useFloorPlanStore((s) => s.detailRack); + const detailVisible = useFloorPlanStore((s) => s.detailVisible); + const setSelectedRoom = useFloorPlanStore((s) => s.setSelectedRoom); + const setSelectedRack = useFloorPlanStore((s) => s.setSelectedRack); + const setHoveredRack = useFloorPlanStore((s) => s.setHoveredRack); + const setViewChange = useFloorPlanStore((s) => s.setViewChange); + const showDetail = useFloorPlanStore((s) => s.showDetail); + const hideDetail = useFloorPlanStore((s) => s.hideDetail); + const reset = useFloorPlanStore((s) => s.reset); + + return { + selectedRoomId, + selectedRack, + hoveredRack, + zoom, + offsetX, + offsetY, + detailRack, + detailVisible, + setSelectedRoom, + setSelectedRack, + setHoveredRack, + setViewChange, + showDetail, + hideDetail, + reset, + }; }; -export default useFloorPlanContext; +export default useFloorPlanContext; \ No newline at end of file diff --git a/frontend/src/hooks/useAuth.js b/frontend/src/hooks/useAuth.js new file mode 100644 index 0000000..7762e7b --- /dev/null +++ b/frontend/src/hooks/useAuth.js @@ -0,0 +1,42 @@ +/** + * 认证状态 Hook + * 直接使用 Zustand Store 管理认证状态 + */ + +import { useEffect } from 'react'; +import { useAuthStore } from '../stores/authStore'; + +export const useAuth = () => { + const user = useAuthStore((s) => s.user); + const token = useAuthStore((s) => s.token); + const loading = useAuthStore((s) => s.loading); + const initialized = useAuthStore((s) => s.initialized); + const login = useAuthStore((s) => s.login); + const register = useAuthStore((s) => s.register); + const logout = useAuthStore((s) => s.logout); + const updateUser = useAuthStore((s) => s.updateUser); + const hasPermission = useAuthStore((s) => s.hasPermission); + const checkAdmin = useAuthStore((s) => s.checkAdmin); + const initialize = useAuthStore((s) => s.initialize); + + useEffect(() => { + if (!initialized) { + initialize(); + } + }, [initialized, initialize]); + + return { + user, + token, + loading, + initialized, + login, + register, + logout, + updateUser, + hasPermission, + checkAdmin, + }; +}; + +export default useAuth; \ No newline at end of file diff --git a/frontend/src/hooks/useConfig.js b/frontend/src/hooks/useConfig.js new file mode 100644 index 0000000..39fb327 --- /dev/null +++ b/frontend/src/hooks/useConfig.js @@ -0,0 +1,25 @@ +/** + * 系统配置 Hook + * 直接使用 Zustand Store 管理系统配置 + */ + +import { useEffect } from 'react'; +import { useConfigStore } from '../stores/configStore'; + +export const useConfig = () => { + const config = useConfigStore((s) => s.config); + const loading = useConfigStore((s) => s.loading); + const updateConfig = useConfigStore((s) => s.updateConfig); + const reloadConfig = useConfigStore((s) => s.reloadConfig); + const loadConfig = useConfigStore((s) => s.loadConfig); + + useEffect(() => { + if (loading) { + loadConfig(); + } + }, [loading, loadConfig]); + + return { config, loading, updateConfig, reloadConfig }; +}; + +export default useConfig; \ No newline at end of file diff --git a/frontend/src/hooks/useDesignTokens.js b/frontend/src/hooks/useDesignTokens.js index 67c7471..f92254c 100644 --- a/frontend/src/hooks/useDesignTokens.js +++ b/frontend/src/hooks/useDesignTokens.js @@ -1,5 +1,5 @@ import { useMemo } from 'react'; -import { useConfig } from '../context/ConfigContext'; +import { useConfigStore } from '../stores/configStore'; /** * 使用设计令牌 Hook @@ -7,12 +7,10 @@ import { useConfig } from '../context/ConfigContext'; * @returns {Object} 设计令牌对象 */ export const useDesignTokens = () => { - const { config } = useConfig(); + const primaryColor = useConfigStore((s) => s.config.primary_color) || '#667eea'; + const secondaryColor = useConfigStore((s) => s.config.secondary_color) || '#764ba2'; const designTokens = useMemo(() => { - const primaryColor = config?.primary_color || '#667eea'; - const secondaryColor = config?.secondary_color || '#764ba2'; - return { colors: { primary: { @@ -61,7 +59,7 @@ export const useDesignTokens = () => { lg: '24px', }, }; - }, [config?.primary_color, config?.secondary_color]); + }, [primaryColor, secondaryColor]); return designTokens; }; @@ -72,10 +70,8 @@ export const useDesignTokens = () => { * @returns {string} RGB字符串 (如: "102, 126, 234") */ function hexToRgb(hex) { - // 移除 # 号 const cleanHex = hex.replace('#', ''); - // 处理简写格式 (如: #fff) const fullHex = cleanHex.length === 3 ? cleanHex @@ -91,4 +87,4 @@ function hexToRgb(hex) { return `${r}, ${g}, ${b}`; } -export default useDesignTokens; +export default useDesignTokens; \ No newline at end of file diff --git a/frontend/src/hooks/useFloorPlan.js b/frontend/src/hooks/useFloorPlan.js new file mode 100644 index 0000000..ae8146c --- /dev/null +++ b/frontend/src/hooks/useFloorPlan.js @@ -0,0 +1,12 @@ +/** + * 平面图 Hook + * 直接使用 Zustand Store 管理平面图状态 + */ + +import { useFloorPlanStore } from '../stores/floorPlanStore'; + +export const useFloorPlan = () => { + return useFloorPlanStore(); +}; + +export default useFloorPlan; \ No newline at end of file diff --git a/frontend/src/hooks/useScene3D.js b/frontend/src/hooks/useScene3D.js new file mode 100644 index 0000000..63e19a2 --- /dev/null +++ b/frontend/src/hooks/useScene3D.js @@ -0,0 +1,12 @@ +/** + * 3D场景 Hook + * 直接使用 Zustand Store 管理3D场景状态 + */ + +import { useScene3DStore } from '../stores/scene3DStore'; + +export const useScene3D = () => { + return useScene3DStore(); +}; + +export default useScene3D; \ No newline at end of file diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 39be00e..50ece19 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -1,16 +1,16 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App'; -import { AuthProvider } from './context/AuthContext'; +import AuthInitializer from './components/AuthInitializer'; import ErrorBoundary from './components/ErrorBoundary'; import './index.css'; ReactDOM.createRoot(document.getElementById('root')).render( - + - + -); +); \ No newline at end of file diff --git a/frontend/src/pages/Login.css b/frontend/src/pages/Login.css new file mode 100644 index 0000000..ca5040f --- /dev/null +++ b/frontend/src/pages/Login.css @@ -0,0 +1,1007 @@ +/* ============================================ + Login Page - Light Corporate Design + Design System: IDC Data Center Management + Style: Clean Modern Corporate + Data Center Viz + Palette: Teal (#14b8a6) / Blue (#3b82f6) / Purple (#8b5cf6) + ============================================ */ + +:root { + --lp-primary: #14b8a6; + --lp-primary-light: #2dd4bf; + --lp-primary-dark: #0d9488; + --lp-secondary: #3b82f6; + --lp-secondary-light: #60a5fa; + --lp-accent: #8b5cf6; + --lp-accent-light: #a78bfa; + --lp-bg: #f8fafc; + --lp-bg-card: #ffffff; + --lp-surface: #f1f5f9; + --lp-border: #e2e8f0; + --lp-border-focus: #14b8a6; + --lp-text: #1e293b; + --lp-text-secondary: #64748b; + --lp-text-muted: #94a3b8; + --lp-success: #22c55e; + --lp-error: #ef4444; + --lp-warning: #f59e0b; + --lp-radius-sm: 6px; + --lp-radius-md: 10px; + --lp-radius-lg: 14px; + --lp-radius-xl: 18px; + --lp-shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + --lp-shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + --lp-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); + --lp-shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); + --lp-transition: 200ms cubic-bezier(0.4, 0, 0.2, 1); +} + +/* ---- Page Container ---- */ +.login-container { + position: relative; + min-height: 100vh; + background: linear-gradient(160deg, #f8fafc 0%, #e0f2fe 50%, #f0fdf4 100%); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +/* ---- Background Decoration ---- */ +.login-bg-decoration { + position: absolute; + inset: 0; + pointer-events: none; + overflow: hidden; + z-index: 0; +} + +.decoration-circle { + position: absolute; + border-radius: 50%; +} + +.decoration-circle--1 { + width: 500px; + height: 500px; + background: radial-gradient(circle, rgba(20, 184, 166, 0.12) 0%, transparent 70%); + top: -15%; + right: -10%; + animation: float1 30s ease-in-out infinite; +} + +.decoration-circle--2 { + width: 400px; + height: 400px; + background: radial-gradient(circle, rgba(59, 130, 246, 0.1) 0%, transparent 70%); + bottom: -10%; + left: -5%; + animation: float2 25s ease-in-out infinite; +} + +.decoration-circle--3 { + width: 250px; + height: 250px; + background: radial-gradient(circle, rgba(139, 92, 246, 0.08) 0%, transparent 70%); + top: 30%; + left: 30%; + animation: float3 20s ease-in-out infinite; +} + +.decoration-grid { + position: absolute; + inset: 0; + background-image: + linear-gradient(rgba(20, 184, 166, 0.03) 1px, transparent 1px), + linear-gradient(90deg, rgba(20, 184, 166, 0.03) 1px, transparent 1px); + background-size: 40px 40px; +} + +@keyframes float1 { + 0%, 100% { transform: translate(0, 0) scale(1); } + 33% { transform: translate(-40px, 50px) scale(1.05); } + 66% { transform: translate(30px, -30px) scale(0.95); } +} + +@keyframes float2 { + 0%, 100% { transform: translate(0, 0) scale(1); } + 33% { transform: translate(50px, -40px) scale(0.95); } + 66% { transform: translate(-30px, 40px) scale(1.05); } +} + +@keyframes float3 { + 0%, 100% { transform: translateY(-50%) scale(1); opacity: 0.5; } + 50% { transform: translateY(-50%) scale(1.3); opacity: 0.8; } +} + +.decoration-wave { + position: absolute; + bottom: 0; + left: 0; + width: 200%; + height: 180px; + background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1440 320'%3E%3Cpath fill='%2314b8a6' fill-opacity='0.06' d='M0,96L48,112C96,128,192,160,288,160C384,160,480,128,576,122.7C672,117,768,139,864,154.7C960,171,1056,181,1152,165.3C1248,149,1344,107,1392,85.3L1440,64L1440,320L1392,320C1344,320,1248,320,1152,320C1056,320,960,320,864,320C768,320,672,320,576,320C480,320,384,320,288,320C192,320,96,320,48,320L0,320Z'%3E%3C/path%3E%3C/svg%3E"); + background-size: 50% 100%; + animation: wave 25s linear infinite; +} + +@keyframes wave { + 0% { transform: translateX(0); } + 100% { transform: translateX(-50%); } +} + +/* ---- Login Wrapper ---- */ +.login-wrapper { + position: relative; + z-index: 1; + width: 100%; + max-width: 1000px; + padding: 24px; + animation: fadeInUp 0.6s ease-out; +} + +@keyframes fadeInUp { + from { opacity: 0; transform: translateY(24px); } + to { opacity: 1; transform: translateY(0); } +} + +/* ---- Login Card ---- */ +.login-card { + display: flex; + background: var(--lp-bg-card); + border-radius: var(--lp-radius-xl); + box-shadow: + 0 0 0 1px rgba(0, 0, 0, 0.02), + var(--lp-shadow-xl), + 0 0 60px -20px rgba(20, 184, 166, 0.15); + overflow: hidden; +} + +/* ---- Brand Section (Left) ---- */ +.login-card__brand { + flex: 0 0 48%; + display: flex; + flex-direction: column; + align-items: center; + padding: 36px 28px; + background: linear-gradient( + 160deg, + rgba(20, 184, 166, 0.05) 0%, + rgba(59, 130, 246, 0.03) 50%, + rgba(139, 92, 246, 0.02) 100% + ); + position: relative; +} + +.login-card__brand::before { + content: ''; + position: absolute; + inset: 0; + background: + radial-gradient(ellipse at 30% 20%, rgba(20, 184, 166, 0.08) 0%, transparent 50%), + radial-gradient(ellipse at 70% 80%, rgba(59, 130, 246, 0.06) 0%, transparent 50%); + pointer-events: none; +} + +/* ---- Brand Header ---- */ +.brand-header { + display: flex; + align-items: center; + gap: 16px; + position: relative; + z-index: 1; + width: 100%; +} + +.brand-logo { + position: relative; + width: 64px; + height: 64px; + background: linear-gradient(135deg, var(--lp-primary) 0%, var(--lp-secondary) 100%); + border-radius: var(--lp-radius-lg); + display: flex; + align-items: center; + justify-content: center; + font-size: 32px; + color: #fff; + box-shadow: + 0 8px 24px rgba(20, 184, 166, 0.25), + 0 0 0 1px rgba(255, 255, 255, 0.1) inset; + flex-shrink: 0; +} + +.logo-pulse { + position: absolute; + inset: -4px; + border-radius: calc(var(--lp-radius-lg) + 4px); + background: linear-gradient(135deg, var(--lp-primary), var(--lp-secondary)); + opacity: 0.3; + z-index: -1; + animation: logoPulse 2s ease-out infinite; +} + +@keyframes logoPulse { + 0% { transform: scale(1); opacity: 0.3; } + 100% { transform: scale(1.4); opacity: 0; } +} + +.brand-title-section { + flex: 1; +} + +.brand-title { + margin: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.brand-title__main { + font-size: 36px; + font-weight: 800; + letter-spacing: 6px; + color: var(--lp-text); + line-height: 1.1; +} + +.brand-title__sub { + font-size: 16px; + font-weight: 600; + background: linear-gradient(135deg, var(--lp-primary) 0%, var(--lp-secondary) 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + letter-spacing: 1px; +} + +.brand-desc { + margin: 4px 0 0; + color: var(--lp-text-muted); + font-size: 12px; + letter-spacing: 0.3px; +} + +/* ---- Rack Visual ---- */ +.rack-visual { + width: 100%; + margin: 16px 0; + position: relative; + z-index: 1; + background: rgba(255, 255, 255, 0.5); + border-radius: var(--lp-radius-md); + border: 1px solid rgba(20, 184, 166, 0.08); + overflow: hidden; +} + +.rack-visual__header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 12px; + background: rgba(20, 184, 166, 0.04); + border-bottom: 1px solid rgba(20, 184, 166, 0.06); +} + +.rack-visual__title { + font-size: 10px; + font-weight: 600; + color: var(--lp-text-secondary); + letter-spacing: 0.5px; +} + +.rack-visual__badge { + font-size: 9px; + font-weight: 500; + color: var(--lp-primary); + background: rgba(20, 184, 166, 0.08); + padding: 1px 8px; + border-radius: 8px; +} + +.rack-visual__body { + display: flex; + gap: 6px; + padding: 10px; +} + +.rack-unit { + flex: 1; + min-width: 0; + background: linear-gradient(180deg, #fafbfc 0%, #f4f6f8 100%); + border-radius: 6px; + padding: 8px 6px; + border: 1px solid rgba(0, 0, 0, 0.04); + animation: rackFadeIn 0.4s ease-out both; + transition: border-color 0.2s ease; +} + +.rack-unit:hover { + border-color: rgba(20, 184, 166, 0.2); +} + +@keyframes rackFadeIn { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: translateY(0); } +} + +.rack-unit__header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 6px; +} + +.rack-unit__id { + font-size: 9px; + font-weight: 700; + color: var(--lp-text-secondary); + font-variant-numeric: tabular-nums; +} + +.rack-unit__status { + width: 4px; + height: 4px; + border-radius: 50%; + background: var(--lp-success); + box-shadow: 0 0 4px rgba(34, 197, 94, 0.4); + animation: statusPulse 2.5s ease-in-out infinite; +} + +@keyframes statusPulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +.rack-unit__slots { + display: flex; + flex-direction: column; + gap: 2px; + margin-bottom: 6px; +} + +.rack-unit__slot { + height: 8px; + border-radius: 2px; + animation: slotFadeIn 0.3s ease-out both; +} + +@keyframes slotFadeIn { + from { opacity: 0; transform: scaleX(0.6); } + to { opacity: 1; transform: scaleX(1); } +} + +.rack-unit__slot--on { + background: linear-gradient(90deg, rgba(20, 184, 166, 0.2), rgba(59, 130, 246, 0.12)); + border: 1px solid rgba(20, 184, 166, 0.18); +} + +.rack-unit__slot--off { + background: rgba(0, 0, 0, 0.02); + border: 1px solid rgba(0, 0, 0, 0.04); +} + +.rack-unit__load { + height: 2px; + background: rgba(0, 0, 0, 0.04); + border-radius: 1px; + overflow: hidden; +} + +.rack-unit__load-bar { + height: 100%; + background: linear-gradient(90deg, var(--lp-primary), var(--lp-secondary)); + border-radius: 1px; + transition: width 0.8s ease-out; +} + +/* ---- Brand Stats ---- */ +.brand-stats { + display: flex; + gap: 12px; + width: 100%; + position: relative; + z-index: 1; + margin-bottom: 20px; +} + +.stat-card { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + padding: 12px 8px; + background: rgba(255, 255, 255, 0.8); + border: 1px solid rgba(20, 184, 166, 0.12); + border-radius: var(--lp-radius-md); + transition: all var(--lp-transition); +} + +.stat-card:hover { + border-color: rgba(20, 184, 166, 0.25); + transform: translateY(-2px); + box-shadow: var(--lp-shadow-md); +} + +.stat-value { + font-size: 16px; + font-weight: 700; + background: linear-gradient(135deg, var(--lp-primary) 0%, var(--lp-secondary) 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.stat-label { + font-size: 10px; + color: var(--lp-text-muted); + margin-top: 2px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +/* ---- Brand Features ---- */ +.brand-features { + display: flex; + flex-direction: column; + gap: 10px; + width: 100%; + position: relative; + z-index: 1; +} + +.brand-feature { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 14px; + background: rgba(255, 255, 255, 0.7); + border: 1px solid rgba(0, 0, 0, 0.05); + border-radius: var(--lp-radius-md); + transition: all var(--lp-transition); + animation: featureSlideIn 0.4s ease-out both; + cursor: default; +} + +@keyframes featureSlideIn { + from { opacity: 0; transform: translateX(-12px); } + to { opacity: 1; transform: translateX(0); } +} + +.brand-feature:hover { + background: rgba(255, 255, 255, 0.95); + border-color: rgba(20, 184, 166, 0.2); + transform: translateX(4px); + box-shadow: var(--lp-shadow-sm); +} + +.feature-icon { + width: 36px; + height: 36px; + border-radius: var(--lp-radius-sm); + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + flex-shrink: 0; +} + +.feature-content { + flex: 1; + display: flex; + flex-direction: column; + gap: 1px; +} + +.feature-title { + font-size: 13px; + font-weight: 600; + color: var(--lp-text); +} + +.feature-desc { + font-size: 11px; + color: var(--lp-text-muted); +} + +.feature-arrow { + font-size: 14px; + color: var(--lp-text-muted); + opacity: 0; + transform: translateX(-4px); + transition: all var(--lp-transition); +} + +.brand-feature:hover .feature-arrow { + opacity: 1; + transform: translateX(0); +} + +/* ---- Brand Footer ---- */ +.brand-footer { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + margin-top: auto; + padding-top: 20px; + position: relative; + z-index: 1; +} + +.footer-trust { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: var(--lp-text-muted); +} + +.trust-icon { + font-size: 14px; +} + +.footer-version { + font-size: 11px; + color: var(--lp-text-muted); + background: rgba(0, 0, 0, 0.04); + padding: 2px 8px; + border-radius: 4px; +} + +/* ---- Card Divider ---- */ +.login-card__divider { + width: 1px; + background: linear-gradient( + to bottom, + transparent 0%, + rgba(20, 184, 166, 0.2) 20%, + rgba(20, 184, 166, 0.2) 80%, + transparent 100% + ); + flex-shrink: 0; +} + +/* ---- Form Section (Right) ---- */ +.login-card__form { + flex: 1; + padding: 44px 36px; + display: flex; + flex-direction: column; + min-width: 0; +} + +/* ---- Form Header ---- */ +.form-header { + margin-bottom: 28px; +} + +.form-title { + margin: 0 0 6px !important; + font-size: 24px !important; + font-weight: 700 !important; + color: var(--lp-text) !important; +} + +.form-subtitle { + color: var(--lp-text-muted); + font-size: 14px; +} + +/* ---- Alert ---- */ +.form-alert { + margin-bottom: 20px; + border-radius: var(--lp-radius-md); + border: 1px solid rgba(20, 184, 166, 0.2); + background: rgba(20, 184, 166, 0.05); +} + +.form-alert .ant-alert-message { + color: var(--lp-primary-dark); + font-weight: 600; +} + +.form-alert .ant-alert-description { + color: var(--lp-text-secondary); +} + +/* ---- Back Button ---- */ +.back-button { + margin-bottom: 12px; + padding: 0; + color: var(--lp-secondary); + font-size: 14px; + transition: color var(--lp-transition); +} + +.back-button:hover { + color: var(--lp-primary); +} + +/* ---- Form Styles ---- */ +.login-form { + margin-top: 8px; +} + +.login-form .ant-form-item-label > label { + font-weight: 500; + color: var(--lp-text-secondary); + font-size: 13px; +} + +.login-form .ant-form-item { + margin-bottom: 16px; +} + +/* ---- Input Styles ---- */ +.login-input { + height: 44px; + border-radius: var(--lp-radius-md); + border: 1px solid var(--lp-border); + background: var(--lp-surface); + color: var(--lp-text); + transition: all var(--lp-transition); +} + +.login-input::placeholder { + color: var(--lp-text-muted); +} + +.login-input:hover { + border-color: rgba(20, 184, 166, 0.4); + background: #fff; +} + +.login-input:focus, +.login-input.ant-input-affix-wrapper-focused { + border-color: var(--lp-border-focus); + background: #fff; + box-shadow: 0 0 0 3px rgba(20, 184, 166, 0.1); +} + +.input-icon { + color: var(--lp-text-muted); + font-size: 15px; + transition: color var(--lp-transition); +} + +/* Ant Design Affix Wrapper Override */ +.login-form .ant-input-affix-wrapper { + height: 44px; + border-radius: var(--lp-radius-md); + border: 1px solid var(--lp-border); + background: var(--lp-surface); + padding: 4px 14px; + transition: all var(--lp-transition); +} + +.login-form .ant-input-affix-wrapper:hover { + border-color: rgba(20, 184, 166, 0.4); + background: #fff; +} + +.login-form .ant-input-affix-wrapper-focused { + border-color: var(--lp-border-focus); + background: #fff; + box-shadow: 0 0 0 3px rgba(20, 184, 166, 0.1); +} + +.login-form .ant-input-affix-wrapper .ant-input { + height: 36px; + background: transparent; + color: var(--lp-text); +} + +.login-form .ant-input-affix-wrapper .ant-input::placeholder { + color: var(--lp-text-muted); +} + +.login-form .ant-input-affix-wrapper .ant-input-prefix { + color: var(--lp-text-muted); + margin-right: 10px; + transition: color var(--lp-transition); +} + +.login-form .ant-input-affix-wrapper-focused .ant-input-prefix { + color: var(--lp-primary); +} + +/* Error state */ +.login-form .ant-form-item-explain-error { + font-size: 12px; + margin-top: 4px; + color: var(--lp-error); +} + +/* ---- Submit Button ---- */ +.submit-item { + margin-top: 24px; + margin-bottom: 0; +} + +.submit-button { + width: 100%; + height: 48px; + border-radius: var(--lp-radius-md); + font-size: 15px; + font-weight: 600; + letter-spacing: 1px; + background: linear-gradient(135deg, var(--lp-primary) 0%, var(--lp-primary-dark) 100%); + border: none; + color: #fff; + cursor: pointer; + transition: all var(--lp-transition); + position: relative; + overflow: hidden; + box-shadow: 0 4px 14px rgba(20, 184, 166, 0.3); +} + +.submit-button::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent); + transition: left 0.6s ease; +} + +.submit-button:hover::before { + left: 100%; +} + +.submit-button:hover { + background: linear-gradient(135deg, var(--lp-primary-light) 0%, var(--lp-primary) 100%); + transform: translateY(-1px); + box-shadow: 0 8px 24px rgba(20, 184, 166, 0.4); +} + +.submit-button:active { + transform: translateY(0); + box-shadow: 0 4px 14px rgba(20, 184, 166, 0.3); +} + +/* Loading / Disabled state */ +.submit-button[disabled], +.submit-button.ant-btn-loading { + background: linear-gradient(135deg, rgba(20, 184, 166, 0.4), rgba(20, 184, 166, 0.3)); + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +.submit-button[disabled]::before, +.submit-button.ant-btn-loading::before { + display: none; +} + +/* ---- Form Actions ---- */ +.form-actions { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid var(--lp-border); +} + +.action-link { + color: var(--lp-text-secondary); + font-size: 13px; + padding: 4px 0; + text-align: center; + transition: color var(--lp-transition); +} + +.action-link:hover { + color: var(--lp-primary); +} + +.action-link--danger { + color: rgba(239, 68, 68, 0.7); +} + +.action-link--danger:hover { + color: var(--lp-error); +} + +/* ---- Form Footer ---- */ +.form-footer { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + margin-top: auto; + padding-top: 24px; + color: var(--lp-text-muted); + font-size: 11px; +} + +.footer-dot { + width: 3px; + height: 3px; + background: var(--lp-text-muted); + border-radius: 50%; +} + +/* ============================================ + Responsive Design + ============================================ */ + +@media (max-width: 960px) { + .login-card { + flex-direction: column; + max-width: 500px; + margin: 0 auto; + } + + .login-card__brand { + flex: none; + padding: 28px 24px 24px; + border-bottom: 1px solid var(--lp-border); + } + + .brand-header { + flex-direction: column; + text-align: center; + } + + .rack-visual { + display: none; + } + + .brand-stats { + display: none; + } + + .brand-features { + max-width: 100%; + } + + .brand-footer { + display: none; + } + + .login-card__divider { + width: 100%; + height: 1px; + background: linear-gradient( + to right, + transparent 0%, + rgba(20, 184, 166, 0.2) 20%, + rgba(20, 184, 166, 0.2) 80%, + transparent 100% + ); + } + + .login-card__form { + padding: 28px 24px; + } +} + +@media (max-width: 540px) { + .login-wrapper { + padding: 16px; + } + + .login-card { + border-radius: var(--lp-radius-lg); + } + + .login-card__brand { + padding: 24px 20px 20px; + } + + .brand-logo { + width: 56px; + height: 56px; + font-size: 28px; + border-radius: var(--lp-radius-md); + } + + .brand-title__main { + font-size: 28px; + letter-spacing: 4px; + } + + .brand-title__sub { + font-size: 14px; + } + + .login-card__form { + padding: 24px 20px; + } + + .form-title { + font-size: 20px !important; + } + + .login-input, + .login-form .ant-input-affix-wrapper { + height: 42px; + } + + .submit-button { + height: 44px; + font-size: 14px; + } +} + +/* ============================================ + Accessibility & Motion Preferences + ============================================ */ + +@media (prefers-reduced-motion: reduce) { + .decoration-circle, + .decoration-wave, + .decoration-grid, + .logo-pulse, + .rack-status, + .flow-line, + .stat-card, + .brand-feature { + animation: none; + } + + .submit-button::before { + display: none; + } + + .login-wrapper { + animation: none; + } + + .rack-unit, + .rack-slot, + .brand-feature { + animation: none; + } + + .brand-feature:hover { + transform: none; + } + + .submit-button:hover { + transform: none; + } + + .stat-card:hover { + transform: none; + } + + .brand-feature:hover .feature-arrow { + opacity: 0; + } +} + +/* Focus visible for keyboard navigation */ +.login-input:focus-visible, +.login-form .ant-input-affix-wrapper:focus-visible, +.submit-button:focus-visible, +.action-link:focus-visible, +.back-button:focus-visible { + outline: 2px solid var(--lp-primary); + outline-offset: 2px; +} + +/* Scrollbar */ +.login-card__form { + max-height: 90vh; + overflow-y: auto; +} + +.login-card__form::-webkit-scrollbar { + width: 4px; +} + +.login-card__form::-webkit-scrollbar-track { + background: transparent; +} + +.login-card__form::-webkit-scrollbar-thumb { + background: rgba(20, 184, 166, 0.2); + border-radius: 4px; +} + +.login-card__form::-webkit-scrollbar-thumb:hover { + background: rgba(20, 184, 166, 0.4); +} \ No newline at end of file diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx index b6c380c..6df37e0 100644 --- a/frontend/src/pages/Login.jsx +++ b/frontend/src/pages/Login.jsx @@ -1,16 +1,12 @@ import React, { useState, useEffect } from 'react'; +import { version as appVersion } from '../../package.json'; import { Form, Input, Button, - Card, message, Typography, - Divider, - Space, Alert, - Row, - Col, } from 'antd'; import { UserOutlined, @@ -20,18 +16,20 @@ import { SafetyCertificateOutlined, CloudServerOutlined, ArrowLeftOutlined, + DashboardOutlined, + SecurityScanOutlined, + ApiOutlined, } from '@ant-design/icons'; import { useNavigate } from 'react-router-dom'; -import { useAuth } from '../context/AuthContext'; -import { authAPI } from '../api'; +import { useAuth } from '../hooks/useAuth'; +import './Login.css'; -const { Title, Text, Paragraph } = Typography; +const { Title, Text } = Typography; const Login = () => { const [loading, setLoading] = useState(false); const [isFirstUser, setIsFirstUser] = useState(false); const [registerMode, setRegisterMode] = useState(false); - const [unlockMode, setUnlockMode] = useState(false); const { login, register, checkAdmin } = useAuth(); const navigate = useNavigate(); @@ -44,61 +42,30 @@ const Login = () => { const response = await checkAdmin(); if (response.success) { setIsFirstUser(!response.data.hasAdmin); - if (!response.data.hasAdmin) { - setRegisterMode(true); - } } } catch (error) { - // 静默处理:登录页调用 check-admin 失败是正常的(未登录状态) - // 不显示错误信息,避免触发 401 重定向循环 - console.log('检查管理员状态:', error); + console.error('检查管理员状态失败:', error); } }; - const onFinishLogin = async values => { + const onFinishLogin = async (values) => { setLoading(true); try { const result = await login(values.username, values.password); if (result.success) { message.success('登录成功'); - navigate('/'); + navigate('/dashboard'); } else { - if (result.code === 'PENDING_APPROVAL') { - message.warning('账户待审核,请联系管理员激活'); - } else { - message.error(result.message || '登录失败'); - } + message.error(result.message); } } catch (error) { - message.error(error || '登录失败'); + message.error('登录失败,请稍后重试'); } finally { setLoading(false); } }; - const onFinishUnlock = async values => { - setLoading(true); - try { - const response = await authAPI.unlock(values); - if (response.success) { - message.success('解锁成功,请重新登录'); - setUnlockMode(false); - } else { - message.error(response.message || '解锁失败'); - } - } catch (error) { - message.error(error || '解锁失败'); - } finally { - setLoading(false); - } - }; - - const onFinishRegister = async values => { - if (values.password !== values.confirmPassword) { - message.error('两次输入的密码不一致'); - return; - } - + const onFinishRegister = async (values) => { setLoading(true); try { const result = await register({ @@ -108,552 +75,313 @@ const Login = () => { phone: values.phone, realName: values.realName, }); - if (result.success) { + message.success(result.isFirstUser ? '管理员账号创建成功' : '注册成功,请等待管理员审核'); if (result.isFirstUser) { - message.success('注册成功,已为您创建管理员账户'); - navigate('/'); - } else if (result.pendingApproval) { - message.success('注册成功,请等待管理员审核'); - setRegisterMode(false); + navigate('/dashboard'); } else { - message.success('注册成功'); - navigate('/'); + setRegisterMode(false); } } else { - message.error(result.message || '注册失败'); + message.error(result.message); } } catch (error) { - message.error(error || '注册失败'); + message.error('注册失败,请稍后重试'); } finally { setLoading(false); } }; - // 左侧宣传区域组件 - const LeftPanel = () => ( -
- {/* 背景装饰 */} -
-
+ const features = [ + { icon: , title: '实时监控', desc: '3D可视化机房全景', color: '#14b8a6' }, + { icon: , title: '安全可靠', desc: '多重权限防护', color: '#3b82f6' }, + { icon: , title: '极速响应', desc: '毫秒级数据采集', color: '#8b5cf6' }, + ]; -
-
- -
+ const stats = [ + { value: '99.9%', label: '可用性', icon: '↑' }, + { value: '24/7', label: '监控', icon: '●' }, + { value: '<100ms', label: '响应', icon: '⚡' }, + ]; - - IDC设备 - <br /> - 管理系统 - - - - 专业的数据中心设备管理平台,提供机房、机柜、设备的全生命周期管理, - 助力企业实现高效的IT资产管理。 - - - - -
-
99.9%
-
系统稳定性
+ const RackVisual = () => ( +
+
+ 机房概览 + 3 在线 +
+
+ {[1, 2, 3].map((rack) => ( +
+
+ {rack.toString().padStart(2, '0')} +
- - -
-
24/7
-
全天候监控
+
+ {[1, 2, 3, 4, 5].map((slot) => ( +
+ ))}
- - -
-
100%
-
数据安全
+
+
- - +
+ ))}
); - // 获取标题和副标题 - const getHeaderContent = () => { - if (isFirstUser) { - return { - title: '创建管理员账户', - subtitle: '首次使用,请创建系统管理员账户', - }; - } - if (unlockMode) { - return { - title: '账户解锁', - subtitle: '输入账户信息以解锁账户', - }; - } - if (registerMode) { - return { - title: '注册新账户', - subtitle: '填写信息完成账户注册', - }; - } - return { - title: '欢迎回来', - subtitle: '请登录您的账户以继续', - }; - }; - - const headerContent = getHeaderContent(); - return ( - - {/* 左侧区域 - 桌面端显示 */} - - - +
+
+
+
+
+
+
+
- {/* 右侧登录区域 */} - - {/* 移动端背景 */} -
+
+
+
+
+
+ +
+
+
+

+ IDC + 机柜管理系统 +

+

智能数据中心基础设施管理平台

+
+
- - {/* 返回按钮 */} - {(registerMode || unlockMode) && !isFirstUser && ( - - )} + - {/* 头部 */} -
-
- +
+ {stats.map((stat, index) => ( +
+ {stat.value} + {stat.label} +
+ ))} +
+ +
+ {features.map((feature, index) => ( +
+
+ {feature.icon} +
+
+ {feature.title} + {feature.desc} +
+
+
+ ))} +
+ +
+ + 🛡️ + 企业级安全认证 + + v{appVersion}
- - {headerContent.title} - - {headerContent.subtitle}
- {/* 首次使用提示 */} - {isFirstUser && ( - - )} +
- {/* 解锁模式提示 */} - {unlockMode && ( - - )} +
+
+ + {isFirstUser ? '初始化系统' : '欢迎回来'} + + + {isFirstUser + ? '创建管理员账号以开始使用系统' + : '请登录您的账号继续访问'} + +
- {/* 表单 */} -
- {registerMode ? ( - <> - - - - } - placeholder="请输入用户名" - style={{ borderRadius: '12px', height: '48px' }} - /> - - - - - - } - placeholder="请输入真实姓名" - style={{ borderRadius: '12px', height: '48px' }} - /> - - - - - - - - } - placeholder="请输入邮箱" - style={{ borderRadius: '12px', height: '48px' }} - /> - - - - - } - placeholder="请输入手机号(可选)" - style={{ borderRadius: '12px', height: '48px' }} - /> - - - - - - - - } - placeholder="请输入密码" - style={{ borderRadius: '12px', height: '48px' }} - /> - - - - ({ - validator(_, value) { - if (!value || getFieldValue('password') === value) { - return Promise.resolve(); - } - return Promise.reject(new Error('两次输入的密码不一致')); - }, - }), - ]} - > - } - placeholder="请再次输入密码" - style={{ borderRadius: '12px', height: '48px' }} - /> - - - - - ) : ( - <> - - } - placeholder="请输入用户名" - style={{ borderRadius: '12px', height: '52px' }} - /> - - - - } - placeholder="请输入密码" - style={{ borderRadius: '12px', height: '52px' }} - /> - - - {!registerMode && !unlockMode && ( -
- -
- )} - + {isFirstUser && ( + )} - + {registerMode && !isFirstUser && ( - - + )} - {/* 底部切换 */} - {!isFirstUser && ( -
- {!unlockMode && !registerMode && ( - } size="large"> - - - + } + /> + + + } + /> + + + } + /> + + )} - {(registerMode || unlockMode) && ( - - 已有账户?{' '} - - + + } + placeholder={isFirstUser ? '请设置管理员账号' : '请输入用户名'} + className="login-input" + /> + + + + } + placeholder={isFirstUser ? '请设置管理员密码' : '请输入密码'} + className="login-input" + /> + + + {registerMode && ( + ({ + validator(_, value) { + if (!value || getFieldValue('password') === value) { + return Promise.resolve(); + } + return Promise.reject(new Error('两次输入的密码不一致')); + }, + }), + ]} + > + } + placeholder="请确认密码" + className="login-input" + /> + )} + + + + + + + {!isFirstUser && !registerMode && ( +
+ +
+ )} + +
+ IDC Management System + + v{appVersion}
- )} - - - {/* 移动端底部版权 */} -
- © 2024 IDC设备管理系统. All rights reserved. +
- - - {/* 响应式样式 */} - - +
+
); }; -export default Login; +export default Login; \ No newline at end of file diff --git a/frontend/src/pages/Rack3DVisualization.jsx b/frontend/src/pages/Rack3DVisualization.jsx index ec2aa33..83cf11d 100644 --- a/frontend/src/pages/Rack3DVisualization.jsx +++ b/frontend/src/pages/Rack3DVisualization.jsx @@ -35,7 +35,7 @@ import DeviceDetailDrawer from '../components/DeviceDetailDrawer'; import CloseButton from '../components/CloseButton'; import RackSelectorHeader from '../components/3d/RackSelectorHeader'; import { Layout } from 'antd'; -import { useScene3D } from '../context/Scene3DContext'; +import { useScene3D } from '../hooks/useScene3D'; import { useSortedRacks } from '../hooks/useSortedRacks'; const { Content } = Layout; @@ -46,7 +46,7 @@ const Rack3DVisualization = () => { // Scene 组件的 ref,用于调用重置视角方法 const sceneRef = useRef(null); - // 使用 Scene3DContext 管理3D场景状态 + // 使用 Zustand Store 管理3D场景状态 const { devices, setDevices, diff --git a/frontend/src/pages/RoomFloorPlan.jsx b/frontend/src/pages/RoomFloorPlan.jsx index 6f747c5..df310a1 100644 --- a/frontend/src/pages/RoomFloorPlan.jsx +++ b/frontend/src/pages/RoomFloorPlan.jsx @@ -1,7 +1,6 @@ import React, { useRef, useCallback, useState, useEffect } from 'react'; import { Spin, Empty, message, Button } from 'antd'; import { HomeOutlined, ReloadOutlined } from '@ant-design/icons'; -import { FloorPlanProvider } from '../context/FloorPlanContext'; import useFloorPlanContext from '../hooks/floorplan/useFloorPlanContext'; import useFloorPlanData from '../hooks/floorplan/useFloorPlanData'; import { FloorPlanCanvas, FloorPlanToolbar, RackDetailPanel } from '../components/floorplan'; @@ -48,28 +47,20 @@ const DeviceTooltip = ({ device, rack, x, y }) => { 状态: - {DEVICE_STATUS_NAMES[device.status] || device.status} - - - 位置: - {rack?.name} - {device.position}U + + {DEVICE_STATUS_NAMES[device.status] || device.status} + {device.ipAddress && ( IP: - {device.ipAddress} + {device.ipAddress} )} - {device.model && ( + {rack && ( - 型号: - {device.model} - - )} - {device.height > 1 && ( - - 高度: - {device.height}U + 位置: + {rack.name} U{device.position} )} @@ -77,26 +68,7 @@ const DeviceTooltip = ({ device, rack, x, y }) => { ); }; -const EmptyState = ({ onRefresh }) => ( - - } - description={ - <> - 暂无机房数据 - 请先在机房管理中创建机房,或检查网络连接 - {onRefresh && ( - - )} - - } - /> - -); - -const FloorPlanContent = () => { +const RoomFloorPlanContent = () => { const { selectedRoomId, setSelectedRoom, @@ -160,113 +132,99 @@ const FloorPlanContent = () => { } }, []); - const handleRackClick = useCallback((rack) => { - if (rack) { - showDetail(rack); - } - }, [showDetail]); - - const handleRackDoubleClick = useCallback((rack) => { - if (rack) { - showDetail(rack); - } - }, [showDetail]); - - const handleDeviceHover = useCallback((device, rack, x, y) => { + const handleDeviceHover = useCallback((device, rack, event) => { if (device) { + const rect = containerRef.current?.getBoundingClientRect(); + if (rect) { + setTooltipPosition({ + x: event.clientX - rect.left + 15, + y: event.clientY - rect.top + 15, + }); + } setHoveredDevice(device); setHoveredDeviceRack(rack); - setTooltipPosition({ x, y }); } else { setHoveredDevice(null); setHoveredDeviceRack(null); } }, []); - const handleViewChange = useCallback((viewState) => { - setCurrentZoom(viewState.zoom); - }, []); - - const handleExport = useCallback(() => { - if (!canvasRef.current || !layoutData?.room) { - message.warning('请先选择机房'); - return; + const handleRackClick = useCallback((rack) => { + if (rack) { + showDetail(rack); } + }, [showDetail]); - const dataUrl = canvasRef.current.exportImage(layoutData.room.name); - if (!dataUrl) { - message.error('导出失败,请稍后重试'); - return; - } + if (loading) { + return ( + + + + 加载中... + + + ); + } - const link = document.createElement('a'); - link.download = `${layoutData.room.name || '机房平面图'}_${new Date().toISOString().slice(0, 10)}.png`; - link.href = dataUrl; - link.click(); - message.success('导出成功'); - }, [layoutData]); + if (!layoutData || layoutData.racks.length === 0) { + return ( + + + + + + ); + } return ( - + canvasRef.current?.zoomIn()} - onZoomOut={() => canvasRef.current?.zoomOut()} - onZoomReset={() => canvasRef.current?.zoomReset()} + onZoomChange={setCurrentZoom} + onFullscreen={handleToggleFullscreen} isFullscreen={isFullscreen} - onToggleFullscreen={handleToggleFullscreen} onRefresh={refetch} - onExport={handleExport} /> - - - {loading && ( - - - - )} - - {!selectedRoomId && } - - {selectedRoomId && layoutData && ( + + + - )} - - - + + {hoveredDevice && ( + + )} + + - - + {detailVisible && detailRack && ( + + )} + ); }; const RoomFloorPlan = () => { - return ( - - - - - - ); + return ; }; export default RoomFloorPlan; diff --git a/frontend/src/pages/SystemSettings.jsx b/frontend/src/pages/SystemSettings.jsx index 977777c..28982ee 100644 --- a/frontend/src/pages/SystemSettings.jsx +++ b/frontend/src/pages/SystemSettings.jsx @@ -49,7 +49,7 @@ import { MenuUnfoldOutlined, } from '@ant-design/icons'; import axios from 'axios'; -import { useConfig } from '../context/ConfigContext'; +import { useConfig } from '../hooks/useConfig'; const { Option } = Select; const { Title, Text, Paragraph } = Typography; diff --git a/frontend/src/stores/authStore.js b/frontend/src/stores/authStore.js new file mode 100644 index 0000000..8cece31 --- /dev/null +++ b/frontend/src/stores/authStore.js @@ -0,0 +1,116 @@ +/** + * 认证状态管理 Store + * 支持精准订阅、持久化、权限缓存 + */ + +import { create } from 'zustand'; +import { authAPI, setAuthInitialized } from '../api'; +import secureStorage, { TOKEN_KEY, USER_KEY } from '../utils/secureStorage'; + +export const useAuthStore = create((set, get) => ({ + user: null, + token: null, + loading: true, + initialized: false, + + initialize: async () => { + try { + const storedToken = await secureStorage.loadFromStorage(TOKEN_KEY); + const storedUser = await secureStorage.loadFromStorage(USER_KEY); + + if (!storedToken) { + set({ token: null, user: null, loading: false, initialized: true }); + setAuthInitialized(true); + return; + } + + set({ token: storedToken, user: storedUser }); + + try { + const response = await authAPI.getProfile(); + if (response.success) { + set({ user: response.data.user }); + await secureStorage.set(USER_KEY, response.data.user); + } + } catch (error) { + const status = error?.response?.status; + if (status === 401 || status === 403) { + secureStorage.remove(TOKEN_KEY); + secureStorage.remove(USER_KEY); + set({ token: null, user: null }); + } + } + } catch { + set({ token: null, user: null }); + } finally { + set({ loading: false, initialized: true }); + setAuthInitialized(true); + } + }, + + login: async (username, password) => { + try { + const response = await authAPI.login({ username, password }); + if (response.success) { + const { token: newToken, user: userData } = response.data; + await secureStorage.set(TOKEN_KEY, newToken); + await secureStorage.set(USER_KEY, userData); + set({ token: newToken, user: userData }); + return { success: true }; + } + return { success: false, message: response.message, code: response.code }; + } catch (error) { + const message = error?.response?.data?.message || error?.message || '登录失败,请稍后重试'; + return { success: false, message }; + } + }, + + register: async (userData) => { + try { + const response = await authAPI.register(userData); + if (response.success) { + const { token: newToken, user: newUser, isFirstUser, pendingApproval } = response.data; + if (newToken) { + await secureStorage.set(TOKEN_KEY, newToken); + await secureStorage.set(USER_KEY, newUser); + set({ token: newToken, user: newUser }); + } + return { success: true, isFirstUser, pendingApproval }; + } + return { success: false, message: response.message }; + } catch (error) { + const message = error?.response?.data?.message || error?.message || '注册失败,请稍后重试'; + return { success: false, message }; + } + }, + + logout: () => { + secureStorage.remove(TOKEN_KEY); + secureStorage.remove(USER_KEY); + set({ token: null, user: null }); + }, + + updateUser: (newUserData) => { + set((state) => { + const updated = { ...state.user, ...newUserData }; + secureStorage.set(USER_KEY, updated).catch(() => {}); + return { user: updated }; + }); + }, + + hasPermission: (permission) => { + const { user } = get(); + if (!user) return false; + const roles = user.roles || []; + if (roles.some((r) => r.roleCode === 'admin')) return true; + if (permission === 'admin') return roles.some((r) => r.roleCode === 'admin'); + return roles.some((r) => r.roleCode === permission); + }, + + checkAdmin: () => authAPI.checkAdmin(), +})); + +export const useUser = () => useAuthStore((state) => state.user); +export const useToken = () => useAuthStore((state) => state.token); +export const useAuthLoading = () => useAuthStore((state) => state.loading); +export const useAuthInitialized = () => useAuthStore((state) => state.initialized); diff --git a/frontend/src/stores/configStore.js b/frontend/src/stores/configStore.js new file mode 100644 index 0000000..6c531cb --- /dev/null +++ b/frontend/src/stores/configStore.js @@ -0,0 +1,88 @@ +/** + * 系统配置状态管理 Store + * 支持精准订阅配置项,避免无关重渲染 + */ + +import { create } from 'zustand'; +import axios from 'axios'; + +const defaultConfig = { + site_name: '机柜管理系统', + primary_color: '#667eea', + secondary_color: '#764ba2', + sidebar_collapsed: false, + compact_mode: false, + animation_enabled: true, + language: 'zh-CN', + timezone: 'Asia/Shanghai', + date_format: 'YYYY-MM-DD', + session_timeout: 30, + max_login_attempts: 5, + maintenance_mode: false, +}; + +const applyThemeColors = (primaryColor, secondaryColor) => { + const root = document.documentElement; + if (primaryColor) { + root.style.setProperty('--primary-color', primaryColor); + root.style.setProperty('--primary-light', `${primaryColor}20`); + root.style.setProperty( + '--primary-gradient', + `linear-gradient(135deg, ${primaryColor} 0%, ${secondaryColor || '#764ba2'} 100%)` + ); + } + if (secondaryColor) { + root.style.setProperty('--secondary-color', secondaryColor); + root.style.setProperty('--secondary-light', `${secondaryColor}20`); + } +}; + +export const useConfigStore = create((set, get) => ({ + config: defaultConfig, + loading: true, + + loadConfig: async () => { + try { + const response = await axios.get('/api/system-settings'); + const settings = response.data; + const configValues = {}; + + Object.entries(settings).forEach(([key, value]) => { + configValues[key] = value.value; + }); + + set((state) => ({ + config: { ...state.config, ...configValues }, + })); + + if (configValues.primary_color || configValues.secondary_color) { + applyThemeColors(configValues.primary_color, configValues.secondary_color); + } + } catch { + // 加载失败使用默认配置 + } finally { + set({ loading: false }); + } + }, + + updateConfig: (newConfig) => { + set((state) => ({ + config: { ...state.config, ...newConfig }, + })); + + if (newConfig.primary_color || newConfig.secondary_color) { + const { config } = get(); + applyThemeColors(config.primary_color, config.secondary_color); + } + }, + + reloadConfig: async () => { + await get().loadConfig(); + }, +})); + +export const useConfig = () => useConfigStore((state) => state.config); +export const useConfigLoading = () => useConfigStore((state) => state.loading); +export const useSiteName = () => useConfigStore((state) => state.config.site_name); +export const usePrimaryColor = () => useConfigStore((state) => state.config.primary_color); +export const useSecondaryColor = () => useConfigStore((state) => state.config.secondary_color); diff --git a/frontend/src/stores/floorPlanStore.js b/frontend/src/stores/floorPlanStore.js new file mode 100644 index 0000000..187f80e --- /dev/null +++ b/frontend/src/stores/floorPlanStore.js @@ -0,0 +1,46 @@ +/** + * 平面图状态管理 Store + * 简化 reducer 逻辑,直接使用 set 更新状态 + */ + +import { create } from 'zustand'; + +export const useFloorPlanStore = create((set) => ({ + selectedRoomId: null, + selectedRack: null, + hoveredRack: null, + zoom: 1, + offsetX: 0, + offsetY: 0, + detailRack: null, + detailVisible: false, + + setSelectedRoom: (roomId) => + set({ + selectedRoomId: roomId, + selectedRack: null, + hoveredRack: null, + detailRack: null, + detailVisible: false, + }), + + setSelectedRack: (rack) => set({ selectedRack: rack }), + setHoveredRack: (rack) => set({ hoveredRack: rack }), + + setViewChange: ({ zoom, offsetX, offsetY }) => set({ zoom, offsetX, offsetY }), + + showDetail: (rack) => set({ detailRack: rack, detailVisible: true }), + hideDetail: () => set({ detailRack: null, detailVisible: false }), + + reset: () => + set({ + selectedRoomId: null, + selectedRack: null, + hoveredRack: null, + zoom: 1, + offsetX: 0, + offsetY: 0, + detailRack: null, + detailVisible: false, + }), +})); diff --git a/frontend/src/stores/index.js b/frontend/src/stores/index.js new file mode 100644 index 0000000..2bc9223 --- /dev/null +++ b/frontend/src/stores/index.js @@ -0,0 +1,15 @@ +/** + * 状态管理统一导出 + */ + +export { useAuthStore, useUser, useToken, useAuthLoading, useAuthInitialized } from './authStore'; +export { + useConfigStore, + useConfig, + useConfigLoading, + useSiteName, + usePrimaryColor, + useSecondaryColor, +} from './configStore'; +export { useScene3DStore, useDevices, useSelectedDevice, useRacks, useSelectedRack } from './scene3DStore'; +export { useFloorPlanStore } from './floorPlanStore'; diff --git a/frontend/src/stores/scene3DStore.js b/frontend/src/stores/scene3DStore.js new file mode 100644 index 0000000..0880a50 --- /dev/null +++ b/frontend/src/stores/scene3DStore.js @@ -0,0 +1,60 @@ +/** + * 3D场景状态管理 Store + * 支持精准订阅,避免3D组件不必要的重渲染 + */ + +import { create } from 'zustand'; + +export const useScene3DStore = create((set) => ({ + devices: [], + selectedDevice: null, + hoveredDevice: null, + deviceSlideEnabled: false, + selectedRack: null, + racks: [], + deviceCables: [], + loadingDevices: false, + + selectDevice: (device) => set({ selectedDevice: device }), + setSelectedDevice: (device) => set({ selectedDevice: device }), + hoverDevice: (device) => set({ hoveredDevice: device }), + setHoveredDevice: (device) => set({ hoveredDevice: device }), + + toggleDeviceSlide: () => set((state) => ({ deviceSlideEnabled: !state.deviceSlideEnabled })), + setDeviceSlide: (enabled) => set({ deviceSlideEnabled: enabled }), + setDeviceSlideEnabled: (enabled) => set({ deviceSlideEnabled: enabled }), + + setDevices: (devices) => set({ devices }), + updateDevices: (devices) => set({ devices }), + + updateRacks: (racks) => set({ racks }), + setRacks: (racks) => set({ racks }), + + selectRack: (rack) => set({ selectedRack: rack }), + setSelectedRack: (rack) => set({ selectedRack: rack }), + + updateDeviceCables: (cables) => set({ deviceCables: cables }), + setDeviceCables: (cables) => set({ deviceCables: cables }), + + setLoading: (loading) => set({ loadingDevices: loading }), + setLoadingDevices: (loading) => set({ loadingDevices: loading }), + + clearDeviceSelection: () => set({ selectedDevice: null, hoveredDevice: null }), + + reset: () => + set({ + devices: [], + selectedDevice: null, + hoveredDevice: null, + deviceSlideEnabled: false, + selectedRack: null, + racks: [], + deviceCables: [], + loadingDevices: false, + }), +})); + +export const useDevices = () => useScene3DStore((state) => state.devices); +export const useSelectedDevice = () => useScene3DStore((state) => state.selectedDevice); +export const useRacks = () => useScene3DStore((state) => state.racks); +export const useSelectedRack = () => useScene3DStore((state) => state.selectedRack); diff --git a/frontend/src/utils/storageAdapter.js b/frontend/src/utils/storageAdapter.js new file mode 100644 index 0000000..8c8aae7 --- /dev/null +++ b/frontend/src/utils/storageAdapter.js @@ -0,0 +1,35 @@ +/** + * SecureStorage 适配器 + * 将异步的 secureStorage 接口适配为 Zustand persist 所需的同步 Storage 接口 + */ + +import { secureStorage } from '../utils/secureStorage'; + +const SecureStorageAdapter = { + getItem: async (name) => { + try { + const value = await secureStorage.loadFromStorage(name); + return value !== null ? JSON.stringify(value) : null; + } catch { + return null; + } + }, + + setItem: async (name, value) => { + try { + await secureStorage.set(name, JSON.parse(value)); + } catch { + // 存储失败静默处理 + } + }, + + removeItem: async (name) => { + try { + await secureStorage.remove(name); + } catch { + // 移除失败静默处理 + } + }, +}; + +export default SecureStorageAdapter;