feat: 实现用户认证与权限管理系统

- 添加用户、角色、权限等数据模型
- 实现JWT认证中间件和密码加密
- 添加用户注册、登录、个人信息管理接口
- 实现前端认证上下文和受保护路由
- 添加登录历史记录和操作日志功能
- 提供管理员初始化脚本和修复工具
- 实现完整的登录页面和用户管理界面
This commit is contained in:
zhang1106
2025-12-25 10:48:35 +08:00
parent 568f9c7db8
commit a911d02999
29 changed files with 4309 additions and 154 deletions
+33
View File
@@ -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();
+125
View File
@@ -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();
+133
View File
@@ -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();
+151
View File
@@ -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
};
+57
View File
@@ -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;
+82
View File
@@ -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;
+48
View File
@@ -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;
+40
View File
@@ -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;
+63
View File
@@ -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;
+22
View File
@@ -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;
+117
View File
@@ -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",
+2
View File
@@ -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",
+341
View File
@@ -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;
+120
View File
@@ -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;
+162
View File
@@ -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');
+275
View File
@@ -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;
+450
View File
@@ -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;
+10
View File
@@ -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'));
-13
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

+372 -140
View File
@@ -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 (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
background: '#f5f5f5'
}}>
<Spin size="large" tip="正在加载认证状态..." />
</div>
);
}
if (!token) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
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: <LogoutOutlined />,
label: '退出登录',
onClick: handleLogout
}
];
return (
<Router>
<Layout>
<Sider
width={220}
collapsedWidth={80}
collapsed={collapsed}
<Layout>
<Sider
width={220}
collapsedWidth={80}
collapsed={collapsed}
style={{
backgroundColor: colorBgContainer,
boxShadow: '2px 0 8px rgba(0,0,0,0.08)',
display: 'flex',
flexDirection: 'column'
}}
>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
height: 64,
padding: '0 12px',
borderBottom: '1px solid #f0f0f0',
marginBottom: 8
}}>
<Button
type="text"
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
onClick={() => setCollapsed(!collapsed)}
style={{
fontSize: 18,
padding: '8px',
borderRadius: 4
}}
/>
</div>
<Menu
mode="inline"
defaultSelectedKeys={['dashboard']}
style={{
backgroundColor: colorBgContainer,
boxShadow: '2px 0 8px rgba(0,0,0,0.08)'
flex: 1,
borderRight: 0,
backgroundColor: 'transparent'
}}
items={[
{
key: 'dashboard',
icon: <BarChartOutlined />,
label: <Link to="/">仪表盘</Link>,
},
{
key: 'room-management',
icon: <HomeOutlined />,
label: '机房管理',
children: [
{
key: 'rooms',
icon: <HomeOutlined />,
label: <Link to="/rooms">机房管理</Link>,
},
{
key: 'racks',
icon: <DatabaseOutlined />,
label: <Link to="/racks">机柜管理</Link>,
},
{
key: 'visualization',
icon: <EyeOutlined />,
label: <Link to="/visualization">机柜可视化</Link>,
},
],
},
{
key: 'asset-management',
icon: <BuildOutlined />,
label: '资产管理',
children: [
{
key: 'devices',
icon: <CloudServerOutlined />,
label: <Link to="/devices">设备管理</Link>,
},
{
key: 'fields',
icon: <DatabaseOutlined />,
label: <Link to="/fields">字段管理</Link>,
},
],
},
{
key: 'consumables-management',
icon: <ShoppingCartOutlined />,
label: '耗材管理',
children: [
{
key: 'consumables-stats',
icon: <BarChartOutlined />,
label: <Link to="/consumables-stats">耗材统计</Link>,
},
{
key: 'consumables',
icon: <DatabaseOutlined />,
label: <Link to="/consumables">耗材列表</Link>,
},
{
key: 'consumables-categories',
icon: <ImportOutlined />,
label: <Link to="/consumables-categories">分类管理</Link>,
},
{
key: 'consumables-logs',
icon: <FileTextOutlined />,
label: <Link to="/consumables-logs">操作日志</Link>,
},
],
},
{
key: 'system-management',
icon: <UserOutlined />,
label: '系统管理',
children: [
{
key: 'users',
icon: <UserOutlined />,
label: <Link to="/users">用户管理</Link>,
},
{
key: 'login-history',
icon: <HistoryOutlined />,
label: <Link to="/login-history">登录历史</Link>,
},
{
key: 'operation-logs',
icon: <AuditOutlined />,
label: <Link to="/operation-logs">操作日志</Link>,
},
],
},
]}
/>
</Sider>
<Layout style={{ padding: '0 24px 24px' }}>
<Header style={{
padding: '0 16px',
height: 56,
background: colorBgContainer,
marginBottom: 24,
borderRadius: borderRadiusLG,
display: 'flex',
justifyContent: 'flex-end',
alignItems: 'center',
boxShadow: '0 1px 4px rgba(0,0,0,0.08)'
}}>
{user && (
<Space size={12}>
<Avatar
style={{ backgroundColor: '#1890ff', cursor: 'pointer' }}
icon={<UserOutlined />}
/>
<span style={{ color: '#666', fontSize: 14 }}>{user.username}</span>
<Divider type="vertical" style={{ margin: 0 }} />
<Button
type="text"
danger
icon={<LogoutOutlined />}
onClick={handleLogout}
style={{ padding: '4px 8px' }}
>
退出
</Button>
</Space>
)}
</Header>
<Content
style={{
padding: 24,
margin: 0,
minHeight: 280,
background: colorBgContainer,
borderRadius: borderRadiusLG,
}}
>
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: 64,
borderBottom: '1px solid #f0f0f0',
marginBottom: 16
}}>
<Button
type="text"
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
onClick={() => setCollapsed(!collapsed)}
style={{
fontSize: 18,
padding: '8px',
borderRadius: 4,
transition: 'all 0.3s'
}}
/>
</div>
<Menu
mode="inline"
defaultSelectedKeys={['dashboard']}
style={{
height: 'calc(100% - 80px)',
borderRight: 0,
backgroundColor: 'transparent'
}}
items={[
{
key: 'dashboard',
icon: <BarChartOutlined />,
label: <Link to="/">仪表盘</Link>,
},
{
key: 'room-management',
icon: <HomeOutlined />,
label: '机房管理',
children: [
{
key: 'rooms',
icon: <HomeOutlined />,
label: <Link to="/rooms">机房管理</Link>,
},
{
key: 'racks',
icon: <DatabaseOutlined />,
label: <Link to="/racks">机柜管理</Link>,
},
{
key: 'visualization',
icon: <EyeOutlined />,
label: <Link to="/visualization">机柜可视化</Link>,
},
],
},
{
key: 'asset-management',
icon: <BuildOutlined />,
label: '资产管理',
children: [
{
key: 'devices',
icon: <CloudServerOutlined />,
label: <Link to="/devices">设备管理</Link>,
},
{
key: 'fields',
icon: <DatabaseOutlined />,
label: <Link to="/fields">字段管理</Link>,
},
],
},
{
key: 'consumables-management',
icon: <ShoppingCartOutlined />,
label: '耗材管理',
children: [
{
key: 'consumables-stats',
icon: <BarChartOutlined />,
label: <Link to="/consumables-stats">耗材统计</Link>,
},
{
key: 'consumables',
icon: <DatabaseOutlined />,
label: <Link to="/consumables">耗材列表</Link>,
},
{
key: 'consumables-categories',
icon: <ImportOutlined />,
label: <Link to="/consumables-categories">分类管理</Link>,
},
{
key: 'consumables-logs',
icon: <FileTextOutlined />,
label: <Link to="/consumables-logs">操作日志</Link>,
},
],
},
]}
/>
</Sider>
<Layout style={{ padding: '0 24px 24px' }}>
<Content
style={{
padding: 24,
margin: 0,
minHeight: 280,
background: colorBgContainer,
borderRadius: borderRadiusLG,
}}
>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/devices" element={<DeviceManagement />} />
<Route path="/racks" element={<RackManagement />} />
<Route path="/rooms" element={<RoomManagement />} />
<Route path="/fields" element={<DeviceFieldManagement />} />
<Route path="/visualization" element={<RackVisualization />} />
<Route path="/consumables" element={<ConsumableManagement />} />
<Route path="/consumables-categories" element={<CategoryManagement />} />
<Route path="/consumables-stats" element={<ConsumableStatistics />} />
<Route path="/consumables-logs" element={<ConsumableLogs />} />
</Routes>
</Content>
</Layout>
{children}
</Content>
</Layout>
</Layout>
);
};
function App() {
return (
<Router>
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/"
element={
<PrivateRoute>
<AppLayout>
<Dashboard />
</AppLayout>
</PrivateRoute>
}
/>
<Route
path="/devices"
element={
<PrivateRoute>
<AppLayout>
<DeviceManagement />
</AppLayout>
</PrivateRoute>
}
/>
<Route
path="/racks"
element={
<PrivateRoute>
<AppLayout>
<RackManagement />
</AppLayout>
</PrivateRoute>
}
/>
<Route
path="/rooms"
element={
<PrivateRoute>
<AppLayout>
<RoomManagement />
</AppLayout>
</PrivateRoute>
}
/>
<Route
path="/fields"
element={
<PrivateRoute>
<AppLayout>
<DeviceFieldManagement />
</AppLayout>
</PrivateRoute>
}
/>
<Route
path="/visualization"
element={
<PrivateRoute>
<AppLayout>
<RackVisualization />
</AppLayout>
</PrivateRoute>
}
/>
<Route
path="/consumables"
element={
<PrivateRoute>
<AppLayout>
<ConsumableManagement />
</AppLayout>
</PrivateRoute>
}
/>
<Route
path="/consumables-categories"
element={
<PrivateRoute>
<AppLayout>
<CategoryManagement />
</AppLayout>
</PrivateRoute>
}
/>
<Route
path="/consumables-stats"
element={
<PrivateRoute>
<AppLayout>
<ConsumableStatistics />
</AppLayout>
</PrivateRoute>
}
/>
<Route
path="/consumables-logs"
element={
<PrivateRoute>
<AppLayout>
<ConsumableLogs />
</AppLayout>
</PrivateRoute>
}
/>
<Route
path="/users"
element={
<PrivateRoute>
<AppLayout>
<UserManagement />
</AppLayout>
</PrivateRoute>
}
/>
<Route
path="/login-history"
element={
<PrivateRoute>
<AppLayout>
<LoginHistory />
</AppLayout>
</PrivateRoute>
}
/>
<Route
path="/operation-logs"
element={
<PrivateRoute>
<AppLayout>
<OperationLogs />
</AppLayout>
</PrivateRoute>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Router>
);
}
+114
View File
@@ -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;
@@ -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 (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh'
}}>
<Spin size="large" tip="加载中..." />
</div>
);
}
if (!token) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
if (requiredPermission && !user) {
return <Navigate to="/" replace />;
}
return children;
};
export default ProtectedRoute;
+155
View File
@@ -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 (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
};
export default AuthContext;
+4 -1
View File
@@ -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(
<React.StrictMode>
<App />
<AuthProvider>
<App />
</AuthProvider>
</React.StrictMode>
);
+303
View File
@@ -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 (
<div style={containerStyle}>
<Card style={cardStyle}>
<div style={headerStyle}>
<div style={{
fontSize: '48px',
marginBottom: '16px',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent'
}}>
<RobotOutlined />
</div>
<Title level={2} style={titleStyle}>
{isFirstUser ? '创建管理员账户' : 'IDC设备管理系统'}
</Title>
<Text style={subtitleStyle}>
{isFirstUser ? '首次使用,请创建系统管理员账户' : '请登录您的账户'}
</Text>
</div>
{isFirstUser && (
<Alert
message="欢迎使用IDC设备管理系统"
description="您是第一个用户,系统将自动为您分配管理员权限。"
type="success"
showIcon
style={{ marginBottom: '24px' }}
/>
)}
<Form
name={registerMode ? 'register' : 'login'}
size="large"
onFinish={registerMode ? onFinishRegister : onFinishLogin}
style={formStyle}
>
{registerMode ? (
<>
<Form.Item
name="username"
rules={[
{ required: true, message: '请输入用户名' },
{ min: 3, max: 20, message: '用户名长度必须在3-20个字符之间' },
{ pattern: /^[a-zA-Z0-9_]+$/, message: '用户名只能包含字母、数字和下划线' }
]}
>
<Input
prefix={<UserOutlined />}
placeholder="用户名"
/>
</Form.Item>
<Form.Item
name="realName"
rules={[{ required: true, message: '请输入真实姓名' }]}
>
<Input
prefix={<SafetyCertificateOutlined />}
placeholder="真实姓名"
/>
</Form.Item>
<Form.Item
name="email"
rules={[
{ required: true, message: '请输入邮箱' },
{ type: 'email', message: '请输入有效的邮箱地址' }
]}
>
<Input
prefix={<MailOutlined />}
placeholder="邮箱"
/>
</Form.Item>
<Form.Item
name="phone"
>
<Input
prefix={<PhoneOutlined />}
placeholder="手机号(可选)"
/>
</Form.Item>
<Form.Item
name="password"
rules={[
{ required: true, message: '请输入密码' },
{ min: 6, message: '密码长度不能少于6个字符' }
]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="密码"
/>
</Form.Item>
<Form.Item
name="confirmPassword"
dependencies={['password']}
rules={[
{ required: true, message: '请确认密码' },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('password') === value) {
return Promise.resolve();
}
return Promise.reject(new Error('两次输入的密码不一致'));
},
}),
]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="确认密码"
/>
</Form.Item>
</>
) : (
<>
<Form.Item
name="username"
rules={[{ required: true, message: '请输入用户名' }]}
>
<Input
prefix={<UserOutlined />}
placeholder="用户名"
/>
</Form.Item>
<Form.Item
name="password"
rules={[{ required: true, message: '请输入密码' }]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="密码"
/>
</Form.Item>
</>
)}
<Form.Item style={{ marginBottom: '16px' }}>
<Button
type="primary"
htmlType="submit"
loading={loading}
style={submitButtonStyle}
>
{registerMode ? '立即注册' : '登 录'}
</Button>
</Form.Item>
</Form>
{!isFirstUser && (
<div style={footerStyle}>
<Space split={<Divider type="vertical" />}>
<Button
type="link"
size="small"
onClick={() => setRegisterMode(!registerMode)}
>
{registerMode ? '已有账户?去登录' : '注册新账户'}
</Button>
</Space>
</div>
)}
</Card>
</div>
);
};
export default Login;
+194
View File
@@ -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) => (
<Tag color={type === 'success' ? 'green' : 'red'}>
{type === 'success' ? '成功' : '失败'}
</Tag>
)
},
{
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 (
<div>
<div style={pageHeaderStyle}>
<h1 style={titleStyle}>登录历史</h1>
<Space>
<Button icon={<ReloadOutlined />} onClick={fetchHistories}>刷新</Button>
<Popconfirm title="确定清理30天前的登录记录?" onConfirm={handleClear}>
<Button danger>清理旧记录</Button>
</Popconfirm>
</Space>
</div>
<Card style={{ marginBottom: '16px' }}>
<Space wrap>
<Select
placeholder="登录状态"
allowClear
style={{ width: 120 }}
onChange={(value) => handleFilterChange('loginType', value)}
>
<Select.Option value="success">成功</Select.Option>
<Select.Option value="failed">失败</Select.Option>
</Select>
<RangePicker onChange={handleDateChange} showTime />
</Space>
</Card>
<Card>
<Table
columns={columns}
dataSource={histories}
rowKey="id"
loading={loading}
pagination={{
...pagination,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `${total} 条记录`
}}
onChange={(newPagination) => {
setPagination(prev => ({ ...prev, ...newPagination }));
}}
/>
</Card>
</div>
);
};
export default LoginHistory;
+335
View File
@@ -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) => (
<div>
<div style={{ fontWeight: 500 }}>{record.realName || record.username}</div>
<div style={{ fontSize: '12px', color: '#999' }}>@{record.username}</div>
</div>
)
},
{
title: '操作类型',
dataIndex: 'action',
key: 'action',
width: 120,
render: (action) => (
<Tag color={getActionColor(action)}>{action || '-'}</Tag>
)
},
{
title: '模块',
dataIndex: 'module',
key: 'module',
width: 100,
render: (module) => (
<Tag color={getModuleColor(module)}>
{module === 'user' ? '用户' :
module === 'role' ? '角色' :
module === 'device' ? '设备' :
module === 'consumable' ? '耗材' :
module === 'system' ? '系统' : module}
</Tag>
)
},
{
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) => (
<Tag color={status === 'success' ? 'green' : 'red'}>
{status === 'success' ? '成功' : '失败'}
</Tag>
)
},
{
title: '操作',
key: 'action',
width: 80,
render: (_, record) => (
<Button
type="text"
icon={<EyeOutlined />}
onClick={() => showDetail(record)}
/>
)
}
];
const pageHeaderStyle = {
marginBottom: '24px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
};
const titleStyle = {
fontSize: '20px',
fontWeight: '600',
margin: 0
};
return (
<div>
<div style={pageHeaderStyle}>
<h1 style={titleStyle}>操作日志</h1>
<Space>
<Button icon={<ReloadOutlined />} onClick={fetchLogs}>刷新</Button>
<Popconfirm title="确定清理30天前的日志?" onConfirm={handleClear}>
<Button danger>清理旧日志</Button>
</Popconfirm>
</Space>
</div>
<Card style={{ marginBottom: '16px' }}>
<Space wrap>
<Input.Search
placeholder="搜索操作人"
style={{ width: 150 }}
onSearch={(value) => handleFilterChange('username', value)}
allowClear
/>
<Select
placeholder="操作类型"
allowClear
style={{ width: 140 }}
onChange={(value) => handleFilterChange('action', value)}
options={actions}
fieldNames={{ label: 'label', value: 'value' }}
/>
<Select
placeholder="模块"
allowClear
style={{ width: 120 }}
onChange={(value) => handleFilterChange('module', value)}
options={modules}
fieldNames={{ label: 'label', value: 'value' }}
/>
<RangePicker onChange={handleDateChange} showTime />
</Space>
</Card>
<Card>
<Table
columns={columns}
dataSource={logs}
rowKey="id"
loading={loading}
pagination={{
...pagination,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `${total} 条记录`
}}
onChange={(newPagination) => {
setPagination(prev => ({ ...prev, ...newPagination }));
}}
/>
</Card>
<Drawer
title="日志详情"
placement="right"
width={500}
open={detailVisible}
onClose={() => setDetailVisible(false)}
>
{selectedLog && (
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="操作时间">
{selectedLog.operateTime ? dayjs(selectedLog.operateTime).format('YYYY-MM-DD HH:mm:ss') : '-'}
</Descriptions.Item>
<Descriptions.Item label="操作人">
{selectedLog.realName || selectedLog.username} (@{selectedLog.username})
</Descriptions.Item>
<Descriptions.Item label="操作类型">
<Tag color={getActionColor(selectedLog.action)}>{selectedLog.action}</Tag>
</Descriptions.Item>
<Descriptions.Item label="模块">
<Tag color={getModuleColor(selectedLog.module)}>{selectedLog.module}</Tag>
</Descriptions.Item>
<Descriptions.Item label="描述">{selectedLog.description || '-'}</Descriptions.Item>
<Descriptions.Item label="目标对象">
{selectedLog.targetName || selectedLog.targetId || '-'}
</Descriptions.Item>
<Descriptions.Item label="IP地址">{selectedLog.ip || '-'}</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={selectedLog.status === 'success' ? 'green' : 'red'}>
{selectedLog.status === 'success' ? '成功' : '失败'}
</Tag>
</Descriptions.Item>
{selectedLog.errorMessage && (
<Descriptions.Item label="错误信息">
<span style={{ color: 'red' }}>{selectedLog.errorMessage}</span>
</Descriptions.Item>
)}
</Descriptions>
)}
{(selectedLog?.oldValue || selectedLog?.newValue) && (
<div style={{ marginTop: '24px' }}>
<Title level={5}>变更内容</Title>
<Descriptions column={1} bordered size="small">
{selectedLog.oldValue && (
<Descriptions.Item label="旧值">
<pre style={{ margin: 0, fontSize: '12px', whiteSpace: 'pre-wrap' }}>
{JSON.stringify(JSON.parse(selectedLog.oldValue), null, 2)}
</pre>
</Descriptions.Item>
)}
{selectedLog.newValue && (
<Descriptions.Item label="新值">
<pre style={{ margin: 0, fontSize: '12px', whiteSpace: 'pre-wrap' }}>
{JSON.stringify(JSON.parse(selectedLog.newValue), null, 2)}
</pre>
</Descriptions.Item>
)}
</Descriptions>
</div>
)}
</Drawer>
</div>
);
};
export default OperationLogs;
+567
View File
@@ -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) => (
<Badge dot={!!record.avatar} color="green" offset={[-5, 35]}>
<Avatar
size={48}
icon={!record.avatar && <UserOutlined />}
src={getAvatarUrl(record)}
style={{
backgroundColor: record.avatar ? 'transparent' : '#1890ff',
cursor: 'pointer'
}}
onClick={() => handleAvatarClick(record)}
/>
</Badge>
)
},
{
title: '用户名',
key: 'username',
width: 150,
render: (_, record) => (
<div>
<div style={{ fontWeight: 500 }}>{record.realName || record.username}</div>
<div style={{ fontSize: '12px', color: '#999' }}>@{record.username}</div>
</div>
)
},
{
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) => (
<Space wrap>
{record.roles?.map(role => (
<Tag key={role.roleId} color={role.roleCode === 'admin' ? 'blue' : 'green'}>
{role.roleName}
</Tag>
)) || '-'}
</Space>
)
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
render: (status) => (
<Tag color={getStatusColor(status)}>{getStatusText(status)}</Tag>
)
},
{
title: '最后登录',
key: 'lastLogin',
render: (_, record) => (
<div style={{ fontSize: '12px' }}>
<div>{record.lastLoginTime ? new Date(record.lastLoginTime).toLocaleString() : '从未登录'}</div>
<div style={{ color: '#999' }}>{record.lastLoginIp || '-'}</div>
</div>
)
},
{
title: '操作',
key: 'action',
render: (_, record) => (
<Space size="small">
<Tooltip title="编辑">
<Button
type="text"
icon={<EditOutlined />}
onClick={() => handleEdit(record)}
/>
</Tooltip>
<Tooltip title="重置密码">
<Button
type="text"
icon={<LockOutlined />}
onClick={() => handleResetPassword(record)}
/>
</Tooltip>
<Popconfirm
title="确定要删除此用户吗?"
onConfirm={() => handleDelete(record.userId)}
okText="确定"
cancelText="取消"
>
<Tooltip title="删除">
<Button type="text" danger icon={<DeleteOutlined />} />
</Tooltip>
</Popconfirm>
</Space>
)
}
];
const pageHeaderStyle = {
marginBottom: '24px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
};
const titleStyle = {
fontSize: '20px',
fontWeight: '600',
margin: 0
};
return (
<div>
<div style={pageHeaderStyle}>
<h1 style={titleStyle}>用户管理</h1>
<Space>
<Button icon={<ReloadOutlined />} onClick={fetchUsers}>刷新</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>
添加用户
</Button>
</Space>
</div>
<Card>
<Table
columns={columns}
dataSource={users}
rowKey="userId"
loading={loading}
pagination={{
...pagination,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `${total} 条记录`
}}
onChange={(newPagination) => {
setPagination(prev => ({ ...prev, ...newPagination }));
}}
/>
</Card>
<Modal
title={editingUser ? '编辑用户' : '添加用户'}
open={modalVisible}
onCancel={() => setModalVisible(false)}
footer={null}
width={500}
>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
style={{ marginTop: '20px' }}
>
<Form.Item
name="username"
label="用户名"
rules={[
{ required: true, message: '请输入用户名' },
{ min: 3, max: 20, message: '用户名长度必须在3-20个字符之间' }
]}
>
<Input placeholder="请输入用户名" />
</Form.Item>
<Form.Item
name="realName"
label="真实姓名"
rules={[{ required: true, message: '请输入真实姓名' }]}
>
<Input placeholder="请输入真实姓名" />
</Form.Item>
<Form.Item
name="email"
label="邮箱"
rules={[
{ required: true, message: '请输入邮箱' },
{ type: 'email', message: '请输入有效的邮箱地址' }
]}
>
<Input placeholder="请输入邮箱" />
</Form.Item>
<Form.Item name="phone" label="手机号">
<Input placeholder="请输入手机号" />
</Form.Item>
<Form.Item
name="roleIds"
label="角色"
rules={[{ required: true, message: '请选择角色' }]}
>
<Select mode="multiple" placeholder="请选择角色">
{roles.map(role => (
<Option key={role.roleId} value={role.roleId}>
{role.roleName}
</Option>
))}
</Select>
</Form.Item>
{!editingUser && (
<Form.Item
name="password"
label="初始密码"
rules={[
{ required: true, message: '请输入初始密码' },
{ min: 6, message: '密码长度不能少于6个字符' }
]}
>
<Input.Password placeholder="请输入初始密码" />
</Form.Item>
)}
<Form.Item name="status" label="状态">
<Select placeholder="请选择状态">
<Option value="active">正常</Option>
<Option value="inactive">禁用</Option>
<Option value="locked">锁定</Option>
</Select>
</Form.Item>
{editingUser && (
<Form.Item
name="newPassword"
label="新密码"
rules={[
{ min: 6, message: '密码长度不能少于6个字符' }
]}
>
<Input.Password placeholder="留空则不修改密码" />
</Form.Item>
)}
<Form.Item style={{ marginBottom: 0, textAlign: 'right' }}>
<Space>
<Button onClick={() => setModalVisible(false)}>取消</Button>
<Button type="primary" htmlType="submit" loading={loading}>
{editingUser ? '更新' : '创建'}
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
<Modal
title={`重置密码 - ${passwordUser?.username}`}
open={passwordModalVisible}
onCancel={() => setPasswordModalVisible(false)}
footer={null}
width={400}
>
<Form
form={passwordForm}
layout="vertical"
onFinish={handleResetPasswordSubmit}
style={{ marginTop: '20px' }}
>
<Form.Item
name="newPassword"
label="新密码"
rules={[
{ required: true, message: '请输入新密码' },
{ min: 6, message: '密码长度不能少于6个字符' }
]}
>
<Input.Password placeholder="请输入新密码" />
</Form.Item>
<Form.Item style={{ marginBottom: 0, textAlign: 'right' }}>
<Space>
<Button onClick={() => setPasswordModalVisible(false)}>取消</Button>
<Button type="primary" htmlType="submit" loading={loading}>
重置
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
<Modal
title={`设置头像 - ${avatarUser?.username}`}
open={avatarModalVisible}
onCancel={() => setAvatarModalVisible(false)}
footer={null}
width={400}
>
<div style={{ textAlign: 'center', padding: '20px 0' }}>
<div style={{ marginBottom: '24px' }}>
<Badge dot={!!avatarUser?.avatar} color="green" offset={[-5, 35]}>
<Avatar
size={120}
icon={!avatarUser?.avatar && <UserOutlined />}
src={getAvatarUrl(avatarUser)}
style={{
backgroundColor: avatarUser?.avatar ? 'transparent' : '#1890ff',
border: '1px solid #f0f0f0'
}}
/>
</Badge>
</div>
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<input
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
style={{ display: 'none' }}
ref={fileInputRef}
onChange={handleAvatarUpload}
/>
<Button
type="primary"
icon={<CameraOutlined />}
onClick={() => fileInputRef.current?.click()}
loading={uploadLoading}
block
>
{avatarUser?.avatar ? '更换头像' : '上传头像'}
</Button>
{avatarUser?.avatar && (
<Button
danger
icon={<DeleteOutlined />}
onClick={handleAvatarDelete}
block
>
删除头像
</Button>
)}
</Space>
<div style={{ marginTop: '16px', color: '#999', fontSize: '12px' }}>
支持 JPGPNGGIFWebP 格式大小不超过 5MB
</div>
</div>
</Modal>
</div>
);
};
export default UserManagement;