refactor: 重写系统设置模块,新增维护模式与登录策略功能
- 移除大量无实际效果的冗余配置项 - 新增维护模式拦截逻辑,支持管理员豁免 - 重构登录失败次数限制,从配置动态读取阈值 - 新增站点Logo支持与配置管理 - 优化用户管理页面数据拉取逻辑 - 新增端口批量创建范围模式功能 - 更新依赖包版本与缓存策略
This commit is contained in:
@@ -1,7 +1,14 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const User = require('../models/User');
|
||||
const UserRole = require('../models/UserRole');
|
||||
const Role = require('../models/Role');
|
||||
const SystemSetting = require('../models/SystemSetting');
|
||||
const logger = require('../utils/logger').module('AuthMiddleware');
|
||||
|
||||
// 维护模式缓存,避免每次请求都查数据库
|
||||
let maintenanceModeCache = { value: false, updatedAt: 0 };
|
||||
const MAINTENANCE_CACHE_TTL = 30000; // 30秒缓存
|
||||
|
||||
function getJwtSecret() {
|
||||
const envSecret = process.env.JWT_SECRET;
|
||||
|
||||
@@ -169,6 +176,41 @@ const authMiddleware = async (req, res, next) => {
|
||||
});
|
||||
}
|
||||
|
||||
// 维护模式检查:普通用户无法访问,管理员不受影响
|
||||
const now = Date.now();
|
||||
if (now - maintenanceModeCache.updatedAt > MAINTENANCE_CACHE_TTL) {
|
||||
try {
|
||||
const maintenanceSetting = await SystemSetting.findByPk('maintenance_mode');
|
||||
maintenanceModeCache = {
|
||||
value: maintenanceSetting ? JSON.parse(maintenanceSetting.settingValue) : false,
|
||||
updatedAt: now,
|
||||
};
|
||||
} catch (err) {
|
||||
logger.warn('读取维护模式设置失败', { error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
if (maintenanceModeCache.value) {
|
||||
// 查询用户角色,判断是否为管理员
|
||||
const userRole = await UserRole.findOne({
|
||||
where: { UserId: user.userId },
|
||||
include: [{ model: Role }],
|
||||
});
|
||||
const isAdmin = userRole && userRole.Role && userRole.Role.roleCode === 'admin';
|
||||
|
||||
if (!isAdmin) {
|
||||
logger.info('维护模式:拒绝普通用户访问', {
|
||||
userId: user.userId,
|
||||
username: user.username,
|
||||
});
|
||||
return res.status(503).json({
|
||||
success: false,
|
||||
code: 'MAINTENANCE_MODE',
|
||||
message: '系统维护中,请稍后再试',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
req.user = decoded;
|
||||
req.userModel = user;
|
||||
next();
|
||||
@@ -229,6 +271,11 @@ const optionalAuth = async (req, res, next) => {
|
||||
}
|
||||
};
|
||||
|
||||
/** 清除维护模式缓存,在设置更新时调用 */
|
||||
const clearMaintenanceCache = () => {
|
||||
maintenanceModeCache = { value: false, updatedAt: 0 };
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
generateToken,
|
||||
verifyToken,
|
||||
@@ -236,4 +283,5 @@ module.exports = {
|
||||
optionalAuth,
|
||||
JWT_SECRET,
|
||||
TOKEN_EXPIRY,
|
||||
clearMaintenanceCache,
|
||||
};
|
||||
|
||||
@@ -53,6 +53,7 @@ const User = sequelize.define(
|
||||
loginCount: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 0,
|
||||
comment: '连续登录失败次数,登录成功后重置',
|
||||
},
|
||||
lockedUntil: {
|
||||
type: DataTypes.DATE,
|
||||
|
||||
Generated
+61
-5
@@ -8,6 +8,7 @@
|
||||
"name": "idc-backend",
|
||||
"version": "2.0.0",
|
||||
"dependencies": {
|
||||
"all": "^0.0.0",
|
||||
"axios": "^1.13.6",
|
||||
"basic-ftp": "^5.0.3",
|
||||
"bcryptjs": "^3.0.3",
|
||||
@@ -44,7 +45,7 @@
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.0",
|
||||
"jest": "^29.7.0",
|
||||
"nodemon": "^3.0.1",
|
||||
"nodemon": "^3.1.14",
|
||||
"prettier": "^3.8.1",
|
||||
"supertest": "^7.1.4"
|
||||
}
|
||||
@@ -142,6 +143,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",
|
||||
@@ -1669,6 +1671,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz",
|
||||
"integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
@@ -1735,6 +1738,7 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -1843,6 +1847,12 @@
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/all": {
|
||||
"version": "0.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/all/-/all-0.0.0.tgz",
|
||||
"integrity": "sha512-0oKlfNVv2d+d7c1gwjGspzgbwot47PGQ4b3v1ccx4mR8l9P/Y6E6Dr/yE8lNT63EcAKEbHo6UG3odDpC/NQcKw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ansi-escapes": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz",
|
||||
@@ -2462,6 +2472,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -3778,6 +3789,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",
|
||||
@@ -3838,6 +3850,7 @@
|
||||
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"eslint-config-prettier": "bin/cli.js"
|
||||
},
|
||||
@@ -4232,6 +4245,7 @@
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
||||
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
@@ -7519,16 +7533,16 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nodemon": {
|
||||
"version": "3.1.11",
|
||||
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz",
|
||||
"integrity": "sha512-is96t8F/1//UHAjNPHpbsNY46ELPpftGUoSVNXwUfMk/qdjSylYrWSu1XavVTBOn526kFiOR733ATgNBCQyH0g==",
|
||||
"version": "3.1.14",
|
||||
"resolved": "https://registry.npmmirror.com/nodemon/-/nodemon-3.1.14.tgz",
|
||||
"integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chokidar": "^3.5.2",
|
||||
"debug": "^4",
|
||||
"ignore-by-default": "^1.0.1",
|
||||
"minimatch": "^3.1.2",
|
||||
"minimatch": "^10.2.1",
|
||||
"pstree.remy": "^1.1.8",
|
||||
"semver": "^7.5.3",
|
||||
"simple-update-notifier": "^2.0.0",
|
||||
@@ -7547,6 +7561,29 @@
|
||||
"url": "https://opencollective.com/nodemon"
|
||||
}
|
||||
},
|
||||
"node_modules/nodemon/node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/nodemon/node_modules/brace-expansion": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/nodemon/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -7565,6 +7602,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/nodemon/node_modules/minimatch": {
|
||||
"version": "10.2.5",
|
||||
"resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.5.tgz",
|
||||
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/nodemon/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -8117,6 +8170,7 @@
|
||||
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
@@ -10319,6 +10373,7 @@
|
||||
"resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz",
|
||||
"integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@colors/colors": "^1.6.0",
|
||||
"@dabh/diagnostics": "^2.0.8",
|
||||
@@ -10567,6 +10622,7 @@
|
||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"test:operation-logs": "jest tests/operationLog.model.test.js tests/operationLogger.test.js tests/operationLogs.api.test.js --runInBand"
|
||||
},
|
||||
"dependencies": {
|
||||
"all": "^0.0.0",
|
||||
"axios": "^1.13.6",
|
||||
"basic-ftp": "^5.0.3",
|
||||
"bcryptjs": "^3.0.3",
|
||||
@@ -54,7 +55,7 @@
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.0",
|
||||
"jest": "^29.7.0",
|
||||
"nodemon": "^3.0.1",
|
||||
"nodemon": "^3.1.14",
|
||||
"prettier": "^3.8.1",
|
||||
"supertest": "^7.1.4"
|
||||
}
|
||||
|
||||
+38
-6
@@ -7,12 +7,11 @@ const UserRole = require('../models/UserRole');
|
||||
const { generateToken, authMiddleware } = require('../middleware/auth');
|
||||
const {
|
||||
SALT_ROUNDS,
|
||||
MAX_LOGIN_ATTEMPTS,
|
||||
LOCK_TIME,
|
||||
PASSWORD_MIN_LENGTH,
|
||||
USERNAME_MIN_LENGTH,
|
||||
USERNAME_MAX_LENGTH,
|
||||
} = require('../config');
|
||||
const SystemSetting = require('../models/SystemSetting');
|
||||
const { generateId } = require('../utils/idGenerator');
|
||||
|
||||
const router = express.Router();
|
||||
@@ -146,6 +145,38 @@ router.post('/login', async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
// 检查维护模式:非管理员无法登录
|
||||
const maintenanceSetting = await SystemSetting.findByPk('maintenance_mode');
|
||||
const isMaintenanceMode = maintenanceSetting
|
||||
? JSON.parse(maintenanceSetting.settingValue)
|
||||
: false;
|
||||
|
||||
if (isMaintenanceMode) {
|
||||
// 先查找用户,判断是否为管理员
|
||||
const checkUser = await User.findOne({ where: { username } });
|
||||
if (checkUser) {
|
||||
const userRole = await UserRole.findOne({
|
||||
where: { UserId: checkUser.userId },
|
||||
include: [{ model: Role }],
|
||||
});
|
||||
const isAdmin = userRole && userRole.Role && userRole.Role.roleCode === 'admin';
|
||||
if (!isAdmin) {
|
||||
return res.status(503).json({
|
||||
success: false,
|
||||
code: 'MAINTENANCE_MODE',
|
||||
message: '系统维护中,暂时无法登录,请联系管理员',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 从系统设置读取最大登录尝试次数
|
||||
const maxAttemptsSetting = await SystemSetting.findByPk('max_login_attempts');
|
||||
const maxLoginAttempts = maxAttemptsSetting
|
||||
? JSON.parse(maxAttemptsSetting.settingValue)
|
||||
: 5;
|
||||
const lockTimeMs = 30 * 60 * 1000; // 锁定30分钟
|
||||
|
||||
const user = await User.findOne({ where: { username } });
|
||||
if (!user) {
|
||||
return res.status(401).json({
|
||||
@@ -163,6 +194,7 @@ router.post('/login', async (req, res) => {
|
||||
message: `账户已被锁定,请在 ${remainingMinutes} 分钟后重试`,
|
||||
});
|
||||
}
|
||||
// 锁定时间已过,自动解锁
|
||||
user.status = 'active';
|
||||
user.loginCount = 0;
|
||||
user.lockedUntil = null;
|
||||
@@ -187,18 +219,18 @@ router.post('/login', async (req, res) => {
|
||||
const isPasswordValid = await bcrypt.compare(password, user.password);
|
||||
if (!isPasswordValid) {
|
||||
user.loginCount = (user.loginCount || 0) + 1;
|
||||
if (user.loginCount >= MAX_LOGIN_ATTEMPTS) {
|
||||
if (user.loginCount >= maxLoginAttempts) {
|
||||
user.status = 'locked';
|
||||
user.lockedUntil = new Date(Date.now() + LOCK_TIME);
|
||||
user.lockedUntil = new Date(Date.now() + lockTimeMs);
|
||||
}
|
||||
await user.save();
|
||||
|
||||
const remainingAttempts = MAX_LOGIN_ATTEMPTS - user.loginCount;
|
||||
const remainingAttempts = maxLoginAttempts - user.loginCount;
|
||||
let message = '用户名或密码错误';
|
||||
if (remainingAttempts > 0) {
|
||||
message += `,剩余 ${remainingAttempts} 次尝试机会`;
|
||||
} else {
|
||||
message = `账户已被锁定,请在 3 分钟后重试`;
|
||||
message = `账户已被锁定,请在 30 分钟后重试`;
|
||||
}
|
||||
|
||||
return res.status(401).json({
|
||||
|
||||
@@ -4,7 +4,7 @@ const router = express.Router();
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { Op } = require('sequelize');
|
||||
const { authMiddleware } = require('../middleware/auth');
|
||||
const { authMiddleware, clearMaintenanceCache } = require('../middleware/auth');
|
||||
|
||||
// 读取 package.json 获取版本号
|
||||
const packageJsonPath = path.join(__dirname, '../../package.json');
|
||||
@@ -28,7 +28,7 @@ const { FRONTEND } = require('../config');
|
||||
// 初始化默认系统设置
|
||||
const initDefaultSettings = async () => {
|
||||
const defaultSettings = [
|
||||
// 全局配置
|
||||
// 基本设置 - 站点信息
|
||||
{
|
||||
settingKey: 'site_name',
|
||||
settingValue: JSON.stringify('机柜管理系统'),
|
||||
@@ -45,30 +45,8 @@ const initDefaultSettings = async () => {
|
||||
description: '网站Logo URL',
|
||||
isEditable: true,
|
||||
},
|
||||
{
|
||||
settingKey: 'timezone',
|
||||
settingValue: JSON.stringify('Asia/Shanghai'),
|
||||
settingType: 'string',
|
||||
category: 'general',
|
||||
description: '时区设置',
|
||||
isEditable: true,
|
||||
},
|
||||
{
|
||||
settingKey: 'date_format',
|
||||
settingValue: JSON.stringify('YYYY-MM-DD'),
|
||||
settingType: 'string',
|
||||
category: 'general',
|
||||
description: '日期格式',
|
||||
isEditable: true,
|
||||
},
|
||||
{
|
||||
settingKey: 'session_timeout',
|
||||
settingValue: JSON.stringify(30),
|
||||
settingType: 'number',
|
||||
category: 'general',
|
||||
description: '登录有效期(分钟)',
|
||||
isEditable: true,
|
||||
},
|
||||
|
||||
// 基本设置 - 安全设置
|
||||
{
|
||||
settingKey: 'idle_timeout',
|
||||
settingValue: JSON.stringify(30),
|
||||
@@ -77,14 +55,6 @@ const initDefaultSettings = async () => {
|
||||
description: '用户空闲超时时间(分钟)',
|
||||
isEditable: true,
|
||||
},
|
||||
{
|
||||
settingKey: 'idle_warning_time',
|
||||
settingValue: JSON.stringify(60),
|
||||
settingType: 'number',
|
||||
category: 'general',
|
||||
description: '空闲超时前警告时间(秒)',
|
||||
isEditable: false,
|
||||
},
|
||||
{
|
||||
settingKey: 'max_login_attempts',
|
||||
settingValue: JSON.stringify(5),
|
||||
@@ -119,38 +89,6 @@ const initDefaultSettings = async () => {
|
||||
description: '主题辅助色调',
|
||||
isEditable: true,
|
||||
},
|
||||
{
|
||||
settingKey: 'compact_mode',
|
||||
settingValue: JSON.stringify(false),
|
||||
settingType: 'boolean',
|
||||
category: 'appearance',
|
||||
description: '紧凑模式',
|
||||
isEditable: true,
|
||||
},
|
||||
{
|
||||
settingKey: 'sidebar_collapsed',
|
||||
settingValue: JSON.stringify(false),
|
||||
settingType: 'boolean',
|
||||
category: 'appearance',
|
||||
description: '侧边栏默认折叠',
|
||||
isEditable: true,
|
||||
},
|
||||
{
|
||||
settingKey: 'table_row_height',
|
||||
settingValue: JSON.stringify('default'),
|
||||
settingType: 'string',
|
||||
category: 'appearance',
|
||||
description: '表格行高: small/default/middle/large',
|
||||
isEditable: true,
|
||||
},
|
||||
{
|
||||
settingKey: 'animation_enabled',
|
||||
settingValue: JSON.stringify(true),
|
||||
settingType: 'boolean',
|
||||
category: 'appearance',
|
||||
description: '启用动画效果',
|
||||
isEditable: true,
|
||||
},
|
||||
|
||||
// 关于页面
|
||||
{
|
||||
@@ -201,22 +139,6 @@ const initDefaultSettings = async () => {
|
||||
description: '系统描述',
|
||||
isEditable: true,
|
||||
},
|
||||
{
|
||||
settingKey: 'privacy_policy',
|
||||
settingValue: JSON.stringify(''),
|
||||
settingType: 'string',
|
||||
category: 'about',
|
||||
description: '隐私政策URL',
|
||||
isEditable: true,
|
||||
},
|
||||
{
|
||||
settingKey: 'terms_of_service',
|
||||
settingValue: JSON.stringify(''),
|
||||
settingType: 'string',
|
||||
category: 'about',
|
||||
description: '服务条款URL',
|
||||
isEditable: true,
|
||||
},
|
||||
];
|
||||
|
||||
let createdCount = 0;
|
||||
@@ -361,6 +283,11 @@ router.put('/:key', async (req, res) => {
|
||||
settingValue: JSON.stringify(parsedValue),
|
||||
});
|
||||
|
||||
// 如果更新了维护模式设置,清除缓存
|
||||
if (key === 'maintenance_mode') {
|
||||
clearMaintenanceCache();
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: '设置更新成功',
|
||||
setting: {
|
||||
@@ -426,6 +353,11 @@ router.put('/', async (req, res) => {
|
||||
updatedSettings,
|
||||
errors: errors.length > 0 ? errors : undefined,
|
||||
});
|
||||
|
||||
// 如果更新了维护模式设置,清除缓存
|
||||
if ('maintenance_mode' in settings) {
|
||||
clearMaintenanceCache();
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
@@ -444,27 +376,16 @@ router.post('/reset/:key', async (req, res) => {
|
||||
const defaultValues = {
|
||||
site_name: '机柜管理系统',
|
||||
site_logo: '',
|
||||
timezone: 'Asia/Shanghai',
|
||||
date_format: 'YYYY-MM-DD',
|
||||
session_timeout: 30,
|
||||
idle_timeout: 30,
|
||||
idle_warning_time: 60,
|
||||
max_login_attempts: 5,
|
||||
maintenance_mode: false,
|
||||
frontend_port: FRONTEND.DEFAULT_PORT,
|
||||
primary_color: '#667eea',
|
||||
secondary_color: '#764ba2',
|
||||
compact_mode: false,
|
||||
sidebar_collapsed: false,
|
||||
table_row_height: 'default',
|
||||
animation_enabled: true,
|
||||
company_name: '',
|
||||
contact_email: '',
|
||||
contact_phone: '',
|
||||
company_address: '',
|
||||
system_description: '机柜管理系统 - 专业的数据中心设备管理解决方案',
|
||||
privacy_policy: '',
|
||||
terms_of_service: '',
|
||||
};
|
||||
|
||||
const defaultValue = defaultValues[key];
|
||||
@@ -476,6 +397,11 @@ router.post('/reset/:key', async (req, res) => {
|
||||
settingValue: JSON.stringify(defaultValue),
|
||||
});
|
||||
|
||||
// 如果重置了维护模式设置,清除缓存
|
||||
if (key === 'maintenance_mode') {
|
||||
clearMaintenanceCache();
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: '设置已重置为默认值',
|
||||
key,
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
# 系统设置页面重写设计
|
||||
|
||||
## 概述
|
||||
|
||||
重写系统设置主页面(`SystemSettings.jsx`),遵循务实原则:每个设置项都必须有实际效果。移除所有"只写不读"的设置项,新增 maintenance_mode 和 max_login_attempts 的后端执行逻辑。
|
||||
|
||||
**范围**:仅重写系统设置主页面,备份相关 3 个页面(数据备份管理、自动备份设置、远端备份配置)保持不变。
|
||||
|
||||
## 设计决策
|
||||
|
||||
- **方案 A**:保持 3 Tab 结构(基本设置、外观设置、关于系统)
|
||||
- **务实派**:移除所有无实际效果的设置项,保留的每个设置项都必须有前后端闭环
|
||||
- **精简优先**:不新增功能,先做精简
|
||||
|
||||
## 设置项清单
|
||||
|
||||
### Tab 1:基本设置
|
||||
|
||||
| 设置项 | Key | 类型 | 实际效果 |
|
||||
|--------|-----|------|----------|
|
||||
| 网站名称 | `site_name` | 文本输入 | 侧边栏顶部显示的系统名称 |
|
||||
| 网站 Logo | `site_logo` | URL 输入 + 预览 | 侧边栏顶部显示的 Logo 图片 |
|
||||
| 空闲超时 | `idle_timeout` | 数字(分钟) | 前端空闲检测倒计时,超时后自动登出 |
|
||||
| 最大登录尝试 | `max_login_attempts` | 数字 | 后端登录接口校验,超限后锁定账户 |
|
||||
| 维护模式 | `maintenance_mode` | 开关 | 后端中间件拦截,普通用户无法登录 |
|
||||
|
||||
### Tab 2:外观设置
|
||||
|
||||
| 设置项 | Key | 类型 | 实际效果 |
|
||||
|--------|-----|------|----------|
|
||||
| 主色调 | `primary_color` | 颜色选择器 | 全局 CSS 变量,影响按钮/链接/渐变等 |
|
||||
| 辅助色调 | `secondary_color` | 颜色选择器 | 全局 CSS 变量,影响渐变/悬停效果 |
|
||||
|
||||
### Tab 3:关于系统
|
||||
|
||||
| 内容 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| 系统版本、Node 版本、平台、运行时间、内存 | 只读展示 | 从后端 `/system/info` 获取 |
|
||||
| 设备/机柜/机房/用户统计 | 只读展示 | 从后端统计获取 |
|
||||
| 公司名称、联系邮箱、联系电话、公司地址、系统描述 | 可编辑表单 | 保存到 system_settings |
|
||||
|
||||
### 移除的设置项
|
||||
|
||||
以下设置项从前端页面和后端 `initDefaultSettings` 中移除(数据库已有记录不主动删除):
|
||||
|
||||
- `timezone` — 时区设置(无效果)
|
||||
- `date_format` — 日期格式(无效果)
|
||||
- `compact_mode` — 紧凑模式(无效果)
|
||||
- `sidebar_collapsed` — 侧边栏折叠(无效果)
|
||||
- `table_row_height` — 表格行高(无效果)
|
||||
- `animation_enabled` — 动画效果(无效果)
|
||||
- `session_timeout` — 登录有效期(与 idle_timeout 重复)
|
||||
- `idle_warning_time` — 空闲警告时间(前端未使用)
|
||||
- `privacy_policy` — 隐私政策 URL(前端未使用)
|
||||
- `terms_of_service` — 服务条款 URL(前端未使用)
|
||||
|
||||
## 后端实现
|
||||
|
||||
### maintenance_mode 实现
|
||||
|
||||
1. 在 `backend/middleware/auth.js` 的 `authMiddleware` 中增加维护模式检查
|
||||
2. 从 `SystemSetting` 表读取 `maintenance_mode` 值(可缓存,避免每次查询)
|
||||
3. 登录接口:`maintenance_mode === true` 时,只允许管理员角色登录,其他用户返回 503 + 提示信息
|
||||
4. 已登录用户:普通用户的 API 请求返回 503,管理员不受影响
|
||||
5. 缓存策略:内存缓存 maintenance_mode 值,设置保存时清除缓存
|
||||
|
||||
### max_login_attempts 实现
|
||||
|
||||
现有 User 模型中已有 `loginCount`(记录失败次数)和 `lockedUntil`(锁定截止时间)字段,无需新增。改动如下:
|
||||
|
||||
1. 登录接口逻辑:
|
||||
- 从 system_settings 动态读取 `max_login_attempts` 值(替代原来的硬编码 `MAX_LOGIN_ATTEMPTS`)
|
||||
- 使用现有 `user.loginCount` 记录失败次数
|
||||
- 使用现有 `user.lockedUntil` 记录锁定截止时间
|
||||
- 锁定时间改为 30 分钟(原来的 3 分钟偏短)
|
||||
2. 登录成功时重置 `loginCount = 0`、`lockedUntil = null`(已有逻辑不变)
|
||||
|
||||
### 移除设置项的后端处理
|
||||
|
||||
1. `initDefaultSettings` 数组中移除上述 10 个设置项
|
||||
2. `reset/:key` 路由的 `defaultValues` 对象同步清理
|
||||
3. 已有数据库记录不主动删除(避免数据迁移问题),前端不再展示即可
|
||||
|
||||
### site_logo 后端
|
||||
|
||||
- 无需额外后端改动,已有 `site_logo` 设置项在 `initDefaultSettings` 中
|
||||
- 前端通过 `/api/system-settings` 接口获取
|
||||
|
||||
## 前端实现
|
||||
|
||||
### 页面结构
|
||||
|
||||
保持当前 `SystemSettings.jsx` 的整体布局(顶部导航栏 + Tab 切换),重写内容区:
|
||||
|
||||
**Tab 1 - 基本设置:**
|
||||
- 站点信息卡片:网站名称 + 网站 Logo URL(带预览按钮,点击弹窗显示 Logo 图片)
|
||||
- 安全设置卡片:空闲超时 + 最大登录尝试 + 维护模式(维护模式开关带醒目 Alert 警告提示)
|
||||
|
||||
**Tab 2 - 外观设置:**
|
||||
- 主题色卡片:主色调 + 辅色调(保留当前 ColorPicker + 实时预览)
|
||||
- 移除界面布局卡片和动画效果卡片
|
||||
|
||||
**Tab 3 - 关于系统:**
|
||||
- 保持当前设计不变(系统信息展示 + 公司信息编辑表单)
|
||||
|
||||
### 清理死代码
|
||||
|
||||
1. 移除 `handleSaveSettings` 中 `frontend_port` 相关逻辑(第 118-203 行)
|
||||
2. 移除 `renderFormItem` 中 `frontend_port` 特殊处理(第 252 行)
|
||||
3. 移除 `getSelectOptions` 中 timezone、date_format、compact_mode、animation_enabled、sidebar_collapsed、dark_mode 的选项映射
|
||||
4. 移除 `settingGroups` 中时间设置分组
|
||||
5. configStore 的 `defaultConfig` 中移除 `timezone`、`date_format`、`compact_mode`、`sidebar_collapsed`、`animation_enabled`
|
||||
6. configStore 的 `defaultConfig` 中增加 `site_logo: ''`
|
||||
|
||||
### site_logo 显示
|
||||
|
||||
1. 侧边栏组件读取 `config.site_logo`,有值时用 `<img>` 显示,无值时用默认图标
|
||||
2. Logo 预览按钮:点击后弹窗显示 Logo 图片,无效 URL 显示错误提示
|
||||
|
||||
### 维护模式前端提示
|
||||
|
||||
1. 维护模式开关开启时,显示醒目的 Alert 提示"开启后普通用户将无法登录"
|
||||
2. 前端 axios 拦截器:如果 API 返回 503(维护模式),跳转到维护提示页面或显示全局提示
|
||||
|
||||
## 涉及的文件
|
||||
|
||||
### 后端
|
||||
- `backend/models/User.js` — 新增 loginFailCount、loginLockedUntil 字段
|
||||
- `backend/routes/auth.js` — 登录接口增加尝试次数限制逻辑
|
||||
- `backend/middleware/auth.js` — 增加维护模式检查
|
||||
- `backend/routes/systemSettings.js` — 清理 initDefaultSettings、reset/:key 的 defaultValues
|
||||
|
||||
### 前端
|
||||
- `frontend/src/pages/SystemSettings.jsx` — 重写页面内容
|
||||
- `frontend/src/stores/configStore.js` — 清理 defaultConfig,增加 site_logo
|
||||
- `frontend/src/App.jsx` — 侧边栏 Logo 显示、503 拦截
|
||||
- `frontend/src/api/index.js` — 可能需要调整(检查是否有清理项)
|
||||
Generated
+40
-27
@@ -9,9 +9,11 @@
|
||||
"version": "2.0.0",
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.1.0",
|
||||
"@ant-design/icons-svg": "^4.4.2",
|
||||
"@react-three/drei": "^9.122.0",
|
||||
"@react-three/fiber": "^8.18.0",
|
||||
"@types/dagre": "^0.7.54",
|
||||
"all": "^0.0.0",
|
||||
"antd": "^5.8.6",
|
||||
"axios": "^1.13.6",
|
||||
"dagre": "^0.8.5",
|
||||
@@ -143,7 +145,7 @@
|
||||
},
|
||||
"node_modules/@ant-design/icons-svg": {
|
||||
"version": "4.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.4.2.tgz",
|
||||
"resolved": "https://registry.npmmirror.com/@ant-design/icons-svg/-/icons-svg-4.4.2.tgz",
|
||||
"integrity": "sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -281,6 +283,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",
|
||||
@@ -629,6 +632,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@@ -672,6 +676,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -693,6 +698,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"
|
||||
}
|
||||
@@ -1767,6 +1773,7 @@
|
||||
"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",
|
||||
@@ -2371,8 +2378,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",
|
||||
@@ -2733,6 +2739,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"
|
||||
}
|
||||
@@ -2763,6 +2770,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",
|
||||
@@ -2908,7 +2916,6 @@
|
||||
"integrity": "sha512-cuzPhD8fwRHk8IGfmYaR4eEe4cAyJEL66Ove/WZL7yWNL134nqLddSLwNRIsFlnnW1kK+p8Ck3viFnC0chXCXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@vue/shared": "3.5.29",
|
||||
@@ -2923,7 +2930,6 @@
|
||||
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
@@ -2936,8 +2942,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",
|
||||
@@ -2945,7 +2950,6 @@
|
||||
"integrity": "sha512-n0G5o7R3uBVmVxjTIYcz7ovr8sy7QObFG8OQJ3xGCDNhbG60biP/P5KnyY8NLd81OuT1WJflG7N4KWYHaeeaIg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/compiler-core": "3.5.29",
|
||||
"@vue/shared": "3.5.29"
|
||||
@@ -2975,8 +2979,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",
|
||||
@@ -2984,7 +2987,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"
|
||||
@@ -2996,7 +2998,6 @@
|
||||
"integrity": "sha512-zcrANcrRdcLtmGZETBxWqIkoQei8HaFpZWx/GHKxx79JZsiZ8j1du0VUJtu4eJjgFvU/iKL5lRXFXksVmI+5DA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/shared": "3.5.29"
|
||||
}
|
||||
@@ -3007,7 +3008,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"
|
||||
@@ -3019,7 +3019,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",
|
||||
@@ -3033,7 +3032,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"
|
||||
@@ -3047,8 +3045,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",
|
||||
@@ -3062,6 +3059,7 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -3115,13 +3113,18 @@
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/all": {
|
||||
"version": "0.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/all/-/all-0.0.0.tgz",
|
||||
"integrity": "sha512-0oKlfNVv2d+d7c1gwjGspzgbwot47PGQ4b3v1ccx4mR8l9P/Y6E6Dr/yE8lNT63EcAKEbHo6UG3odDpC/NQcKw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
@@ -3521,6 +3524,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -3997,6 +4001,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"
|
||||
}
|
||||
@@ -4127,7 +4132,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",
|
||||
@@ -4242,8 +4248,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",
|
||||
@@ -4542,6 +4547,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",
|
||||
@@ -4602,6 +4608,7 @@
|
||||
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"eslint-config-prettier": "bin/cli.js"
|
||||
},
|
||||
@@ -5965,6 +5972,7 @@
|
||||
"integrity": "sha512-GtldT42B8+jefDUC4yUKAvsaOrH7PDHmZxZXNgF2xMmymjUbRYJvpAybZAKEmXDGTM0mCsz8duOa4vTm5AY2Kg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@acemir/cssom": "^0.9.28",
|
||||
"@asamuzakjp/dom-selector": "^6.7.6",
|
||||
@@ -6179,7 +6187,6 @@
|
||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"lz-string": "bin/bin.js"
|
||||
}
|
||||
@@ -6721,6 +6728,7 @@
|
||||
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
@@ -6750,7 +6758,6 @@
|
||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1",
|
||||
"ansi-styles": "^5.0.0",
|
||||
@@ -6766,7 +6773,6 @@
|
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
@@ -6779,8 +6785,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",
|
||||
@@ -7453,6 +7458,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"
|
||||
},
|
||||
@@ -7477,6 +7483,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"
|
||||
@@ -8424,6 +8431,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",
|
||||
@@ -8450,7 +8458,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",
|
||||
@@ -8973,6 +8982,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"
|
||||
}
|
||||
@@ -9001,6 +9011,7 @@
|
||||
"integrity": "sha512-+v57oAaoYNnO3hIu5Z/tJRZjq5aHM2zDve9YZ8HngVHbhk66RStobhb1sqPMIPEleV6cNKYK4eGrAbE9Ulbl2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.18.10",
|
||||
"postcss": "^8.4.27",
|
||||
@@ -9620,6 +9631,7 @@
|
||||
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -10025,6 +10037,7 @@
|
||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@@ -14,9 +14,11 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.1.0",
|
||||
"@ant-design/icons-svg": "^4.4.2",
|
||||
"@react-three/drei": "^9.122.0",
|
||||
"@react-three/fiber": "^8.18.0",
|
||||
"@types/dagre": "^0.7.54",
|
||||
"all": "^0.0.0",
|
||||
"antd": "^5.8.6",
|
||||
"axios": "^1.13.6",
|
||||
"dagre": "^0.8.5",
|
||||
|
||||
+14
-2
@@ -48,6 +48,7 @@ import {
|
||||
} from 'react-router-dom';
|
||||
import { useAuth } from './hooks/useAuth';
|
||||
import { useConfig } from './hooks/useConfig';
|
||||
import { useSiteLogo } from './stores/configStore';
|
||||
import { useDesignTokens } from './hooks/useDesignTokens';
|
||||
import useIdleTimeout from './hooks/useIdleTimeout';
|
||||
import { SWRConfig, swrConfig } from './hooks/useSWR';
|
||||
@@ -153,6 +154,7 @@ const AppLayout = ({ children }) => {
|
||||
const [idleConfig, setIdleConfig] = useState(DEFAULT_IDLE_CONFIG);
|
||||
const { user, logout } = useAuth();
|
||||
const { config } = useConfig();
|
||||
const siteLogo = useSiteLogo();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const designTokens = useDesignTokens();
|
||||
@@ -409,14 +411,24 @@ const AppLayout = ({ children }) => {
|
||||
width: '36px',
|
||||
height: '36px',
|
||||
borderRadius: designTokens.borderRadius.small,
|
||||
background: designTokens.colors.primary.gradient,
|
||||
background: siteLogo ? 'transparent' : designTokens.colors.primary.gradient,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<CloudServerOutlined style={{ fontSize: '18px', color: '#ffffff' }} />
|
||||
{siteLogo ? (
|
||||
<img
|
||||
src={siteLogo}
|
||||
alt="Logo"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
||||
onError={e => { e.target.style.display = 'none'; }}
|
||||
/>
|
||||
) : (
|
||||
<CloudServerOutlined style={{ fontSize: '18px', color: '#ffffff' }} />
|
||||
)}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div>
|
||||
|
||||
@@ -36,6 +36,12 @@ axios.interceptors.response.use(
|
||||
}
|
||||
}
|
||||
}
|
||||
// 维护模式拦截:503 + MAINTENANCE_MODE code
|
||||
if (error.response?.status === 503 && error.response?.data?.code === 'MAINTENANCE_MODE') {
|
||||
if (maintenanceCallback) {
|
||||
maintenanceCallback(true);
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -24,8 +24,14 @@ function MaintenanceBanner() {
|
||||
return;
|
||||
}
|
||||
|
||||
setReason(maintenanceData.reason || '系统维护中');
|
||||
setStartTime(maintenanceData.startTime);
|
||||
// 支持布尔值(从503拦截器)或对象(从其他来源)
|
||||
if (typeof maintenanceData === 'boolean' && maintenanceData) {
|
||||
setReason('系统维护中,仅管理员可访问');
|
||||
setStartTime(new Date().toISOString());
|
||||
} else if (typeof maintenanceData === 'object') {
|
||||
setReason(maintenanceData.reason || '系统维护中');
|
||||
setStartTime(maintenanceData.startTime);
|
||||
}
|
||||
setVisible(true);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Form,
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Divider,
|
||||
Row,
|
||||
Col,
|
||||
Segmented,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
TagOutlined,
|
||||
FileTextOutlined,
|
||||
CheckCircleOutlined,
|
||||
SwapOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
import { designTokens } from '../config/theme';
|
||||
@@ -31,6 +33,24 @@ import CloseButton from './CloseButton';
|
||||
const { Option } = Select;
|
||||
const { TextArea } = Input;
|
||||
|
||||
/**
|
||||
* 从端口名中提取前缀和末尾数字
|
||||
* @param {string} portName - 端口名称,如 "1/0/48" 或 "eth0/1"
|
||||
* @returns {{ prefix: string, num: number } | null} 前缀和数字,无数字则返回 null
|
||||
*/
|
||||
function extractPrefixAndNum(portName) {
|
||||
if (!portName || typeof portName !== 'string') return null;
|
||||
const match = portName.match(/^(.*?)(\d+)$/);
|
||||
if (!match) return null;
|
||||
return { prefix: match[1], num: parseInt(match[2], 10) };
|
||||
}
|
||||
|
||||
/**
|
||||
* 快捷模式解析端口范围(单输入框格式)
|
||||
* 支持完整格式 "1/0/1-1/0/48" 和简写格式 "1/0/1-48"
|
||||
* @param {string} portName - 端口范围字符串
|
||||
* @returns {{ isRange: boolean, prefix: string, startNum: number, endNum: number, portCount: number, ports: string[], error?: string } | null}
|
||||
*/
|
||||
function parsePortRange(portName) {
|
||||
if (!portName || typeof portName !== 'string') {
|
||||
return null;
|
||||
@@ -42,54 +62,135 @@ function parsePortRange(portName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [startPart, endPart] = trimmed.split('-').map(s => s.trim());
|
||||
// 用最后一个 - 分割,解决多 - 问题(如 eth-0-1-eth-0-48)
|
||||
const lastDashIdx = trimmed.lastIndexOf('-');
|
||||
const startPart = trimmed.substring(0, lastDashIdx).trim();
|
||||
const endPart = trimmed.substring(lastDashIdx + 1).trim();
|
||||
|
||||
if (!startPart || !endPart) {
|
||||
return null;
|
||||
return { isRange: false, prefix: '', startNum: 0, endNum: 0, portCount: 0, ports: [], error: '格式不完整,- 前后都需要有内容' };
|
||||
}
|
||||
|
||||
const startNumMatch = startPart.match(/(\d+)$/);
|
||||
const endNumMatch = endPart.match(/(\d+)$/);
|
||||
const startParsed = extractPrefixAndNum(startPart);
|
||||
const endParsed = extractPrefixAndNum(endPart);
|
||||
|
||||
if (!startNumMatch || !endNumMatch) {
|
||||
return null;
|
||||
if (!startParsed) {
|
||||
return { isRange: false, prefix: '', startNum: 0, endNum: 0, portCount: 0, ports: [], error: `起始端口名 "${startPart}" 末尾无数字,无法解析范围` };
|
||||
}
|
||||
|
||||
const startNum = parseInt(startNumMatch[1], 10);
|
||||
const endNum = parseInt(endNumMatch[1], 10);
|
||||
// 简写格式:endPart 只有数字(如 "1/0/1-48"),endParsed.prefix 为空
|
||||
if (!endParsed) {
|
||||
return { isRange: false, prefix: '', startNum: 0, endNum: 0, portCount: 0, ports: [], error: `结束端口名 "${endPart}" 末尾无数字,无法解析范围` };
|
||||
}
|
||||
|
||||
if (startNum >= endNum || endNum - startNum > 1000) {
|
||||
return null;
|
||||
let prefix;
|
||||
let startNum = startParsed.num;
|
||||
let endNum = endParsed.num;
|
||||
|
||||
if (endParsed.prefix === '') {
|
||||
// 简写格式:1/0/1-48,结束部分只有数字,前缀沿用起始部分
|
||||
prefix = startParsed.prefix;
|
||||
} else {
|
||||
// 完整格式:1/0/1-1/0/48,校验前缀一致
|
||||
if (startParsed.prefix !== endParsed.prefix) {
|
||||
return { isRange: false, prefix: '', startNum: 0, endNum: 0, portCount: 0, ports: [], error: `前后端口名前缀不一致:"${startParsed.prefix}" vs "${endParsed.prefix}"` };
|
||||
}
|
||||
prefix = startParsed.prefix;
|
||||
}
|
||||
|
||||
if (startNum >= endNum) {
|
||||
return { isRange: false, prefix: '', startNum: 0, endNum: 0, portCount: 0, ports: [], error: `起始数字 ${startNum} 必须小于结束数字 ${endNum}` };
|
||||
}
|
||||
|
||||
if (endNum - startNum > 1000) {
|
||||
return { isRange: false, prefix: '', startNum: 0, endNum: 0, portCount: 0, ports: [], error: '单次最多创建1000个端口' };
|
||||
}
|
||||
|
||||
const startIdx = startPart.length - startNumMatch[0].length;
|
||||
const prefix = startPart.substring(0, startIdx);
|
||||
const portCount = endNum - startNum + 1;
|
||||
|
||||
const ports = [];
|
||||
for (let i = 0; i < portCount; i++) {
|
||||
const num = startNum + i;
|
||||
ports.push(`${prefix}${num}`);
|
||||
ports.push(`${prefix}${startNum + i}`);
|
||||
}
|
||||
|
||||
return {
|
||||
isRange: true,
|
||||
prefix,
|
||||
startNum,
|
||||
endNum,
|
||||
portCount,
|
||||
ports,
|
||||
};
|
||||
return { isRange: true, prefix, startNum, endNum, portCount, ports };
|
||||
}
|
||||
|
||||
function generatePortNames(portName) {
|
||||
const result = parsePortRange(portName);
|
||||
|
||||
if (result && result.isRange) {
|
||||
return result.ports;
|
||||
/**
|
||||
* 范围模式解析(双输入框:起始端口名 + 结束端口名)
|
||||
* @param {string} startPortName - 起始端口名
|
||||
* @param {string} endPortName - 结束端口名
|
||||
* @returns {{ isRange: boolean, prefix: string, startNum: number, endNum: number, portCount: number, ports: string[], error?: string }}
|
||||
*/
|
||||
function parseRangeMode(startPortName, endPortName) {
|
||||
if (!startPortName || !endPortName) {
|
||||
return { isRange: false, prefix: '', startNum: 0, endNum: 0, portCount: 0, ports: [], error: '请填写起始和结束端口名' };
|
||||
}
|
||||
|
||||
return [portName];
|
||||
const startParsed = extractPrefixAndNum(startPortName.trim());
|
||||
const endParsed = extractPrefixAndNum(endPortName.trim());
|
||||
|
||||
if (!startParsed) {
|
||||
return { isRange: false, prefix: '', startNum: 0, endNum: 0, portCount: 0, ports: [], error: `起始端口名 "${startPortName}" 末尾无数字` };
|
||||
}
|
||||
if (!endParsed) {
|
||||
return { isRange: false, prefix: '', startNum: 0, endNum: 0, portCount: 0, ports: [], error: `结束端口名 "${endPortName}" 末尾无数字` };
|
||||
}
|
||||
|
||||
if (startParsed.prefix !== endParsed.prefix) {
|
||||
return { isRange: false, prefix: '', startNum: 0, endNum: 0, portCount: 0, ports: [], error: `前后端口名前缀不一致:"${startParsed.prefix || '(空)'}" vs "${endParsed.prefix || '(空)'}"` };
|
||||
}
|
||||
|
||||
if (startParsed.num >= endParsed.num) {
|
||||
return { isRange: false, prefix: '', startNum: 0, endNum: 0, portCount: 0, ports: [], error: `起始数字 ${startParsed.num} 必须小于结束数字 ${endParsed.num}` };
|
||||
}
|
||||
|
||||
if (endParsed.num - startParsed.num > 1000) {
|
||||
return { isRange: false, prefix: '', startNum: 0, endNum: 0, portCount: 0, ports: [], error: '单次最多创建1000个端口' };
|
||||
}
|
||||
|
||||
const prefix = startParsed.prefix;
|
||||
const startNum = startParsed.num;
|
||||
const endNum = endParsed.num;
|
||||
const portCount = endNum - startNum + 1;
|
||||
const ports = [];
|
||||
for (let i = 0; i < portCount; i++) {
|
||||
ports.push(`${prefix}${startNum + i}`);
|
||||
}
|
||||
|
||||
return { isRange: true, prefix, startNum, endNum, portCount, ports };
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据模式和输入生成端口名列表
|
||||
* @param {string} mode - 'range' 或 'quick'
|
||||
* @param {object} params - { portName } 或 { startPortName, endPortName }
|
||||
* @returns {{ ports: string[], error: string | null, parseResult: object | null }}
|
||||
*/
|
||||
function generatePortNames(mode, params) {
|
||||
if (mode === 'range') {
|
||||
const { startPortName, endPortName } = params;
|
||||
if (!startPortName && !endPortName) {
|
||||
return { ports: [], error: null, parseResult: null };
|
||||
}
|
||||
if (!startPortName || !endPortName) {
|
||||
// 只填了一个,暂不报错,返回空
|
||||
return { ports: [], error: null, parseResult: null };
|
||||
}
|
||||
const result = parseRangeMode(startPortName, endPortName);
|
||||
if (result.isRange) {
|
||||
return { ports: result.ports, error: null, parseResult: result };
|
||||
}
|
||||
return { ports: [], error: result.error, parseResult: result };
|
||||
}
|
||||
|
||||
// 快捷模式(仅单端口,不支持范围)
|
||||
const { portName } = params;
|
||||
if (!portName) {
|
||||
return { ports: [], error: null, parseResult: null };
|
||||
}
|
||||
|
||||
// 快捷模式直接作为单端口处理
|
||||
return { ports: [portName.trim()], error: null, parseResult: null };
|
||||
}
|
||||
|
||||
function PortCreateModal({
|
||||
@@ -107,12 +208,18 @@ function PortCreateModal({
|
||||
const [previewPorts, setPreviewPorts] = useState([]);
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [nicList, setNicList] = useState([]);
|
||||
const [portMode, setPortMode] = useState('quick'); // 'range' | 'quick'
|
||||
const [parseError, setParseError] = useState(null);
|
||||
const [parseResult, setParseResult] = useState(null);
|
||||
const prevVisibleRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && !prevVisibleRef.current) {
|
||||
setPreviewPorts([]);
|
||||
setShowPreview(false);
|
||||
setPortMode('quick');
|
||||
setParseError(null);
|
||||
setParseResult(null);
|
||||
form.resetFields();
|
||||
|
||||
if (networkCard) {
|
||||
@@ -144,25 +251,84 @@ function PortCreateModal({
|
||||
}
|
||||
};
|
||||
|
||||
const handlePortNameChange = useCallback(e => {
|
||||
const value = e.target.value;
|
||||
const ports = generatePortNames(value);
|
||||
/**
|
||||
* 更新端口预览和解析状态
|
||||
* @param {string} mode - 当前模式
|
||||
* @param {object} params - 解析参数
|
||||
*/
|
||||
const updatePortPreview = useCallback((mode, params) => {
|
||||
const result = generatePortNames(mode, params);
|
||||
|
||||
if (ports.length > 1) {
|
||||
setPreviewPorts(ports.slice(0, 20));
|
||||
setParseError(result.error);
|
||||
setParseResult(result.parseResult);
|
||||
|
||||
if (result.ports.length > 1) {
|
||||
setPreviewPorts(result.ports.slice(0, 20));
|
||||
setShowPreview(true);
|
||||
} else if (result.ports.length === 1) {
|
||||
setPreviewPorts([]);
|
||||
setShowPreview(false);
|
||||
} else {
|
||||
setPreviewPorts([]);
|
||||
setShowPreview(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/** 快捷模式:端口名输入变化 */
|
||||
const handleQuickPortNameChange = useCallback(e => {
|
||||
const value = e.target.value;
|
||||
updatePortPreview('quick', { portName: value });
|
||||
}, [updatePortPreview]);
|
||||
|
||||
/** 范围模式:起始/结束端口名输入变化 */
|
||||
const handleRangePortNameChange = useCallback(() => {
|
||||
const startPortName = form.getFieldValue('startPortName');
|
||||
const endPortName = form.getFieldValue('endPortName');
|
||||
updatePortPreview('range', { startPortName, endPortName });
|
||||
}, [form, updatePortPreview]);
|
||||
|
||||
/** 模式切换处理 */
|
||||
const handleModeChange = useCallback((newMode) => {
|
||||
setPortMode(newMode);
|
||||
setParseError(null);
|
||||
setParseResult(null);
|
||||
setPreviewPorts([]);
|
||||
setShowPreview(false);
|
||||
// 清空端口名相关字段
|
||||
form.setFieldsValue({ portName: undefined, startPortName: undefined, endPortName: undefined });
|
||||
}, [form]);
|
||||
|
||||
/** 获取当前生成的端口名列表 */
|
||||
const getCurrentPortNames = useCallback(() => {
|
||||
if (portMode === 'range') {
|
||||
const startPortName = form.getFieldValue('startPortName');
|
||||
const endPortName = form.getFieldValue('endPortName');
|
||||
return generatePortNames('range', { startPortName, endPortName });
|
||||
}
|
||||
const portName = form.getFieldValue('portName');
|
||||
return generatePortNames('quick', { portName });
|
||||
}, [portMode, form]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setLoading(true);
|
||||
|
||||
const portNames = generatePortNames(values.portName);
|
||||
// 获取端口名列表
|
||||
const { ports: portNames, error: genError } = getCurrentPortNames();
|
||||
|
||||
if (genError) {
|
||||
message.error(genError);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!portNames || portNames.length === 0) {
|
||||
message.error('请输入端口名称');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const finalNicId = disableNicChange && defaultNicId ? defaultNicId : values.nicId || null;
|
||||
|
||||
if (portNames.length === 1) {
|
||||
@@ -203,6 +369,8 @@ function PortCreateModal({
|
||||
form.resetFields();
|
||||
setPreviewPorts([]);
|
||||
setShowPreview(false);
|
||||
setParseError(null);
|
||||
setParseResult(null);
|
||||
onSuccess?.();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
@@ -214,22 +382,36 @@ function PortCreateModal({
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [device, form, onClose, onSuccess]);
|
||||
}, [device, form, onClose, onSuccess, getCurrentPortNames, disableNicChange, defaultNicId]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
form.resetFields();
|
||||
setPreviewPorts([]);
|
||||
setShowPreview(false);
|
||||
setParseError(null);
|
||||
setParseResult(null);
|
||||
onClose();
|
||||
}, [form, onClose]);
|
||||
|
||||
const handleValuesChange = changedValues => {
|
||||
if (changedValues.portName) {
|
||||
handlePortNameChange({ target: { value: changedValues.portName } });
|
||||
if (portMode === 'quick' && changedValues.portName) {
|
||||
handleQuickPortNameChange({ target: { value: changedValues.portName } });
|
||||
}
|
||||
if (portMode === 'range' && (changedValues.startPortName !== undefined || changedValues.endPortName !== undefined)) {
|
||||
handleRangePortNameChange();
|
||||
}
|
||||
};
|
||||
|
||||
const portCount = previewPorts.length || (form.getFieldValue('portName') && !showPreview ? 1 : 0);
|
||||
const portCount = useMemo(() => {
|
||||
if (parseResult?.portCount) return parseResult.portCount;
|
||||
if (previewPorts.length > 0) return previewPorts.length;
|
||||
// 单端口情况
|
||||
if (portMode === 'quick') {
|
||||
const portName = form.getFieldValue('portName');
|
||||
if (portName && !parseError) return 1;
|
||||
}
|
||||
return 0;
|
||||
}, [parseResult, previewPorts, portMode, form, parseError]);
|
||||
const selectedNic = nicList.find(nic => nic.nicId === form.getFieldValue('nicId'));
|
||||
|
||||
const styles = {
|
||||
@@ -429,12 +611,28 @@ function PortCreateModal({
|
||||
message="格式说明"
|
||||
description={
|
||||
<div style={{ fontSize: '11px', lineHeight: '1.6' }}>
|
||||
<div>
|
||||
• <strong>单个端口:</strong>eth0/1、gigabitethernet1/0/1
|
||||
</div>
|
||||
<div>
|
||||
• <strong>端口范围:</strong>1/0/1-1/0/48(创建 1/0/1 到 1/0/48 共48个端口)
|
||||
</div>
|
||||
{portMode === 'range' ? (
|
||||
<>
|
||||
<div>
|
||||
• <strong>范围模式:</strong>分别输入起始和结束端口名,系统自动生成端口序列
|
||||
</div>
|
||||
<div>
|
||||
• <strong>规则:</strong>起始和结束端口名的前缀必须一致,且起始数字需小于结束数字
|
||||
</div>
|
||||
<div>
|
||||
• <strong>示例:</strong>起始 1/0/1,结束 1/0/48 → 创建 1/0/1 至 1/0/48 共 48 个端口
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
• <strong>单端口模式:</strong>每次创建一个端口,如需批量创建请切换至范围模式
|
||||
</div>
|
||||
<div>
|
||||
• <strong>示例:</strong>eth0/1、GigabitEthernet0/0/1、1/0/24
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
type="info"
|
||||
@@ -448,52 +646,114 @@ function PortCreateModal({
|
||||
<div style={styles.sectionTitle}>
|
||||
<TagOutlined style={{ color: designTokens.colors.primary.main }} />
|
||||
端口标识
|
||||
<div style={{ marginLeft: 'auto' }}>
|
||||
<Segmented
|
||||
size="small"
|
||||
value={portMode}
|
||||
onChange={handleModeChange}
|
||||
options={[
|
||||
{ label: '单端口', value: 'quick' },
|
||||
{ label: '范围模式', value: 'range' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
name="portName"
|
||||
rules={[
|
||||
{ required: true, message: '请输入端口名称' },
|
||||
{
|
||||
pattern: /^[\w\/:\-]+$/,
|
||||
message: '端口名称格式不正确',
|
||||
},
|
||||
{
|
||||
validator: (_, value) => {
|
||||
if (!value) return Promise.resolve();
|
||||
const ports = generatePortNames(value);
|
||||
if (ports.length > 1000) {
|
||||
return Promise.reject(new Error('单次最多创建1000个端口'));
|
||||
{portMode === 'range' ? (
|
||||
<>
|
||||
<Form.Item
|
||||
name="startPortName"
|
||||
label={<span style={styles.fieldLabel}>起始端口名</span>}
|
||||
rules={[
|
||||
{ required: true, message: '请输入起始端口名' },
|
||||
{
|
||||
pattern: /^[\w\/:]+$/,
|
||||
message: '端口名称格式不正确',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
size="small"
|
||||
placeholder="例如: 1/0/1"
|
||||
prefix={
|
||||
<TagOutlined
|
||||
style={{ color: designTokens.colors.neutral[400], fontSize: '12px' }}
|
||||
/>
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
size="small"
|
||||
placeholder="例如: eth0/1 或 1/0/1-1/0/48"
|
||||
prefix={
|
||||
<TagOutlined
|
||||
style={{ color: designTokens.colors.neutral[400], fontSize: '12px' }}
|
||||
style={{ borderRadius: '6px' }}
|
||||
onChange={handleRangePortNameChange}
|
||||
/>
|
||||
}
|
||||
style={{ borderRadius: '6px' }}
|
||||
suffix={
|
||||
<Tooltip title="支持单个端口或端口范围(如 1/0/1-1/0/48)">
|
||||
<InfoCircleOutlined
|
||||
style={{ color: designTokens.colors.neutral[400], fontSize: '11px' }}
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="endPortName"
|
||||
label={<span style={styles.fieldLabel}>结束端口名</span>}
|
||||
rules={[
|
||||
{ required: true, message: '请输入结束端口名' },
|
||||
{
|
||||
pattern: /^[\w\/:]+$/,
|
||||
message: '端口名称格式不正确',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
size="small"
|
||||
placeholder="例如: 1/0/48"
|
||||
prefix={
|
||||
<TagOutlined
|
||||
style={{ color: designTokens.colors.neutral[400], fontSize: '12px' }}
|
||||
/>
|
||||
}
|
||||
style={{ borderRadius: '6px' }}
|
||||
onChange={handleRangePortNameChange}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
) : (
|
||||
<Form.Item
|
||||
name="portName"
|
||||
rules={[
|
||||
{ required: true, message: '请输入端口名称' },
|
||||
{
|
||||
pattern: /^[\w\/:]+$/,
|
||||
message: '端口名称格式不正确',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
size="small"
|
||||
placeholder="例如: eth0/1 或 1/0/24"
|
||||
prefix={
|
||||
<TagOutlined
|
||||
style={{ color: designTokens.colors.neutral[400], fontSize: '12px' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
}
|
||||
style={{ borderRadius: '6px' }}
|
||||
suffix={
|
||||
<Tooltip title="输入单个端口名称,批量创建请切换至范围模式">
|
||||
<InfoCircleOutlined
|
||||
style={{ color: designTokens.colors.neutral[400], fontSize: '11px' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{showPreview && (
|
||||
{/* 解析失败提示 */}
|
||||
{parseError && (
|
||||
<Alert
|
||||
message={parseError}
|
||||
type="error"
|
||||
showIcon
|
||||
style={{ marginBottom: '8px', borderRadius: '6px', fontSize: '12px' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showPreview && parseResult && (
|
||||
<div style={styles.previewCard}>
|
||||
<div style={styles.previewTitle}>
|
||||
<ThunderboltOutlined />
|
||||
将创建 {previewPorts.length} 个端口
|
||||
将创建 {parseResult.portCount} 个端口
|
||||
</div>
|
||||
<div style={styles.previewTags}>
|
||||
{previewPorts.map((port, index) => (
|
||||
@@ -501,15 +761,14 @@ function PortCreateModal({
|
||||
{port}
|
||||
</Tag>
|
||||
))}
|
||||
{parsePortRange(form.getFieldValue('portName'))?.portCount >
|
||||
previewPorts.length && (
|
||||
{parseResult.portCount > previewPorts.length && (
|
||||
<Tag
|
||||
style={{
|
||||
...styles.previewTag,
|
||||
background: designTokens.colors.neutral[100],
|
||||
}}
|
||||
>
|
||||
...等 {parsePortRange(form.getFieldValue('portName'))?.portCount} 个
|
||||
...等 {parseResult.portCount} 个
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+272
-1023
File diff suppressed because it is too large
Load Diff
@@ -48,11 +48,6 @@ const UserManagement = () => {
|
||||
const [passwordForm] = Form.useForm();
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
fetchRoles();
|
||||
}, [pagination.current]);
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -89,6 +84,16 @@ const UserManagement = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 分页或 Tab 切换时刷新用户列表(依赖 fetchUsers,由其内部 useCallback 依赖自动驱动)
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, [fetchUsers]);
|
||||
|
||||
// 角色列表仅在挂载时拉取一次,避免重复请求
|
||||
useEffect(() => {
|
||||
fetchRoles();
|
||||
}, []);
|
||||
|
||||
const handleAdd = useCallback(() => {
|
||||
setEditingUser(null);
|
||||
form.resetFields();
|
||||
|
||||
@@ -8,15 +8,10 @@ import axios from 'axios';
|
||||
|
||||
const defaultConfig = {
|
||||
site_name: '机柜管理系统',
|
||||
site_logo: '',
|
||||
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,
|
||||
idle_timeout: 30,
|
||||
max_login_attempts: 5,
|
||||
maintenance_mode: false,
|
||||
};
|
||||
@@ -84,5 +79,6 @@ export const useConfigStore = create((set, get) => ({
|
||||
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 useSiteLogo = () => useConfigStore((state) => state.config.site_logo);
|
||||
export const usePrimaryColor = () => useConfigStore((state) => state.config.primary_color);
|
||||
export const useSecondaryColor = () => useConfigStore((state) => state.config.secondary_color);
|
||||
|
||||
Generated
+8
-2
@@ -10,6 +10,7 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ant-design/cssinjs": "^2.1.2",
|
||||
"all": "^0.0.0",
|
||||
"papaparse": "^5.5.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -70,6 +71,12 @@
|
||||
"react-dom": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/all": {
|
||||
"version": "0.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/all/-/all-0.0.0.tgz",
|
||||
"integrity": "sha512-0oKlfNVv2d+d7c1gwjGspzgbwot47PGQ4b3v1ccx4mR8l9P/Y6E6Dr/yE8lNT63EcAKEbHo6UG3odDpC/NQcKw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
@@ -313,8 +320,7 @@
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/shell-quote": {
|
||||
"version": "1.8.3",
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/cssinjs": "^2.1.2",
|
||||
"all": "^0.0.0",
|
||||
"papaparse": "^5.5.3"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user