diff --git a/backend/check_password.js b/backend/check_password.js new file mode 100644 index 0000000..ee4791a --- /dev/null +++ b/backend/check_password.js @@ -0,0 +1,33 @@ +const { sequelize } = require('./db'); +const User = require('./models/User'); +const bcrypt = require('bcryptjs'); + +async function main() { + try { + await sequelize.authenticate(); + console.log('数据库连接成功!\n'); + + const users = await User.findAll({ + attributes: ['userId', 'username', 'password', 'email', 'status', 'createdAt'] + }); + + console.log('用户列表:'); + console.log('============'); + + for (const user of users) { + console.log('用户名:', user.username); + console.log('密码(加密后):', user.password); + console.log('状态:', user.status); + console.log('创建时间:', user.createdAt); + console.log('------------'); + } + + console.log('\n' + users.length + ' 个用户'); + + await sequelize.close(); + } catch (error) { + console.error('错误:', error.message); + } +} + +main(); diff --git a/backend/fix_admin.js b/backend/fix_admin.js new file mode 100644 index 0000000..09ca3fc --- /dev/null +++ b/backend/fix_admin.js @@ -0,0 +1,125 @@ +const bcrypt = require('bcryptjs'); +const { sequelize } = require('./db'); +const User = require('./models/User'); +const Role = require('./models/Role'); +const UserRole = require('./models/UserRole'); + +async function fixAdminUser() { + try { + await sequelize.authenticate(); + console.log('数据库连接成功!\n'); + + const adminUser = await User.findOne({ + where: { username: 'admin' } + }); + + if (!adminUser) { + console.log('未找到 admin 用户'); + return; + } + + console.log('当前 admin 用户信息:'); + console.log('用户名:', adminUser.username); + console.log('邮箱:', adminUser.email); + console.log('状态:', adminUser.status); + console.log(''); + + const userRoles = await UserRole.findAll({ + where: { UserId: adminUser.userId }, + include: [{ model: Role }] + }); + + console.log('当前用户角色:'); + if (userRoles.length === 0) { + console.log(' - 无角色分配'); + } else { + userRoles.forEach(ur => { + console.log(` - ${ur.Role.roleName} (${ur.Role.roleCode})`); + }); + } + console.log(''); + + let adminRole = await Role.findOne({ + where: { roleCode: 'admin' } + }); + + if (!adminRole) { + console.log('正在创建管理员角色...'); + adminRole = await Role.create({ + roleId: 'role_admin', + roleName: '管理员', + roleCode: 'admin', + description: '系统管理员,拥有所有权限', + status: 'active', + permissions: [] + }); + console.log('管理员角色创建成功!'); + } else { + console.log('管理员角色已存在'); + } + + const existingUserRole = await UserRole.findOne({ + where: { + UserId: adminUser.userId, + RoleId: adminRole.roleId + } + }); + + if (!existingUserRole) { + console.log('正在为 admin 用户分配管理员角色...'); + await UserRole.create({ + UserId: adminUser.userId, + RoleId: adminRole.roleId + }); + console.log('角色分配成功!'); + } else { + console.log('管理员角色已分配给该用户'); + } + console.log(''); + + const newPassword = 'admin123'; + const salt = await bcrypt.genSalt(10); + const hashedPassword = await bcrypt.hash(newPassword, salt); + + await adminUser.update({ + password: hashedPassword + }); + + console.log('密码已重置为: admin123'); + console.log(''); + + const updatedUser = await User.findOne({ + where: { username: 'admin' }, + include: [{ model: Role }] + }); + + console.log('修复后的 admin 用户信息:'); + console.log('用户名:', updatedUser.username); + console.log('邮箱:', updatedUser.email); + console.log('状态:', updatedUser.status); + console.log('角色:', + updatedUser.Roles && updatedUser.Roles.length > 0 + ? updatedUser.Roles.map(r => r.roleName).join(', ') + : '无角色' + ); + console.log(''); + + const testResult = bcrypt.compareSync(newPassword, updatedUser.password); + console.log('密码验证测试:'); + console.log(` 输入密码: "${newPassword}"`); + console.log(` 验证结果: ${testResult ? '✓ 成功' : '✗ 失败'}`); + + console.log('\n========================================'); + console.log('修复完成!现在可以使用以下信息登录:'); + console.log(' 用户名: admin'); + console.log(' 密码: admin123'); + console.log('========================================'); + + } catch (error) { + console.error('修复过程中发生错误:', error.message); + } finally { + await sequelize.close(); + } +} + +fixAdminUser(); diff --git a/backend/fix_user_delete.js b/backend/fix_user_delete.js new file mode 100644 index 0000000..ef4cad7 --- /dev/null +++ b/backend/fix_user_delete.js @@ -0,0 +1,133 @@ +const { sequelize } = require('./db'); +const Role = require('./models/Role'); +const User = require('./models/User'); +const UserRole = require('./models/UserRole'); + +async function fixForeignKeys() { + try { + console.log('开始修复外键约束...'); + + await sequelize.query('PRAGMA foreign_keys = OFF'); + + await sequelize.query('DROP TABLE IF EXISTS user_roles'); + await sequelize.query('DROP TABLE IF EXISTS users'); + await sequelize.query('DROP TABLE IF EXISTS roles'); + + console.log('已删除现有表,正在重新创建...'); + + await sequelize.query(` + CREATE TABLE IF NOT EXISTS roles ( + roleId TEXT PRIMARY KEY, + roleName TEXT NOT NULL, + roleCode TEXT NOT NULL UNIQUE, + description TEXT, + permissions TEXT DEFAULT '[]', + sort INTEGER DEFAULT 0, + status TEXT DEFAULT 'active', + createdAt DATETIME, + updatedAt DATETIME + ) + `); + + await sequelize.query(` + CREATE TABLE IF NOT EXISTS users ( + userId TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password TEXT NOT NULL, + email TEXT, + phone TEXT, + realName TEXT, + avatar TEXT, + status TEXT DEFAULT 'active', + lastLoginTime DATETIME, + lastLoginIp TEXT, + loginCount INTEGER DEFAULT 0, + remark TEXT, + createdAt DATETIME, + updatedAt DATETIME + ) + `); + + await sequelize.query(` + CREATE TABLE IF NOT EXISTS user_roles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + UserId TEXT NOT NULL, + RoleId TEXT NOT NULL, + createdAt DATETIME, + updatedAt DATETIME, + FOREIGN KEY (UserId) REFERENCES users(userId) ON DELETE CASCADE, + FOREIGN KEY (RoleId) REFERENCES roles(roleId) ON DELETE CASCADE, + UNIQUE(UserId, RoleId) + ) + `); + + await sequelize.query('PRAGMA foreign_keys = ON'); + + console.log('外键约束修复完成!'); + console.log('正在重新初始化角色数据...'); + + const bcrypt = require('bcryptjs'); + const SALT_ROUNDS = 10; + + await Role.bulkCreate([ + { + roleId: 'role_admin', + roleName: '管理员', + roleCode: 'admin', + description: '系统管理员,拥有所有权限', + permissions: '[]', + sort: 1, + status: 'active' + }, + { + roleId: 'role_operator', + roleName: '操作员', + roleCode: 'operator', + description: '设备操作员,可进行设备操作', + permissions: '[]', + sort: 2, + status: 'active' + }, + { + roleId: 'role_viewer', + roleName: '访客', + roleCode: 'viewer', + description: '只读权限,只能查看数据', + permissions: '[]', + sort: 3, + status: 'active' + } + ]); + + console.log('角色数据初始化完成!'); + + const hashedPassword = await bcrypt.hash('admin123', SALT_ROUNDS); + + const adminUser = await User.create({ + userId: 'user_admin', + username: 'admin', + password: hashedPassword, + email: 'admin@example.com', + realName: '系统管理员', + status: 'active' + }); + + await UserRole.create({ + UserId: adminUser.userId, + RoleId: 'role_admin' + }); + + console.log('管理员账户创建完成!'); + console.log('用户名: admin'); + console.log('密码: admin123'); + + console.log('\n修复完成!请重启后端服务。'); + + } catch (error) { + console.error('修复外键约束失败:', error); + } finally { + await sequelize.close(); + } +} + +fixForeignKeys(); diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js new file mode 100644 index 0000000..db9e6ff --- /dev/null +++ b/backend/middleware/auth.js @@ -0,0 +1,151 @@ +const jwt = require('jsonwebtoken'); +const User = require('../models/User'); +const LoginHistory = require('../models/LoginHistory'); + +const JWT_SECRET = process.env.JWT_SECRET || 'idc-management-secret-key-2024'; +const TOKEN_EXPIRY = process.env.TOKEN_EXPIRY || '24h'; + +const getBrowserInfo = (userAgent) => { + let device = 'Desktop'; + let browser = 'Unknown'; + let os = 'Unknown'; + + if (/Mobile|Android|iPhone|iPad|iPod/i.test(userAgent)) { + device = 'Mobile'; + if (/iPad/i.test(userAgent)) device = 'Tablet'; + } + + if (/Firefox/i.test(userAgent)) { + browser = 'Firefox'; + } else if (/Chrome/i.test(userAgent)) { + browser = 'Chrome'; + } else if (/Safari/i.test(userAgent)) { + browser = 'Safari'; + } else if (/Edge/i.test(userAgent)) { + browser = 'Edge'; + } else if (/MSIE|Trident/i.test(userAgent)) { + browser = 'IE'; + } + + if (/Windows/i.test(userAgent)) { + os = 'Windows'; + } else if (/Mac OS/i.test(userAgent)) { + os = 'macOS'; + } else if (/Linux/i.test(userAgent)) { + os = 'Linux'; + } else if (/Android/i.test(userAgent)) { + os = 'Android'; + } else if (/iOS|iPhone|iPad|iPod/i.test(userAgent)) { + os = 'iOS'; + } + + return { device, browser, os }; +}; + +const generateToken = (user) => { + return jwt.sign( + { + userId: user.userId, + username: user.username, + roleId: user.roleId + }, + JWT_SECRET, + { expiresIn: TOKEN_EXPIRY } + ); +}; + +const verifyToken = (token) => { + try { + return jwt.verify(token, JWT_SECRET); + } catch (error) { + return null; + } +}; + +const authMiddleware = async (req, res, next) => { + try { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ + success: false, + message: '未提供认证令牌' + }); + } + + const token = authHeader.substring(7); + const decoded = verifyToken(token); + + if (!decoded) { + return res.status(401).json({ + success: false, + message: '令牌无效或已过期' + }); + } + + const user = await User.findByPk(decoded.userId); + + if (!user) { + return res.status(401).json({ + success: false, + message: '用户不存在' + }); + } + + if (user.status === 'locked') { + return res.status(403).json({ + success: false, + message: '账户已被锁定' + }); + } + + if (user.status === 'inactive') { + return res.status(403).json({ + success: false, + message: '账户已禁用' + }); + } + + req.user = decoded; + req.userModel = user; + next(); + } catch (error) { + console.error('认证中间件错误:', error); + return res.status(500).json({ + success: false, + message: '认证失败' + }); + } +}; + +const optionalAuth = async (req, res, next) => { + try { + const authHeader = req.headers.authorization; + + if (authHeader && authHeader.startsWith('Bearer ')) { + const token = authHeader.substring(7); + const decoded = verifyToken(token); + + if (decoded) { + const user = await User.findByPk(decoded.userId); + if (user && user.status === 'active') { + req.user = decoded; + req.userModel = user; + } + } + } + + next(); + } catch (error) { + next(); + } +}; + +module.exports = { + generateToken, + verifyToken, + authMiddleware, + optionalAuth, + JWT_SECRET, + TOKEN_EXPIRY +}; diff --git a/backend/models/LoginHistory.js b/backend/models/LoginHistory.js new file mode 100644 index 0000000..63166fd --- /dev/null +++ b/backend/models/LoginHistory.js @@ -0,0 +1,57 @@ +const { DataTypes } = require('sequelize'); +const { sequelize } = require('../db'); + +const LoginHistory = sequelize.define('LoginHistory', { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + userId: { + type: DataTypes.STRING, + allowNull: false, + comment: '用户ID' + }, + username: { + type: DataTypes.STRING, + allowNull: false, + comment: '用户名' + }, + realName: { + type: DataTypes.STRING, + allowNull: true, + comment: '真实姓名' + }, + loginTime: { + type: DataTypes.DATE, + defaultValue: DataTypes.NOW, + comment: '登录时间' + }, + loginIp: { + type: DataTypes.STRING, + allowNull: true, + comment: '登录IP' + }, + userAgent: { + type: DataTypes.TEXT, + allowNull: true, + comment: '浏览器UA' + }, + loginType: { + type: DataTypes.ENUM('success', 'failed'), + defaultValue: 'success', + comment: '登录结果' + }, + failReason: { + type: DataTypes.STRING, + allowNull: true, + comment: '失败原因' + } +}, { + tableName: 'login_histories', + timestamps: true, + createdAt: 'loginTime', + updatedAt: false +}); + +module.exports = LoginHistory; diff --git a/backend/models/OperationLog.js b/backend/models/OperationLog.js new file mode 100644 index 0000000..d217adc --- /dev/null +++ b/backend/models/OperationLog.js @@ -0,0 +1,82 @@ +const { DataTypes } = require('sequelize'); +const { sequelize } = require('../db'); + +const OperationLog = sequelize.define('OperationLog', { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + userId: { + type: DataTypes.STRING, + allowNull: false, + comment: '操作人ID' + }, + username: { + type: DataTypes.STRING, + allowNull: false, + comment: '操作人用户名' + }, + realName: { + type: DataTypes.STRING, + allowNull: true, + comment: '操作人真实姓名' + }, + action: { + type: DataTypes.STRING, + allowNull: false, + comment: '操作类型' + }, + module: { + type: DataTypes.STRING, + allowNull: true, + comment: '操作模块' + }, + description: { + type: DataTypes.TEXT, + allowNull: true, + comment: '操作描述' + }, + targetId: { + type: DataTypes.STRING, + allowNull: true, + comment: '目标对象ID' + }, + targetName: { + type: DataTypes.STRING, + allowNull: true, + comment: '目标对象名称' + }, + oldValue: { + type: DataTypes.TEXT, + allowNull: true, + comment: '旧值' + }, + newValue: { + type: DataTypes.TEXT, + allowNull: true, + comment: '新值' + }, + ip: { + type: DataTypes.STRING, + allowNull: true, + comment: '操作IP' + }, + status: { + type: DataTypes.ENUM('success', 'failed'), + defaultValue: 'success', + comment: '操作状态' + }, + errorMessage: { + type: DataTypes.TEXT, + allowNull: true, + comment: '错误信息' + } +}, { + tableName: 'operation_logs', + timestamps: true, + createdAt: 'operateTime', + updatedAt: false +}); + +module.exports = OperationLog; diff --git a/backend/models/Permission.js b/backend/models/Permission.js new file mode 100644 index 0000000..b7ab3bf --- /dev/null +++ b/backend/models/Permission.js @@ -0,0 +1,48 @@ +const { DataTypes } = require('sequelize'); +const { sequelize } = require('../db'); + +const Permission = sequelize.define('Permission', { + permissionId: { + type: DataTypes.STRING, + primaryKey: true, + allowNull: false + }, + permissionName: { + type: DataTypes.STRING, + allowNull: false + }, + permissionCode: { + type: DataTypes.STRING, + allowNull: false, + unique: true + }, + parentId: { + type: DataTypes.STRING, + allowNull: true + }, + type: { + type: DataTypes.ENUM('menu', 'button'), + defaultValue: 'button' + }, + path: { + type: DataTypes.STRING, + allowNull: true + }, + icon: { + type: DataTypes.STRING, + allowNull: true + }, + sort: { + type: DataTypes.INTEGER, + defaultValue: 0 + }, + status: { + type: DataTypes.ENUM('active', 'inactive'), + defaultValue: 'active' + } +}, { + tableName: 'permissions', + timestamps: true +}); + +module.exports = Permission; diff --git a/backend/models/Role.js b/backend/models/Role.js new file mode 100644 index 0000000..ad35796 --- /dev/null +++ b/backend/models/Role.js @@ -0,0 +1,40 @@ +const { DataTypes } = require('sequelize'); +const { sequelize } = require('../db'); + +const Role = sequelize.define('Role', { + roleId: { + type: DataTypes.STRING, + primaryKey: true, + allowNull: false + }, + roleName: { + type: DataTypes.STRING, + allowNull: false + }, + roleCode: { + type: DataTypes.STRING, + allowNull: false, + unique: true + }, + description: { + type: DataTypes.STRING, + allowNull: true + }, + status: { + type: DataTypes.ENUM('active', 'inactive'), + defaultValue: 'active' + }, + permissions: { + type: DataTypes.JSON, + defaultValue: [] + }, + sort: { + type: DataTypes.INTEGER, + defaultValue: 0 + } +}, { + tableName: 'roles', + timestamps: true +}); + +module.exports = Role; diff --git a/backend/models/User.js b/backend/models/User.js new file mode 100644 index 0000000..ac28830 --- /dev/null +++ b/backend/models/User.js @@ -0,0 +1,63 @@ +const { DataTypes } = require('sequelize'); +const { sequelize } = require('../db'); + +const User = sequelize.define('User', { + userId: { + type: DataTypes.STRING, + primaryKey: true, + allowNull: false + }, + username: { + type: DataTypes.STRING, + allowNull: false, + unique: true + }, + password: { + type: DataTypes.STRING, + allowNull: false + }, + email: { + type: DataTypes.STRING, + allowNull: true, + validate: { + isEmail: true + } + }, + phone: { + type: DataTypes.STRING, + allowNull: true + }, + realName: { + type: DataTypes.STRING, + allowNull: true + }, + avatar: { + type: DataTypes.STRING, + allowNull: true + }, + status: { + type: DataTypes.ENUM('active', 'inactive', 'locked'), + defaultValue: 'active' + }, + lastLoginTime: { + type: DataTypes.DATE, + allowNull: true + }, + lastLoginIp: { + type: DataTypes.STRING, + allowNull: true + }, + loginCount: { + type: DataTypes.INTEGER, + defaultValue: 0 + }, + remark: { + type: DataTypes.TEXT, + allowNull: true + } +}, { + tableName: 'users', + timestamps: true +}); + +module.exports = User; diff --git a/backend/models/UserRole.js b/backend/models/UserRole.js new file mode 100644 index 0000000..1a15741 --- /dev/null +++ b/backend/models/UserRole.js @@ -0,0 +1,22 @@ +const { DataTypes } = require('sequelize'); +const { sequelize } = require('../db'); +const User = require('./User'); +const Role = require('./Role'); + +const UserRole = sequelize.define('UserRole', { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + } +}, { + tableName: 'user_roles', + timestamps: true +}); + +UserRole.belongsTo(User, { foreignKey: 'UserId', onDelete: 'CASCADE' }); +UserRole.belongsTo(Role, { foreignKey: 'RoleId', onDelete: 'CASCADE' }); +User.hasMany(UserRole, { foreignKey: 'UserId', onDelete: 'CASCADE' }); +Role.hasMany(UserRole, { foreignKey: 'RoleId', onDelete: 'CASCADE' }); + +module.exports = UserRole; diff --git a/backend/package-lock.json b/backend/package-lock.json index 5086c94..48fc880 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -8,6 +8,7 @@ "name": "idc-backend", "version": "1.0.0", "dependencies": { + "bcryptjs": "^3.0.3", "cors": "^2.8.5", "csv-parser": "^3.2.0", "csv-writer": "^1.6.0", @@ -16,6 +17,7 @@ "express": "^4.18.2", "express-fileupload": "^1.5.2", "iconv-lite": "^0.6.3", + "jsonwebtoken": "^9.0.3", "mysql2": "^3.16.0", "sequelize": "^6.32.1", "sqlite3": "^5.1.6", @@ -281,6 +283,15 @@ ], "license": "MIT" }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -398,6 +409,12 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", @@ -761,6 +778,15 @@ "node": ">= 0.4" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -1522,12 +1548,103 @@ "license": "ISC", "optional": true }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "license": "MIT" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/long": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", diff --git a/backend/package.json b/backend/package.json index 229b185..1feddc8 100644 --- a/backend/package.json +++ b/backend/package.json @@ -7,6 +7,7 @@ "dev": "nodemon server.js" }, "dependencies": { + "bcryptjs": "^3.0.3", "cors": "^2.8.5", "csv-parser": "^3.2.0", "csv-writer": "^1.6.0", @@ -15,6 +16,7 @@ "express": "^4.18.2", "express-fileupload": "^1.5.2", "iconv-lite": "^0.6.3", + "jsonwebtoken": "^9.0.3", "mysql2": "^3.16.0", "sequelize": "^6.32.1", "sqlite3": "^5.1.6", diff --git a/backend/routes/auth.js b/backend/routes/auth.js new file mode 100644 index 0000000..101b3ce --- /dev/null +++ b/backend/routes/auth.js @@ -0,0 +1,341 @@ +const express = require('express'); +const bcrypt = require('bcryptjs'); +const User = require('../models/User'); +const Role = require('../models/Role'); +const UserRole = require('../models/UserRole'); +const { generateToken, authMiddleware } = require('../middleware/auth'); + +const router = express.Router(); + +const SALT_ROUNDS = 10; + +const generateId = () => { + return 'user_' + Date.now().toString(36) + Math.random().toString(36).substr(2, 9); +}; + +router.post('/register', async (req, res) => { + try { + const { username, password, email, phone, realName } = req.body; + + if (!username || !password) { + return res.status(400).json({ + success: false, + message: '用户名和密码不能为空' + }); + } + + if (username.length < 3 || username.length > 20) { + return res.status(400).json({ + success: false, + message: '用户名长度必须在3-20个字符之间' + }); + } + + if (password.length < 6) { + return res.status(400).json({ + success: false, + message: '密码长度不能少于6个字符' + }); + } + + const existingUser = await User.findOne({ where: { username } }); + if (existingUser) { + return res.status(400).json({ + success: false, + message: '用户名已存在' + }); + } + + const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS); + + const userCount = await User.count(); + const isFirstUser = userCount === 0; + + const user = await User.create({ + userId: generateId(), + username, + password: hashedPassword, + email, + phone, + realName: realName || username, + status: 'active' + }); + + if (isFirstUser) { + let adminRole = await Role.findOne({ where: { roleCode: 'admin' } }); + + if (!adminRole) { + adminRole = await Role.create({ + roleId: 'role_admin', + roleName: '管理员', + roleCode: 'admin', + description: '系统管理员,拥有所有权限', + status: 'active', + permissions: [] + }); + } + + await UserRole.create({ + UserId: user.userId, + RoleId: adminRole.roleId + }); + } else { + const defaultRole = await Role.findOne({ where: { roleCode: 'viewer' } }); + + if (defaultRole) { + await UserRole.create({ + UserId: user.userId, + RoleId: defaultRole.roleId + }); + } + } + + const token = generateToken(user); + + res.status(201).json({ + success: true, + message: isFirstUser ? '注册成功,已为您分配管理员权限' : '注册成功', + data: { + user: { + userId: user.userId, + username: user.username, + email: user.email, + realName: user.realName + }, + token + } + }); + } catch (error) { + console.error('注册错误:', error); + res.status(500).json({ + success: false, + message: '注册失败', + error: error.message + }); + } +}); + +router.post('/login', async (req, res) => { + try { + const { username, password } = req.body; + + if (!username || !password) { + return res.status(400).json({ + success: false, + message: '用户名和密码不能为空' + }); + } + + const user = await User.findOne({ where: { username } }); + if (!user) { + return res.status(401).json({ + success: false, + message: '用户名或密码错误' + }); + } + + if (user.status === 'locked') { + return res.status(403).json({ + success: false, + message: '账户已被锁定,请联系管理员' + }); + } + + if (user.status === 'inactive') { + return res.status(403).json({ + success: false, + message: '账户已禁用' + }); + } + + const isPasswordValid = await bcrypt.compare(password, user.password); + if (!isPasswordValid) { + user.loginCount = (user.loginCount || 0) + 1; + if (user.loginCount >= 5) { + user.status = 'locked'; + } + await user.save(); + + return res.status(401).json({ + success: false, + message: '用户名或密码错误' + }); + } + + const token = generateToken(user); + + user.lastLoginTime = new Date(); + user.lastLoginIp = req.ip || req.connection.remoteAddress; + user.loginCount = 0; + await user.save(); + + res.json({ + success: true, + message: '登录成功', + data: { + user: { + userId: user.userId, + username: user.username, + email: user.email, + realName: user.realName, + avatar: user.avatar + }, + token + } + }); + } catch (error) { + console.error('登录错误:', error); + res.status(500).json({ + success: false, + message: '登录失败', + error: error.message + }); + } +}); + +router.get('/profile', authMiddleware, async (req, res) => { + try { + const user = await User.findByPk(req.user.userId, { + attributes: { exclude: ['password'] } + }); + + if (!user) { + return res.status(404).json({ + success: false, + message: '用户不存在' + }); + } + + const roles = await Role.findAll({ + include: [{ + model: User, + where: { userId: req.user.userId }, + attributes: [] + }] + }); + + res.json({ + success: true, + data: { + user, + roles: roles.map(r => ({ + roleId: r.roleId, + roleName: r.roleName, + roleCode: r.roleCode + })) + } + }); + } catch (error) { + console.error('获取profile错误:', error); + res.status(500).json({ + success: false, + message: '获取用户信息失败' + }); + } +}); + +router.put('/profile', authMiddleware, async (req, res) => { + try { + const { email, phone, realName, avatar } = req.body; + const user = await User.findByPk(req.user.userId); + + if (!user) { + return res.status(404).json({ + success: false, + message: '用户不存在' + }); + } + + if (email !== undefined) user.email = email; + if (phone !== undefined) user.phone = phone; + if (realName !== undefined) user.realName = realName; + if (avatar !== undefined) user.avatar = avatar; + + await user.save(); + + res.json({ + success: true, + message: '更新成功', + data: { + userId: user.userId, + username: user.username, + email: user.email, + phone: user.phone, + realName: user.realName, + avatar: user.avatar + } + }); + } catch (error) { + console.error('更新profile错误:', error); + res.status(500).json({ + success: false, + message: '更新失败' + }); + } +}); + +router.put('/password', authMiddleware, async (req, res) => { + try { + const { oldPassword, newPassword } = req.body; + + if (!oldPassword || !newPassword) { + return res.status(400).json({ + success: false, + message: '旧密码和新密码都不能为空' + }); + } + + if (newPassword.length < 6) { + return res.status(400).json({ + success: false, + message: '新密码长度不能少于6个字符' + }); + } + + const user = await User.findByPk(req.user.userId); + const isPasswordValid = await bcrypt.compare(oldPassword, user.password); + + if (!isPasswordValid) { + return res.status(401).json({ + success: false, + message: '旧密码错误' + }); + } + + user.password = await bcrypt.hash(newPassword, SALT_ROUNDS); + await user.save(); + + res.json({ + success: true, + message: '密码修改成功' + }); + } catch (error) { + console.error('修改密码错误:', error); + res.status(500).json({ + success: false, + message: '密码修改失败' + }); + } +}); + +router.post('/check-admin', async (req, res) => { + try { + const userCount = await User.count(); + + res.json({ + success: true, + data: { + hasAdmin: userCount > 0, + userCount + } + }); + } catch (error) { + console.error('检查管理员错误:', error); + res.status(500).json({ + success: false, + message: '检查失败' + }); + } +}); + +module.exports = router; diff --git a/backend/routes/loginHistory.js b/backend/routes/loginHistory.js new file mode 100644 index 0000000..51434ca --- /dev/null +++ b/backend/routes/loginHistory.js @@ -0,0 +1,120 @@ +const express = require('express'); +const LoginHistory = require('../models/LoginHistory'); +const User = require('../models/User'); +const { authMiddleware } = require('../middleware/auth'); + +const router = express.Router(); + +router.get('/', authMiddleware, async (req, res) => { + try { + const { page = 1, pageSize = 10, userId, loginType, startDate, endDate } = req.query; + const offset = (parseInt(page) - 1) * parseInt(pageSize); + const limit = parseInt(pageSize); + const where = {}; + + if (userId) where.userId = userId; + if (loginType) where.loginType = loginType; + if (startDate || endDate) { + where.loginTime = {}; + if (startDate) where.loginTime[Op.gte] = new Date(startDate); + if (endDate) where.loginTime[Op.lte] = new Date(endDate); + } + + const { count, rows } = await LoginHistory.findAndCountAll({ + where, + limit, + offset, + order: [['loginTime', 'DESC']] + }); + + res.json({ + success: true, + data: { + total: count, + page: parseInt(page), + pageSize: parseInt(pageSize), + histories: rows.map(h => ({ + id: h.id, + userId: h.userId, + username: h.username, + realName: h.realName, + loginTime: h.loginTime, + loginIp: h.loginIp, + userAgent: h.userAgent, + loginType: h.loginType, + failReason: h.failReason + })) + } + }); + } catch (error) { + console.error('获取登录历史错误:', error); + res.status(500).json({ + success: false, + message: '获取登录历史失败' + }); + } +}); + +router.get('/user/:userId', authMiddleware, async (req, res) => { + try { + const { page = 1, pageSize = 10 } = req.query; + const offset = (parseInt(page) - 1) * parseInt(pageSize); + const limit = parseInt(pageSize); + + const { count, rows } = await LoginHistory.findAndCountAll({ + where: { userId: req.params.userId }, + limit, + offset, + order: [['loginTime', 'DESC']] + }); + + res.json({ + success: true, + data: { + total: count, + page: parseInt(page), + pageSize: parseInt(pageSize), + histories: rows + } + }); + } catch (error) { + console.error('获取用户登录历史错误:', error); + res.status(500).json({ + success: false, + message: '获取登录历史失败' + }); + } +}); + +router.delete('/:id', authMiddleware, async (req, res) => { + try { + await LoginHistory.destroy({ where: { id: req.params.id } }); + res.json({ success: true, message: '删除成功' }); + } catch (error) { + console.error('删除登录历史错误:', error); + res.status(500).json({ success: false, message: '删除失败' }); + } +}); + +router.delete('/', authMiddleware, async (req, res) => { + try { + const { days } = req.body; + const where = { loginType: 'success' }; + + if (days) { + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - days); + where.loginTime = { [Op.lt]: cutoffDate }; + } + + await LoginHistory.destroy({ where }); + res.json({ success: true, message: '清理成功' }); + } catch (error) { + console.error('清理登录历史错误:', error); + res.status(500).json({ success: false, message: '清理失败' }); + } +}); + +const { Op } = require('sequelize'); + +module.exports = router; diff --git a/backend/routes/operationLogs.js b/backend/routes/operationLogs.js new file mode 100644 index 0000000..2ceb94c --- /dev/null +++ b/backend/routes/operationLogs.js @@ -0,0 +1,162 @@ +const express = require('express'); +const OperationLog = require('../models/OperationLog'); +const { authMiddleware } = require('../middleware/auth'); + +const router = express.Router(); + +const ACTION_TYPES = { + USER_CREATE: '创建用户', + USER_UPDATE: '修改用户', + USER_DELETE: '删除用户', + USER_LOGIN: '用户登录', + USER_LOGOUT: '用户登出', + ROLE_CREATE: '创建角色', + ROLE_UPDATE: '修改角色', + ROLE_DELETE: '删除角色', + ROLE_ASSIGN: '分配角色', + DEVICE_CREATE: '创建设备', + DEVICE_UPDATE: '修改设备', + DEVICE_DELETE: '删除设备', + CONSUMABLE_IN: '耗材入库', + CONSUMABLE_OUT: '耗材出库', + CONSUMABLE_RECORD: '耗材记录', + SYSTEM_CONFIG: '系统配置' +}; + +router.get('/', authMiddleware, async (req, res) => { + try { + const { page = 1, pageSize = 20, userId, action, module, startDate, endDate } = req.query; + const offset = (parseInt(page) - 1) * parseInt(pageSize); + const limit = parseInt(pageSize); + const where = {}; + + if (userId) where.userId = userId; + if (action) where.action = action; + if (module) where.module = module; + if (startDate || endDate) { + where.operateTime = {}; + if (startDate) where.operateTime[Op.gte] = new Date(startDate); + if (endDate) where.operateTime[Op.lte] = new Date(endDate); + } + + const { count, rows } = await OperationLog.findAndCountAll({ + where, + limit, + offset, + order: [['operateTime', 'DESC']] + }); + + res.json({ + success: true, + data: { + total: count, + page: parseInt(page), + pageSize: parseInt(pageSize), + logs: rows.map(l => ({ + id: l.id, + userId: l.userId, + username: l.username, + realName: l.realName, + action: l.action, + module: l.module, + description: l.description, + targetId: l.targetId, + targetName: l.targetName, + oldValue: l.oldValue, + newValue: l.newValue, + ip: l.ip, + status: l.status, + errorMessage: l.errorMessage, + operateTime: l.operateTime + })) + } + }); + } catch (error) { + console.error('获取操作日志错误:', error); + res.status(500).json({ + success: false, + message: '获取操作日志失败' + }); + } +}); + +router.get('/actions', authMiddleware, (req, res) => { + res.json({ + success: true, + data: Object.entries(ACTION_TYPES).map(([key, value]) => ({ + key, + value, + label: value + })) + }); +}); + +router.get('/modules', authMiddleware, (req, res) => { + res.json({ + success: true, + data: [ + { key: 'user', value: 'user', label: '用户管理' }, + { key: 'role', value: 'role', label: '角色管理' }, + { key: 'device', value: 'device', label: '设备管理' }, + { key: 'consumable', value: 'consumable', label: '耗材管理' }, + { key: 'system', value: 'system', label: '系统设置' } + ] + }); +}); + +router.delete('/:id', authMiddleware, async (req, res) => { + try { + await OperationLog.destroy({ where: { id: req.params.id } }); + res.json({ success: true, message: '删除成功' }); + } catch (error) { + console.error('删除操作日志错误:', error); + res.status(500).json({ success: false, message: '删除失败' }); + } +}); + +router.delete('/', authMiddleware, async (req, res) => { + try { + const { days } = req.body; + const where = {}; + + if (days) { + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - days); + where.operateTime = { [Op.lt]: cutoffDate }; + } + + await OperationLog.destroy({ where }); + res.json({ success: true, message: '清理成功' }); + } catch (error) { + console.error('清理操作日志错误:', error); + res.status(500).json({ success: false, message: '清理失败' }); + } +}); + +const logOperation = async (req, action, module, description, targetId, targetName, oldValue, newValue, status = 'success', errorMessage = null) => { + try { + await OperationLog.create({ + userId: req.user?.userId, + username: req.user?.username, + realName: req.userModel?.realName, + action, + module, + description, + targetId, + targetName, + oldValue: oldValue ? JSON.stringify(oldValue) : null, + newValue: newValue ? JSON.stringify(newValue) : null, + ip: req.ip, + status, + errorMessage + }); + } catch (error) { + console.error('记录操作日志错误:', error); + } +}; + +module.exports = router; +module.exports.ACTION_TYPES = ACTION_TYPES; +module.exports.logOperation = logOperation; + +const { Op } = require('sequelize'); diff --git a/backend/routes/roles.js b/backend/routes/roles.js new file mode 100644 index 0000000..675c949 --- /dev/null +++ b/backend/routes/roles.js @@ -0,0 +1,275 @@ +const express = require('express'); +const Role = require('../models/Role'); +const Permission = require('../models/Permission'); +const UserRole = require('../models/UserRole'); +const User = require('../models/User'); +const { authMiddleware } = require('../middleware/auth'); + +const router = express.Router(); + +const generateId = () => { + return 'role_' + Date.now().toString(36) + Math.random().toString(36).substr(2, 9); +}; + +const { Op } = require('sequelize'); + +router.get('/', authMiddleware, async (req, res) => { + try { + const { page = 1, pageSize = 10, roleName, status } = req.query; + const offset = (parseInt(page) - 1) * parseInt(pageSize); + const limit = parseInt(pageSize); + + const where = {}; + if (roleName) { + where.roleName = { [Op.like]: `%${roleName}%` }; + } + if (status) { + where.status = status; + } + + const { count, rows: roles } = await Role.findAndCountAll({ + where, + limit, + offset, + order: [['sort', 'ASC'], ['createdAt', 'DESC']] + }); + + res.json({ + success: true, + data: { + total: count, + page: parseInt(page), + pageSize: parseInt(pageSize), + roles + } + }); + } catch (error) { + console.error('获取角色列表错误:', error); + res.status(500).json({ + success: false, + message: '获取角色列表失败' + }); + } +}); + +router.get('/all', authMiddleware, async (req, res) => { + try { + const roles = await Role.findAll({ + where: { status: 'active' }, + order: [['sort', 'ASC']] + }); + + res.json({ + success: true, + data: roles + }); + } catch (error) { + console.error('获取所有角色错误:', error); + res.status(500).json({ + success: false, + message: '获取角色列表失败' + }); + } +}); + +router.get('/:roleId', authMiddleware, async (req, res) => { + try { + const role = await Role.findByPk(req.params.roleId); + + if (!role) { + return res.status(404).json({ + success: false, + message: '角色不存在' + }); + } + + const permissions = await Permission.findAll({ + where: { status: 'active' }, + order: [['sort', 'ASC']] + }); + + res.json({ + success: true, + data: { + role, + permissions, + rolePermissions: role.permissions || [] + } + }); + } catch (error) { + console.error('获取角色详情错误:', error); + res.status(500).json({ + success: false, + message: '获取角色详情失败' + }); + } +}); + +router.post('/', authMiddleware, async (req, res) => { + try { + const { roleName, roleCode, description, permissions, status, sort } = req.body; + + if (!roleName || !roleCode) { + return res.status(400).json({ + success: false, + message: '角色名称和角色编码不能为空' + }); + } + + const existingRole = await Role.findOne({ where: { roleCode } }); + if (existingRole) { + return res.status(400).json({ + success: false, + message: '角色编码已存在' + }); + } + + const role = await Role.create({ + roleId: generateId(), + roleName, + roleCode, + description, + permissions: permissions || [], + status: status || 'active', + sort: sort || 0 + }); + + res.status(201).json({ + success: true, + message: '创建成功', + data: role + }); + } catch (error) { + console.error('创建角色错误:', error); + res.status(500).json({ + success: false, + message: '创建角色失败' + }); + } +}); + +router.put('/:roleId', authMiddleware, async (req, res) => { + try { + const { roleName, description, permissions, status, sort } = req.body; + const role = await Role.findByPk(req.params.roleId); + + if (!role) { + return res.status(404).json({ + success: false, + message: '角色不存在' + }); + } + + if (roleName !== undefined) role.roleName = roleName; + if (description !== undefined) role.description = description; + if (permissions !== undefined) role.permissions = permissions; + if (status !== undefined) role.status = status; + if (sort !== undefined) role.sort = sort; + + await role.save(); + + res.json({ + success: true, + message: '更新成功', + data: role + }); + } catch (error) { + console.error('更新角色错误:', error); + res.status(500).json({ + success: false, + message: '更新角色失败' + }); + } +}); + +router.delete('/:roleId', authMiddleware, async (req, res) => { + try { + const role = await Role.findByPk(req.params.roleId); + + if (!role) { + return res.status(404).json({ + success: false, + message: '角色不存在' + }); + } + + if (role.roleCode === 'admin') { + return res.status(400).json({ + success: false, + message: '不能删除管理员角色' + }); + } + + const userCount = await UserRole.count({ where: { RoleId: role.roleId } }); + if (userCount > 0) { + return res.status(400).json({ + success: false, + message: '该角色下有用户,不能删除' + }); + } + + await role.destroy(); + + res.json({ + success: true, + message: '删除成功' + }); + } catch (error) { + console.error('删除角色错误:', error); + res.status(500).json({ + success: false, + message: '删除角色失败' + }); + } +}); + +router.post('/init-roles', async (req, res) => { + try { + const defaultRoles = [ + { + roleId: 'role_admin', + roleName: '管理员', + roleCode: 'admin', + description: '系统管理员,拥有所有权限', + permissions: ['*'], + status: 'active', + sort: 1 + }, + { + roleId: 'role_operator', + roleName: '运维人员', + roleCode: 'operator', + description: '负责日常运维操作', + permissions: ['devices:read', 'devices:write', 'racks:read', 'rooms:read', 'consumables:read', 'consumables:write'], + status: 'active', + sort: 2 + }, + { + roleId: 'role_viewer', + roleName: '只读用户', + roleCode: 'viewer', + description: '仅能查看数据', + permissions: ['devices:read', 'racks:read', 'rooms:read', 'consumables:read'], + status: 'active', + sort: 3 + } + ]; + + for (const roleData of defaultRoles) { + await Role.upsert(roleData); + } + + res.json({ + success: true, + message: '初始化角色成功' + }); + } catch (error) { + console.error('初始化角色错误:', error); + res.status(500).json({ + success: false, + message: '初始化角色失败' + }); + } +}); + +module.exports = router; diff --git a/backend/routes/users.js b/backend/routes/users.js new file mode 100644 index 0000000..5fe5e44 --- /dev/null +++ b/backend/routes/users.js @@ -0,0 +1,450 @@ +const express = require('express'); +const bcrypt = require('bcryptjs'); +const path = require('path'); +const fs = require('fs'); +const User = require('../models/User'); +const Role = require('../models/Role'); +const UserRole = require('../models/UserRole'); +const { authMiddleware } = require('../middleware/auth'); + +const router = express.Router(); + +const SALT_ROUNDS = 10; + +const generateId = () => { + return 'user_' + Date.now().toString(36) + Math.random().toString(36).substr(2, 9); +}; + +const getWhereClause = (query) => { + const where = {}; + + if (query.username) { + where.username = { [Op.like]: `%${query.username}%` }; + } + + if (query.status) { + where.status = query.status; + } + + if (query.realName) { + where.realName = { [Op.like]: `%${query.realName}%` }; + } + + return where; +}; + +const { Op } = require('sequelize'); + +router.get('/', authMiddleware, async (req, res) => { + try { + const { page = 1, pageSize = 10, username, status, realName } = req.query; + const offset = (parseInt(page) - 1) * parseInt(pageSize); + const limit = parseInt(pageSize); + + const where = getWhereClause({ username, status, realName }); + + const { count, rows: users } = await User.findAndCountAll({ + where, + attributes: { exclude: ['password'] }, + limit, + offset, + order: [['createdAt', 'DESC']] + }); + + for (const user of users) { + const userRoles = await UserRole.findAll({ + include: [{ + model: Role, + where: { status: 'active' } + }], + where: { UserId: user.userId } + }); + + user.dataValues.roles = userRoles.map(ur => ({ + roleId: ur.Role.roleId, + roleName: ur.Role.roleName, + roleCode: ur.Role.roleCode + })); + } + + res.json({ + success: true, + data: { + total: count, + page: parseInt(page), + pageSize: parseInt(pageSize), + users + } + }); + } catch (error) { + console.error('获取用户列表错误:', error); + res.status(500).json({ + success: false, + message: '获取用户列表失败' + }); + } +}); + +router.get('/all', authMiddleware, async (req, res) => { + try { + const users = await User.findAll({ + where: { status: 'active' }, + attributes: ['userId', 'username', 'realName', 'email'], + order: [['realName', 'ASC']] + }); + + res.json({ + success: true, + data: users + }); + } catch (error) { + console.error('获取所有用户错误:', error); + res.status(500).json({ + success: false, + message: '获取用户列表失败' + }); + } +}); + +router.get('/:userId', authMiddleware, async (req, res) => { + try { + const user = await User.findByPk(req.params.userId, { + attributes: { exclude: ['password'] } + }); + + if (!user) { + return res.status(404).json({ + success: false, + message: '用户不存在' + }); + } + + const userRoles = await UserRole.findAll({ + include: [{ + model: Role, + where: { status: 'active' } + }], + where: { UserId: user.userId } + }); + + user.dataValues.roles = userRoles.map(ur => ({ + roleId: ur.Role.roleId, + roleName: ur.Role.roleName, + roleCode: ur.Role.roleCode + })); + + res.json({ + success: true, + data: user + }); + } catch (error) { + console.error('获取用户详情错误:', error); + res.status(500).json({ + success: false, + message: '获取用户详情失败' + }); + } +}); + +router.post('/', authMiddleware, async (req, res) => { + try { + const { username, password, email, phone, realName, roleIds, status, remark } = req.body; + + if (!username || !password) { + return res.status(400).json({ + success: false, + message: '用户名和密码不能为空' + }); + } + + const existingUser = await User.findOne({ where: { username } }); + if (existingUser) { + return res.status(400).json({ + success: false, + message: '用户名已存在' + }); + } + + const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS); + + const user = await User.create({ + userId: generateId(), + username, + password: hashedPassword, + email, + phone, + realName: realName || username, + status: status || 'active', + remark + }); + + if (roleIds && roleIds.length > 0) { + for (const roleId of roleIds) { + await UserRole.create({ + UserId: user.userId, + RoleId: roleId + }); + } + } + + res.status(201).json({ + success: true, + message: '创建成功', + data: { + userId: user.userId, + username: user.username, + email: user.email, + realName: user.realName, + status: user.status + } + }); + } catch (error) { + console.error('创建用户错误:', error); + res.status(500).json({ + success: false, + message: '创建用户失败' + }); + } +}); + +router.put('/:userId', authMiddleware, async (req, res) => { + try { + const { username, email, phone, realName, roleIds, status, remark, newPassword } = req.body; + const user = await User.findByPk(req.params.userId); + + if (!user) { + return res.status(404).json({ + success: false, + message: '用户不存在' + }); + } + + if (username !== undefined && username !== user.username) { + const existingUser = await User.findOne({ + where: { username, userId: { [Op.ne]: user.userId } } + }); + if (existingUser) { + return res.status(400).json({ + success: false, + message: '用户名已存在' + }); + } + user.username = username; + } + + if (email !== undefined) user.email = email; + if (phone !== undefined) user.phone = phone; + if (realName !== undefined) user.realName = realName; + if (status !== undefined) user.status = status; + if (remark !== undefined) user.remark = remark; + + if (newPassword && newPassword.length >= 6) { + user.password = await bcrypt.hash(newPassword, SALT_ROUNDS); + } + + await user.save(); + + if (roleIds !== undefined) { + await UserRole.destroy({ where: { UserId: user.userId } }); + + for (const roleId of roleIds) { + await UserRole.create({ + UserId: user.userId, + RoleId: roleId + }); + } + } + + const updatedUser = await User.findByPk(req.params.userId, { + attributes: { exclude: ['password'] } + }); + + res.json({ + success: true, + message: '更新成功', + data: updatedUser + }); + } catch (error) { + console.error('更新用户错误:', error); + res.status(500).json({ + success: false, + message: '更新用户失败' + }); + } +}); + +router.put('/:userId/password', authMiddleware, async (req, res) => { + try { + const { newPassword } = req.body; + const user = await User.findByPk(req.params.userId); + + if (!user) { + return res.status(404).json({ + success: false, + message: '用户不存在' + }); + } + + if (!newPassword || newPassword.length < 6) { + return res.status(400).json({ + success: false, + message: '密码长度不能少于6个字符' + }); + } + + user.password = await bcrypt.hash(newPassword, SALT_ROUNDS); + await user.save(); + + res.json({ + success: true, + message: '密码重置成功' + }); + } catch (error) { + console.error('重置密码错误:', error); + res.status(500).json({ + success: false, + message: '重置密码失败' + }); + } +}); + +router.delete('/:userId', authMiddleware, async (req, res) => { + try { + const user = await User.findByPk(req.params.userId); + + if (!user) { + return res.status(404).json({ + success: false, + message: '用户不存在' + }); + } + + if (user.userId === req.user.userId) { + return res.status(400).json({ + success: false, + message: '不能删除当前登录用户' + }); + } + + await UserRole.destroy({ where: { UserId: user.userId } }); + await user.destroy(); + + res.json({ + success: true, + message: '删除成功' + }); + } catch (error) { + console.error('删除用户错误:', error); + res.status(500).json({ + success: false, + message: '删除用户失败' + }); + } +}); + +router.post('/:userId/avatar', authMiddleware, async (req, res) => { + try { + const user = await User.findByPk(req.params.userId); + + if (!user) { + return res.status(404).json({ + success: false, + message: '用户不存在' + }); + } + + if (!req.files || !req.files.avatar) { + return res.status(400).json({ + success: false, + message: '请选择要上传的头像文件' + }); + } + + const avatarFile = req.files.avatar; + const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; + + if (!allowedTypes.includes(avatarFile.mimetype)) { + return res.status(400).json({ + success: false, + message: '只支持 JPG、PNG、GIF 和 WebP 格式的图片' + }); + } + + if (avatarFile.size > 5 * 1024 * 1024) { + return res.status(400).json({ + success: false, + message: '图片大小不能超过 5MB' + }); + } + + const uploadDir = path.join(__dirname, '..', 'uploads', 'avatars'); + if (!fs.existsSync(uploadDir)) { + fs.mkdirSync(uploadDir, { recursive: true }); + } + + const ext = path.extname(avatarFile.name) || '.jpg'; + const filename = `avatar_${user.userId}_${Date.now()}${ext}`; + const filepath = path.join(uploadDir, filename); + + await avatarFile.mv(filepath); + + if (user.avatar && user.avatar.startsWith('/uploads/avatars/')) { + const oldFile = path.join(__dirname, '..', user.avatar); + if (fs.existsSync(oldFile)) { + fs.unlinkSync(oldFile); + } + } + + const avatarUrl = `/uploads/avatars/${filename}`; + user.avatar = avatarUrl; + await user.save(); + + res.json({ + success: true, + message: '头像上传成功', + data: { avatar: avatarUrl } + }); + } catch (error) { + console.error('上传头像错误:', error); + res.status(500).json({ + success: false, + message: '上传头像失败' + }); + } +}); + +router.delete('/:userId/avatar', authMiddleware, async (req, res) => { + try { + const user = await User.findByPk(req.params.userId); + + if (!user) { + return res.status(404).json({ + success: false, + message: '用户不存在' + }); + } + + if (user.avatar && user.avatar.startsWith('/uploads/avatars/')) { + const oldFile = path.join(__dirname, '..', user.avatar); + if (fs.existsSync(oldFile)) { + fs.unlinkSync(oldFile); + } + } + + user.avatar = null; + await user.save(); + + res.json({ + success: true, + message: '头像删除成功' + }); + } catch (error) { + console.error('删除头像错误:', error); + res.status(500).json({ + success: false, + message: '删除头像失败' + }); + } +}); + +module.exports = router; diff --git a/backend/server.js b/backend/server.js index 06565d0..752bf68 100644 --- a/backend/server.js +++ b/backend/server.js @@ -42,6 +42,11 @@ const backgroundRoutes = require('./routes/background'); const consumableRoutes = require('./routes/consumables'); const consumableRecordRoutes = require('./routes/consumableRecords'); const consumableCategoryRoutes = require('./routes/consumableCategories'); +const authRoutes = require('./routes/auth'); +const usersRoutes = require('./routes/users'); +const rolesRoutes = require('./routes/roles'); +const loginHistoryRoutes = require('./routes/loginHistory'); +const operationLogsRoutes = require('./routes/operationLogs'); // 使用路由 app.use('/api/devices', deviceRoutes); @@ -52,6 +57,11 @@ app.use('/api/background', backgroundRoutes); app.use('/api/consumables', consumableRoutes); app.use('/api/consumable-records', consumableRecordRoutes); app.use('/api/consumable-categories', consumableCategoryRoutes); +app.use('/api/auth', authRoutes); +app.use('/api/users', usersRoutes); +app.use('/api/roles', rolesRoutes); +app.use('/api/login-history', loginHistoryRoutes); +app.use('/api/operation-logs', operationLogsRoutes); // 静态文件服务 app.use('/uploads', express.static('uploads')); diff --git a/backend/test_inout.ps1 b/backend/test_inout.ps1 deleted file mode 100644 index f46d88e..0000000 --- a/backend/test_inout.ps1 +++ /dev/null @@ -1,13 +0,0 @@ -$postData = @{ - consumableId = "CON1766493042245" - type = "in" - quantity = 5 - operator = "测试管理员" - reason = "测试入库" - notes = "测试日志记录功能" -} | ConvertTo-Json - -$response = Invoke-RestMethod -Uri "http://localhost:8000/api/consumables/quick-inout" -Method Post -Body $postData -ContentType "application/json" - -Write-Host "入库API响应:" -$response | ConvertTo-Json -Depth 5 diff --git a/backend/uploads/avatars/avatar_user_mjkrahqi31ud7tdo5_1766628699440.jpg b/backend/uploads/avatars/avatar_user_mjkrahqi31ud7tdo5_1766628699440.jpg new file mode 100644 index 0000000..31e2e8a Binary files /dev/null and b/backend/uploads/avatars/avatar_user_mjkrahqi31ud7tdo5_1766628699440.jpg differ diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index f2c00f0..b388a80 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,7 +1,8 @@ import React, { useState } from 'react'; -import { Layout, Menu, theme, Button } from 'antd'; -import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom'; -import { BarChartOutlined, DatabaseOutlined, CloudServerOutlined, MenuUnfoldOutlined, MenuFoldOutlined, EyeOutlined, BuildOutlined, HomeOutlined, ShoppingCartOutlined, InboxOutlined, ImportOutlined, FileTextOutlined } from '@ant-design/icons'; +import { Layout, Menu, theme, Button, Dropdown, Avatar, message, Space, Divider } from 'antd'; +import { BarChartOutlined, DatabaseOutlined, CloudServerOutlined, MenuUnfoldOutlined, MenuFoldOutlined, EyeOutlined, BuildOutlined, HomeOutlined, ShoppingCartOutlined, InboxOutlined, ImportOutlined, FileTextOutlined, UserOutlined, LogoutOutlined, UserOutlined as UserIcon, HistoryOutlined, AuditOutlined } from '@ant-design/icons'; +import { BrowserRouter as Router, Routes, Route, Link, Navigate, useLocation, useNavigate } from 'react-router-dom'; +import { useAuth } from './context/AuthContext'; import Dashboard from './pages/Dashboard'; import DeviceManagement from './pages/DeviceManagement'; import RackManagement from './pages/RackManagement'; @@ -12,155 +13,386 @@ import ConsumableManagement from './pages/ConsumableManagement'; import ConsumableStatistics from './pages/ConsumableStatistics'; import ConsumableLogs from './pages/ConsumableLogs'; import CategoryManagement from './pages/CategoryManagement'; +import UserManagement from './pages/UserManagement'; +import LoginHistory from './pages/LoginHistory'; +import OperationLogs from './pages/OperationLogs'; +import Login from './pages/Login'; +import { Spin } from 'antd'; -const { Content, Sider } = Layout; +const { Header, Content, Sider } = Layout; -function App() { +const PrivateRoute = ({ children }) => { + const { token, initialized, loading } = useAuth(); + const location = useLocation(); + + if (!initialized) { + return ( +
+ +
+ ); + } + + if (!token) { + return ; + } + + return children; +}; + +const AppLayout = ({ children }) => { const [collapsed, setCollapsed] = useState(false); + const { user, logout } = useAuth(); + const navigate = useNavigate(); const { token: { colorBgContainer, borderRadiusLG }, } = theme.useToken(); + const handleLogout = () => { + logout(); + message.success('已退出登录'); + navigate('/login'); + }; + + const userMenuItems = [ + { + key: 'logout', + icon: , + label: '退出登录', + onClick: handleLogout + } + ]; + return ( - - - + +
+
+ + , + label: 仪表盘, + }, + { + key: 'room-management', + icon: , + label: '机房管理', + children: [ + { + key: 'rooms', + icon: , + label: 机房管理, + }, + { + key: 'racks', + icon: , + label: 机柜管理, + }, + { + key: 'visualization', + icon: , + label: 机柜可视化, + }, + ], + }, + { + key: 'asset-management', + icon: , + label: '资产管理', + children: [ + { + key: 'devices', + icon: , + label: 设备管理, + }, + { + key: 'fields', + icon: , + label: 字段管理, + }, + ], + }, + { + key: 'consumables-management', + icon: , + label: '耗材管理', + children: [ + { + key: 'consumables-stats', + icon: , + label: 耗材统计, + }, + { + key: 'consumables', + icon: , + label: 耗材列表, + }, + { + key: 'consumables-categories', + icon: , + label: 分类管理, + }, + { + key: 'consumables-logs', + icon: , + label: 操作日志, + }, + ], + }, + { + key: 'system-management', + icon: , + label: '系统管理', + children: [ + { + key: 'users', + icon: , + label: 用户管理, + }, + { + key: 'login-history', + icon: , + label: 登录历史, + }, + { + key: 'operation-logs', + icon: , + label: 操作日志, + }, + ], + }, + ]} + /> + + +
+ {user && ( + + } + /> + {user.username} + + + + )} +
+ -
-
- , - label: 仪表盘, - }, - { - key: 'room-management', - icon: , - label: '机房管理', - children: [ - { - key: 'rooms', - icon: , - label: 机房管理, - }, - { - key: 'racks', - icon: , - label: 机柜管理, - }, - { - key: 'visualization', - icon: , - label: 机柜可视化, - }, - ], - }, - { - key: 'asset-management', - icon: , - label: '资产管理', - children: [ - { - key: 'devices', - icon: , - label: 设备管理, - }, - { - key: 'fields', - icon: , - label: 字段管理, - }, - ], - }, - { - key: 'consumables-management', - icon: , - label: '耗材管理', - children: [ - { - key: 'consumables-stats', - icon: , - label: 耗材统计, - }, - { - key: 'consumables', - icon: , - label: 耗材列表, - }, - { - key: 'consumables-categories', - icon: , - label: 分类管理, - }, - { - key: 'consumables-logs', - icon: , - label: 操作日志, - }, - ], - }, - ]} - /> - - - - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - + {children} + + + ); +}; + +function App() { + return ( + + + } /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + } /> + ); } diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js new file mode 100644 index 0000000..b8bec67 --- /dev/null +++ b/frontend/src/api/index.js @@ -0,0 +1,114 @@ +import axios from 'axios'; + +const API_BASE_URL = '/api'; + +const api = axios.create({ + baseURL: API_BASE_URL, + timeout: 30000, + headers: { + 'Content-Type': 'application/json' + } +}); + +api.interceptors.request.use( + (config) => { + const token = localStorage.getItem('token'); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } else { + console.log('[API] No token found in localStorage'); + } + return config; + }, + (error) => { + return Promise.reject(error); + } +); + +api.interceptors.response.use( + (response) => { + return response.data; + }, + (error) => { + if (error.response) { + const { status, data } = error.response; + + if (status === 401) { + const currentPath = window.location.pathname; + console.log('[API] 401 error, current path:', currentPath); + + if (!currentPath.startsWith('/login')) { + const savedToken = localStorage.getItem('token'); + if (savedToken) { + console.log('[API] Token exists but got 401, might be expired'); + } + localStorage.removeItem('token'); + localStorage.removeItem('user'); + window.location.href = '/login'; + } + } + + return Promise.reject(data.message || '请求失败'); + } + + if (error.code === 'ECONNABORTED') { + return Promise.reject('请求超时,请稍后重试'); + } + + return Promise.reject('网络错误,请检查网络连接'); + } +); + +export const authAPI = { + checkAdmin: () => api.get('/auth/check-admin'), + register: (data) => api.post('/auth/register', data), + login: (data) => api.post('/auth/login', data), + getProfile: () => api.get('/auth/profile'), + updateProfile: (data) => api.put('/auth/profile', data), + changePassword: (data) => api.put('/auth/password', data) +}; + +export const userAPI = { + list: (params) => api.get('/users', { params }), + all: () => api.get('/users/all'), + get: (userId) => api.get(`/users/${userId}`), + create: (data) => api.post('/users', data), + update: (userId, data) => api.put(`/users/${userId}`, data), + resetPassword: (userId, data) => api.put(`/users/${userId}/password`, data), + delete: (userId) => api.delete(`/users/${userId}`), + uploadAvatar: (userId, file) => { + const formData = new FormData(); + formData.append('avatar', file); + return api.post(`/users/${userId}/avatar`, formData, { + headers: { 'Content-Type': 'multipart/form-data' } + }); + }, + deleteAvatar: (userId) => api.delete(`/users/${userId}/avatar`) +}; + +export const roleAPI = { + list: (params) => api.get('/roles', { params }), + all: () => api.get('/roles/all'), + get: (roleId) => api.get(`/roles/${roleId}`), + create: (data) => api.post('/roles', data), + update: (roleId, data) => api.put(`/roles/${roleId}`, data), + delete: (roleId) => api.delete(`/roles/${roleId}`), + initRoles: () => api.post('/roles/init-roles') +}; + +export const loginHistoryAPI = { + list: (params) => api.get('/login-history', { params }), + getByUser: (userId, params) => api.get(`/login-history/user/${userId}`, { params }), + delete: (id) => api.delete(`/login-history/${id}`), + clear: (data) => api.delete('/login-history', { data }) +}; + +export const operationLogAPI = { + list: (params) => api.get('/operation-logs', { params }), + getActions: () => api.get('/operation-logs/actions'), + getModules: () => api.get('/operation-logs/modules'), + delete: (id) => api.delete(`/operation-logs/${id}`), + clear: (data) => api.delete('/operation-logs', { data }) +}; + +export default api; diff --git a/frontend/src/components/ProtectedRoute.jsx b/frontend/src/components/ProtectedRoute.jsx new file mode 100644 index 0000000..c555c94 --- /dev/null +++ b/frontend/src/components/ProtectedRoute.jsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { Navigate, useLocation } from 'react-router-dom'; +import { Spin } from 'antd'; +import { useAuth } from '../context/AuthContext'; + +const ProtectedRoute = ({ children, requiredPermission }) => { + const { user, token, loading, initialized } = useAuth(); + const location = useLocation(); + + if (!initialized) { + return ( +
+ +
+ ); + } + + if (!token) { + return ; + } + + if (requiredPermission && !user) { + return ; + } + + return children; +}; + +export default ProtectedRoute; diff --git a/frontend/src/context/AuthContext.jsx b/frontend/src/context/AuthContext.jsx new file mode 100644 index 0000000..7d8322b --- /dev/null +++ b/frontend/src/context/AuthContext.jsx @@ -0,0 +1,155 @@ +import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'; +import { authAPI } from '../api'; + +const AuthContext = createContext(null); + +export const useAuth = () => { + const context = useContext(AuthContext); + if (!context) { + throw new Error('useAuth必须在AuthProvider内部使用'); + } + return context; +}; + +export const AuthProvider = ({ children }) => { + console.log('[AuthContext] Initializing...'); + + const savedToken = localStorage.getItem('token'); + const savedUser = localStorage.getItem('user'); + + console.log('[AuthContext] Saved token:', savedToken ? 'exists' : 'null'); + console.log('[AuthContext] Saved user:', savedUser ? 'exists' : 'null'); + + const [user, setUser] = useState(() => { + try { + if (savedUser) { + return JSON.parse(savedUser); + } + } catch (e) { + console.error('[AuthContext] Parse user error:', e); + } + return null; + }); + + const [token, setToken] = useState(() => savedToken); + const [loading, setLoading] = useState(false); + const [initialized, setInitialized] = useState(false); + + useEffect(() => { + const currentToken = localStorage.getItem('token'); + const currentUser = localStorage.getItem('user'); + + if (currentToken && currentToken === token) { + if (currentUser) { + try { + const parsedUser = JSON.parse(currentUser); + if (JSON.stringify(parsedUser) !== JSON.stringify(user)) { + setUser(parsedUser); + } + } catch (e) { + console.error('[AuthContext] Parse user error:', e); + } + } + setInitialized(true); + } else if (!currentToken) { + setToken(null); + setUser(null); + setInitialized(true); + } else { + setToken(currentToken); + setInitialized(true); + } + }, []); + + const fetchProfile = useCallback(async () => { + const currentToken = localStorage.getItem('token'); + if (!currentToken) { + setLoading(false); + return; + } + + try { + const response = await authAPI.getProfile(); + if (response.success) { + setUser(response.data.user); + } + } catch (error) { + console.error('获取用户信息失败:', error); + } finally { + setLoading(false); + } + }, []); + + const login = async (username, password) => { + try { + const response = await authAPI.login({ username, password }); + if (response.success) { + const { token: newToken, user: userData } = response.data; + localStorage.setItem('token', newToken); + localStorage.setItem('user', JSON.stringify(userData)); + setToken(newToken); + setUser(userData); + return { success: true }; + } + return { success: false, message: response.message }; + } catch (error) { + return { success: false, message: error }; + } + }; + + const register = async (userData) => { + try { + const response = await authAPI.register(userData); + if (response.success) { + const { token: newToken, user: newUser } = response.data; + localStorage.setItem('token', newToken); + localStorage.setItem('user', JSON.stringify(newUser)); + setToken(newToken); + setUser(newUser); + return { success: true, isFirstUser: response.data.isFirstUser }; + } + return { success: false, message: response.message }; + } catch (error) { + return { success: false, message: error }; + } + }; + + const logout = useCallback(() => { + localStorage.removeItem('token'); + localStorage.removeItem('user'); + setToken(null); + setUser(null); + }, []); + + const updateUser = (newUserData) => { + const updatedUser = { ...user, ...newUserData }; + setUser(updatedUser); + localStorage.setItem('user', JSON.stringify(updatedUser)); + }; + + const hasPermission = (permission) => { + if (!user) return false; + return true; + }; + + const value = { + user, + token, + loading, + initialized, + login, + register, + logout, + updateUser, + hasPermission, + checkAdmin: () => authAPI.checkAdmin() + }; + + return ( + + {children} + + ); +}; + +export default AuthContext; diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 013ef3a..a402382 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -1,10 +1,13 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App'; +import { AuthProvider } from './context/AuthContext'; import './index.css'; ReactDOM.createRoot(document.getElementById('root')).render( - + + + ); \ No newline at end of file diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx new file mode 100644 index 0000000..88fc8c9 --- /dev/null +++ b/frontend/src/pages/Login.jsx @@ -0,0 +1,303 @@ +import React, { useState, useEffect } from 'react'; +import { Form, Input, Button, Card, message, Typography, Tabs, Divider, Space, Modal, Alert } from 'antd'; +import { UserOutlined, LockOutlined, MailOutlined, PhoneOutlined, SafetyCertificateOutlined, RobotOutlined } from '@ant-design/icons'; +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '../context/AuthContext'; + +const { Title, Text } = Typography; + +const Login = () => { + const [loading, setLoading] = useState(false); + const [isFirstUser, setIsFirstUser] = useState(false); + const [registerMode, setRegisterMode] = useState(false); + const { login, register, checkAdmin } = useAuth(); + const navigate = useNavigate(); + + useEffect(() => { + checkIsFirstUser(); + }, []); + + const checkIsFirstUser = async () => { + try { + const response = await checkAdmin(); + if (response.success) { + setIsFirstUser(!response.data.hasAdmin); + if (!response.data.hasAdmin) { + setRegisterMode(true); + } + } + } catch (error) { + console.error('检查用户状态失败:', error); + } + }; + + const onFinishLogin = async (values) => { + setLoading(true); + try { + const result = await login(values.username, values.password); + if (result.success) { + message.success('登录成功'); + navigate('/'); + } else { + message.error(result.message || '登录失败'); + } + } catch (error) { + message.error(error || '登录失败'); + } finally { + setLoading(false); + } + }; + + const onFinishRegister = async (values) => { + if (values.password !== values.confirmPassword) { + message.error('两次输入的密码不一致'); + return; + } + + setLoading(true); + try { + const result = await register({ + username: values.username, + password: values.password, + email: values.email, + phone: values.phone, + realName: values.realName + }); + + if (result.success) { + message.success(result.isFirstUser ? '注册成功,已为您创建管理员账户' : '注册成功'); + navigate('/'); + } else { + message.error(result.message || '注册失败'); + } + } catch (error) { + message.error(error || '注册失败'); + } finally { + setLoading(false); + } + }; + + const containerStyle = { + minHeight: '100vh', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', + padding: '24px' + }; + + const cardStyle = { + width: '100%', + maxWidth: isFirstUser ? 450 : 400, + borderRadius: '16px', + boxShadow: '0 20px 60px rgba(0,0,0,0.3)' + }; + + const headerStyle = { + textAlign: 'center', + marginBottom: '32px' + }; + + const titleStyle = { + fontSize: '28px', + fontWeight: '700', + color: '#1a1a2e', + marginBottom: '8px' + }; + + const subtitleStyle = { + fontSize: '14px', + color: '#666' + }; + + const formStyle = { + marginTop: '24px' + }; + + const submitButtonStyle = { + width: '100%', + height: '48px', + fontSize: '16px', + fontWeight: '600', + borderRadius: '8px' + }; + + const footerStyle = { + textAlign: 'center', + marginTop: '24px' + }; + + return ( +
+ +
+
+ +
+ + {isFirstUser ? '创建管理员账户' : 'IDC设备管理系统'} + + + {isFirstUser ? '首次使用,请创建系统管理员账户' : '请登录您的账户'} + +
+ + {isFirstUser && ( + + )} + +
+ {registerMode ? ( + <> + + } + placeholder="用户名" + /> + + + + } + placeholder="真实姓名" + /> + + + + } + placeholder="邮箱" + /> + + + + } + placeholder="手机号(可选)" + /> + + + + } + placeholder="密码" + /> + + + ({ + validator(_, value) { + if (!value || getFieldValue('password') === value) { + return Promise.resolve(); + } + return Promise.reject(new Error('两次输入的密码不一致')); + }, + }), + ]} + > + } + placeholder="确认密码" + /> + + + ) : ( + <> + + } + placeholder="用户名" + /> + + + + } + placeholder="密码" + /> + + + )} + + + + +
+ + {!isFirstUser && ( +
+ }> + + +
+ )} +
+
+ ); +}; + +export default Login; diff --git a/frontend/src/pages/LoginHistory.jsx b/frontend/src/pages/LoginHistory.jsx new file mode 100644 index 0000000..249c969 --- /dev/null +++ b/frontend/src/pages/LoginHistory.jsx @@ -0,0 +1,194 @@ +import React, { useState, useEffect } from 'react'; +import { Card, Table, Tag, Space, Button, DatePicker, Select, message, Popconfirm, Typography, Descriptions } from 'antd'; +import { ReloadOutlined, DeleteOutlined, EyeOutlined, SafetyCertificateOutlined } from '@ant-design/icons'; +import { loginHistoryAPI } from '../api'; +import dayjs from 'dayjs'; + +const { Title } = Typography; +const { RangePicker } = DatePicker; + +const LoginHistory = () => { + const [histories, setHistories] = useState([]); + const [loading, setLoading] = useState(false); + const [pagination, setPagination] = useState({ current: 1, pageSize: 10, total: 0 }); + const [filters, setFilters] = useState({}); + + useEffect(() => { + fetchHistories(); + }, [pagination.current, filters]); + + const fetchHistories = async () => { + setLoading(true); + try { + const params = { + page: pagination.current, + pageSize: pagination.pageSize, + ...filters + }; + const response = await loginHistoryAPI.list(params); + if (response.success) { + setHistories(response.data.histories); + setPagination(prev => ({ ...prev, total: response.data.total })); + } + } catch (error) { + message.error('获取登录历史失败'); + } finally { + setLoading(false); + } + }; + + const handleFilterChange = (key, value) => { + setFilters(prev => ({ ...prev, [key]: value })); + setPagination(prev => ({ ...prev, current: 1 })); + }; + + const handleDateChange = (dates) => { + if (dates) { + setFilters(prev => ({ + ...prev, + startDate: dates[0].toISOString(), + endDate: dates[1].toISOString() + })); + } else { + setFilters(prev => ({ ...prev, startDate: undefined, endDate: undefined })); + } + setPagination(prev => ({ ...prev, current: 1 })); + }; + + const handleClear = async () => { + try { + const response = await loginHistoryAPI.clear({ days: 30 }); + if (response.success) { + message.success('已清理30天前的登录记录'); + fetchHistories(); + } + } catch (error) { + message.error('清理失败'); + } + }; + + const columns = [ + { + title: '用户名', + dataIndex: 'username', + key: 'username', + width: 120 + }, + { + title: '真实姓名', + dataIndex: 'realName', + key: 'realName', + width: 100, + render: (name) => name || '-' + }, + { + title: '登录时间', + dataIndex: 'loginTime', + key: 'loginTime', + width: 180, + render: (time) => time ? dayjs(time).format('YYYY-MM-DD HH:mm:ss') : '-' + }, + { + title: 'IP地址', + dataIndex: 'loginIp', + key: 'loginIp', + width: 140, + render: (ip) => ip || '-' + }, + { + title: '登录状态', + dataIndex: 'loginType', + key: 'loginType', + width: 100, + render: (type) => ( + + {type === 'success' ? '成功' : '失败'} + + ) + }, + { + title: '失败原因', + dataIndex: 'failReason', + key: 'failReason', + width: 150, + render: (reason) => reason || '-' + }, + { + title: '浏览器', + dataIndex: 'userAgent', + key: 'userAgent', + ellipsis: true, + render: (ua) => { + if (!ua) return '-'; + let browser = 'Unknown'; + if (ua.includes('Chrome')) browser = 'Chrome'; + else if (ua.includes('Firefox')) browser = 'Firefox'; + else if (ua.includes('Safari')) browser = 'Safari'; + else if (ua.includes('Edge')) browser = 'Edge'; + return browser; + } + } + ]; + + const pageHeaderStyle = { + marginBottom: '24px', + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center' + }; + + const titleStyle = { + fontSize: '20px', + fontWeight: '600', + margin: 0 + }; + + return ( +
+
+

登录历史

+ + + + + + +
+ + + + + + + + + + `共 ${total} 条记录` + }} + onChange={(newPagination) => { + setPagination(prev => ({ ...prev, ...newPagination })); + }} + /> + + + ); +}; + +export default LoginHistory; diff --git a/frontend/src/pages/OperationLogs.jsx b/frontend/src/pages/OperationLogs.jsx new file mode 100644 index 0000000..dd9488e --- /dev/null +++ b/frontend/src/pages/OperationLogs.jsx @@ -0,0 +1,335 @@ +import React, { useState, useEffect } from 'react'; +import { Card, Table, Tag, Space, Button, DatePicker, Select, Input, message, Popconfirm, Typography, Drawer, Descriptions, Timeline } from 'antd'; +import { ReloadOutlined, DeleteOutlined, EyeOutlined, FileTextOutlined } from '@ant-design/icons'; +import { operationLogAPI } from '../api'; +import dayjs from 'dayjs'; + +const { Title } = Typography; +const { RangePicker } = DatePicker; + +const OperationLogs = () => { + const [logs, setLogs] = useState([]); + const [loading, setLoading] = useState(false); + const [pagination, setPagination] = useState({ current: 1, pageSize: 20, total: 0 }); + const [filters, setFilters] = useState({}); + const [actions, setActions] = useState([]); + const [modules, setModules] = useState([]); + const [detailVisible, setDetailVisible] = useState(false); + const [selectedLog, setSelectedLog] = useState(null); + + useEffect(() => { + fetchLogs(); + fetchOptions(); + }, [pagination.current, filters]); + + const fetchLogs = async () => { + setLoading(true); + try { + const params = { + page: pagination.current, + pageSize: pagination.pageSize, + ...filters + }; + const response = await operationLogAPI.list(params); + if (response.success) { + setLogs(response.data.logs); + setPagination(prev => ({ ...prev, total: response.data.total })); + } + } catch (error) { + message.error('获取操作日志失败'); + } finally { + setLoading(false); + } + }; + + const fetchOptions = async () => { + try { + const [actionsRes, modulesRes] = await Promise.all([ + operationLogAPI.getActions(), + operationLogAPI.getModules() + ]); + if (actionsRes.success) setActions(actionsRes.data); + if (modulesRes.success) setModules(modulesRes.data); + } catch (error) { + console.error('获取选项失败:', error); + } + }; + + const handleFilterChange = (key, value) => { + setFilters(prev => ({ ...prev, [key]: value })); + setPagination(prev => ({ ...prev, current: 1 })); + }; + + const handleDateChange = (dates) => { + if (dates) { + setFilters(prev => ({ + ...prev, + startDate: dates[0].toISOString(), + endDate: dates[1].toISOString() + })); + } else { + setFilters(prev => ({ ...prev, startDate: undefined, endDate: undefined })); + } + setPagination(prev => ({ ...prev, current: 1 })); + }; + + const handleClear = async () => { + try { + const response = await operationLogAPI.clear({ days: 30 }); + if (response.success) { + message.success('已清理30天前的日志'); + fetchLogs(); + } + } catch (error) { + message.error('清理失败'); + } + }; + + const showDetail = (log) => { + setSelectedLog(log); + setDetailVisible(true); + }; + + const getActionColor = (action) => { + if (action.includes('删除')) return 'red'; + if (action.includes('创建')) return 'green'; + if (action.includes('修改')) return 'blue'; + if (action.includes('登录')) return 'purple'; + return 'default'; + }; + + const getModuleColor = (module) => { + const colors = { + user: 'blue', + role: 'green', + device: 'orange', + consumable: 'purple', + system: 'cyan' + }; + return colors[module] || 'default'; + }; + + const columns = [ + { + title: '操作时间', + dataIndex: 'operateTime', + key: 'operateTime', + width: 180, + sorter: (a, b) => new Date(b.operateTime) - new Date(a.operateTime), + render: (time) => time ? dayjs(time).format('YYYY-MM-DD HH:mm:ss') : '-' + }, + { + title: '操作人', + key: 'operator', + width: 150, + render: (_, record) => ( +
+
{record.realName || record.username}
+
@{record.username}
+
+ ) + }, + { + title: '操作类型', + dataIndex: 'action', + key: 'action', + width: 120, + render: (action) => ( + {action || '-'} + ) + }, + { + title: '模块', + dataIndex: 'module', + key: 'module', + width: 100, + render: (module) => ( + + {module === 'user' ? '用户' : + module === 'role' ? '角色' : + module === 'device' ? '设备' : + module === 'consumable' ? '耗材' : + module === 'system' ? '系统' : module} + + ) + }, + { + title: '描述', + dataIndex: 'description', + key: 'description', + ellipsis: true + }, + { + title: '目标', + key: 'target', + width: 120, + render: (_, record) => record.targetName || record.targetId || '-' + }, + { + title: 'IP', + dataIndex: 'ip', + key: 'ip', + width: 130, + render: (ip) => ip || '-' + }, + { + title: '状态', + dataIndex: 'status', + key: 'status', + width: 80, + render: (status) => ( + + {status === 'success' ? '成功' : '失败'} + + ) + }, + { + title: '操作', + key: 'action', + width: 80, + render: (_, record) => ( + + + + + + + + + + handleFilterChange('username', value)} + allowClear + /> + handleFilterChange('module', value)} + options={modules} + fieldNames={{ label: 'label', value: 'value' }} + /> + + + + + +
`共 ${total} 条记录` + }} + onChange={(newPagination) => { + setPagination(prev => ({ ...prev, ...newPagination })); + }} + /> + + + setDetailVisible(false)} + > + {selectedLog && ( + + + {selectedLog.operateTime ? dayjs(selectedLog.operateTime).format('YYYY-MM-DD HH:mm:ss') : '-'} + + + {selectedLog.realName || selectedLog.username} (@{selectedLog.username}) + + + {selectedLog.action} + + + {selectedLog.module} + + {selectedLog.description || '-'} + + {selectedLog.targetName || selectedLog.targetId || '-'} + + {selectedLog.ip || '-'} + + + {selectedLog.status === 'success' ? '成功' : '失败'} + + + {selectedLog.errorMessage && ( + + {selectedLog.errorMessage} + + )} + + )} + {(selectedLog?.oldValue || selectedLog?.newValue) && ( +
+ 变更内容 + + {selectedLog.oldValue && ( + +
+                    {JSON.stringify(JSON.parse(selectedLog.oldValue), null, 2)}
+                  
+
+ )} + {selectedLog.newValue && ( + +
+                    {JSON.stringify(JSON.parse(selectedLog.newValue), null, 2)}
+                  
+
+ )} +
+
+ )} +
+ + ); +}; + +export default OperationLogs; diff --git a/frontend/src/pages/UserManagement.jsx b/frontend/src/pages/UserManagement.jsx new file mode 100644 index 0000000..cce06fd --- /dev/null +++ b/frontend/src/pages/UserManagement.jsx @@ -0,0 +1,567 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { Card, Table, Button, Space, Modal, Form, Input, Select, message, Tag, Popconfirm, Avatar, Tooltip, Badge } from 'antd'; +import { PlusOutlined, EditOutlined, DeleteOutlined, UserOutlined, ReloadOutlined, LockOutlined, CameraOutlined } from '@ant-design/icons'; +import { userAPI, roleAPI } from '../api'; + +const { Option } = Select; + +const UserManagement = () => { + const [users, setUsers] = useState([]); + const [roles, setRoles] = useState([]); + const [loading, setLoading] = useState(false); + const [pagination, setPagination] = useState({ current: 1, pageSize: 10, total: 0 }); + const [modalVisible, setModalVisible] = useState(false); + const [passwordModalVisible, setPasswordModalVisible] = useState(false); + const [avatarModalVisible, setAvatarModalVisible] = useState(false); + const [editingUser, setEditingUser] = useState(null); + const [passwordUser, setPasswordUser] = useState(null); + const [avatarUser, setAvatarUser] = useState(null); + const [uploadLoading, setUploadLoading] = useState(false); + const [form] = Form.useForm(); + const [passwordForm] = Form.useForm(); + const fileInputRef = useRef(null); + + useEffect(() => { + fetchUsers(); + fetchRoles(); + }, [pagination.current]); + + const fetchUsers = async () => { + setLoading(true); + try { + const response = await userAPI.list({ + page: pagination.current, + pageSize: pagination.pageSize + }); + if (response.success) { + setUsers(response.data.users); + setPagination(prev => ({ ...prev, total: response.data.total })); + } + } catch (error) { + message.error('获取用户列表失败'); + } finally { + setLoading(false); + } + }; + + const fetchRoles = async () => { + try { + const response = await roleAPI.all(); + if (response.success) { + setRoles(response.data); + } else { + message.error('获取角色列表失败: ' + (response.message || '未知错误')); + } + } catch (error) { + console.error('获取角色列表失败:', error); + message.error('获取角色列表失败,请检查网络连接'); + } + }; + + const handleAdd = () => { + setEditingUser(null); + form.resetFields(); + setModalVisible(true); + }; + + const handleEdit = (user) => { + setEditingUser(user); + form.setFieldsValue({ + username: user.username, + email: user.email, + phone: user.phone, + realName: user.realName, + status: user.status, + roleIds: user.roles?.map(r => r.roleId) || [] + }); + setModalVisible(true); + }; + + const handleResetPassword = (user) => { + setPasswordUser(user); + passwordForm.resetFields(); + setPasswordModalVisible(true); + }; + + const handleAvatarClick = (user) => { + setAvatarUser(user); + setAvatarModalVisible(true); + }; + + const handleAvatarUpload = async (e) => { + const file = e.target.files[0]; + if (!file) return; + + if (!file.type.match(/image\/(jpeg|png|gif|webp)/)) { + message.error('只支持 JPG、PNG、GIF 和 WebP 格式的图片'); + return; + } + + if (file.size > 5 * 1024 * 1024) { + message.error('图片大小不能超过 5MB'); + return; + } + + setUploadLoading(true); + try { + const response = await userAPI.uploadAvatar(avatarUser.userId, file); + if (response.success) { + message.success('头像上传成功'); + fetchUsers(); + setAvatarModalVisible(false); + } else { + message.error(response.message || '上传失败'); + } + } catch (error) { + message.error('上传失败'); + } finally { + setUploadLoading(false); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + } + }; + + const handleAvatarDelete = async () => { + try { + const response = await userAPI.deleteAvatar(avatarUser.userId); + if (response.success) { + message.success('头像已删除'); + fetchUsers(); + setAvatarModalVisible(false); + } else { + message.error(response.message || '删除失败'); + } + } catch (error) { + message.error('删除失败'); + } + }; + + const handleDelete = async (userId) => { + try { + const response = await userAPI.delete(userId); + if (response.success) { + message.success('删除成功'); + fetchUsers(); + } else { + message.error(response.message || '删除失败'); + } + } catch (error) { + message.error('删除失败'); + } + }; + + const handleSubmit = async (values) => { + try { + let response; + if (editingUser) { + response = await userAPI.update(editingUser.userId, values); + } else { + response = await userAPI.create(values); + } + + if (response.success) { + message.success(editingUser ? '更新成功' : '创建成功'); + setModalVisible(false); + fetchUsers(); + } else { + message.error(response.message || '操作失败'); + } + } catch (error) { + message.error('操作失败'); + } + }; + + const handleResetPasswordSubmit = async (values) => { + try { + const response = await userAPI.resetPassword(passwordUser.userId, values); + if (response.success) { + message.success('密码重置成功'); + setPasswordModalVisible(false); + } else { + message.error(response.message || '重置失败'); + } + } catch (error) { + message.error('重置失败'); + } + }; + + const getStatusColor = (status) => { + const colors = { + active: 'green', + inactive: 'red', + locked: 'orange' + }; + return colors[status] || 'default'; + }; + + const getStatusText = (status) => { + const texts = { + active: '正常', + inactive: '禁用', + locked: '锁定' + }; + return texts[status] || status; + }; + + const getAvatarUrl = (user) => { + if (!user?.avatar) return null; + return user.avatar; + }; + + const columns = [ + { + title: '头像', + key: 'avatar', + width: 80, + render: (_, record) => ( + + } + src={getAvatarUrl(record)} + style={{ + backgroundColor: record.avatar ? 'transparent' : '#1890ff', + cursor: 'pointer' + }} + onClick={() => handleAvatarClick(record)} + /> + + ) + }, + { + title: '用户名', + key: 'username', + width: 150, + render: (_, record) => ( +
+
{record.realName || record.username}
+
@{record.username}
+
+ ) + }, + { + title: '邮箱', + dataIndex: 'email', + key: 'email', + width: 200, + render: (email) => email || '-' + }, + { + title: '手机号', + dataIndex: 'phone', + key: 'phone', + width: 130, + render: (phone) => phone || '-' + }, + { + title: '角色', + key: 'roles', + render: (_, record) => ( + + {record.roles?.map(role => ( + + {role.roleName} + + )) || '-'} + + ) + }, + { + title: '状态', + dataIndex: 'status', + key: 'status', + render: (status) => ( + {getStatusText(status)} + ) + }, + { + title: '最后登录', + key: 'lastLogin', + render: (_, record) => ( +
+
{record.lastLoginTime ? new Date(record.lastLoginTime).toLocaleString() : '从未登录'}
+
{record.lastLoginIp || '-'}
+
+ ) + }, + { + title: '操作', + key: 'action', + render: (_, record) => ( + + + + + + + + +
`共 ${total} 条记录` + }} + onChange={(newPagination) => { + setPagination(prev => ({ ...prev, ...newPagination })); + }} + /> + + + setModalVisible(false)} + footer={null} + width={500} + > +
+ + + + + + + + + + + + + + + + + + + + + {!editingUser && ( + + + + )} + + + + + + {editingUser && ( + + + + )} + + + + + + + + +
+ + setPasswordModalVisible(false)} + footer={null} + width={400} + > +
+ + + + + + + + + + + +
+ + setAvatarModalVisible(false)} + footer={null} + width={400} + > +
+
+ + } + src={getAvatarUrl(avatarUser)} + style={{ + backgroundColor: avatarUser?.avatar ? 'transparent' : '#1890ff', + border: '1px solid #f0f0f0' + }} + /> + +
+ + + + + + + {avatarUser?.avatar && ( + + )} + + +
+ 支持 JPG、PNG、GIF、WebP 格式,大小不超过 5MB +
+
+
+ + ); +}; + +export default UserManagement;