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设备
-
- 管理系统
-
-
-
- 专业的数据中心设备管理平台,提供机房、机柜、设备的全生命周期管理,
- 助力企业实现高效的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) => (
+
+ ))}
-
-
-
+ ))}
);
- // 获取标题和副标题
- 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 && (
- }
- onClick={() => {
- setRegisterMode(false);
- setUnlockMode(false);
- }}
- style={{
- position: 'absolute',
- top: '24px',
- left: '24px',
- color: '#667eea',
- padding: '4px 8px',
- }}
- >
- 返回
-
- )}
+
- {/* 头部 */}
-
-
-
+
+ {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
+ ? '创建管理员账号以开始使用系统'
+ : '请登录您的账号继续访问'}
+
+
- {/* 表单 */}
-
- }
- placeholder="请输入用户名"
- style={{ borderRadius: '12px', height: '52px' }}
- />
-
-
-
- }
- placeholder="请输入密码"
- style={{ borderRadius: '12px', height: '52px' }}
- />
-
-
- {!registerMode && !unlockMode && (
-
-
-
- )}
- >
+ {isFirstUser && (
+
)}
-
+ {registerMode && !isFirstUser && (
}
+ onClick={() => {
+ setRegisterMode(false);
}}
+ className="back-button"
>
- {registerMode ? '立即注册' : unlockMode ? '立即解锁' : '登 录'}
+ 返回登录
-
-
+ )}
- {/* 底部切换 */}
- {!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 && (
- } onClick={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;