feat(auth): 添加登录日志记录功能并配置软删除逻辑
- 在application.yml中配置MyBatis-Plus逻辑删除字段 - 在BaseEntity中添加@TableLogic注解实现软删除 - 在AuthServiceImpl中注入SysLogService和HttpServletRequest - 实现登录成功和失败的日志记录功能 - 添加LoginUser类用于安全认证 - 创建Log注解和LogAspect切面实现操作日志记录 - 添加PermissionService用于权限验证 - 在RoleController中添加用户查询接口和日志注解 - 在前端添加Logs页面展示操作日志 - 更新Roles页面UI并添加相关API调用 - 添加AOP依赖并在启动类启用异步支持master
parent
e379a228a3
commit
a1db81892c
|
|
@ -43,6 +43,10 @@
|
|||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-cache</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-aop</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.baomidou</groupId>
|
||||
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@ package com.imeeting;
|
|||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.scheduling.annotation.EnableAsync;
|
||||
|
||||
@SpringBootApplication
|
||||
@EnableAsync
|
||||
public class ImeetingApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(ImeetingApplication.class, args);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
package com.imeeting.common.annotation;
|
||||
|
||||
import java.lang.annotation.*;
|
||||
|
||||
@Target(ElementType.METHOD)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
public @interface Log {
|
||||
String value() default ""; // 操作描述
|
||||
String type() default ""; // 资源类型/模块名
|
||||
}
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
package com.imeeting.common.aspect;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.imeeting.common.annotation.Log;
|
||||
import com.imeeting.entity.SysLog;
|
||||
import com.imeeting.security.LoginUser;
|
||||
import com.imeeting.service.SysLogService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.aspectj.lang.ProceedingJoinPoint;
|
||||
import org.aspectj.lang.annotation.Around;
|
||||
import org.aspectj.lang.annotation.Aspect;
|
||||
import org.aspectj.lang.reflect.MethodSignature;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Aspect
|
||||
@Component
|
||||
public class LogAspect {
|
||||
|
||||
private final SysLogService sysLogService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public LogAspect(SysLogService sysLogService, ObjectMapper objectMapper) {
|
||||
this.sysLogService = sysLogService;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Around("@annotation(com.imeeting.common.annotation.Log)")
|
||||
public Object around(ProceedingJoinPoint point) throws Throwable {
|
||||
long start = System.currentTimeMillis();
|
||||
Object result = null;
|
||||
Exception exception = null;
|
||||
|
||||
try {
|
||||
result = point.proceed();
|
||||
return result;
|
||||
} catch (Exception e) {
|
||||
exception = e;
|
||||
throw e;
|
||||
} finally {
|
||||
saveLog(point, result, exception, System.currentTimeMillis() - start);
|
||||
}
|
||||
}
|
||||
|
||||
private void saveLog(ProceedingJoinPoint joinPoint, Object result, Exception e, long duration) {
|
||||
try {
|
||||
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
||||
if (attributes == null) return;
|
||||
HttpServletRequest request = attributes.getRequest();
|
||||
|
||||
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
|
||||
Method method = signature.getMethod();
|
||||
Log logAnnotation = method.getAnnotation(Log.class);
|
||||
|
||||
SysLog sysLog = new SysLog();
|
||||
sysLog.setOperationType(request.getMethod());
|
||||
sysLog.setResourceType(logAnnotation.type().isEmpty() ? logAnnotation.value() : logAnnotation.type());
|
||||
|
||||
// Capture request parameters
|
||||
String params = getArgsJson(joinPoint);
|
||||
sysLog.setDetail(logAnnotation.value() + " | Params: " + params);
|
||||
|
||||
sysLog.setIpAddress(request.getRemoteAddr());
|
||||
sysLog.setUserAgent(request.getHeader("User-Agent"));
|
||||
sysLog.setCreatedAt(LocalDateTime.now());
|
||||
|
||||
// Get Current User
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth != null && auth.getPrincipal() instanceof LoginUser) {
|
||||
LoginUser user = (LoginUser) auth.getPrincipal();
|
||||
sysLog.setUserId(user.getUserId());
|
||||
sysLog.setUsername(user.getUsername());
|
||||
}
|
||||
|
||||
if (e != null) {
|
||||
sysLog.setStatus(0);
|
||||
sysLog.setErrorMessage(e.getMessage());
|
||||
} else {
|
||||
sysLog.setStatus(1);
|
||||
}
|
||||
|
||||
// Record duration in detail or a separate field if we want
|
||||
// sysLog.setDetail(sysLog.getDetail() + " (Time: " + duration + "ms)");
|
||||
|
||||
sysLogService.recordLog(sysLog);
|
||||
} catch (Exception ex) {
|
||||
// Log the logging error to console only
|
||||
ex.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
private String getArgsJson(ProceedingJoinPoint joinPoint) {
|
||||
try {
|
||||
Object[] args = joinPoint.getArgs();
|
||||
if (args == null || args.length == 0) {
|
||||
return "[]";
|
||||
}
|
||||
Object[] filterArgs = new Object[args.length];
|
||||
for (int i = 0; i < args.length; i++) {
|
||||
if (args[i] instanceof jakarta.servlet.ServletRequest
|
||||
|| args[i] instanceof jakarta.servlet.ServletResponse
|
||||
|| args[i] instanceof org.springframework.web.multipart.MultipartFile) {
|
||||
continue;
|
||||
}
|
||||
filterArgs[i] = args[i];
|
||||
}
|
||||
return objectMapper.writeValueAsString(filterArgs);
|
||||
} catch (Exception e) {
|
||||
return "[Error capturing params]";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,10 +2,13 @@ package com.imeeting.controller;
|
|||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.imeeting.common.ApiResponse;
|
||||
import com.imeeting.common.annotation.Log;
|
||||
import com.imeeting.entity.SysRole;
|
||||
import com.imeeting.entity.SysRolePermission;
|
||||
import com.imeeting.entity.SysUser;
|
||||
import com.imeeting.mapper.SysRolePermissionMapper;
|
||||
import com.imeeting.service.SysRoleService;
|
||||
import com.imeeting.service.SysUserService;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
|
|
@ -16,10 +19,12 @@ import java.util.List;
|
|||
@RequestMapping("/api/roles")
|
||||
public class RoleController {
|
||||
private final SysRoleService sysRoleService;
|
||||
private final SysUserService sysUserService;
|
||||
private final SysRolePermissionMapper sysRolePermissionMapper;
|
||||
|
||||
public RoleController(SysRoleService sysRoleService, SysRolePermissionMapper sysRolePermissionMapper) {
|
||||
public RoleController(SysRoleService sysRoleService, SysUserService sysUserService, SysRolePermissionMapper sysRolePermissionMapper) {
|
||||
this.sysRoleService = sysRoleService;
|
||||
this.sysUserService = sysUserService;
|
||||
this.sysRolePermissionMapper = sysRolePermissionMapper;
|
||||
}
|
||||
|
||||
|
|
@ -29,6 +34,12 @@ public class RoleController {
|
|||
return ApiResponse.ok(sysRoleService.list());
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/users")
|
||||
@PreAuthorize("@ss.hasPermi('sys_role:query')")
|
||||
public ApiResponse<List<SysUser>> listUsers(@PathVariable Long id) {
|
||||
return ApiResponse.ok(sysUserService.listUsersByRoleId(id));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
@PreAuthorize("@ss.hasPermi('sys_role:query')")
|
||||
public ApiResponse<SysRole> get(@PathVariable Long id) {
|
||||
|
|
@ -37,12 +48,14 @@ public class RoleController {
|
|||
|
||||
@PostMapping
|
||||
@PreAuthorize("@ss.hasPermi('sys_role:create')")
|
||||
@com.imeeting.common.annotation.Log(value = "新增角色", type = "角色管理")
|
||||
public ApiResponse<Boolean> create(@RequestBody SysRole role) {
|
||||
return ApiResponse.ok(sysRoleService.save(role));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@PreAuthorize("@ss.hasPermi('sys_role:update')")
|
||||
@com.imeeting.common.annotation.Log(value = "修改角色", type = "角色管理")
|
||||
public ApiResponse<Boolean> update(@PathVariable Long id, @RequestBody SysRole role) {
|
||||
role.setRoleId(id);
|
||||
return ApiResponse.ok(sysRoleService.updateById(role));
|
||||
|
|
@ -50,6 +63,7 @@ public class RoleController {
|
|||
|
||||
@DeleteMapping("/{id}")
|
||||
@PreAuthorize("@ss.hasPermi('sys_role:delete')")
|
||||
@Log(value = "删除角色", type = "角色管理")
|
||||
public ApiResponse<Boolean> delete(@PathVariable Long id) {
|
||||
return ApiResponse.ok(sysRoleService.removeById(id));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
package com.imeeting.controller;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.imeeting.common.ApiResponse;
|
||||
import com.imeeting.entity.SysLog;
|
||||
import com.imeeting.service.SysLogService;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/logs")
|
||||
public class SysLogController {
|
||||
private final SysLogService sysLogService;
|
||||
|
||||
public SysLogController(SysLogService sysLogService) {
|
||||
this.sysLogService = sysLogService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@PreAuthorize("@ss.hasPermi('sys_log:list')")
|
||||
public ApiResponse<Page<SysLog>> list(
|
||||
@RequestParam(defaultValue = "1") Integer current,
|
||||
@RequestParam(defaultValue = "10") Integer size,
|
||||
@RequestParam(required = false) String username,
|
||||
@RequestParam(required = false) String operationType, // LOGIN or others
|
||||
@RequestParam(required = false) Integer status,
|
||||
@RequestParam(required = false) String startDate,
|
||||
@RequestParam(required = false) String endDate
|
||||
) {
|
||||
LambdaQueryWrapper<SysLog> query = new LambdaQueryWrapper<>();
|
||||
|
||||
if (username != null && !username.isEmpty()) {
|
||||
query.like(SysLog::getUsername, username);
|
||||
}
|
||||
if (operationType != null && !operationType.isEmpty()) {
|
||||
if ("LOGIN".equals(operationType)) {
|
||||
query.eq(SysLog::getOperationType, "LOGIN");
|
||||
} else {
|
||||
query.ne(SysLog::getOperationType, "LOGIN");
|
||||
}
|
||||
}
|
||||
if (status != null) {
|
||||
query.eq(SysLog::getStatus, status);
|
||||
}
|
||||
// Simplified date range filtering
|
||||
if (startDate != null && !startDate.isEmpty()) {
|
||||
query.ge(SysLog::getCreatedAt, startDate + " 00:00:00");
|
||||
}
|
||||
if (endDate != null && !endDate.isEmpty()) {
|
||||
query.le(SysLog::getCreatedAt, endDate + " 23:59:59");
|
||||
}
|
||||
|
||||
query.orderByDesc(SysLog::getCreatedAt);
|
||||
return ApiResponse.ok(sysLogService.page(new Page<>(current, size), query));
|
||||
}
|
||||
}
|
||||
|
|
@ -72,6 +72,7 @@ public class UserController {
|
|||
|
||||
@PostMapping
|
||||
@PreAuthorize("@ss.hasPermi('sys_user:create')")
|
||||
@com.imeeting.common.annotation.Log(value = "新增用户", type = "用户管理")
|
||||
public ApiResponse<Boolean> create(@RequestBody SysUser user) {
|
||||
if (user.getPasswordHash() != null && !user.getPasswordHash().isEmpty()) {
|
||||
user.setPasswordHash(passwordEncoder.encode(user.getPasswordHash()));
|
||||
|
|
@ -81,6 +82,7 @@ public class UserController {
|
|||
|
||||
@PutMapping("/{id}")
|
||||
@PreAuthorize("@ss.hasPermi('sys_user:update')")
|
||||
@com.imeeting.common.annotation.Log(value = "修改用户", type = "用户管理")
|
||||
public ApiResponse<Boolean> update(@PathVariable Long id, @RequestBody SysUser user) {
|
||||
user.setUserId(id);
|
||||
if (user.getPasswordHash() != null && !user.getPasswordHash().isEmpty()) {
|
||||
|
|
@ -91,6 +93,7 @@ public class UserController {
|
|||
|
||||
@DeleteMapping("/{id}")
|
||||
@PreAuthorize("@ss.hasPermi('sys_user:delete')")
|
||||
@com.imeeting.common.annotation.Log(value = "删除用户", type = "用户管理")
|
||||
public ApiResponse<Boolean> delete(@PathVariable Long id) {
|
||||
return ApiResponse.ok(sysUserService.removeById(id));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package com.imeeting.entity;
|
|||
|
||||
import com.baomidou.mybatisplus.annotation.FieldFill;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableLogic;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
|
@ -10,6 +11,8 @@ import java.time.LocalDateTime;
|
|||
public class BaseEntity {
|
||||
private Long tenantId;
|
||||
private Integer status;
|
||||
|
||||
@TableLogic
|
||||
private Integer isDeleted;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
package com.imeeting.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("sys_log")
|
||||
public class SysLog {
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private Long userId;
|
||||
private String username;
|
||||
private String operationType; // LOGIN, LOGOUT, CREATE, UPDATE, DELETE, QUERY
|
||||
private String resourceType; // 所属模块/资源
|
||||
private Long resourceId;
|
||||
private String detail; // 操作详情(可以是 JSON)
|
||||
private String ipAddress;
|
||||
private String userAgent;
|
||||
private Integer status; // 1-成功, 0-失败
|
||||
private String errorMessage;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package com.imeeting.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.imeeting.entity.SysLog;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface SysLogMapper extends BaseMapper<SysLog> {
|
||||
}
|
||||
|
|
@ -3,6 +3,12 @@ package com.imeeting.mapper;
|
|||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.imeeting.entity.SysUser;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface SysUserMapper extends BaseMapper<SysUser> {}
|
||||
public interface SysUserMapper extends BaseMapper<SysUser> {
|
||||
@Select("SELECT u.* FROM sys_user u JOIN sys_user_role ur ON u.user_id = ur.user_id WHERE ur.role_id = #{roleId}")
|
||||
List<SysUser> selectUsersByRoleId(@Param("roleId") Long roleId);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
package com.imeeting.security;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class LoginUser implements UserDetails {
|
||||
private Long userId;
|
||||
private String username;
|
||||
private Set<String> permissions;
|
||||
|
||||
@Override
|
||||
public Collection<? extends GrantedAuthority> getAuthorities() {
|
||||
if (permissions == null) return null;
|
||||
return permissions.stream()
|
||||
.map(SimpleGrantedAuthority::new)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getPassword() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isAccountNonExpired() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isAccountNonLocked() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isCredentialsNonExpired() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isEnabled() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
package com.imeeting.security;
|
||||
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
@Service("ss")
|
||||
public class PermissionService {
|
||||
|
||||
/**
|
||||
* 验证用户是否具备某权限
|
||||
*
|
||||
* @param permission 权限字符串
|
||||
* @return 用户是否具备某权限
|
||||
*/
|
||||
public boolean hasPermi(String permission) {
|
||||
if (permission == null || permission.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (authentication == null || !(authentication.getPrincipal() instanceof LoginUser)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
LoginUser loginUser = (LoginUser) authentication.getPrincipal();
|
||||
|
||||
// 超级管理员(ID=1) 拥有所有权限
|
||||
if (loginUser.getUserId() != null && loginUser.getUserId() == 1L) {
|
||||
return true;
|
||||
}
|
||||
|
||||
Set<String> permissions = loginUser.getPermissions();
|
||||
if (CollectionUtils.isEmpty(permissions)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return permissions.contains(permission);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package com.imeeting.service;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.imeeting.entity.SysLog;
|
||||
|
||||
public interface SysLogService extends IService<SysLog> {
|
||||
void recordLog(SysLog log);
|
||||
}
|
||||
|
|
@ -1,6 +1,17 @@
|
|||
package com.imeeting.service;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
|
||||
import com.imeeting.entity.SysUser;
|
||||
|
||||
public interface SysUserService extends IService<SysUser> {}
|
||||
import java.util.List;
|
||||
|
||||
|
||||
|
||||
public interface SysUserService extends IService<SysUser> {
|
||||
|
||||
List<SysUser> listUsersByRoleId(Long roleId);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -7,17 +7,17 @@ import com.imeeting.auth.dto.TokenResponse;
|
|||
import com.imeeting.common.RedisKeys;
|
||||
import com.imeeting.common.SysParamKeys;
|
||||
import com.imeeting.entity.Device;
|
||||
import com.imeeting.entity.SysLog;
|
||||
import com.imeeting.entity.SysUser;
|
||||
import com.imeeting.service.AuthService;
|
||||
import com.imeeting.service.DeviceService;
|
||||
import com.imeeting.service.SysParamService;
|
||||
import com.imeeting.service.SysUserService;
|
||||
import com.imeeting.service.*;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.Duration;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
|
@ -31,6 +31,8 @@ public class AuthServiceImpl implements AuthService {
|
|||
private final StringRedisTemplate stringRedisTemplate;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final JwtTokenProvider jwtTokenProvider;
|
||||
private final SysLogService sysLogService;
|
||||
private final HttpServletRequest httpServletRequest;
|
||||
|
||||
@Value("${app.token.access-default-minutes:30}")
|
||||
private long accessDefaultMinutes;
|
||||
|
|
@ -41,17 +43,20 @@ public class AuthServiceImpl implements AuthService {
|
|||
|
||||
public AuthServiceImpl(SysUserService sysUserService, DeviceService deviceService, SysParamService sysParamService,
|
||||
StringRedisTemplate stringRedisTemplate, PasswordEncoder passwordEncoder,
|
||||
JwtTokenProvider jwtTokenProvider) {
|
||||
JwtTokenProvider jwtTokenProvider, SysLogService sysLogService, HttpServletRequest httpServletRequest) {
|
||||
this.sysUserService = sysUserService;
|
||||
this.deviceService = deviceService;
|
||||
this.sysParamService = sysParamService;
|
||||
this.stringRedisTemplate = stringRedisTemplate;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.jwtTokenProvider = jwtTokenProvider;
|
||||
this.sysLogService = sysLogService;
|
||||
this.httpServletRequest = httpServletRequest;
|
||||
}
|
||||
|
||||
@Override
|
||||
public TokenResponse login(LoginRequest request) {
|
||||
try {
|
||||
if (isCaptchaEnabled()) {
|
||||
validateCaptcha(request.getCaptchaId(), request.getCaptchaCode());
|
||||
}
|
||||
|
|
@ -86,7 +91,27 @@ public class AuthServiceImpl implements AuthService {
|
|||
}
|
||||
TokenResponse tokens = issueTokens(user, deviceCode, accessMinutes, refreshDays);
|
||||
cacheRefreshToken(user.getUserId(), deviceCode, tokens.getRefreshToken(), refreshDays);
|
||||
|
||||
recordLoginLog(user.getUserId(), user.getUsername(), 1, "登录成功");
|
||||
return tokens;
|
||||
} catch (Exception e) {
|
||||
recordLoginLog(null, request.getUsername(), 0, e.getMessage());
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private void recordLoginLog(Long userId, String username, Integer status, String msg) {
|
||||
SysLog sysLog = new SysLog();
|
||||
sysLog.setUserId(userId);
|
||||
sysLog.setUsername(username);
|
||||
sysLog.setOperationType("LOGIN");
|
||||
sysLog.setResourceType("认证模块");
|
||||
sysLog.setDetail(msg);
|
||||
sysLog.setStatus(status);
|
||||
sysLog.setIpAddress(httpServletRequest.getRemoteAddr());
|
||||
sysLog.setUserAgent(httpServletRequest.getHeader("User-Agent"));
|
||||
sysLog.setCreatedAt(LocalDateTime.now());
|
||||
sysLogService.recordLog(sysLog);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
package com.imeeting.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.imeeting.entity.SysLog;
|
||||
import com.imeeting.mapper.SysLogMapper;
|
||||
import com.imeeting.service.SysLogService;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class SysLogServiceImpl extends ServiceImpl<SysLogMapper, SysLog> implements SysLogService {
|
||||
|
||||
@Async
|
||||
@Override
|
||||
public void recordLog(SysLog log) {
|
||||
save(log);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,31 @@
|
|||
package com.imeeting.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
|
||||
import com.imeeting.entity.SysUser;
|
||||
|
||||
import com.imeeting.mapper.SysUserMapper;
|
||||
|
||||
import com.imeeting.service.SysUserService;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
|
||||
|
||||
@Service
|
||||
public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> implements SysUserService {}
|
||||
|
||||
public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> implements SysUserService {
|
||||
|
||||
@Override
|
||||
|
||||
public List<SysUser> listUsersByRoleId(Long roleId) {
|
||||
|
||||
return baseMapper.selectUsersByRoleId(roleId);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,11 @@ spring:
|
|||
mybatis-plus:
|
||||
configuration:
|
||||
map-underscore-to-camel-case: true
|
||||
global-config:
|
||||
db-config:
|
||||
logic-delete-field: isDeleted
|
||||
logic-delete-value: 1
|
||||
logic-not-delete-value: 0
|
||||
|
||||
security:
|
||||
jwt:
|
||||
|
|
|
|||
|
|
@ -122,5 +122,15 @@ export async function saveRolePermissions(roleId: number, permIds: number[]) {
|
|||
return resp.data.data as boolean;
|
||||
}
|
||||
|
||||
export async function fetchUsersByRoleId(roleId: number) {
|
||||
const resp = await http.get(`/api/roles/${roleId}/users`);
|
||||
return resp.data.data as SysUser[];
|
||||
}
|
||||
|
||||
export async function fetchLogs(params: any) {
|
||||
const resp = await http.get("/api/logs", { params });
|
||||
return resp.data.data;
|
||||
}
|
||||
|
||||
export * from "./dict";
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,171 @@
|
|||
import { Card, Table, Tabs, Tag, Input, Space, Button, DatePicker, Select } from "antd";
|
||||
import { useEffect, useState } from "react";
|
||||
import { fetchLogs } from "../api";
|
||||
import { SearchOutlined, ReloadOutlined } from "@ant-design/icons";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
export default function Logs() {
|
||||
const [activeTab, setActiveTab] = useState("OPERATION");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [data, setData] = useState([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [params, setParams] = useState({
|
||||
current: 1,
|
||||
size: 10,
|
||||
username: "",
|
||||
status: undefined,
|
||||
startDate: "",
|
||||
endDate: ""
|
||||
});
|
||||
|
||||
const loadData = async (currentParams = params) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const operationType = activeTab === "LOGIN" ? "LOGIN" : "OPERATION";
|
||||
const result = await fetchLogs({ ...currentParams, operationType });
|
||||
setData(result.records || []);
|
||||
setTotal(result.total || 0);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [activeTab, params.current, params.size]);
|
||||
|
||||
const handleSearch = () => {
|
||||
setParams({ ...params, current: 1 });
|
||||
loadData({ ...params, current: 1 });
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
const resetParams = {
|
||||
current: 1,
|
||||
size: 10,
|
||||
username: "",
|
||||
status: undefined,
|
||||
startDate: "",
|
||||
endDate: ""
|
||||
};
|
||||
setParams(resetParams);
|
||||
loadData(resetParams);
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: "用户名",
|
||||
dataIndex: "username",
|
||||
key: "username",
|
||||
width: 120,
|
||||
render: (text: string) => text || "系统/访客"
|
||||
},
|
||||
{
|
||||
title: activeTab === "LOGIN" ? "登录模块" : "操作模块",
|
||||
dataIndex: "resourceType",
|
||||
key: "resourceType",
|
||||
width: 150
|
||||
},
|
||||
{
|
||||
title: "操作详情",
|
||||
dataIndex: "detail",
|
||||
key: "detail",
|
||||
ellipsis: true
|
||||
},
|
||||
{
|
||||
title: "IP地址",
|
||||
dataIndex: "ipAddress",
|
||||
key: "ipAddress",
|
||||
width: 140
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
dataIndex: "status",
|
||||
key: "status",
|
||||
width: 100,
|
||||
render: (status: number) => (
|
||||
<Tag color={status === 1 ? "green" : "red"}>
|
||||
{status === 1 ? "成功" : "失败"}
|
||||
</Tag>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: "操作时间",
|
||||
dataIndex: "createdAt",
|
||||
key: "createdAt",
|
||||
width: 180,
|
||||
render: (text: string) => text?.replace('T', ' ').substring(0, 19)
|
||||
}
|
||||
];
|
||||
|
||||
if (activeTab === "OPERATION") {
|
||||
columns.splice(1, 0, {
|
||||
title: "请求方式",
|
||||
dataIndex: "operationType",
|
||||
key: "operationType",
|
||||
width: 100,
|
||||
render: (t: string) => <Tag>{t}</Tag>
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<Card className="mb-4">
|
||||
<Space wrap size="middle">
|
||||
<Input
|
||||
placeholder="用户名"
|
||||
style={{ width: 160 }}
|
||||
value={params.username}
|
||||
onChange={e => setParams({ ...params, username: e.target.value })}
|
||||
/>
|
||||
<Select
|
||||
placeholder="状态"
|
||||
style={{ width: 120 }}
|
||||
allowClear
|
||||
value={params.status}
|
||||
onChange={v => setParams({ ...params, status: v })}
|
||||
options={[
|
||||
{ label: "成功", value: 1 },
|
||||
{ label: "失败", value: 0 }
|
||||
]}
|
||||
/>
|
||||
<RangePicker
|
||||
onChange={(dates) => {
|
||||
setParams({
|
||||
...params,
|
||||
startDate: dates ? dates[0]?.format("YYYY-MM-DD") || "" : "",
|
||||
endDate: dates ? dates[1]?.format("YYYY-MM-DD") || "" : ""
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Button type="primary" icon={<SearchOutlined />} onClick={handleSearch}>搜索</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={handleReset}>重置</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Tabs activeKey={activeTab} onChange={setActiveTab}>
|
||||
<Tabs.TabPane tab="操作日志" key="OPERATION" />
|
||||
<Tabs.TabPane tab="登录日志" key="LOGIN" />
|
||||
</Tabs>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: params.current,
|
||||
pageSize: params.size,
|
||||
total: total,
|
||||
showSizeChanger: true,
|
||||
onChange: (page, size) => setParams({ ...params, current: page, size }),
|
||||
showTotal: (total) => `共 ${total} 条`
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,224 +1,87 @@
|
|||
.roles-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
.roles-page-v2 {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.roles-header {
|
||||
.full-height-card {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.full-height-card .ant-card-body {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.role-list-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.role-row {
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.role-row:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.role-row-selected {
|
||||
background-color: #e6f7ff !important;
|
||||
border-right: 3px solid #1890ff;
|
||||
}
|
||||
|
||||
.role-item-content {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.roles-title {
|
||||
margin-bottom: 4px !important;
|
||||
}
|
||||
|
||||
.roles-subtitle {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.roles-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.roles-empty {
|
||||
color: #94a3b8;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.role-card {
|
||||
background: #fff;
|
||||
border: 1px solid #eef0f5;
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 10px 20px rgba(15, 23, 42, 0.04);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 230px;
|
||||
}
|
||||
|
||||
.role-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.role-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 12px;
|
||||
background: #eef4ff;
|
||||
color: #3b82f6;
|
||||
.role-item-name {
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
.role-item-code {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.role-tabs {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.role-tabs .ant-tabs-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.role-permission-tree-v2 {
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 4px;
|
||||
padding: 16px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.flex-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.role-edit-btn {
|
||||
color: #94a3b8;
|
||||
background: #f1f5f9;
|
||||
border-radius: 10px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.role-edit-btn:hover {
|
||||
color: #2563eb !important;
|
||||
}
|
||||
|
||||
.role-main {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.role-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.role-id {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.role-permission-summary {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.role-permission-badge {
|
||||
background: #e8f1ff;
|
||||
color: #2563eb;
|
||||
border-radius: 999px;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.role-permission-tags {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.role-permission-tag {
|
||||
border: none;
|
||||
background: #f1f5ff;
|
||||
color: #2563eb;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
padding: 2px 10px;
|
||||
}
|
||||
|
||||
.role-footer {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.role-drawer-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.role-drawer-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 12px;
|
||||
background: #e8f1ff;
|
||||
color: #2563eb;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.role-drawer-heading {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.role-form .ant-form-item {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.role-permission-section {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.role-permission-group-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.role-permission-group-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 6px;
|
||||
background: #e8f1ff;
|
||||
color: #2563eb;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.role-permission-tree {
|
||||
padding: 12px;
|
||||
border: 1px solid #eef0f5;
|
||||
border-radius: 12px;
|
||||
background: #fbfcff;
|
||||
max-height: 520px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.role-permission-node {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.role-drawer-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.role-drawer-cancel {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.role-drawer-submit {
|
||||
background: #0f172a;
|
||||
border-color: #0f172a;
|
||||
border-radius: 10px;
|
||||
height: 40px;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.role-drawer-submit:hover {
|
||||
background: #1f2937 !important;
|
||||
border-color: #1f2937 !important;
|
||||
.mb-4 {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,21 @@
|
|||
import { Button, Drawer, Form, Input, message, Tag, Typography, Tree } from "antd";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
message,
|
||||
Popconfirm,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
Tree,
|
||||
Row,
|
||||
Col,
|
||||
Tabs,
|
||||
Empty
|
||||
} from "antd";
|
||||
import type { DataNode } from "antd/es/tree";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
|
|
@ -7,11 +24,22 @@ import {
|
|||
listRolePermissions,
|
||||
listRoles,
|
||||
saveRolePermissions,
|
||||
updateRole
|
||||
updateRole,
|
||||
deleteRole,
|
||||
fetchUsersByRoleId
|
||||
} from "../api";
|
||||
import type { SysPermission, SysRole } from "../types";
|
||||
import type { SysPermission, SysRole, SysUser } from "../types";
|
||||
import { usePermission } from "../hooks/usePermission";
|
||||
import { EditOutlined, PlusOutlined, SafetyCertificateOutlined } from "@ant-design/icons";
|
||||
import {
|
||||
EditOutlined,
|
||||
PlusOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
SearchOutlined,
|
||||
DeleteOutlined,
|
||||
KeyOutlined,
|
||||
UserOutlined,
|
||||
SaveOutlined
|
||||
} from "@ant-design/icons";
|
||||
import "./Roles.css";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
|
@ -35,10 +63,6 @@ const buildPermissionTree = (list: SysPermission[]): PermissionNode[] => {
|
|||
const parent = map.get(node.parentId);
|
||||
if (parent) {
|
||||
parent.children!.push(node);
|
||||
} else {
|
||||
// If parent is missing, it's an orphan.
|
||||
// We don't push it to roots to avoid "submenu becomes root" issue.
|
||||
console.warn(`Orphan node detected: ${node.name} (ID: ${node.permId}, ParentID: ${node.parentId})`);
|
||||
}
|
||||
} else {
|
||||
roots.push(node);
|
||||
|
|
@ -59,32 +83,36 @@ const toTreeData = (nodes: PermissionNode[]): DataNode[] =>
|
|||
title: (
|
||||
<span className="role-permission-node">
|
||||
<span>{node.name}</span>
|
||||
{node.permType === "button" && <Tag color="blue">按钮</Tag>}
|
||||
{node.permType === "button" && <Tag color="blue" style={{ marginLeft: 8 }}>按钮</Tag>}
|
||||
</span>
|
||||
),
|
||||
children: node.children ? toTreeData(node.children) : undefined
|
||||
children: node.children && node.children.length > 0 ? toTreeData(node.children) : undefined
|
||||
}));
|
||||
|
||||
const generateRoleCode = () => `ROLE-${Date.now().toString(36).toUpperCase()}`;
|
||||
const generateRoleCode = () => `ROLE_${Date.now().toString(36).toUpperCase()}`;
|
||||
|
||||
export default function Roles() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [data, setData] = useState<SysRole[]>([]);
|
||||
const [permissions, setPermissions] = useState<SysPermission[]>([]);
|
||||
const [rolePermMap, setRolePermMap] = useState<Record<number, number[]>>({});
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<SysRole | null>(null);
|
||||
const [selectedRole, setSelectedRole] = useState<SysRole | null>(null);
|
||||
|
||||
// Right side states
|
||||
const [selectedPermIds, setSelectedPermIds] = useState<number[]>([]);
|
||||
const [halfCheckedIds, setHalfCheckedIds] = useState<number[]>([]);
|
||||
const [form] = Form.useForm();
|
||||
const { can } = usePermission();
|
||||
const [roleUsers, setRoleUsers] = useState<SysUser[]>([]);
|
||||
const [loadingUsers, setLoadingUsers] = useState(false);
|
||||
|
||||
const permissionMap = useMemo(() => {
|
||||
const map = new Map<number, SysPermission>();
|
||||
permissions.forEach((p) => map.set(p.permId, p));
|
||||
return map;
|
||||
}, [permissions]);
|
||||
// Search
|
||||
const [searchText, setSearchText] = useState("");
|
||||
|
||||
// Drawer (Only for Add/Edit basic info)
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<SysRole | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const { can } = usePermission();
|
||||
|
||||
const permissionTreeData = useMemo(
|
||||
() => toTreeData(buildPermissionTree(permissions)),
|
||||
|
|
@ -97,250 +125,233 @@ export default function Roles() {
|
|||
setPermissions(list || []);
|
||||
} catch (e) {
|
||||
setPermissions([]);
|
||||
message.error("加载权限失败,请确认有管理员权限");
|
||||
}
|
||||
};
|
||||
|
||||
const loadRolePermissions = async (roles: SysRole[]) => {
|
||||
const entries = await Promise.all(
|
||||
roles.map(async (role) => {
|
||||
try {
|
||||
const ids = await listRolePermissions(role.roleId);
|
||||
const normalized = (ids || []).map((id) => Number(id)).filter((id) => !Number.isNaN(id));
|
||||
return [role.roleId, normalized] as const;
|
||||
} catch (e) {
|
||||
return [role.roleId, []] as const;
|
||||
}
|
||||
})
|
||||
);
|
||||
setRolePermMap(Object.fromEntries(entries));
|
||||
};
|
||||
|
||||
const loadRoles = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const list = await listRoles();
|
||||
const roles = list || [];
|
||||
setData(roles);
|
||||
if (roles.length > 0 && !selectedRole) {
|
||||
selectRole(roles[0]);
|
||||
} else if (selectedRole) {
|
||||
const updated = roles.find(r => r.roleId === selectedRole.roleId);
|
||||
if (updated) setSelectedRole(updated);
|
||||
}
|
||||
await loadPermissions();
|
||||
await loadRolePermissions(roles);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const selectRole = async (role: SysRole) => {
|
||||
setSelectedRole(role);
|
||||
try {
|
||||
// Load permissions for this role
|
||||
const ids = await listRolePermissions(role.roleId);
|
||||
const normalized = (ids || []).map((id) => Number(id)).filter((id) => !Number.isNaN(id));
|
||||
|
||||
// Filter out parents for Tree回显
|
||||
const leafIds = normalized.filter(id => {
|
||||
return !permissions.some(p => p.parentId === id);
|
||||
});
|
||||
setSelectedPermIds(leafIds);
|
||||
setHalfCheckedIds([]);
|
||||
|
||||
// Load users for this role
|
||||
setLoadingUsers(true);
|
||||
const users = await fetchUsersByRoleId(role.roleId);
|
||||
setRoleUsers(users || []);
|
||||
} catch (e) {
|
||||
message.error("加载角色详情失败");
|
||||
} finally {
|
||||
setLoadingUsers(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadRoles();
|
||||
}, []);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
setSelectedPermIds([]);
|
||||
setHalfCheckedIds([]);
|
||||
form.resetFields();
|
||||
setDrawerOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (record: SysRole) => {
|
||||
setEditing(record);
|
||||
const roleIds = rolePermMap[record.roleId] || [];
|
||||
|
||||
// Filter out parent IDs. AntD Tree will re-calculate the checked/half-checked
|
||||
// status of parents based on the leaf nodes provided to checkedKeys.
|
||||
const leafIds = roleIds.filter(id => {
|
||||
// Reload role detail if permissions list loaded later
|
||||
useEffect(() => {
|
||||
if (selectedRole && permissions.length > 0) {
|
||||
// We don't want to infinite loop, but we need to ensure leafIds are correct
|
||||
// after permissions are loaded.
|
||||
const leafIds = selectedPermIds.filter(id => {
|
||||
return !permissions.some(p => p.parentId === id);
|
||||
});
|
||||
|
||||
if (leafIds.length !== selectedPermIds.length) {
|
||||
setSelectedPermIds(leafIds);
|
||||
setHalfCheckedIds([]);
|
||||
form.setFieldsValue({
|
||||
roleName: record.roleName,
|
||||
remark: record.remark
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [permissions]);
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
if (!searchText) return data;
|
||||
const lower = searchText.toLowerCase();
|
||||
return data.filter(r =>
|
||||
r.roleName.toLowerCase().includes(lower) ||
|
||||
r.roleCode.toLowerCase().includes(lower)
|
||||
);
|
||||
}, [data, searchText]);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ status: 1 });
|
||||
setDrawerOpen(true);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setDrawerOpen(false);
|
||||
const openEditBasic = (e: React.MouseEvent, record: SysRole) => {
|
||||
e.stopPropagation();
|
||||
setEditing(record);
|
||||
form.setFieldsValue(record);
|
||||
setDrawerOpen(true);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
const handleRemove = async (e: React.MouseEvent, id: number) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await deleteRole(id);
|
||||
message.success("角色已删除");
|
||||
if (selectedRole?.roleId === id) setSelectedRole(null);
|
||||
loadRoles();
|
||||
} catch (e) {
|
||||
message.error("删除失败");
|
||||
}
|
||||
};
|
||||
|
||||
const submitBasic = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
const payload: Partial<SysRole> = {
|
||||
roleCode: editing?.roleCode || generateRoleCode(),
|
||||
roleCode: editing?.roleCode || values.roleCode || generateRoleCode(),
|
||||
roleName: values.roleName,
|
||||
remark: values.remark,
|
||||
status: editing?.status ?? DEFAULT_STATUS
|
||||
status: values.status ?? DEFAULT_STATUS
|
||||
};
|
||||
let roleId = editing?.roleId;
|
||||
|
||||
if (editing) {
|
||||
await updateRole(editing.roleId, payload);
|
||||
message.success("角色已更新");
|
||||
} else {
|
||||
await createRole(payload);
|
||||
message.success("角色已创建");
|
||||
}
|
||||
|
||||
const list = await listRoles();
|
||||
const roles = list || [];
|
||||
setData(roles);
|
||||
if (!roleId) {
|
||||
roleId = roles.find((r) => r.roleCode === payload.roleCode)?.roleId;
|
||||
}
|
||||
if (roleId) {
|
||||
const allPermIds = Array.from(new Set([...selectedPermIds, ...halfCheckedIds]));
|
||||
await saveRolePermissions(roleId, allPermIds);
|
||||
}
|
||||
await loadRolePermissions(roles);
|
||||
setDrawerOpen(false);
|
||||
message.success(editing ? "角色已更新" : "角色已创建");
|
||||
loadRoles();
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message) {
|
||||
message.error(e.message);
|
||||
}
|
||||
if (e instanceof Error && e.message) message.error(e.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderRolePermissions = (role: SysRole) => {
|
||||
const permIds = rolePermMap[role.roleId] || [];
|
||||
const perms = permIds
|
||||
.map((id) => permissionMap.get(id))
|
||||
.filter((p): p is SysPermission => Boolean(p));
|
||||
const preview = perms.slice(0, 3);
|
||||
const totalCount = permIds.length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="role-permission-summary">
|
||||
<span>权限概览</span>
|
||||
<span className="role-permission-badge">{`${totalCount}个权限`}</span>
|
||||
</div>
|
||||
<div className="role-permission-tags">
|
||||
{preview.length ? (
|
||||
preview.map((p) => (
|
||||
<Tag key={p.permId} className="role-permission-tag">
|
||||
{p.name}
|
||||
</Tag>
|
||||
))
|
||||
) : (
|
||||
<Tag className="role-permission-tag">
|
||||
{totalCount ? "已选权限" : "暂无权限"}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
const savePermissions = async () => {
|
||||
if (!selectedRole) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const allPermIds = Array.from(new Set([...selectedPermIds, ...halfCheckedIds]));
|
||||
await saveRolePermissions(selectedRole.roleId, allPermIds);
|
||||
message.success("权限已保存并生效");
|
||||
} catch (e) {
|
||||
message.error("保存权限失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="roles-page">
|
||||
<div className="roles-header">
|
||||
<div>
|
||||
<Title level={4} className="roles-title">
|
||||
系统角色权限
|
||||
</Title>
|
||||
<Text type="secondary" className="roles-subtitle">
|
||||
设置系统中不同角色的访问权限和操作边界
|
||||
</Text>
|
||||
</div>
|
||||
{can("sys_role:create") && (
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
添加角色
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="roles-grid">
|
||||
{data.map((role) => (
|
||||
<div key={role.roleId} className="role-card">
|
||||
<div className="role-card-header">
|
||||
<div className="role-icon">
|
||||
<SafetyCertificateOutlined />
|
||||
</div>
|
||||
{can("sys_role:update") && (
|
||||
<Button
|
||||
type="text"
|
||||
className="role-edit-btn"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => openEdit(role)}
|
||||
<div className="roles-page-v2">
|
||||
<Row gutter={24} style={{ height: 'calc(100vh - 120px)' }}>
|
||||
{/* Left: Role List */}
|
||||
<Col span={8} style={{ height: '100%' }}>
|
||||
<Card
|
||||
title="系统角色"
|
||||
className="full-height-card"
|
||||
extra={can("sys_role:create") && <Button type="primary" size="small" icon={<PlusOutlined />} onClick={openCreate}>新增</Button>}
|
||||
>
|
||||
<div className="mb-4">
|
||||
<Input
|
||||
placeholder="搜索角色..."
|
||||
prefix={<SearchOutlined />}
|
||||
value={searchText}
|
||||
onChange={e => setSearchText(e.target.value)}
|
||||
allowClear
|
||||
/>
|
||||
</div>
|
||||
<div className="role-list-container">
|
||||
<Table
|
||||
rowKey="roleId"
|
||||
showHeader={false}
|
||||
dataSource={filteredData}
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
onRow={(record) => ({
|
||||
onClick: () => selectRole(record),
|
||||
className: `cursor-pointer role-row ${selectedRole?.roleId === record.roleId ? 'role-row-selected' : ''}`
|
||||
})}
|
||||
columns={[
|
||||
{
|
||||
title: '角色',
|
||||
render: (_, record) => (
|
||||
<div className="role-item-content">
|
||||
<div className="role-item-main">
|
||||
<div className="role-item-name">{record.roleName}</div>
|
||||
<div className="role-item-code">{record.roleCode}</div>
|
||||
</div>
|
||||
<div className="role-item-actions">
|
||||
{can("sys_role:update") && <Button type="text" size="small" icon={<EditOutlined />} onClick={e => openEditBasic(e, record)} />}
|
||||
{can("sys_role:delete") && record.roleCode !== 'ADMIN' && (
|
||||
<Popconfirm title="删除角色?" onConfirm={e => handleRemove(e!, record.roleId)}>
|
||||
<Button type="text" size="small" danger icon={<DeleteOutlined />} onClick={e => e.stopPropagation()} />
|
||||
</Popconfirm>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="role-main">
|
||||
<div className="role-name">{role.roleName}</div>
|
||||
<div className="role-id">{`ID: ${role.roleCode || role.roleId}`}</div>
|
||||
</div>
|
||||
|
||||
{renderRolePermissions(role)}
|
||||
|
||||
<div className="role-footer">
|
||||
<span>最后同步</span>
|
||||
<span>{role.updatedAt ? "刚刚" : "刚刚"}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!data.length && !loading && (
|
||||
<div className="roles-empty">暂无角色</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Drawer
|
||||
open={drawerOpen}
|
||||
onClose={handleClose}
|
||||
width={420}
|
||||
closable
|
||||
title={
|
||||
<div className="role-drawer-title">
|
||||
<div className="role-drawer-icon">
|
||||
<SafetyCertificateOutlined />
|
||||
</div>
|
||||
<div>
|
||||
<div className="role-drawer-heading">
|
||||
{editing ? "编辑角色" : "创建新角色"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
footer={
|
||||
<div className="role-drawer-footer">
|
||||
<Button type="link" className="role-drawer-cancel" onClick={handleClose}>
|
||||
取消更改
|
||||
</Button>
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* Right: Detail Tabs */}
|
||||
<Col span={16} style={{ height: '100%' }}>
|
||||
{selectedRole ? (
|
||||
<Card
|
||||
className="full-height-card"
|
||||
title={
|
||||
<Space>
|
||||
<SafetyCertificateOutlined style={{ color: '#1890ff' }} />
|
||||
<span>{selectedRole.roleName}</span>
|
||||
<Tag color="blue">{selectedRole.roleCode}</Tag>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Button
|
||||
type="primary"
|
||||
className="role-drawer-submit"
|
||||
icon={<SaveOutlined />}
|
||||
loading={saving}
|
||||
onClick={submit}
|
||||
onClick={savePermissions}
|
||||
disabled={!can("sys_role:permission:save")}
|
||||
>
|
||||
确认并同步到系统
|
||||
保存权限配置
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Form form={form} layout="vertical" className="role-form">
|
||||
<Form.Item
|
||||
label="角色名称"
|
||||
name="roleName"
|
||||
rules={[{ required: true, message: "请输入角色名称" }]}
|
||||
<Tabs defaultActiveKey="permissions" className="role-tabs">
|
||||
<Tabs.TabPane
|
||||
tab={<Space><KeyOutlined />功能权限</Space>}
|
||||
key="permissions"
|
||||
>
|
||||
<Input placeholder="例如:Auditor" />
|
||||
</Form.Item>
|
||||
<Form.Item label="描述" name="remark">
|
||||
<Input placeholder="该角色的职责描述..." />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<div className="role-permission-section">
|
||||
<div className="role-permission-group-title">
|
||||
<span className="role-permission-group-icon">
|
||||
<SafetyCertificateOutlined />
|
||||
</span>
|
||||
<span>权限选择</span>
|
||||
</div>
|
||||
<div className="role-permission-tree">
|
||||
<div className="role-permission-tree-v2">
|
||||
<Tree
|
||||
checkable
|
||||
selectable={false}
|
||||
|
|
@ -350,15 +361,87 @@ export default function Roles() {
|
|||
onCheck={(keys, info) => {
|
||||
const checked = Array.isArray(keys) ? keys : keys.checked;
|
||||
const halfChecked = info.halfCheckedKeys || [];
|
||||
|
||||
setSelectedPermIds(checked.map(k => Number(k)));
|
||||
setHalfCheckedIds(halfChecked.map(k => Number(k)));
|
||||
}}
|
||||
defaultExpandAll
|
||||
/>
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane
|
||||
tab={<Space><UserOutlined />关联用户 ({roleUsers.length})</Space>}
|
||||
key="users"
|
||||
>
|
||||
<Table
|
||||
rowKey="userId"
|
||||
size="small"
|
||||
loading={loadingUsers}
|
||||
dataSource={roleUsers}
|
||||
pagination={{ pageSize: 10 }}
|
||||
columns={[
|
||||
{
|
||||
title: '用户',
|
||||
render: (_, r) => (
|
||||
<Space>
|
||||
<UserOutlined />
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{r.displayName}</div>
|
||||
<div style={{ fontSize: 12, color: '#8c8c8c' }}>@{r.username}</div>
|
||||
</div>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
{ title: '手机号', dataIndex: 'phone' },
|
||||
{ title: '邮箱', dataIndex: 'email' },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
render: s => <Tag color={s === 1 ? 'green' : 'red'}>{s === 1 ? '正常' : '禁用'}</Tag>
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="full-height-card flex-center">
|
||||
<Empty description="请从左侧选择一个角色以查看详情" />
|
||||
</Card>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Basic Info Drawer */}
|
||||
<Drawer
|
||||
title={editing ? "修改角色基础信息" : "新增系统角色"}
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
width={400}
|
||||
destroyOnClose
|
||||
footer={
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button onClick={() => setDrawerOpen(false)}>取消</Button>
|
||||
<Button type="primary" loading={saving} onClick={submitBasic}>提交</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item label="角色名称" name="roleName" rules={[{ required: true }]}>
|
||||
<Input placeholder="输入名称" />
|
||||
</Form.Item>
|
||||
<Form.Item label="角色编码" name="roleCode" rules={[{ required: true }]}>
|
||||
<Input placeholder="输入唯一编码" disabled={!!editing} />
|
||||
</Form.Item>
|
||||
<Form.Item label="状态" name="status" initialValue={1}>
|
||||
<Select options={[{label: '启用', value: 1}, {label: '禁用', value: 0}]} />
|
||||
</Form.Item>
|
||||
<Form.Item label="备注" name="remark">
|
||||
<Input.TextArea rows={3} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
import { Select } from "antd";
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import Roles from "../pages/Roles";
|
|||
import Permissions from "../pages/Permissions";
|
||||
import Devices from "../pages/Devices";
|
||||
import Dictionaries from "../pages/Dictionaries";
|
||||
import Logs from "../pages/Logs";
|
||||
import UserRoleBinding from "../pages/UserRoleBinding";
|
||||
import RolePermissionBinding from "../pages/RolePermissionBinding";
|
||||
|
||||
|
|
@ -15,6 +16,7 @@ export const menuRoutes: MenuRoute[] = [
|
|||
{ path: "/roles", label: "角色管理", element: <Roles />, perm: "menu:roles" },
|
||||
{ path: "/permissions", label: "权限管理", element: <Permissions />, perm: "menu:permissions" },
|
||||
{ path: "/dictionaries", label: "字典管理", element: <Dictionaries />, perm: "menu:dict" },
|
||||
{ path: "/logs", label: "日志管理", element: <Logs />, perm: "menu:logs" },
|
||||
{ path: "/devices", label: "设备管理", element: <Devices />, perm: "menu:devices" },
|
||||
{ path: "/user-roles", label: "用户角色绑定", element: <UserRoleBinding />, perm: "menu:user-roles" },
|
||||
{ path: "/role-permissions", label: "角色权限绑定", element: <RolePermissionBinding />, perm: "menu:role-permissions" }
|
||||
|
|
|
|||
Loading…
Reference in New Issue