From 85d6b2c633a252b0ffd596395917c7a77ba5c24f Mon Sep 17 00:00:00 2001 From: zhang1106 <849185023@qq.com> Date: Fri, 12 Jun 2026 14:16:29 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E9=87=8D=E5=86=99=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E8=AE=BE=E7=BD=AE=E6=A8=A1=E5=9D=97=EF=BC=8C=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E7=BB=B4=E6=8A=A4=E6=A8=A1=E5=BC=8F=E4=B8=8E=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E7=AD=96=E7=95=A5=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除大量无实际效果的冗余配置项 - 新增维护模式拦截逻辑,支持管理员豁免 - 重构登录失败次数限制,从配置动态读取阈值 - 新增站点Logo支持与配置管理 - 优化用户管理页面数据拉取逻辑 - 新增端口批量创建范围模式功能 - 更新依赖包版本与缓存策略 --- backend/middleware/auth.js | 48 + backend/models/User.js | 1 + backend/package-lock.json | 66 +- backend/package.json | 3 +- backend/routes/auth.js | 44 +- backend/routes/systemSettings.js | 112 +- ...26-06-12-system-settings-rewrite-design.md | 137 ++ frontend/package-lock.json | 67 +- frontend/package.json | 2 + frontend/src/App.jsx | 16 +- frontend/src/api/index.js | 6 + frontend/src/components/MaintenanceBanner.jsx | 10 +- frontend/src/components/PortCreateModal.jsx | 431 ++++-- frontend/src/pages/PortManagement.jsx | 1025 ++++++++----- frontend/src/pages/SystemSettings.jsx | 1295 ++++------------- frontend/src/pages/UserManagement.jsx | 15 +- frontend/src/stores/configStore.js | 10 +- package-lock.json | 10 +- package.json | 1 + 19 files changed, 1699 insertions(+), 1600 deletions(-) create mode 100644 docs/superpowers/specs/2026-06-12-system-settings-rewrite-design.md diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js index 3ae44a4..76c7e8d 100644 --- a/backend/middleware/auth.js +++ b/backend/middleware/auth.js @@ -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, }; diff --git a/backend/models/User.js b/backend/models/User.js index 93a09e9..0bd8e96 100644 --- a/backend/models/User.js +++ b/backend/models/User.js @@ -53,6 +53,7 @@ const User = sequelize.define( loginCount: { type: DataTypes.INTEGER, defaultValue: 0, + comment: '连续登录失败次数,登录成功后重置', }, lockedUntil: { type: DataTypes.DATE, diff --git a/backend/package-lock.json b/backend/package-lock.json index f5cc912..aaa3ec3 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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" } diff --git a/backend/package.json b/backend/package.json index a7dcb62..0bbd4d1 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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" } diff --git a/backend/routes/auth.js b/backend/routes/auth.js index 1daa89b..b81b5b8 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -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({ diff --git a/backend/routes/systemSettings.js b/backend/routes/systemSettings.js index fe0d62e..0945c13 100644 --- a/backend/routes/systemSettings.js +++ b/backend/routes/systemSettings.js @@ -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, diff --git a/docs/superpowers/specs/2026-06-12-system-settings-rewrite-design.md b/docs/superpowers/specs/2026-06-12-system-settings-rewrite-design.md new file mode 100644 index 0000000..d5a0a32 --- /dev/null +++ b/docs/superpowers/specs/2026-06-12-system-settings-rewrite-design.md @@ -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`,有值时用 `` 显示,无值时用默认图标 +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` — 可能需要调整(检查是否有清理项) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f858873..e7c2231 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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" } diff --git a/frontend/package.json b/frontend/package.json index 598a29a..d93a4aa 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 4fdec7d..629ea4a 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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', }} > - + {siteLogo ? ( + Logo { e.target.style.display = 'none'; }} + /> + ) : ( + + )} {!collapsed && (
diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 6eafffb..fa2624d 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -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); } ); diff --git a/frontend/src/components/MaintenanceBanner.jsx b/frontend/src/components/MaintenanceBanner.jsx index 5de3145..bfdef14 100644 --- a/frontend/src/components/MaintenanceBanner.jsx +++ b/frontend/src/components/MaintenanceBanner.jsx @@ -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); }; diff --git a/frontend/src/components/PortCreateModal.jsx b/frontend/src/components/PortCreateModal.jsx index 83d7da2..108773d 100644 --- a/frontend/src/components/PortCreateModal.jsx +++ b/frontend/src/components/PortCreateModal.jsx @@ -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={
-
- • 单个端口:eth0/1、gigabitethernet1/0/1 -
-
- • 端口范围:1/0/1-1/0/48(创建 1/0/1 到 1/0/48 共48个端口) -
+ {portMode === 'range' ? ( + <> +
+ • 范围模式:分别输入起始和结束端口名,系统自动生成端口序列 +
+
+ • 规则:起始和结束端口名的前缀必须一致,且起始数字需小于结束数字 +
+
+ • 示例:起始 1/0/1,结束 1/0/48 → 创建 1/0/1 至 1/0/48 共 48 个端口 +
+ + ) : ( + <> +
+ • 单端口模式:每次创建一个端口,如需批量创建请切换至范围模式 +
+
+ • 示例:eth0/1、GigabitEthernet0/0/1、1/0/24 +
+ + )}
} type="info" @@ -448,52 +646,114 @@ function PortCreateModal({
端口标识 +
+ +
- { - if (!value) return Promise.resolve(); - const ports = generatePortNames(value); - if (ports.length > 1000) { - return Promise.reject(new Error('单次最多创建1000个端口')); + {portMode === 'range' ? ( + <> + 起始端口名} + rules={[ + { required: true, message: '请输入起始端口名' }, + { + pattern: /^[\w\/:]+$/, + message: '端口名称格式不正确', + }, + ]} + > + } - return Promise.resolve(); - }, - }, - ]} - > - - } - style={{ borderRadius: '6px' }} - suffix={ - - + 结束端口名} + rules={[ + { required: true, message: '请输入结束端口名' }, + { + pattern: /^[\w\/:]+$/, + message: '端口名称格式不正确', + }, + ]} + > + + } + style={{ borderRadius: '6px' }} + onChange={handleRangePortNameChange} + /> + + + ) : ( + + - - } - /> - + } + style={{ borderRadius: '6px' }} + suffix={ + + + + } + /> + + )} - {showPreview && ( + {/* 解析失败提示 */} + {parseError && ( + + )} + + {showPreview && parseResult && (
- 将创建 {previewPorts.length} 个端口 + 将创建 {parseResult.portCount} 个端口
{previewPorts.map((port, index) => ( @@ -501,15 +761,14 @@ function PortCreateModal({ {port} ))} - {parsePortRange(form.getFieldValue('portName'))?.portCount > - previewPorts.length && ( + {parseResult.portCount > previewPorts.length && ( - ...等 {parsePortRange(form.getFieldValue('portName'))?.portCount} 个 + ...等 {parseResult.portCount} 个 )}
diff --git a/frontend/src/pages/PortManagement.jsx b/frontend/src/pages/PortManagement.jsx index 5095d62..37194b2 100644 --- a/frontend/src/pages/PortManagement.jsx +++ b/frontend/src/pages/PortManagement.jsx @@ -27,6 +27,7 @@ import { Typography, Divider, Pagination, + Segmented, } from 'antd'; import { PlusOutlined, @@ -142,6 +143,11 @@ function PortManagement() { const [selectDeviceModalVisible, setSelectDeviceModalVisible] = useState(false); const [selectedDeviceForPort, setSelectedDeviceForPort] = useState(null); const [nicList, setNicList] = useState([]); + const [portMode, setPortMode] = useState('quick'); // 'range' | 'quick' + const [parseError, setParseError] = useState(null); + const [parseResult, setParseResult] = useState(null); + const [previewPorts, setPreviewPorts] = useState([]); + const [showPreview, setShowPreview] = useState(false); // 批量选择相关状态 const [selectedRowKeys, setSelectedRowKeys] = useState([]); @@ -623,18 +629,153 @@ function PortManagement() { }); }; - const parsePortRange = portName => { - const rangeMatch = portName.match(/^(.*?)\/(\d+)-\1\/(\d+)$/); - if (rangeMatch) { - const prefix = rangeMatch[1]; - const start = parseInt(rangeMatch[2]); - const end = parseInt(rangeMatch[3]); + /** + * 从端口名中提取前缀和末尾数字 + * @param {string} portName - 端口名称 + * @returns {{ prefix: string, num: number } | null} + */ + const 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) }; + }; - if (start <= end && end - start < 100) { - return Array.from({ length: end - start + 1 }, (_, i) => `${prefix}/${start + i}`); - } + /** + * 快捷模式解析端口范围 + * @param {string} portName - 端口范围字符串 + * @returns {{ isRange: boolean, ports: string[], portCount: number, error?: string } | null} + */ + const parsePortRangeQuick = portName => { + if (!portName || typeof portName !== 'string') return null; + const trimmed = portName.trim(); + if (!trimmed.includes('-')) return null; + + const lastDashIdx = trimmed.lastIndexOf('-'); + const startPart = trimmed.substring(0, lastDashIdx).trim(); + const endPart = trimmed.substring(lastDashIdx + 1).trim(); + + if (!startPart || !endPart) { + return { isRange: false, ports: [], portCount: 0, error: '格式不完整,- 前后都需要有内容' }; } - return [portName]; + + const startParsed = extractPrefixAndNum(startPart); + const endParsed = extractPrefixAndNum(endPart); + + if (!startParsed) { + return { isRange: false, ports: [], portCount: 0, error: `起始端口名 "${startPart}" 末尾无数字` }; + } + if (!endParsed) { + return { isRange: false, ports: [], portCount: 0, error: `结束端口名 "${endPart}" 末尾无数字` }; + } + + let prefix; + if (endParsed.prefix === '') { + prefix = startParsed.prefix; + } else { + if (startParsed.prefix !== endParsed.prefix) { + return { isRange: false, ports: [], portCount: 0, error: `前后端口名前缀不一致:"${startParsed.prefix}" vs "${endParsed.prefix}"` }; + } + prefix = startParsed.prefix; + } + + if (startParsed.num >= endParsed.num) { + return { isRange: false, ports: [], portCount: 0, error: `起始数字 ${startParsed.num} 必须小于结束数字 ${endParsed.num}` }; + } + if (endParsed.num - startParsed.num > 1000) { + return { isRange: false, ports: [], portCount: 0, error: '单次最多创建1000个端口' }; + } + + const portCount = endParsed.num - startParsed.num + 1; + const ports = []; + for (let i = 0; i < portCount; i++) { + ports.push(`${prefix}${startParsed.num + i}`); + } + return { isRange: true, ports, portCount }; + }; + + /** + * 范围模式解析(双输入框) + * @param {string} startPortName - 起始端口名 + * @param {string} endPortName - 结束端口名 + * @returns {{ isRange: boolean, ports: string[], portCount: number, error?: string }} + */ + const parsePortRangeMode = (startPortName, endPortName) => { + if (!startPortName || !endPortName) { + return { isRange: false, ports: [], portCount: 0, error: '请填写起始和结束端口名' }; + } + + const startParsed = extractPrefixAndNum(startPortName.trim()); + const endParsed = extractPrefixAndNum(endPortName.trim()); + + if (!startParsed) { + return { isRange: false, ports: [], portCount: 0, error: `起始端口名 "${startPortName}" 末尾无数字` }; + } + if (!endParsed) { + return { isRange: false, ports: [], portCount: 0, error: `结束端口名 "${endPortName}" 末尾无数字` }; + } + if (startParsed.prefix !== endParsed.prefix) { + return { isRange: false, ports: [], portCount: 0, error: `前后端口名前缀不一致:"${startParsed.prefix || '(空)'}" vs "${endParsed.prefix || '(空)'}"` }; + } + if (startParsed.num >= endParsed.num) { + return { isRange: false, ports: [], portCount: 0, error: `起始数字 ${startParsed.num} 必须小于结束数字 ${endParsed.num}` }; + } + if (endParsed.num - startParsed.num > 1000) { + return { isRange: false, ports: [], portCount: 0, error: '单次最多创建1000个端口' }; + } + + const portCount = endParsed.num - startParsed.num + 1; + const ports = []; + for (let i = 0; i < portCount; i++) { + ports.push(`${startParsed.prefix}${startParsed.num + i}`); + } + return { isRange: true, ports, portCount }; + }; + + /** + * 根据模式生成端口名列表 + * @param {string} mode - 'range' 或 'quick' + * @param {object} params - { portName } 或 { startPortName, endPortName } + * @returns {{ ports: string[], error: string | null, parseResult: object | null }} + */ + const 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 = parsePortRangeMode(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 }; + }; + + /** 更新端口预览 */ + const updatePortPreview = (mode, params) => { + const result = generatePortNames(mode, params); + setParseError(result.error); + setParseResult(result.parseResult); + if (result.ports.length > 1) { + setPreviewPorts(result.ports.slice(0, 20)); + setShowPreview(true); + } else { + setPreviewPorts([]); + setShowPreview(false); + } + }; + + /** 模式切换 */ + const handleModeChange = newMode => { + setPortMode(newMode); + setParseError(null); + setParseResult(null); + setPreviewPorts([]); + setShowPreview(false); + form.setFieldsValue({ portName: undefined, startPortName: undefined, endPortName: undefined }); }; const generateUniquePortId = () => { @@ -652,7 +793,22 @@ function PortManagement() { icon: , }); } else { - const portNames = parsePortRange(values.portName); + // 根据模式获取端口名列表 + const genResult = portMode === 'range' + ? generatePortNames('range', { startPortName: values.startPortName, endPortName: values.endPortName }) + : generatePortNames('quick', { portName: values.portName }); + + if (genResult.error) { + message.error(genResult.error); + return; + } + + const portNames = genResult.ports; + if (!portNames || portNames.length === 0) { + message.error('请输入端口名称'); + return; + } + const targetDeviceId = selectedDeviceForPort ? selectedDeviceForPort.deviceId : values.deviceId; @@ -701,6 +857,10 @@ function PortManagement() { setModalVisible(false); form.resetFields(); setSelectedDeviceForPort(null); + setParseError(null); + setParseResult(null); + setPreviewPorts([]); + setShowPreview(false); fetchPorts(1, false); } catch (error) { message.error(error.response?.data?.error || editingPort ? '更新失败' : '创建失败'); @@ -2037,9 +2197,13 @@ function PortManagement() { setModalVisible(false); form.resetFields(); setSelectedDeviceForPort(null); + setParseError(null); + setParseResult(null); + setPreviewPorts([]); + setShowPreview(false); }} footer={null} - width={600} + width={860} zIndex={1050} style={{ borderRadius: '16px' }} styles={{ body: { padding: 0 } }} @@ -2167,16 +2331,33 @@ function PortManagement() {
+ {/* 格式说明 - 顶部 */} -
- • 单个端口:eth0/1、gigabitethernet1/0/1 -
-
- • 端口范围:1/0/1-1/0/48(创建 1/0/1 到 1/0/48 共48个端口) -
+ {portMode === 'range' ? ( + <> +
+ • 范围模式:分别输入起始和结束端口名,系统自动生成端口序列 +
+
+ • 规则:起始和结束端口名的前缀必须一致,且起始数字需小于结束数字 +
+
+ • 示例:起始 1/0/1,结束 1/0/48 → 创建 1/0/1 至 1/0/48 共 48 个端口 +
+ + ) : ( + <> +
+ • 单端口模式:每次创建一个端口,如需批量创建请切换至范围模式 +
+
+ • 示例:eth0/1、GigabitEthernet0/0/1、1/0/24 +
+ + )}
} type="info" @@ -2189,347 +2370,505 @@ function PortManagement() { }} /> -
-
- - 端口标识 -
- - {selectedDeviceForPort ? ( - <> - 设备} - > - - {isServerDevice(selectedDeviceForPort) - ? '服务器' - : isSwitchDevice(selectedDeviceForPort) - ? '交换机' - : '设备'} - - } - style={{ borderRadius: '8px' }} - /> - - -
- {isServerDevice(selectedDeviceForPort) && ( - 所属网卡} - rules={[{ required: true, message: '请选择网卡' }]} - > - - - )} - - 端口名称} - rules={[{ required: true, message: '请输入端口名称' }]} - > - } - suffix={ - - - - } - style={{ borderRadius: '8px' }} - /> - -
- - ) : ( - 设备} - rules={[{ required: true, message: '请选择设备' }]} - > - - - )} -
- -
-
- - 端口属性 -
- -
- 端口类型} - rules={[{ required: true, message: '请选择端口类型' }]} - initialValue="RJ45" - > - - - - VLAN ID} - > - - -
- - {!editingPort && ( + {/* 端口标识 + 端口属性 左右排列 */} + + {/* 左侧:端口标识 */} +
- -
-
+ + 端口标识 + {!editingPort && ( +
+ +
+ )} +
+ + {selectedDeviceForPort ? ( + <> + 设备} + style={{ marginBottom: '12px' }} + > + + {isServerDevice(selectedDeviceForPort) + ? '服务器' + : isSwitchDevice(selectedDeviceForPort) + ? '交换机' + : '设备'} + + } + style={{ borderRadius: '8px' }} + /> + + + {isServerDevice(selectedDeviceForPort) && ( + 所属网卡} + rules={[{ required: true, message: '请选择网卡' }]} + style={{ marginBottom: '12px' }} + > + + + )} + + {editingPort ? ( + 端口名称} + rules={[{ required: true, message: '请输入端口名称' }]} + style={{ marginBottom: '0' }} + > + } + style={{ borderRadius: '8px' }} + /> + + ) : portMode === 'range' ? ( + <> + 起始端口名} + rules={[{ required: true, message: '请输入起始端口名' }]} + style={{ marginBottom: '12px' }} + > + } + style={{ borderRadius: '8px' }} + onChange={() => { + const startPortName = form.getFieldValue('startPortName'); + const endPortName = form.getFieldValue('endPortName'); + updatePortPreview('range', { startPortName, endPortName }); + }} + /> + + 结束端口名} + rules={[{ required: true, message: '请输入结束端口名' }]} + style={{ marginBottom: '0' }} + > + } + style={{ borderRadius: '8px' }} + onChange={() => { + const startPortName = form.getFieldValue('startPortName'); + const endPortName = form.getFieldValue('endPortName'); + updatePortPreview('range', { startPortName, endPortName }); + }} + /> + + + ) : ( + 端口名称} + rules={[{ required: true, message: '请输入端口名称' }]} + style={{ marginBottom: '0' }} + > + } + suffix={ + + + + } + style={{ borderRadius: '8px' }} + onChange={e => updatePortPreview('quick', { portName: e.target.value })} + /> + + )} + + {/* 解析失败提示 */} + {parseError && !editingPort && ( + + )} + + {/* 端口预览 */} + {showPreview && parseResult && !editingPort && ( +
+
+ + 将创建 {parseResult.portCount} 个端口 +
+
+ {previewPorts.map((port, index) => ( + + {port} + + ))} + {parseResult.portCount > previewPorts.length && ( + + ...等 {parseResult.portCount} 个 + + )} +
+
+ )} + + ) : ( + 设备} + rules={[{ required: true, message: '请选择设备' }]} + style={{ marginBottom: '0' }} > - 新建端口默认状态为空闲 -
-
- 接线后状态将自动更新为占用 -
+ + + )} +
+ + + {/* 右侧:端口属性 */} + +
+
+ + 端口属性
-
- )} - {editingPort && ( -
状态} - rules={[{ required: true, message: '请选择状态' }]} + name="portType" + label={端口类型} + rules={[{ required: true, message: '请选择端口类型' }]} + initialValue="RJ45" + style={{ marginBottom: '12px' }} > -
- )} -
+ 端口速率} + initialValue="1G" + style={{ marginBottom: '12px' }} + > + + + + VLAN ID} + style={{ marginBottom: '0' }} + > + + + + {editingPort && ( + 状态} + rules={[{ required: true, message: '请选择状态' }]} + style={{ marginTop: '12px', marginBottom: '0' }} + > + + + )} + + {!editingPort && ( +
+ +
+
+ 新建端口默认状态为空闲 +
+
+ 接线后状态将自动更新为占用 +
+
+
+ )} + + + + + {/* 描述信息 - 底部 */}
@@ -2538,7 +2877,7 @@ function PortManagement() { fontSize: '14px', fontWeight: 600, color: designTokens.colors.neutral[800], - marginBottom: '16px', + marginBottom: '12px', display: 'flex', alignItems: 'center', gap: '8px', @@ -2549,7 +2888,7 @@ function PortManagement() {