update admin

This commit is contained in:
2026-05-06 19:10:27 +08:00
parent e24b41f53c
commit e8f36222e7
25 changed files with 1103 additions and 354 deletions
@@ -1,42 +1,365 @@
package com.yaoyuan.jiscuss.controller; package com.yaoyuan.jiscuss.controller;
import com.yaoyuan.jiscuss.entity.Discussion;
import com.yaoyuan.jiscuss.entity.Post;
import com.yaoyuan.jiscuss.entity.Role;
import com.yaoyuan.jiscuss.entity.UpgradeLog;
import com.yaoyuan.jiscuss.entity.User;
import com.yaoyuan.jiscuss.entity.UserInfo; import com.yaoyuan.jiscuss.entity.UserInfo;
import com.yaoyuan.jiscuss.entity.UserRole;
import com.yaoyuan.jiscuss.entity.AuditLog;
import com.yaoyuan.jiscuss.repository.DiscussionsRepository;
import com.yaoyuan.jiscuss.repository.DiscussionsTagsRepository;
import com.yaoyuan.jiscuss.repository.PostsRepository;
import com.yaoyuan.jiscuss.repository.RoleRepository;
import com.yaoyuan.jiscuss.repository.UpgradeLogRepository;
import com.yaoyuan.jiscuss.repository.UserRoleRepository;
import com.yaoyuan.jiscuss.repository.UsersRepository;
import com.yaoyuan.jiscuss.repository.AuditLogRepository;
import com.yaoyuan.jiscuss.service.AuditLogService;
import org.springframework.boot.SpringBootVersion;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.ui.ModelMap; import org.springframework.ui.ModelMap;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import java.lang.management.ManagementFactory;
import java.lang.management.MemoryMXBean;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/** /**
* @author yaoyuan2.chu * @author yaoyuan2.chu
* 后台系统控制器 * 后台系统控制器
*/ */
@Controller @Controller
public class AdminSystemController { public class AdminSystemController extends BaseController {
/** private final UsersRepository usersRepository;
* 后台登录 private final DiscussionsRepository discussionsRepository;
*/ private final PostsRepository postsRepository;
private final DiscussionsTagsRepository discussionsTagsRepository;
private final RoleRepository roleRepository;
private final UserRoleRepository userRoleRepository;
private final UpgradeLogRepository upgradeLogRepository;
private final AuditLogRepository auditLogRepository;
private final AuditLogService auditLogService;
/** public AdminSystemController(
* 后台退出 UsersRepository usersRepository,
*/ DiscussionsRepository discussionsRepository,
@RequestMapping("/admin/logout") PostsRepository postsRepository,
public String logout(@RequestParam(defaultValue = "discussion") String type, HttpServletRequest request, ModelMap map) { DiscussionsTagsRepository discussionsTagsRepository,
return "admin/logout"; RoleRepository roleRepository,
UserRoleRepository userRoleRepository,
UpgradeLogRepository upgradeLogRepository,
AuditLogRepository auditLogRepository,
AuditLogService auditLogService) {
this.usersRepository = usersRepository;
this.discussionsRepository = discussionsRepository;
this.postsRepository = postsRepository;
this.discussionsTagsRepository = discussionsTagsRepository;
this.roleRepository = roleRepository;
this.userRoleRepository = userRoleRepository;
this.upgradeLogRepository = upgradeLogRepository;
this.auditLogRepository = auditLogRepository;
this.auditLogService = auditLogService;
} }
/** @GetMapping("/admin/home")
* 后台设置管理 public String home(HttpServletRequest request, ModelMap map) {
*/ fillCommon(request, map, "home");
map.put("userCount", usersRepository.count());
map.put("discussionCount", discussionsRepository.count());
map.put("postCount", postsRepository.count());
map.put("roleCount", roleRepository.count());
map.put("sys", buildSystemMetrics());
map.put("deps", buildDependencyVersions());
/**
* 后台页面
* @return
*/
@RequestMapping("/admin/home")
public String home(@RequestParam(defaultValue = "discussion") String type, HttpServletRequest request, ModelMap map) {
return "admin/home"; return "admin/home";
} }
@GetMapping("/admin/users")
public String users(HttpServletRequest request, ModelMap map) {
fillCommon(request, map, "users");
List<User> users = usersRepository.findAll(Sort.by(Sort.Direction.ASC, "id"));
List<Role> roles = roleRepository.findAllByOrderByIdAsc();
Map<Integer, Set<Integer>> userRoleIds = new HashMap<>();
for (UserRole ur : userRoleRepository.findAll()) {
userRoleIds.computeIfAbsent(ur.getUserId(), key -> new HashSet<>()).add(ur.getRoleId());
}
map.put("users", users);
map.put("roles", roles);
map.put("userRoleIds", userRoleIds);
return "admin/users";
}
@PostMapping("/admin/users/{id}/roles")
@Transactional
public String updateUserRoles(HttpServletRequest request,
@PathVariable("id") Integer id,
@RequestParam(value = "roleIds", required = false) List<Integer> roleIds) {
UserInfo currentAdmin = getUserInfo(request);
userRoleRepository.deleteByUserId(id);
if (roleIds != null) {
Date now = new Date();
Set<Integer> uniqueRoleIds = new HashSet<>(roleIds);
Set<Integer> validEnabledRoleIds = roleRepository.findAllById(uniqueRoleIds).stream()
.filter(role -> role.getEnabled() != null && role.getEnabled() == 1)
.map(Role::getId)
.collect(Collectors.toSet());
for (Integer roleId : validEnabledRoleIds) {
UserRole ur = new UserRole();
ur.setUserId(id);
ur.setRoleId(roleId);
ur.setCreateTime(now);
userRoleRepository.save(ur);
}
}
auditLogService.log(currentAdmin, "update_user_roles", "user", id,
"Updated user role assignments. Role count: " + (roleIds == null ? 0 : roleIds.size()));
return "redirect:/admin/users";
}
@PostMapping("/admin/roles")
public String createRole(HttpServletRequest request,
@RequestParam("code") String code,
@RequestParam("name") String name,
@RequestParam(value = "description", required = false) String description) {
UserInfo currentAdmin = getUserInfo(request);
String normalizedCode = code == null ? "" : code.trim().toUpperCase();
if (normalizedCode.isEmpty()) {
return "redirect:/admin/users";
}
Role exists = roleRepository.findByCodeIgnoreCase(normalizedCode);
if (exists == null) {
Date now = new Date();
Role role = new Role();
role.setCode(normalizedCode);
role.setName(name == null ? "" : name.trim());
role.setDescription(description);
role.setEnabled(1);
role.setCreateTime(now);
role.setUpdateTime(now);
roleRepository.save(role);
auditLogService.log(currentAdmin, "create_role", "role", role.getId(),
"Created role: " + normalizedCode);
} else {
auditLogService.log(currentAdmin, "create_role_duplicate", "role", exists.getId(),
"Attempted to create duplicate role: " + normalizedCode, "ignored");
}
return "redirect:/admin/users";
}
@PostMapping("/admin/roles/{id}/toggle")
public String toggleRole(HttpServletRequest request,
@PathVariable("id") Integer id) {
UserInfo currentAdmin = getUserInfo(request);
Role role = roleRepository.findById(id).orElse(null);
if (role != null) {
int nextStatus = role.getEnabled() != null && role.getEnabled() == 1 ? 0 : 1;
// Keep built-in ADMIN role enabled to avoid locking out management access.
if ("ADMIN".equalsIgnoreCase(role.getCode()) && nextStatus == 0) {
auditLogService.log(currentAdmin, "toggle_role_blocked", "role", id,
"Attempted to disable built-in ADMIN role", "blocked");
return "redirect:/admin/users";
}
role.setEnabled(nextStatus);
role.setUpdateTime(new Date());
roleRepository.save(role);
auditLogService.log(currentAdmin, "toggle_role", "role", id,
"Toggled role status to " + (nextStatus == 1 ? "enabled" : "disabled"));
}
return "redirect:/admin/users";
}
@GetMapping("/admin/discussions")
public String discussions(HttpServletRequest request, ModelMap map) {
fillCommon(request, map, "discussions");
List<Discussion> discussions = discussionsRepository.findAll(Sort.by(Sort.Direction.DESC, "createTime"));
map.put("discussions", discussions);
return "admin/discussions";
}
@PostMapping("/admin/discussions/{id}/delete")
@Transactional
public String deleteDiscussion(HttpServletRequest request,
@PathVariable("id") Integer id) {
UserInfo currentAdmin = getUserInfo(request);
Discussion discussion = discussionsRepository.findById(id).orElse(null);
String title = discussion == null ? "unknown" : discussion.getTitle();
discussionsTagsRepository.deleteByDiscussionId(id);
postsRepository.deleteByDiscussionId(id);
discussionsRepository.deleteById(id);
auditLogService.log(currentAdmin, "delete_discussion", "discussion", id,
"Deleted discussion: " + title);
return "redirect:/admin/discussions";
}
@GetMapping("/admin/posts")
public String posts(HttpServletRequest request, ModelMap map) {
fillCommon(request, map, "posts");
List<Post> posts = postsRepository.findAll(Sort.by(Sort.Direction.DESC, "createTime"));
map.put("posts", posts);
Set<Integer> userIds = posts.stream().map(Post::getCreateId).filter(v -> v != null).collect(Collectors.toSet());
Set<Integer> discussionIds = posts.stream().map(Post::getDiscussionId).filter(v -> v != null).collect(Collectors.toSet());
Map<Integer, String> userNames = usersRepository.findAllById(userIds).stream()
.collect(Collectors.toMap(User::getId, User::getUsername));
Map<Integer, String> discussionNames = discussionsRepository.findAllById(discussionIds).stream()
.collect(Collectors.toMap(Discussion::getId, Discussion::getTitle));
map.put("userNames", userNames);
map.put("discussionNames", discussionNames);
return "admin/posts";
}
@PostMapping("/admin/posts/{id}/delete")
public String deletePost(HttpServletRequest request,
@PathVariable("id") Integer id) {
UserInfo currentAdmin = getUserInfo(request);
Post post = postsRepository.findById(id).orElse(null);
String content = post == null ? "unknown" : (post.getContent() == null ? "" : post.getContent());
if (content.length() > 50) {
content = content.substring(0, 50) + "...";
}
postsRepository.deleteById(id);
auditLogService.log(currentAdmin, "delete_post", "post", id,
"Deleted post: " + content);
return "redirect:/admin/posts";
}
@GetMapping("/admin/upgrade-logs")
public String upgradeLogs(HttpServletRequest request, ModelMap map) {
fillCommon(request, map, "upgradeLogs");
map.put("logs", upgradeLogRepository.findAllByOrderByCreateTimeDesc());
return "admin/upgrade-logs";
}
@PostMapping("/admin/upgrade-logs")
public String createUpgradeLog(HttpServletRequest request,
@RequestParam("version") String version,
@RequestParam("title") String title,
@RequestParam(value = "type", defaultValue = "feature") String type,
@RequestParam("content") String content) {
UserInfo user = getUserInfo(request);
UpgradeLog log = new UpgradeLog();
log.setVersion(version);
log.setTitle(title);
log.setType(type);
log.setContent(content);
log.setCreatedBy(user == null ? null : user.getId());
log.setCreateTime(new Date());
upgradeLogRepository.save(log);
auditLogService.log(user, "create_upgrade_log", "upgrade_log", log.getId(),
"Created upgrade log: " + version + " - " + title);
return "redirect:/admin/upgrade-logs";
}
@GetMapping("/admin/plugins")
public String plugins(HttpServletRequest request, ModelMap map) {
fillCommon(request, map, "plugins");
return "admin/plugins";
}
@GetMapping("/admin/audit-logs")
public String auditLogs(HttpServletRequest request, ModelMap map) {
fillCommon(request, map, "auditLogs");
List<AuditLog> logs = auditLogRepository.findAllByOrderByCreateTimeDesc();
map.put("logs", logs);
return "admin/audit-logs";
}
@GetMapping("/admin/themes")
public String themes(HttpServletRequest request, ModelMap map) {
fillCommon(request, map, "themes");
return "admin/themes";
}
private void fillCommon(HttpServletRequest request, ModelMap map, String active) {
UserInfo user = getUserInfo(request);
map.put("adminName", user == null ? "admin" : user.getUsername());
map.put("active", active);
}
private Map<String, Object> buildSystemMetrics() {
Map<String, Object> result = new HashMap<>();
Runtime rt = Runtime.getRuntime();
MemoryMXBean memoryMXBean = ManagementFactory.getMemoryMXBean();
long totalMb = bytesToMb(rt.totalMemory());
long freeMb = bytesToMb(rt.freeMemory());
long usedMb = totalMb - freeMb;
long maxMb = bytesToMb(rt.maxMemory());
long heapUsedMb = bytesToMb(memoryMXBean.getHeapMemoryUsage().getUsed());
result.put("totalMb", totalMb);
result.put("freeMb", freeMb);
result.put("usedMb", usedMb);
result.put("maxMb", maxMb);
result.put("heapUsedMb", heapUsedMb);
result.put("uptimeMs", ManagementFactory.getRuntimeMXBean().getUptime());
result.put("cpuLoad", readCpuLoadPercent());
return result;
}
private Map<String, String> buildDependencyVersions() {
Map<String, String> result = new HashMap<>();
result.put("springBoot", SpringBootVersion.getVersion());
result.put("java", System.getProperty("java.version"));
result.put("spring", implVersion("org.springframework.core.SpringVersion"));
result.put("hibernate", implVersion("org.hibernate.Version"));
result.put("flyway", implVersion("org.flywaydb.core.Flyway"));
result.put("druid", implVersion("com.alibaba.druid.pool.DruidDataSource"));
result.put("ehcache", implVersion("org.ehcache.CacheManager"));
result.put("springdoc", implVersion("org.springdoc.core.configuration.SpringDocConfiguration"));
return result;
}
private String implVersion(String className) {
try {
Class<?> clazz = Class.forName(className);
Package pkg = clazz.getPackage();
String version = pkg == null ? null : pkg.getImplementationVersion();
return version == null ? "unknown" : version;
} catch (ClassNotFoundException e) {
return "unknown";
}
}
private String readCpuLoadPercent() {
try {
java.lang.management.OperatingSystemMXBean osBean = ManagementFactory.getOperatingSystemMXBean();
if (osBean instanceof com.sun.management.OperatingSystemMXBean sunOsBean) {
double value = sunOsBean.getCpuLoad();
if (value >= 0) {
return String.format("%.2f%%", value * 100);
}
}
} catch (Exception ignored) {
}
return "N/A";
}
private long bytesToMb(long bytes) {
return bytes / 1024 / 1024;
}
} }
@@ -0,0 +1,47 @@
package com.yaoyuan.jiscuss.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
@Data
@Entity
@Table(name = "audit_log")
public class AuditLog implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Column(name = "admin_id")
private Integer adminId;
@Column(name = "admin_name")
private String adminName;
@Column(name = "action_type")
private String actionType;
@Column(name = "target_type")
private String targetType;
@Column(name = "target_id")
private Integer targetId;
@Column(name = "description")
private String description;
@Column(name = "status")
private String status;
@Column(name = "create_time")
private Date createTime;
}
@@ -0,0 +1,41 @@
package com.yaoyuan.jiscuss.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
@Data
@Entity
@Table(name = "rbac_role")
public class Role implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Column(name = "code")
private String code;
@Column(name = "name")
private String name;
@Column(name = "description")
private String description;
@Column(name = "enabled")
private Integer enabled;
@Column(name = "create_time")
private Date createTime;
@Column(name = "update_time")
private Date updateTime;
}
@@ -0,0 +1,41 @@
package com.yaoyuan.jiscuss.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
@Data
@Entity
@Table(name = "upgrade_log")
public class UpgradeLog implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Column(name = "version")
private String version;
@Column(name = "title")
private String title;
@Column(name = "content")
private String content;
@Column(name = "type")
private String type;
@Column(name = "created_by")
private Integer createdBy;
@Column(name = "create_time")
private Date createTime;
}
@@ -0,0 +1,32 @@
package com.yaoyuan.jiscuss.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
@Data
@Entity
@Table(name = "rbac_user_role")
public class UserRole implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Column(name = "user_id")
private Integer userId;
@Column(name = "role_id")
private Integer roleId;
@Column(name = "create_time")
private Date createTime;
}
@@ -0,0 +1,20 @@
package com.yaoyuan.jiscuss.repository;
import com.yaoyuan.jiscuss.entity.AuditLog;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface AuditLogRepository extends JpaRepository<AuditLog, Integer> {
List<AuditLog> findAllByOrderByCreateTimeDesc();
Page<AuditLog> findAllByOrderByCreateTimeDesc(Pageable pageable);
List<AuditLog> findByTargetTypeAndTargetIdOrderByCreateTimeDesc(String targetType, Integer targetId);
List<AuditLog> findByAdminIdOrderByCreateTimeDesc(Integer adminId);
}
@@ -7,4 +7,5 @@ import org.springframework.stereotype.Repository;
@Repository @Repository
public interface DiscussionsTagsRepository extends JpaRepository<DiscussionTag, Integer> { public interface DiscussionsTagsRepository extends JpaRepository<DiscussionTag, Integer> {
void deleteByDiscussionId(Integer discussionId);
} }
@@ -12,6 +12,8 @@ import java.util.Map;
@Repository @Repository
public interface PostsRepository extends JpaRepository<Post, Integer> { public interface PostsRepository extends JpaRepository<Post, Integer> {
void deleteByDiscussionId(Integer discussionId);
/** /**
* @param dId * @param dId
* @return * @return
@@ -0,0 +1,16 @@
package com.yaoyuan.jiscuss.repository;
import com.yaoyuan.jiscuss.entity.Role;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface RoleRepository extends JpaRepository<Role, Integer> {
Role findByCode(String code);
Role findByCodeIgnoreCase(String code);
List<Role> findAllByOrderByIdAsc();
}
@@ -0,0 +1,12 @@
package com.yaoyuan.jiscuss.repository;
import com.yaoyuan.jiscuss.entity.UpgradeLog;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface UpgradeLogRepository extends JpaRepository<UpgradeLog, Integer> {
List<UpgradeLog> findAllByOrderByCreateTimeDesc();
}
@@ -0,0 +1,20 @@
package com.yaoyuan.jiscuss.repository;
import com.yaoyuan.jiscuss.entity.UserRole;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface UserRoleRepository extends JpaRepository<UserRole, Integer> {
List<UserRole> findByUserId(Integer userId);
void deleteByUserId(Integer userId);
@Query(value = "select r.code from rbac_user_role ur join rbac_role r on ur.role_id = r.id where ur.user_id = :userId and r.enabled = 1",
nativeQuery = true)
List<String> findRoleCodesByUserId(@Param("userId") Integer userId);
}
@@ -0,0 +1,59 @@
package com.yaoyuan.jiscuss.service;
import com.yaoyuan.jiscuss.entity.AuditLog;
import com.yaoyuan.jiscuss.entity.UserInfo;
import com.yaoyuan.jiscuss.repository.AuditLogRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Date;
/**
* Service for recording admin operations in audit log.
*/
@Service
public class AuditLogService {
private final AuditLogRepository auditLogRepository;
public AuditLogService(AuditLogRepository auditLogRepository) {
this.auditLogRepository = auditLogRepository;
}
@Transactional
public void log(Integer adminId, String adminName, String actionType, String targetType,
Integer targetId, String description, String status) {
AuditLog log = new AuditLog();
log.setAdminId(adminId);
log.setAdminName(adminName);
log.setActionType(actionType);
log.setTargetType(targetType);
log.setTargetId(targetId);
log.setDescription(description);
log.setStatus(status == null ? "success" : status);
log.setCreateTime(new Date());
auditLogRepository.save(log);
}
@Transactional
public void log(Integer adminId, String adminName, String actionType, String targetType,
Integer targetId, String description) {
log(adminId, adminName, actionType, targetType, targetId, description, "success");
}
@Transactional
public void log(UserInfo user, String actionType, String targetType,
Integer targetId, String description) {
Integer userId = user == null ? null : user.getId();
String userName = user == null ? "unknown" : user.getUsername();
log(userId, userName, actionType, targetType, targetId, description, "success");
}
@Transactional
public void log(UserInfo user, String actionType, String targetType,
Integer targetId, String description, String status) {
Integer userId = user == null ? null : user.getId();
String userName = user == null ? "unknown" : user.getUsername();
log(userId, userName, actionType, targetType, targetId, description, status);
}
}
@@ -2,6 +2,7 @@ package com.yaoyuan.jiscuss.service.impl;
import com.yaoyuan.jiscuss.entity.User; import com.yaoyuan.jiscuss.entity.User;
import com.yaoyuan.jiscuss.entity.UserInfo; import com.yaoyuan.jiscuss.entity.UserInfo;
import com.yaoyuan.jiscuss.repository.UserRoleRepository;
import com.yaoyuan.jiscuss.service.IUsersService; import com.yaoyuan.jiscuss.service.IUsersService;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.GrantedAuthority;
@@ -29,6 +30,9 @@ public class UserDetailServiceImpl implements UserDetailsService {
@Autowired @Autowired
private IUsersService userInfoService; private IUsersService userInfoService;
@Autowired
private UserRoleRepository userRoleRepository;
@Override @Override
public UserInfo loadUserByUsername(String username) throws UsernameNotFoundException { public UserInfo loadUserByUsername(String username) throws UsernameNotFoundException {
User userInfo = userInfoService.getByUsername(username); User userInfo = userInfoService.getByUsername(username);
@@ -36,11 +40,16 @@ public class UserDetailServiceImpl implements UserDetailsService {
throw new UsernameNotFoundException("用户不存在: " + username); throw new UsernameNotFoundException("用户不存在: " + username);
} }
// Assign role based on user level: level >= 1 → ADMIN, else → USER
String role = (userInfo.getLevel() != null && userInfo.getLevel() >= 1) ? "ADMIN" : "USER";
List<GrantedAuthority> authorities = new ArrayList<>(); List<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(new SimpleGrantedAuthority("ROLE_" + role)); List<String> roleCodes = userRoleRepository.findRoleCodesByUserId(userInfo.getId());
if (roleCodes == null || roleCodes.isEmpty()) {
// Compatibility fallback for legacy data before RBAC migration.
String role = (userInfo.getLevel() != null && userInfo.getLevel() >= 1) ? "ADMIN" : "USER";
roleCodes = List.of(role);
}
for (String roleCode : roleCodes) {
authorities.add(new SimpleGrantedAuthority("ROLE_" + roleCode));
}
return new UserInfo( return new UserInfo(
authorities, authorities,
@@ -0,0 +1,67 @@
-- V2.0.2: RBAC foundation + admin upgrade logs (Spring Boot 3 optimization)
CREATE TABLE IF NOT EXISTS rbac_role
(
id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT,
code VARCHAR(50) NOT NULL,
name VARCHAR(100) NOT NULL,
description VARCHAR(255),
enabled INTEGER NOT NULL DEFAULT 1,
create_time DATETIME,
update_time DATETIME
);
CREATE TABLE IF NOT EXISTS rbac_user_role
(
id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT,
user_id INTEGER NOT NULL,
role_id INTEGER NOT NULL,
create_time DATETIME
);
CREATE TABLE IF NOT EXISTS upgrade_log
(
id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT,
version VARCHAR(50) NOT NULL,
title VARCHAR(200) NOT NULL,
content TEXT,
type VARCHAR(50),
created_by INTEGER,
create_time DATETIME
);
INSERT INTO rbac_role (code, name, description, enabled, create_time, update_time)
SELECT 'ADMIN', '管理员', '后台管理与运维权限', 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM rbac_role WHERE code = 'ADMIN');
INSERT INTO rbac_role (code, name, description, enabled, create_time, update_time)
SELECT 'USER', '普通用户', '论坛基础访问权限', 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM rbac_role WHERE code = 'USER');
INSERT INTO rbac_user_role (user_id, role_id, create_time)
SELECT u.id, r.id, NOW()
FROM user u
JOIN rbac_role r ON r.code = 'USER'
WHERE NOT EXISTS (
SELECT 1 FROM rbac_user_role ur WHERE ur.user_id = u.id AND ur.role_id = r.id
);
INSERT INTO rbac_user_role (user_id, role_id, create_time)
SELECT u.id, r.id, NOW()
FROM user u
JOIN rbac_role r ON r.code = 'ADMIN'
WHERE u.level >= 1
AND NOT EXISTS (
SELECT 1 FROM rbac_user_role ur WHERE ur.user_id = u.id AND ur.role_id = r.id
);
INSERT INTO upgrade_log (version, title, content, type, created_by, create_time)
SELECT 'v3.5.13',
'后台管理与RBAC架构初始化',
'新增角色与用户角色映射;新增后台管理与运维面板;新增升级日志管理入口。',
'feature',
1,
NOW()
WHERE NOT EXISTS (
SELECT 1 FROM upgrade_log WHERE version = 'v3.5.13' AND title = '后台管理与RBAC架构初始化'
);
@@ -0,0 +1,17 @@
-- V2.0.3: Audit log for admin operations (Spring Boot 3 optimization)
CREATE TABLE IF NOT EXISTS audit_log
(
id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT,
admin_id INTEGER,
admin_name VARCHAR(100),
action_type VARCHAR(50) NOT NULL,
target_type VARCHAR(50),
target_id INTEGER,
description VARCHAR(500),
status VARCHAR(20) NOT NULL DEFAULT 'success',
create_time DATETIME
);
CREATE INDEX idx_audit_admin_time ON audit_log(admin_id, create_time DESC);
CREATE INDEX idx_audit_target ON audit_log(target_type, target_id);
@@ -0,0 +1,54 @@
<#macro page title active adminName="admin">
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="_csrf" content="${_csrf.token}"/>
<meta name="_csrf_header" content="${_csrf.headerName}"/>
<title>${title}</title>
<#include "admin-commjs.ftl"/>
<style>
body { background: #f6f7fb; }
.admin-header { background: #1f2937; color: #fff; padding: 12px 18px; }
.admin-grid { display: grid; grid-template-columns: 230px 1fr; gap: 16px; padding: 16px; }
.admin-sidebar { background: #fff; border-radius: 8px; padding: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.08); }
.admin-main { background: #fff; border-radius: 8px; padding: 16px; box-shadow: 0 1px 3px rgba(0,0,0,.08); }
.admin-sidebar a.item.active { background: #1d4ed8 !important; color: #fff !important; border-radius: 6px; }
.metric-card { border: 1px solid #e5e7eb; border-radius: 8px; padding: 12px; }
.small-muted { color: #6b7280; font-size: 12px; }
@media (max-width: 900px) {
.admin-grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="admin-header">
<div class="ui grid">
<div class="ten wide column">Jiscuss 后台管理</div>
<div class="six wide right aligned column">当前管理员: ${adminName} | <a style="color:#93c5fd" href="/swagger-ui.html" target="_blank">接口文档</a> | <a style="color:#93c5fd" href="/">前台首页</a></div>
</div>
</div>
<div class="admin-grid">
<div class="admin-sidebar">
<div class="ui vertical fluid menu">
<a class="item <#if active=='home'>active</#if>" href="/admin/home">总览与运维</a>
<a class="item <#if active=='users'>active</#if>" href="/admin/users">用户与角色</a>
<a class="item <#if active=='discussions'>active</#if>" href="/admin/discussions">文章管理</a>
<a class="item <#if active=='posts'>active</#if>" href="/admin/posts">评论管理</a>
<a class="item <#if active=='upgradeLogs'>active</#if>" href="/admin/upgrade-logs">版本升级日志</a>
<a class="item <#if active=='auditLogs'>active</#if>" href="/admin/audit-logs">操作审计日志</a>
<div class="item small-muted">预留扩展</div>
<a class="item <#if active=='plugins'>active</#if>" href="/admin/plugins">插件管理(预留)</a>
<a class="item <#if active=='themes'>active</#if>" href="/admin/themes">主题管理(预留)</a>
</div>
</div>
<div class="admin-main">
<#nested>
</div>
</div>
</body>
</html>
</#macro>
@@ -0,0 +1,48 @@
<#import "admin-shell.ftl" as shell>
<@shell.page title="后台管理 - 操作审计日志" active=active adminName=adminName>
<h2 class="ui header">操作审计日志</h2>
<p class="small-muted">记录所有管理员的后台操作,包括角色管理、用户管理、内容管理等。</p>
<table class="ui striped celled table">
<thead>
<tr>
<th>ID</th>
<th>管理员</th>
<th>操作类型</th>
<th>目标类型</th>
<th>目标ID</th>
<th>描述</th>
<th>状态</th>
<th>操作时间</th>
</tr>
</thead>
<tbody>
<#list logs as l>
<tr>
<td>${l.id}</td>
<td>${l.adminName!""}</td>
<td><span class="ui label">${l.actionType!""}</span></td>
<td>${l.targetType!""}</td>
<td>${l.targetId!""}</td>
<td>
<div style="max-width:300px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;" title="${l.description!""}">
${l.description!""}
</div>
</td>
<td>
<#if l.status == "success">
<span class="ui label green">成功</span>
<#elseif l.status == "blocked">
<span class="ui label orange">阻止</span>
<#elseif l.status == "ignored">
<span class="ui label yellow">忽略</span>
<#else>
<span class="ui label">${l.status!""}</span>
</#if>
</td>
<td>${l.createTime?string('yyyy-MM-dd HH:mm:ss')!""}</td>
</tr>
</#list>
</tbody>
</table>
</@shell.page>
@@ -0,0 +1,32 @@
<#import "admin-shell.ftl" as shell>
<@shell.page title="后台管理 - 文章管理" active=active adminName=adminName>
<h2 class="ui header">文章管理</h2>
<table class="ui striped celled table">
<thead>
<tr>
<th>ID</th>
<th>标题</th>
<th>创建人ID</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<#list discussions as d>
<tr>
<td>${d.id}</td>
<td>${d.title!""}</td>
<td>${d.createId!""}</td>
<td>${d.createTime?string('yyyy-MM-dd HH:mm:ss')!""}</td>
<td>
<a class="ui tiny button" href="/getdiscussionsbyid?id=${d.id}" target="_blank">查看</a>
<form style="display:inline" method="post" action="/admin/discussions/${d.id}/delete" onsubmit="return confirm('确认删除该文章及其评论吗?');">
<input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}"/>
<button class="ui tiny red button" type="submit">删除</button>
</form>
</td>
</tr>
</#list>
</tbody>
</table>
</@shell.page>
+46 -334
View File
@@ -1,338 +1,50 @@
<!DOCTYPE html> <#import "admin-shell.ftl" as shell>
<html> <@shell.page title="后台管理 - 总览" active=active adminName=adminName>
<title>后台管理首页</title> <h2 class="ui header">系统总览</h2>
<head>
<meta name="_csrf" content="${_csrf.token}"/>
<meta name="_csrf_header" content="${_csrf.headerName}"/>
<#--jquery--> <div class="ui four stackable cards">
<script crossorigin="anonymous" integrity="sha384-vk5WoKIaW/vJyUAd9n/wmopsmNhiy+L2Z+SBxGYnUkunIxVxAv/UtMOhba/xskxh" <div class="card"><div class="content"><div class="header">用户总数</div><div class="description">${userCount}</div></div></div>
src="https://lib.baomitu.com/jquery/3.4.1/jquery.min.js"></script> <div class="card"><div class="content"><div class="header">文章总数</div><div class="description">${discussionCount}</div></div></div>
<div class="card"><div class="content"><div class="header">评论总数</div><div class="description">${postCount}</div></div></div>
<div class="card"><div class="content"><div class="header">角色总数</div><div class="description">${roleCount}</div></div></div>
</div>
<#--semantic-ui--> <h3 class="ui dividing header">服务器与运行时</h3>
<link rel="stylesheet" type="text/css" href="/static/semanticui/semantic.css"> <div class="ui two stackable cards">
<script type="text/javascript" src="/static/semanticui/semantic.js"></script> <div class="card">
<div class="content">
<div class="header">CPU / 内存</div>
<div class="description">
<p>CPU 负载: ${sys.cpuLoad}</p>
<p>JVM 已用内存: ${sys.usedMb} MB / ${sys.totalMb} MB</p>
<p>JVM 最大内存: ${sys.maxMb} MB</p>
<p>Heap 已用: ${sys.heapUsedMb} MB</p>
<p>应用运行时长: ${sys.uptimeMs} ms</p>
</div>
</div>
</div>
<div class="card">
<div class="content">
<div class="header">关键依赖版本</div>
<div class="description">
<p>Spring Boot: ${deps.springBoot}</p>
<p>Java: ${deps.java}</p>
<p>Spring: ${deps.spring}</p>
<p>Hibernate: ${deps.hibernate}</p>
<p>Flyway: ${deps.flyway}</p>
<p>Druid: ${deps.druid}</p>
<p>Ehcache: ${deps.ehcache}</p>
<p>SpringDoc: ${deps.springdoc}</p>
</div>
</div>
</div>
</div>
<link rel="stylesheet" type="text/css" href="/static/semanticui/my.css"> <h3 class="ui dividing header">快捷入口</h3>
<div class="ui buttons">
<#--tinymce--> <a class="ui primary button" href="/admin/users">用户与角色</a>
<script crossorigin="anonymous" integrity="sha384-CpsBIlOAWHuSRRN235sCBzEeKN6hLT6SpOGRkGadKpYj0gDP7+s3Q8pC38z8uGHH" <a class="ui button" href="/admin/discussions">文章管理</a>
src="https://lib.baomitu.com/tinymce/5.1.1/tinymce.min.js"></script> <a class="ui button" href="/admin/posts">评论管理</a>
<a class="ui button" href="/swagger-ui.html" target="_blank">接口文档</a>
<#--layx-->
<link href="/static/layx/layx.min.css?b14794a8a3baf3e8b58e" rel="stylesheet">
<script type="text/javascript" src="/static/layx/layx.min.js?b14794a8a3baf3e8b58e"></script>
<script type="text/javascript" charset="UTF-8" src="/static/js/comm/util.js"></script>
<style type="text/css">
body {
-webkit-font-smoothing: antialiased;
-moz-font-smoothing: grayscale;
}
#sidebar {
position: fixed;
height: 100vh;
background-color: #f5f5f5;
padding-top: 68px;
padding-left: 0;
padding-right: 0;
}
#sidebar .ui.menu > a.item {
padding: 10px 20px;
line-height: 20px;
color: #337ab7;
border-radius: 0 !important;
margin-top: 0;
margin-bottom: 0;
}
#sidebar .ui.menu > a.item.active {
background-color: #337ab7;
color: white;
border: none !important;
}
#sidebar .ui.menu > a.item:hover {
background-color: #eee;
color: #23527c;
}
#content {
padding-top: 56px;
padding-left: 20px;
padding-right: 20px;
}
#content h1 {
font-size: 36px;
}
#content .ui.dividing.header {
width: 100%;
}
.ui.centered.small.circular.image {
margin-top: 14px;
margin-bottom: 14px;
}
.ui.borderless.menu {
box-shadow: none;
flex-wrap: wrap;
border: none;
padding-left: 0;
padding-right: 0;
}
.ui.mobile.only.grid .ui.menu .ui.vertical.menu {
display: none;
}
</style>
</head>
<body id="root">
<div class="ui tablet computer only padded grid">
<div class="ui inverted borderless top fixed fluid menu">
<a class="header item">Jiscuss后台管理</a>
<div class="right menu">
<div class="item">
<div class="ui small input"><input placeholder="Search..." /></div>
</div> </div>
<a class="item">常用管理</a> <a class="item">设置</a> </@shell.page>
<a class="item">用户</a> <a class="item">主题</a> <a class="item">注销</a>
</div>
</div>
</div>
<div class="ui mobile only padded grid">
<div class="ui top fixed borderless fluid inverted menu">
<a class="header item">Project Name</a>
<div class="right menu">
<div class="item">
<button class="ui icon toggle basic inverted button">
<i class="content icon"></i>
</button>
</div>
</div>
<div class="ui vertical borderless inverted fluid menu">
<a class="item">Dashboard</a> <a class="item">Settings</a>
<a class="item">Profile</a> <a class="item">Help</a>
<div class="ui fitted divider"></div>
<div class="item">
<div class="ui small input"><input placeholder="Search..." /></div>
</div>
</div>
</div>
</div>
<div class="ui padded grid">
<div
class="three wide tablet only three wide computer only column"
id="sidebar"
>
<div class="ui vertical borderless fluid text menu">
<a class="active item">常用管理</a> <a class="item">设置</a>
<div class="ui hidden divider"></div>
<a class="item">用户</a> <a class="item">主题</a>
<div class="ui hidden divider"></div>
<a class="item">Jiscuss文档</a>
<a class="item" href="/">返回首页</a>
</div>
</div>
<div
class="sixteen wide mobile thirteen wide tablet thirteen wide computer right floated column"
id="content"
>
<div class="ui padded grid">
<div class="row">
<h1 class="ui huge dividing header">Dashboard</h1>
</div>
<div class="center aligned row">
<div
class="eight wide mobile four wide tablet four wide computer column"
>
<img
class="ui centered small circular image"
src="./static/images/wireframe/square-image.png"
/>
<div class="ui large basic label">Label</div>
<p>Something else</p>
</div>
<div
class="eight wide mobile four wide tablet four wide computer column"
>
<img
class="ui centered small circular image"
src="./static/images/wireframe/square-image.png"
/>
<div class="ui large basic label">Label</div>
<p>Something else</p>
</div>
<div
class="eight wide mobile four wide tablet four wide computer column"
>
<img
class="ui centered small circular image"
src="./static/images/wireframe/square-image.png"
/>
<div class="ui large basic label">Label</div>
<p>Something else</p>
</div>
<div
class="eight wide mobile four wide tablet four wide computer column"
>
<img
class="ui centered small circular image"
src="./static/images/wireframe/square-image.png"
/>
<div class="ui large basic label">Label</div>
<p>Something else</p>
</div>
</div>
<div class="ui hidden section divider"></div>
<div class="row">
<h1 class="ui huge dividing header">Section title</h1>
</div>
<div class="row">
<table class="ui single line striped selectable unstackable table">
<thead>
<tr>
<th>#</th>
<th>Header</th>
<th>Header</th>
<th>Header</th>
<th>Header</th>
</tr>
</thead>
<tbody>
<tr>
<td>1,001</td>
<td>Lorem</td>
<td>ipsum</td>
<td>dolor</td>
<td>sit</td>
</tr>
<tr>
<td>1,002</td>
<td>amet</td>
<td>consectetur</td>
<td>adipiscing</td>
<td>elit</td>
</tr>
<tr>
<td>1,003</td>
<td>Integer</td>
<td>nec</td>
<td>odio</td>
<td>Praesent</td>
</tr>
<tr>
<td>1,003</td>
<td>libero</td>
<td>Sed</td>
<td>cursus</td>
<td>ante</td>
</tr>
<tr>
<td>1,004</td>
<td>dapibus</td>
<td>diam</td>
<td>Sed</td>
<td>nisi</td>
</tr>
<tr>
<td>1,005</td>
<td>Nulla</td>
<td>quis</td>
<td>sem</td>
<td>at</td>
</tr>
<tr>
<td>1,006</td>
<td>nibh</td>
<td>elementum</td>
<td>imperdiet</td>
<td>Duis</td>
</tr>
<tr>
<td>1,007</td>
<td>sagittis</td>
<td>ipsum</td>
<td>Praesent</td>
<td>mauris</td>
</tr>
<tr>
<td>1,008</td>
<td>Fusce</td>
<td>nec</td>
<td>tellus</td>
<td>sed</td>
</tr>
<tr>
<td>1,009</td>
<td>augue</td>
<td>semper</td>
<td>porta</td>
<td>Mauris</td>
</tr>
<tr>
<td>1,010</td>
<td>massa</td>
<td>Vestibulum</td>
<td>lacinia</td>
<td>arcu</td>
</tr>
<tr>
<td>1,011</td>
<td>eget</td>
<td>nulla</td>
<td>Class</td>
<td>aptent</td>
</tr>
<tr>
<td>1,012</td>
<td>taciti</td>
<td>sociosqu</td>
<td>ad</td>
<td>litora</td>
</tr>
<tr>
<td>1,013</td>
<td>torquent</td>
<td>per</td>
<td>conubia</td>
<td>nostra</td>
</tr>
<tr>
<td>1,014</td>
<td>per</td>
<td>inceptos</td>
<td>himenaeos</td>
<td>Curabitur</td>
</tr>
<tr>
<td>1,015</td>
<td>sodales</td>
<td>ligula</td>
<td>in</td>
<td>libero</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<script>
$(document).ready(function() {
$(".ui.toggle.button").click(function() {
$(".mobile.only.grid .ui.vertical.menu").toggle(100);
});
});
</script>
</body>
</html>
@@ -0,0 +1,8 @@
<#import "admin-shell.ftl" as shell>
<@shell.page title="后台管理 - 插件管理(预留)" active=active adminName=adminName>
<h2 class="ui header">插件管理(预留)</h2>
<div class="ui info message">
<div class="header">预留扩展点</div>
<p>后续可在此接入插件生命周期管理:安装、启停、配置、版本升级、依赖校验。</p>
</div>
</@shell.page>
@@ -0,0 +1,33 @@
<#import "admin-shell.ftl" as shell>
<@shell.page title="后台管理 - 评论管理" active=active adminName=adminName>
<h2 class="ui header">评论管理</h2>
<table class="ui compact celled table">
<thead>
<tr>
<th>ID</th>
<th>文章</th>
<th>作者</th>
<th>内容</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<#list posts as p>
<tr>
<td>${p.id}</td>
<td>${discussionNames[p.discussionId]!('#'+p.discussionId?c)}</td>
<td>${userNames[p.createId]!('#'+p.createId?c)}</td>
<td><div style="max-width:420px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${p.content!""}</div></td>
<td>${p.createTime?string('yyyy-MM-dd HH:mm:ss')!""}</td>
<td>
<form style="display:inline" method="post" action="/admin/posts/${p.id}/delete" onsubmit="return confirm('确认删除该评论吗?');">
<input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}"/>
<button class="ui tiny red button" type="submit">删除</button>
</form>
</td>
</tr>
</#list>
</tbody>
</table>
</@shell.page>
@@ -0,0 +1,8 @@
<#import "admin-shell.ftl" as shell>
<@shell.page title="后台管理 - 主题管理(预留)" active=active adminName=adminName>
<h2 class="ui header">主题管理(预留)</h2>
<div class="ui info message">
<div class="header">预留扩展点</div>
<p>后续可在此增加主题包管理、配色切换、模板覆盖与预览发布。</p>
</div>
</@shell.page>
@@ -0,0 +1,65 @@
<#import "admin-shell.ftl" as shell>
<@shell.page title="后台管理 - 版本升级日志" active=active adminName=adminName>
<h2 class="ui header">版本功能升级日志</h2>
<p class="small-muted">记录每次升级新增与修复内容,便于发布追踪与回滚评估。</p>
<form class="ui form" method="post" action="/admin/upgrade-logs">
<input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}"/>
<div class="three fields">
<div class="field">
<label>版本号</label>
<input name="version" placeholder="例如 v3.5.13" required>
</div>
<div class="field">
<label>类型</label>
<select class="ui dropdown" name="type">
<option value="feature">feature</option>
<option value="fix">fix</option>
<option value="security">security</option>
<option value="refactor">refactor</option>
</select>
</div>
<div class="field">
<label>标题</label>
<input name="title" placeholder="本次升级摘要" required>
</div>
</div>
<div class="field">
<label>详细内容</label>
<textarea name="content" rows="4" placeholder="描述新增、修复、兼容性说明等"></textarea>
</div>
<button class="ui primary button" type="submit">新增日志</button>
</form>
<h3 class="ui dividing header">历史记录</h3>
<table class="ui celled table">
<thead>
<tr>
<th>ID</th>
<th>版本</th>
<th>类型</th>
<th>标题</th>
<th>内容</th>
<th>创建人</th>
<th>创建时间</th>
</tr>
</thead>
<tbody>
<#list logs as l>
<tr>
<td>${l.id}</td>
<td>${l.version!""}</td>
<td>${l.type!""}</td>
<td>${l.title!""}</td>
<td>${l.content!""}</td>
<td>${l.createdBy!""}</td>
<td>${l.createTime?string('yyyy-MM-dd HH:mm:ss')!""}</td>
</tr>
</#list>
</tbody>
</table>
<script>
$('.ui.dropdown').dropdown();
</script>
</@shell.page>
@@ -0,0 +1,82 @@
<#import "admin-shell.ftl" as shell>
<@shell.page title="后台管理 - 用户与角色" active=active adminName=adminName>
<h2 class="ui header">用户与角色管理</h2>
<p class="small-muted">简单 RBAC:用户可绑定多个角色,拥有 ADMIN 角色的用户可以进入后台管理。</p>
<h3 class="ui dividing header">角色管理</h3>
<form class="ui form" method="post" action="/admin/roles">
<input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}"/>
<div class="three fields">
<div class="field"><label>角色编码</label><input name="code" placeholder="如 ADMIN_AUDIT" required></div>
<div class="field"><label>角色名称</label><input name="name" placeholder="如 审计管理员" required></div>
<div class="field"><label>描述</label><input name="description" placeholder="角色用途说明"></div>
</div>
<button class="ui small primary button" type="submit">新增角色</button>
</form>
<table class="ui celled compact table">
<thead><tr><th>ID</th><th>编码</th><th>名称</th><th>状态</th><th>操作</th></tr></thead>
<tbody>
<#list roles as r>
<tr>
<td>${r.id}</td>
<td>${r.code!""}</td>
<td>${r.name!""}</td>
<td><#if r.enabled?? && r.enabled==1>启用<#else>停用</#if></td>
<td>
<form style="display:inline" method="post" action="/admin/roles/${r.id}/toggle">
<input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}"/>
<button class="ui tiny button" type="submit">切换启停</button>
</form>
</td>
</tr>
</#list>
</tbody>
</table>
<h3 class="ui dividing header">用户角色绑定</h3>
<table class="ui celled table">
<thead>
<tr>
<th>ID</th>
<th>用户名</th>
<th>邮箱</th>
<th>角色绑定</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<#list users as u>
<tr>
<td>${u.id}</td>
<td>${u.username!""}</td>
<td>${u.email!""}</td>
<td>
<form class="ui form" method="post" action="/admin/users/${u.id}/roles">
<input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}"/>
<div class="inline fields">
<#list roles as r>
<div class="field">
<div class="ui checkbox">
<input type="checkbox" name="roleIds" value="${r.id}"
<#if userRoleIds[u.id]?? && userRoleIds[u.id]?seq_contains(r.id)>checked</#if>>
<label>${r.code}</label>
</div>
</div>
</#list>
</div>
<button class="ui tiny primary button" type="submit">保存角色</button>
</form>
</td>
<td>
<a class="ui tiny button" href="/user?id=${u.id}" target="_blank">查看用户页</a>
</td>
</tr>
</#list>
</tbody>
</table>
<script>
$('.ui.checkbox').checkbox();
</script>
</@shell.page>