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>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-cache</artifactId>
|
<artifactId>spring-boot-starter-cache</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-aop</artifactId>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.baomidou</groupId>
|
<groupId>com.baomidou</groupId>
|
||||||
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
|
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,10 @@ package com.imeeting;
|
||||||
|
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.scheduling.annotation.EnableAsync;
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
|
@EnableAsync
|
||||||
public class ImeetingApplication {
|
public class ImeetingApplication {
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
SpringApplication.run(ImeetingApplication.class, 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.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||||
import com.imeeting.common.ApiResponse;
|
import com.imeeting.common.ApiResponse;
|
||||||
|
import com.imeeting.common.annotation.Log;
|
||||||
import com.imeeting.entity.SysRole;
|
import com.imeeting.entity.SysRole;
|
||||||
import com.imeeting.entity.SysRolePermission;
|
import com.imeeting.entity.SysRolePermission;
|
||||||
|
import com.imeeting.entity.SysUser;
|
||||||
import com.imeeting.mapper.SysRolePermissionMapper;
|
import com.imeeting.mapper.SysRolePermissionMapper;
|
||||||
import com.imeeting.service.SysRoleService;
|
import com.imeeting.service.SysRoleService;
|
||||||
|
import com.imeeting.service.SysUserService;
|
||||||
import org.springframework.security.access.prepost.PreAuthorize;
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
|
@ -16,10 +19,12 @@ import java.util.List;
|
||||||
@RequestMapping("/api/roles")
|
@RequestMapping("/api/roles")
|
||||||
public class RoleController {
|
public class RoleController {
|
||||||
private final SysRoleService sysRoleService;
|
private final SysRoleService sysRoleService;
|
||||||
|
private final SysUserService sysUserService;
|
||||||
private final SysRolePermissionMapper sysRolePermissionMapper;
|
private final SysRolePermissionMapper sysRolePermissionMapper;
|
||||||
|
|
||||||
public RoleController(SysRoleService sysRoleService, SysRolePermissionMapper sysRolePermissionMapper) {
|
public RoleController(SysRoleService sysRoleService, SysUserService sysUserService, SysRolePermissionMapper sysRolePermissionMapper) {
|
||||||
this.sysRoleService = sysRoleService;
|
this.sysRoleService = sysRoleService;
|
||||||
|
this.sysUserService = sysUserService;
|
||||||
this.sysRolePermissionMapper = sysRolePermissionMapper;
|
this.sysRolePermissionMapper = sysRolePermissionMapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -29,6 +34,12 @@ public class RoleController {
|
||||||
return ApiResponse.ok(sysRoleService.list());
|
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}")
|
@GetMapping("/{id}")
|
||||||
@PreAuthorize("@ss.hasPermi('sys_role:query')")
|
@PreAuthorize("@ss.hasPermi('sys_role:query')")
|
||||||
public ApiResponse<SysRole> get(@PathVariable Long id) {
|
public ApiResponse<SysRole> get(@PathVariable Long id) {
|
||||||
|
|
@ -37,12 +48,14 @@ public class RoleController {
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
@PreAuthorize("@ss.hasPermi('sys_role:create')")
|
@PreAuthorize("@ss.hasPermi('sys_role:create')")
|
||||||
|
@com.imeeting.common.annotation.Log(value = "新增角色", type = "角色管理")
|
||||||
public ApiResponse<Boolean> create(@RequestBody SysRole role) {
|
public ApiResponse<Boolean> create(@RequestBody SysRole role) {
|
||||||
return ApiResponse.ok(sysRoleService.save(role));
|
return ApiResponse.ok(sysRoleService.save(role));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/{id}")
|
@PutMapping("/{id}")
|
||||||
@PreAuthorize("@ss.hasPermi('sys_role:update')")
|
@PreAuthorize("@ss.hasPermi('sys_role:update')")
|
||||||
|
@com.imeeting.common.annotation.Log(value = "修改角色", type = "角色管理")
|
||||||
public ApiResponse<Boolean> update(@PathVariable Long id, @RequestBody SysRole role) {
|
public ApiResponse<Boolean> update(@PathVariable Long id, @RequestBody SysRole role) {
|
||||||
role.setRoleId(id);
|
role.setRoleId(id);
|
||||||
return ApiResponse.ok(sysRoleService.updateById(role));
|
return ApiResponse.ok(sysRoleService.updateById(role));
|
||||||
|
|
@ -50,6 +63,7 @@ public class RoleController {
|
||||||
|
|
||||||
@DeleteMapping("/{id}")
|
@DeleteMapping("/{id}")
|
||||||
@PreAuthorize("@ss.hasPermi('sys_role:delete')")
|
@PreAuthorize("@ss.hasPermi('sys_role:delete')")
|
||||||
|
@Log(value = "删除角色", type = "角色管理")
|
||||||
public ApiResponse<Boolean> delete(@PathVariable Long id) {
|
public ApiResponse<Boolean> delete(@PathVariable Long id) {
|
||||||
return ApiResponse.ok(sysRoleService.removeById(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
|
@PostMapping
|
||||||
@PreAuthorize("@ss.hasPermi('sys_user:create')")
|
@PreAuthorize("@ss.hasPermi('sys_user:create')")
|
||||||
|
@com.imeeting.common.annotation.Log(value = "新增用户", type = "用户管理")
|
||||||
public ApiResponse<Boolean> create(@RequestBody SysUser user) {
|
public ApiResponse<Boolean> create(@RequestBody SysUser user) {
|
||||||
if (user.getPasswordHash() != null && !user.getPasswordHash().isEmpty()) {
|
if (user.getPasswordHash() != null && !user.getPasswordHash().isEmpty()) {
|
||||||
user.setPasswordHash(passwordEncoder.encode(user.getPasswordHash()));
|
user.setPasswordHash(passwordEncoder.encode(user.getPasswordHash()));
|
||||||
|
|
@ -81,6 +82,7 @@ public class UserController {
|
||||||
|
|
||||||
@PutMapping("/{id}")
|
@PutMapping("/{id}")
|
||||||
@PreAuthorize("@ss.hasPermi('sys_user:update')")
|
@PreAuthorize("@ss.hasPermi('sys_user:update')")
|
||||||
|
@com.imeeting.common.annotation.Log(value = "修改用户", type = "用户管理")
|
||||||
public ApiResponse<Boolean> update(@PathVariable Long id, @RequestBody SysUser user) {
|
public ApiResponse<Boolean> update(@PathVariable Long id, @RequestBody SysUser user) {
|
||||||
user.setUserId(id);
|
user.setUserId(id);
|
||||||
if (user.getPasswordHash() != null && !user.getPasswordHash().isEmpty()) {
|
if (user.getPasswordHash() != null && !user.getPasswordHash().isEmpty()) {
|
||||||
|
|
@ -91,6 +93,7 @@ public class UserController {
|
||||||
|
|
||||||
@DeleteMapping("/{id}")
|
@DeleteMapping("/{id}")
|
||||||
@PreAuthorize("@ss.hasPermi('sys_user:delete')")
|
@PreAuthorize("@ss.hasPermi('sys_user:delete')")
|
||||||
|
@com.imeeting.common.annotation.Log(value = "删除用户", type = "用户管理")
|
||||||
public ApiResponse<Boolean> delete(@PathVariable Long id) {
|
public ApiResponse<Boolean> delete(@PathVariable Long id) {
|
||||||
return ApiResponse.ok(sysUserService.removeById(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.FieldFill;
|
||||||
import com.baomidou.mybatisplus.annotation.TableField;
|
import com.baomidou.mybatisplus.annotation.TableField;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableLogic;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
|
@ -10,6 +11,8 @@ import java.time.LocalDateTime;
|
||||||
public class BaseEntity {
|
public class BaseEntity {
|
||||||
private Long tenantId;
|
private Long tenantId;
|
||||||
private Integer status;
|
private Integer status;
|
||||||
|
|
||||||
|
@TableLogic
|
||||||
private Integer isDeleted;
|
private Integer isDeleted;
|
||||||
|
|
||||||
@TableField(fill = FieldFill.INSERT)
|
@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.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
import com.imeeting.entity.SysUser;
|
import com.imeeting.entity.SysUser;
|
||||||
import org.apache.ibatis.annotations.Mapper;
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
@Mapper
|
@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;
|
package com.imeeting.service;
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.extension.service.IService;
|
import com.baomidou.mybatisplus.extension.service.IService;
|
||||||
|
|
||||||
import com.imeeting.entity.SysUser;
|
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.RedisKeys;
|
||||||
import com.imeeting.common.SysParamKeys;
|
import com.imeeting.common.SysParamKeys;
|
||||||
import com.imeeting.entity.Device;
|
import com.imeeting.entity.Device;
|
||||||
|
import com.imeeting.entity.SysLog;
|
||||||
import com.imeeting.entity.SysUser;
|
import com.imeeting.entity.SysUser;
|
||||||
import com.imeeting.service.AuthService;
|
import com.imeeting.service.*;
|
||||||
import com.imeeting.service.DeviceService;
|
|
||||||
import com.imeeting.service.SysParamService;
|
|
||||||
import com.imeeting.service.SysUserService;
|
|
||||||
import io.jsonwebtoken.Claims;
|
import io.jsonwebtoken.Claims;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
@ -31,6 +31,8 @@ public class AuthServiceImpl implements AuthService {
|
||||||
private final StringRedisTemplate stringRedisTemplate;
|
private final StringRedisTemplate stringRedisTemplate;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
private final JwtTokenProvider jwtTokenProvider;
|
private final JwtTokenProvider jwtTokenProvider;
|
||||||
|
private final SysLogService sysLogService;
|
||||||
|
private final HttpServletRequest httpServletRequest;
|
||||||
|
|
||||||
@Value("${app.token.access-default-minutes:30}")
|
@Value("${app.token.access-default-minutes:30}")
|
||||||
private long accessDefaultMinutes;
|
private long accessDefaultMinutes;
|
||||||
|
|
@ -41,52 +43,75 @@ public class AuthServiceImpl implements AuthService {
|
||||||
|
|
||||||
public AuthServiceImpl(SysUserService sysUserService, DeviceService deviceService, SysParamService sysParamService,
|
public AuthServiceImpl(SysUserService sysUserService, DeviceService deviceService, SysParamService sysParamService,
|
||||||
StringRedisTemplate stringRedisTemplate, PasswordEncoder passwordEncoder,
|
StringRedisTemplate stringRedisTemplate, PasswordEncoder passwordEncoder,
|
||||||
JwtTokenProvider jwtTokenProvider) {
|
JwtTokenProvider jwtTokenProvider, SysLogService sysLogService, HttpServletRequest httpServletRequest) {
|
||||||
this.sysUserService = sysUserService;
|
this.sysUserService = sysUserService;
|
||||||
this.deviceService = deviceService;
|
this.deviceService = deviceService;
|
||||||
this.sysParamService = sysParamService;
|
this.sysParamService = sysParamService;
|
||||||
this.stringRedisTemplate = stringRedisTemplate;
|
this.stringRedisTemplate = stringRedisTemplate;
|
||||||
this.passwordEncoder = passwordEncoder;
|
this.passwordEncoder = passwordEncoder;
|
||||||
this.jwtTokenProvider = jwtTokenProvider;
|
this.jwtTokenProvider = jwtTokenProvider;
|
||||||
|
this.sysLogService = sysLogService;
|
||||||
|
this.httpServletRequest = httpServletRequest;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public TokenResponse login(LoginRequest request) {
|
public TokenResponse login(LoginRequest request) {
|
||||||
if (isCaptchaEnabled()) {
|
try {
|
||||||
validateCaptcha(request.getCaptchaId(), request.getCaptchaCode());
|
if (isCaptchaEnabled()) {
|
||||||
}
|
validateCaptcha(request.getCaptchaId(), request.getCaptchaCode());
|
||||||
|
|
||||||
SysUser user = sysUserService.getOne(new LambdaQueryWrapper<SysUser>()
|
|
||||||
.eq(SysUser::getUsername, request.getUsername())
|
|
||||||
.eq(SysUser::getIsDeleted, 0)
|
|
||||||
.eq(SysUser::getStatus, 1));
|
|
||||||
if (user == null || !passwordEncoder.matches(request.getPassword(), user.getPasswordHash())) {
|
|
||||||
throw new IllegalArgumentException("用户名或密码错误");
|
|
||||||
}
|
|
||||||
|
|
||||||
String deviceCode = request.getDeviceCode();
|
|
||||||
if (deviceCode != null && !deviceCode.isEmpty()) {
|
|
||||||
Device device = deviceService.getOne(new LambdaQueryWrapper<Device>()
|
|
||||||
.eq(Device::getUserId, user.getUserId())
|
|
||||||
.eq(Device::getDeviceCode, deviceCode)
|
|
||||||
.eq(Device::getIsDeleted, 0)
|
|
||||||
.eq(Device::getStatus, 1));
|
|
||||||
if (device == null) {
|
|
||||||
throw new IllegalArgumentException("设备码无效");
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
long accessMinutes = parseLong(sysParamService.getParamValue("security.token.access_ttl_minutes",
|
SysUser user = sysUserService.getOne(new LambdaQueryWrapper<SysUser>()
|
||||||
String.valueOf(accessDefaultMinutes)), accessDefaultMinutes);
|
.eq(SysUser::getUsername, request.getUsername())
|
||||||
long refreshDays = parseLong(sysParamService.getParamValue("security.token.refresh_ttl_days",
|
.eq(SysUser::getIsDeleted, 0)
|
||||||
String.valueOf(refreshDefaultDays)), refreshDefaultDays);
|
.eq(SysUser::getStatus, 1));
|
||||||
|
if (user == null || !passwordEncoder.matches(request.getPassword(), user.getPasswordHash())) {
|
||||||
|
throw new IllegalArgumentException("用户名或密码错误");
|
||||||
|
}
|
||||||
|
|
||||||
if (deviceCode == null || deviceCode.isEmpty()) {
|
String deviceCode = request.getDeviceCode();
|
||||||
deviceCode = "default";
|
if (deviceCode != null && !deviceCode.isEmpty()) {
|
||||||
|
Device device = deviceService.getOne(new LambdaQueryWrapper<Device>()
|
||||||
|
.eq(Device::getUserId, user.getUserId())
|
||||||
|
.eq(Device::getDeviceCode, deviceCode)
|
||||||
|
.eq(Device::getIsDeleted, 0)
|
||||||
|
.eq(Device::getStatus, 1));
|
||||||
|
if (device == null) {
|
||||||
|
throw new IllegalArgumentException("设备码无效");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
long accessMinutes = parseLong(sysParamService.getParamValue("security.token.access_ttl_minutes",
|
||||||
|
String.valueOf(accessDefaultMinutes)), accessDefaultMinutes);
|
||||||
|
long refreshDays = parseLong(sysParamService.getParamValue("security.token.refresh_ttl_days",
|
||||||
|
String.valueOf(refreshDefaultDays)), refreshDefaultDays);
|
||||||
|
|
||||||
|
if (deviceCode == null || deviceCode.isEmpty()) {
|
||||||
|
deviceCode = "default";
|
||||||
|
}
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
TokenResponse tokens = issueTokens(user, deviceCode, accessMinutes, refreshDays);
|
}
|
||||||
cacheRefreshToken(user.getUserId(), deviceCode, tokens.getRefreshToken(), refreshDays);
|
|
||||||
return tokens;
|
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
|
@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;
|
package com.imeeting.service.impl;
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||||
|
|
||||||
import com.imeeting.entity.SysUser;
|
import com.imeeting.entity.SysUser;
|
||||||
|
|
||||||
import com.imeeting.mapper.SysUserMapper;
|
import com.imeeting.mapper.SysUserMapper;
|
||||||
|
|
||||||
import com.imeeting.service.SysUserService;
|
import com.imeeting.service.SysUserService;
|
||||||
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@Service
|
@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:
|
mybatis-plus:
|
||||||
configuration:
|
configuration:
|
||||||
map-underscore-to-camel-case: true
|
map-underscore-to-camel-case: true
|
||||||
|
global-config:
|
||||||
|
db-config:
|
||||||
|
logic-delete-field: isDeleted
|
||||||
|
logic-delete-value: 1
|
||||||
|
logic-not-delete-value: 0
|
||||||
|
|
||||||
security:
|
security:
|
||||||
jwt:
|
jwt:
|
||||||
|
|
|
||||||
|
|
@ -122,5 +122,15 @@ export async function saveRolePermissions(roleId: number, permIds: number[]) {
|
||||||
return resp.data.data as boolean;
|
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";
|
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 {
|
.roles-page-v2 {
|
||||||
display: flex;
|
padding: 24px;
|
||||||
flex-direction: column;
|
|
||||||
gap: 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;
|
display: flex;
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
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;
|
align-items: center;
|
||||||
justify-content: space-between;
|
padding: 4px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.role-icon {
|
.role-item-name {
|
||||||
width: 40px;
|
font-weight: 600;
|
||||||
height: 40px;
|
color: #262626;
|
||||||
border-radius: 12px;
|
}
|
||||||
background: #eef4ff;
|
|
||||||
color: #3b82f6;
|
.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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: 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 {
|
.role-permission-node {
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-drawer-footer {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.role-drawer-cancel {
|
.mb-4 {
|
||||||
color: #64748b;
|
margin-bottom: 16px;
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 type { DataNode } from "antd/es/tree";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
|
|
@ -7,11 +24,22 @@ import {
|
||||||
listRolePermissions,
|
listRolePermissions,
|
||||||
listRoles,
|
listRoles,
|
||||||
saveRolePermissions,
|
saveRolePermissions,
|
||||||
updateRole
|
updateRole,
|
||||||
|
deleteRole,
|
||||||
|
fetchUsersByRoleId
|
||||||
} from "../api";
|
} from "../api";
|
||||||
import type { SysPermission, SysRole } from "../types";
|
import type { SysPermission, SysRole, SysUser } from "../types";
|
||||||
import { usePermission } from "../hooks/usePermission";
|
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";
|
import "./Roles.css";
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
|
|
@ -35,10 +63,6 @@ const buildPermissionTree = (list: SysPermission[]): PermissionNode[] => {
|
||||||
const parent = map.get(node.parentId);
|
const parent = map.get(node.parentId);
|
||||||
if (parent) {
|
if (parent) {
|
||||||
parent.children!.push(node);
|
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 {
|
} else {
|
||||||
roots.push(node);
|
roots.push(node);
|
||||||
|
|
@ -59,32 +83,36 @@ const toTreeData = (nodes: PermissionNode[]): DataNode[] =>
|
||||||
title: (
|
title: (
|
||||||
<span className="role-permission-node">
|
<span className="role-permission-node">
|
||||||
<span>{node.name}</span>
|
<span>{node.name}</span>
|
||||||
{node.permType === "button" && <Tag color="blue">按钮</Tag>}
|
{node.permType === "button" && <Tag color="blue" style={{ marginLeft: 8 }}>按钮</Tag>}
|
||||||
</span>
|
</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() {
|
export default function Roles() {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [data, setData] = useState<SysRole[]>([]);
|
const [data, setData] = useState<SysRole[]>([]);
|
||||||
const [permissions, setPermissions] = useState<SysPermission[]>([]);
|
const [permissions, setPermissions] = useState<SysPermission[]>([]);
|
||||||
const [rolePermMap, setRolePermMap] = useState<Record<number, number[]>>({});
|
const [selectedRole, setSelectedRole] = useState<SysRole | null>(null);
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
||||||
const [editing, setEditing] = useState<SysRole | null>(null);
|
// Right side states
|
||||||
const [selectedPermIds, setSelectedPermIds] = useState<number[]>([]);
|
const [selectedPermIds, setSelectedPermIds] = useState<number[]>([]);
|
||||||
const [halfCheckedIds, setHalfCheckedIds] = useState<number[]>([]);
|
const [halfCheckedIds, setHalfCheckedIds] = useState<number[]>([]);
|
||||||
const [form] = Form.useForm();
|
const [roleUsers, setRoleUsers] = useState<SysUser[]>([]);
|
||||||
const { can } = usePermission();
|
const [loadingUsers, setLoadingUsers] = useState(false);
|
||||||
|
|
||||||
const permissionMap = useMemo(() => {
|
// Search
|
||||||
const map = new Map<number, SysPermission>();
|
const [searchText, setSearchText] = useState("");
|
||||||
permissions.forEach((p) => map.set(p.permId, p));
|
|
||||||
return map;
|
// Drawer (Only for Add/Edit basic info)
|
||||||
}, [permissions]);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<SysRole | null>(null);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
const { can } = usePermission();
|
||||||
|
|
||||||
const permissionTreeData = useMemo(
|
const permissionTreeData = useMemo(
|
||||||
() => toTreeData(buildPermissionTree(permissions)),
|
() => toTreeData(buildPermissionTree(permissions)),
|
||||||
|
|
@ -97,268 +125,323 @@ export default function Roles() {
|
||||||
setPermissions(list || []);
|
setPermissions(list || []);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setPermissions([]);
|
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 () => {
|
const loadRoles = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const list = await listRoles();
|
const list = await listRoles();
|
||||||
const roles = list || [];
|
const roles = list || [];
|
||||||
setData(roles);
|
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 loadPermissions();
|
||||||
await loadRolePermissions(roles);
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
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(() => {
|
useEffect(() => {
|
||||||
loadRoles();
|
loadRoles();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [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 = () => {
|
const openCreate = () => {
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
setSelectedPermIds([]);
|
|
||||||
setHalfCheckedIds([]);
|
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
|
form.setFieldsValue({ status: 1 });
|
||||||
setDrawerOpen(true);
|
setDrawerOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEdit = (record: SysRole) => {
|
const openEditBasic = (e: React.MouseEvent, record: SysRole) => {
|
||||||
|
e.stopPropagation();
|
||||||
setEditing(record);
|
setEditing(record);
|
||||||
const roleIds = rolePermMap[record.roleId] || [];
|
form.setFieldsValue(record);
|
||||||
|
|
||||||
// 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 => {
|
|
||||||
return !permissions.some(p => p.parentId === id);
|
|
||||||
});
|
|
||||||
|
|
||||||
setSelectedPermIds(leafIds);
|
|
||||||
setHalfCheckedIds([]);
|
|
||||||
form.setFieldsValue({
|
|
||||||
roleName: record.roleName,
|
|
||||||
remark: record.remark
|
|
||||||
});
|
|
||||||
setDrawerOpen(true);
|
setDrawerOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleRemove = async (e: React.MouseEvent, id: number) => {
|
||||||
setDrawerOpen(false);
|
e.stopPropagation();
|
||||||
|
try {
|
||||||
|
await deleteRole(id);
|
||||||
|
message.success("角色已删除");
|
||||||
|
if (selectedRole?.roleId === id) setSelectedRole(null);
|
||||||
|
loadRoles();
|
||||||
|
} catch (e) {
|
||||||
|
message.error("删除失败");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const submit = async () => {
|
const submitBasic = async () => {
|
||||||
try {
|
try {
|
||||||
const values = await form.validateFields();
|
const values = await form.validateFields();
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
const payload: Partial<SysRole> = {
|
const payload: Partial<SysRole> = {
|
||||||
roleCode: editing?.roleCode || generateRoleCode(),
|
roleCode: editing?.roleCode || values.roleCode || generateRoleCode(),
|
||||||
roleName: values.roleName,
|
roleName: values.roleName,
|
||||||
remark: values.remark,
|
remark: values.remark,
|
||||||
status: editing?.status ?? DEFAULT_STATUS
|
status: values.status ?? DEFAULT_STATUS
|
||||||
};
|
};
|
||||||
let roleId = editing?.roleId;
|
|
||||||
if (editing) {
|
if (editing) {
|
||||||
await updateRole(editing.roleId, payload);
|
await updateRole(editing.roleId, payload);
|
||||||
|
message.success("角色已更新");
|
||||||
} else {
|
} else {
|
||||||
await createRole(payload);
|
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);
|
setDrawerOpen(false);
|
||||||
message.success(editing ? "角色已更新" : "角色已创建");
|
loadRoles();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof Error && e.message) {
|
if (e instanceof Error && e.message) message.error(e.message);
|
||||||
message.error(e.message);
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderRolePermissions = (role: SysRole) => {
|
const savePermissions = async () => {
|
||||||
const permIds = rolePermMap[role.roleId] || [];
|
if (!selectedRole) return;
|
||||||
const perms = permIds
|
setSaving(true);
|
||||||
.map((id) => permissionMap.get(id))
|
try {
|
||||||
.filter((p): p is SysPermission => Boolean(p));
|
const allPermIds = Array.from(new Set([...selectedPermIds, ...halfCheckedIds]));
|
||||||
const preview = perms.slice(0, 3);
|
await saveRolePermissions(selectedRole.roleId, allPermIds);
|
||||||
const totalCount = permIds.length;
|
message.success("权限已保存并生效");
|
||||||
|
} catch (e) {
|
||||||
return (
|
message.error("保存权限失败");
|
||||||
<>
|
} finally {
|
||||||
<div className="role-permission-summary">
|
setSaving(false);
|
||||||
<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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="roles-page">
|
<div className="roles-page-v2">
|
||||||
<div className="roles-header">
|
<Row gutter={24} style={{ height: 'calc(100vh - 120px)' }}>
|
||||||
<div>
|
{/* Left: Role List */}
|
||||||
<Title level={4} className="roles-title">
|
<Col span={8} style={{ height: '100%' }}>
|
||||||
系统角色权限
|
<Card
|
||||||
</Title>
|
title="系统角色"
|
||||||
<Text type="secondary" className="roles-subtitle">
|
className="full-height-card"
|
||||||
设置系统中不同角色的访问权限和操作边界
|
extra={can("sys_role:create") && <Button type="primary" size="small" icon={<PlusOutlined />} onClick={openCreate}>新增</Button>}
|
||||||
</Text>
|
>
|
||||||
</div>
|
<div className="mb-4">
|
||||||
{can("sys_role:create") && (
|
<Input
|
||||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
placeholder="搜索角色..."
|
||||||
添加角色
|
prefix={<SearchOutlined />}
|
||||||
</Button>
|
value={searchText}
|
||||||
)}
|
onChange={e => setSearchText(e.target.value)}
|
||||||
</div>
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
|
||||||
<div className="roles-grid">
|
{/* Right: Detail Tabs */}
|
||||||
{data.map((role) => (
|
<Col span={16} style={{ height: '100%' }}>
|
||||||
<div key={role.roleId} className="role-card">
|
{selectedRole ? (
|
||||||
<div className="role-card-header">
|
<Card
|
||||||
<div className="role-icon">
|
className="full-height-card"
|
||||||
<SafetyCertificateOutlined />
|
title={
|
||||||
</div>
|
<Space>
|
||||||
{can("sys_role:update") && (
|
<SafetyCertificateOutlined style={{ color: '#1890ff' }} />
|
||||||
|
<span>{selectedRole.roleName}</span>
|
||||||
|
<Tag color="blue">{selectedRole.roleCode}</Tag>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
extra={
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
type="primary"
|
||||||
className="role-edit-btn"
|
icon={<SaveOutlined />}
|
||||||
icon={<EditOutlined />}
|
loading={saving}
|
||||||
onClick={() => openEdit(role)}
|
onClick={savePermissions}
|
||||||
/>
|
disabled={!can("sys_role:permission:save")}
|
||||||
)}
|
>
|
||||||
</div>
|
保存权限配置
|
||||||
|
</Button>
|
||||||
<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>
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
className="role-drawer-submit"
|
|
||||||
loading={saving}
|
|
||||||
onClick={submit}
|
|
||||||
>
|
>
|
||||||
确认并同步到系统
|
<Tabs defaultActiveKey="permissions" className="role-tabs">
|
||||||
</Button>
|
<Tabs.TabPane
|
||||||
|
tab={<Space><KeyOutlined />功能权限</Space>}
|
||||||
|
key="permissions"
|
||||||
|
>
|
||||||
|
<div className="role-permission-tree-v2">
|
||||||
|
<Tree
|
||||||
|
checkable
|
||||||
|
selectable={false}
|
||||||
|
checkStrictly={false}
|
||||||
|
treeData={permissionTreeData}
|
||||||
|
checkedKeys={selectedPermIds}
|
||||||
|
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>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Form form={form} layout="vertical" className="role-form">
|
<Form form={form} layout="vertical">
|
||||||
<Form.Item
|
<Form.Item label="角色名称" name="roleName" rules={[{ required: true }]}>
|
||||||
label="角色名称"
|
<Input placeholder="输入名称" />
|
||||||
name="roleName"
|
|
||||||
rules={[{ required: true, message: "请输入角色名称" }]}
|
|
||||||
>
|
|
||||||
<Input placeholder="例如:Auditor" />
|
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label="描述" name="remark">
|
<Form.Item label="角色编码" name="roleCode" rules={[{ required: true }]}>
|
||||||
<Input placeholder="该角色的职责描述..." />
|
<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.Item>
|
||||||
</Form>
|
</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">
|
|
||||||
<Tree
|
|
||||||
checkable
|
|
||||||
selectable={false}
|
|
||||||
checkStrictly={false}
|
|
||||||
treeData={permissionTreeData}
|
|
||||||
checkedKeys={selectedPermIds}
|
|
||||||
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>
|
|
||||||
</div>
|
|
||||||
</Drawer>
|
</Drawer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { Select } from "antd";
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import Roles from "../pages/Roles";
|
||||||
import Permissions from "../pages/Permissions";
|
import Permissions from "../pages/Permissions";
|
||||||
import Devices from "../pages/Devices";
|
import Devices from "../pages/Devices";
|
||||||
import Dictionaries from "../pages/Dictionaries";
|
import Dictionaries from "../pages/Dictionaries";
|
||||||
|
import Logs from "../pages/Logs";
|
||||||
import UserRoleBinding from "../pages/UserRoleBinding";
|
import UserRoleBinding from "../pages/UserRoleBinding";
|
||||||
import RolePermissionBinding from "../pages/RolePermissionBinding";
|
import RolePermissionBinding from "../pages/RolePermissionBinding";
|
||||||
|
|
||||||
|
|
@ -15,6 +16,7 @@ export const menuRoutes: MenuRoute[] = [
|
||||||
{ path: "/roles", label: "角色管理", element: <Roles />, perm: "menu:roles" },
|
{ path: "/roles", label: "角色管理", element: <Roles />, perm: "menu:roles" },
|
||||||
{ path: "/permissions", label: "权限管理", element: <Permissions />, perm: "menu:permissions" },
|
{ path: "/permissions", label: "权限管理", element: <Permissions />, perm: "menu:permissions" },
|
||||||
{ path: "/dictionaries", label: "字典管理", element: <Dictionaries />, perm: "menu:dict" },
|
{ 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: "/devices", label: "设备管理", element: <Devices />, perm: "menu:devices" },
|
||||||
{ path: "/user-roles", label: "用户角色绑定", element: <UserRoleBinding />, perm: "menu:user-roles" },
|
{ path: "/user-roles", label: "用户角色绑定", element: <UserRoleBinding />, perm: "menu:user-roles" },
|
||||||
{ path: "/role-permissions", label: "角色权限绑定", element: <RolePermissionBinding />, perm: "menu:role-permissions" }
|
{ path: "/role-permissions", label: "角色权限绑定", element: <RolePermissionBinding />, perm: "menu:role-permissions" }
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue