diff --git a/backend/pom.xml b/backend/pom.xml index 0fe2850..bcb1635 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -43,6 +43,10 @@ org.springframework.boot spring-boot-starter-cache + + org.springframework.boot + spring-boot-starter-aop + com.baomidou mybatis-plus-spring-boot3-starter diff --git a/backend/src/main/java/com/imeeting/ImeetingApplication.java b/backend/src/main/java/com/imeeting/ImeetingApplication.java index ffe62df..645a28f 100644 --- a/backend/src/main/java/com/imeeting/ImeetingApplication.java +++ b/backend/src/main/java/com/imeeting/ImeetingApplication.java @@ -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); diff --git a/backend/src/main/java/com/imeeting/common/annotation/Log.java b/backend/src/main/java/com/imeeting/common/annotation/Log.java new file mode 100644 index 0000000..b9fee6e --- /dev/null +++ b/backend/src/main/java/com/imeeting/common/annotation/Log.java @@ -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 ""; // 资源类型/模块名 +} diff --git a/backend/src/main/java/com/imeeting/common/aspect/LogAspect.java b/backend/src/main/java/com/imeeting/common/aspect/LogAspect.java new file mode 100644 index 0000000..28b5870 --- /dev/null +++ b/backend/src/main/java/com/imeeting/common/aspect/LogAspect.java @@ -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]"; + } + } +} diff --git a/backend/src/main/java/com/imeeting/controller/RoleController.java b/backend/src/main/java/com/imeeting/controller/RoleController.java index 34b08c1..8e791c7 100644 --- a/backend/src/main/java/com/imeeting/controller/RoleController.java +++ b/backend/src/main/java/com/imeeting/controller/RoleController.java @@ -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> listUsers(@PathVariable Long id) { + return ApiResponse.ok(sysUserService.listUsersByRoleId(id)); + } + @GetMapping("/{id}") @PreAuthorize("@ss.hasPermi('sys_role:query')") public ApiResponse 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 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 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 delete(@PathVariable Long id) { return ApiResponse.ok(sysRoleService.removeById(id)); } diff --git a/backend/src/main/java/com/imeeting/controller/SysLogController.java b/backend/src/main/java/com/imeeting/controller/SysLogController.java new file mode 100644 index 0000000..d251499 --- /dev/null +++ b/backend/src/main/java/com/imeeting/controller/SysLogController.java @@ -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> 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 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)); + } +} diff --git a/backend/src/main/java/com/imeeting/controller/UserController.java b/backend/src/main/java/com/imeeting/controller/UserController.java index 16da093..f5c7b4e 100644 --- a/backend/src/main/java/com/imeeting/controller/UserController.java +++ b/backend/src/main/java/com/imeeting/controller/UserController.java @@ -72,6 +72,7 @@ public class UserController { @PostMapping @PreAuthorize("@ss.hasPermi('sys_user:create')") + @com.imeeting.common.annotation.Log(value = "新增用户", type = "用户管理") public ApiResponse 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 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 delete(@PathVariable Long id) { return ApiResponse.ok(sysUserService.removeById(id)); } diff --git a/backend/src/main/java/com/imeeting/entity/BaseEntity.java b/backend/src/main/java/com/imeeting/entity/BaseEntity.java index 8ea44c1..252193f 100644 --- a/backend/src/main/java/com/imeeting/entity/BaseEntity.java +++ b/backend/src/main/java/com/imeeting/entity/BaseEntity.java @@ -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) diff --git a/backend/src/main/java/com/imeeting/entity/SysLog.java b/backend/src/main/java/com/imeeting/entity/SysLog.java new file mode 100644 index 0000000..73307ab --- /dev/null +++ b/backend/src/main/java/com/imeeting/entity/SysLog.java @@ -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; +} diff --git a/backend/src/main/java/com/imeeting/mapper/SysLogMapper.java b/backend/src/main/java/com/imeeting/mapper/SysLogMapper.java new file mode 100644 index 0000000..319ecd8 --- /dev/null +++ b/backend/src/main/java/com/imeeting/mapper/SysLogMapper.java @@ -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 { +} diff --git a/backend/src/main/java/com/imeeting/mapper/SysUserMapper.java b/backend/src/main/java/com/imeeting/mapper/SysUserMapper.java index 27fbb1c..0fcbd5a 100644 --- a/backend/src/main/java/com/imeeting/mapper/SysUserMapper.java +++ b/backend/src/main/java/com/imeeting/mapper/SysUserMapper.java @@ -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 {} +public interface SysUserMapper extends BaseMapper { + @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 selectUsersByRoleId(@Param("roleId") Long roleId); +} diff --git a/backend/src/main/java/com/imeeting/security/LoginUser.java b/backend/src/main/java/com/imeeting/security/LoginUser.java new file mode 100644 index 0000000..9d9acd0 --- /dev/null +++ b/backend/src/main/java/com/imeeting/security/LoginUser.java @@ -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 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; + } +} diff --git a/backend/src/main/java/com/imeeting/security/PermissionService.java b/backend/src/main/java/com/imeeting/security/PermissionService.java new file mode 100644 index 0000000..5a1cbdb --- /dev/null +++ b/backend/src/main/java/com/imeeting/security/PermissionService.java @@ -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 permissions = loginUser.getPermissions(); + if (CollectionUtils.isEmpty(permissions)) { + return false; + } + + return permissions.contains(permission); + } +} diff --git a/backend/src/main/java/com/imeeting/service/SysLogService.java b/backend/src/main/java/com/imeeting/service/SysLogService.java new file mode 100644 index 0000000..55f0ad0 --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/SysLogService.java @@ -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 { + void recordLog(SysLog log); +} diff --git a/backend/src/main/java/com/imeeting/service/SysUserService.java b/backend/src/main/java/com/imeeting/service/SysUserService.java index 62efe5f..f0a00db 100644 --- a/backend/src/main/java/com/imeeting/service/SysUserService.java +++ b/backend/src/main/java/com/imeeting/service/SysUserService.java @@ -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 {} +import java.util.List; + + + +public interface SysUserService extends IService { + + List listUsersByRoleId(Long roleId); + +} + + diff --git a/backend/src/main/java/com/imeeting/service/impl/AuthServiceImpl.java b/backend/src/main/java/com/imeeting/service/impl/AuthServiceImpl.java index fc0c828..7ba00d2 100644 --- a/backend/src/main/java/com/imeeting/service/impl/AuthServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/impl/AuthServiceImpl.java @@ -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,52 +43,75 @@ 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) { - if (isCaptchaEnabled()) { - validateCaptcha(request.getCaptchaId(), request.getCaptchaCode()); - } - - SysUser user = sysUserService.getOne(new LambdaQueryWrapper() - .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() - .eq(Device::getUserId, user.getUserId()) - .eq(Device::getDeviceCode, deviceCode) - .eq(Device::getIsDeleted, 0) - .eq(Device::getStatus, 1)); - if (device == null) { - throw new IllegalArgumentException("设备码无效"); + try { + if (isCaptchaEnabled()) { + validateCaptcha(request.getCaptchaId(), request.getCaptchaCode()); } - } - 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); + SysUser user = sysUserService.getOne(new LambdaQueryWrapper() + .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("用户名或密码错误"); + } - if (deviceCode == null || deviceCode.isEmpty()) { - deviceCode = "default"; + String deviceCode = request.getDeviceCode(); + if (deviceCode != null && !deviceCode.isEmpty()) { + Device device = deviceService.getOne(new LambdaQueryWrapper() + .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 diff --git a/backend/src/main/java/com/imeeting/service/impl/SysLogServiceImpl.java b/backend/src/main/java/com/imeeting/service/impl/SysLogServiceImpl.java new file mode 100644 index 0000000..35004c2 --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/impl/SysLogServiceImpl.java @@ -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 implements SysLogService { + + @Async + @Override + public void recordLog(SysLog log) { + save(log); + } +} diff --git a/backend/src/main/java/com/imeeting/service/impl/SysUserServiceImpl.java b/backend/src/main/java/com/imeeting/service/impl/SysUserServiceImpl.java index cbdefe6..c8951df 100644 --- a/backend/src/main/java/com/imeeting/service/impl/SysUserServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/impl/SysUserServiceImpl.java @@ -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 implements SysUserService {} + +public class SysUserServiceImpl extends ServiceImpl implements SysUserService { + + @Override + + public List listUsersByRoleId(Long roleId) { + + return baseMapper.selectUsersByRoleId(roleId); + + } + +} + + diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 65178d3..b6134ce 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -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: diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 49b3d9c..bbb5f7a 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -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"; diff --git a/frontend/src/pages/Logs.tsx b/frontend/src/pages/Logs.tsx new file mode 100644 index 0000000..a76c830 --- /dev/null +++ b/frontend/src/pages/Logs.tsx @@ -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) => ( + + {status === 1 ? "成功" : "失败"} + + ) + }, + { + 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) => {t} + }); + } + + return ( + + + + setParams({ ...params, username: e.target.value })} + /> + setParams({ ...params, status: v })} + options={[ + { label: "成功", value: 1 }, + { label: "失败", value: 0 } + ]} + /> + { + setParams({ + ...params, + startDate: dates ? dates[0]?.format("YYYY-MM-DD") || "" : "", + endDate: dates ? dates[1]?.format("YYYY-MM-DD") || "" : "" + }); + }} + /> + } onClick={handleSearch}>搜索 + } onClick={handleReset}>重置 + + + + + + + + + + setParams({ ...params, current: page, size }), + showTotal: (total) => `共 ${total} 条` + }} + /> + + + ); +} diff --git a/frontend/src/pages/Roles.css b/frontend/src/pages/Roles.css index 980a24f..4dc6a8a 100644 --- a/frontend/src/pages/Roles.css +++ b/frontend/src/pages/Roles.css @@ -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; } diff --git a/frontend/src/pages/Roles.tsx b/frontend/src/pages/Roles.tsx index a2046aa..7e11c5a 100644 --- a/frontend/src/pages/Roles.tsx +++ b/frontend/src/pages/Roles.tsx @@ -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: ( {node.name} - {node.permType === "button" && 按钮} + {node.permType === "button" && 按钮} ), - 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([]); const [permissions, setPermissions] = useState([]); - const [rolePermMap, setRolePermMap] = useState>({}); - const [drawerOpen, setDrawerOpen] = useState(false); - const [editing, setEditing] = useState(null); + const [selectedRole, setSelectedRole] = useState(null); + + // Right side states const [selectedPermIds, setSelectedPermIds] = useState([]); const [halfCheckedIds, setHalfCheckedIds] = useState([]); - const [form] = Form.useForm(); - const { can } = usePermission(); + const [roleUsers, setRoleUsers] = useState([]); + const [loadingUsers, setLoadingUsers] = useState(false); - const permissionMap = useMemo(() => { - const map = new Map(); - 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(null); + const [form] = Form.useForm(); + + const { can } = usePermission(); const permissionTreeData = useMemo( () => toTreeData(buildPermissionTree(permissions)), @@ -97,268 +125,323 @@ 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(); }, []); + // 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 = () => { setEditing(null); - setSelectedPermIds([]); - setHalfCheckedIds([]); form.resetFields(); + form.setFieldsValue({ status: 1 }); setDrawerOpen(true); }; - const openEdit = (record: SysRole) => { + const openEditBasic = (e: React.MouseEvent, record: SysRole) => { + e.stopPropagation(); 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 => { - return !permissions.some(p => p.parentId === id); - }); - - setSelectedPermIds(leafIds); - setHalfCheckedIds([]); - form.setFieldsValue({ - roleName: record.roleName, - remark: record.remark - }); + form.setFieldsValue(record); setDrawerOpen(true); }; - const handleClose = () => { - setDrawerOpen(false); + 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 submit = async () => { + const submitBasic = async () => { try { const values = await form.validateFields(); setSaving(true); const payload: Partial = { - 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 ( - <> - - 权限概览 - {`${totalCount}个权限`} - - - {preview.length ? ( - preview.map((p) => ( - - {p.name} - - )) - ) : ( - - {totalCount ? "已选权限" : "暂无权限"} - - )} - - > - ); + 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 ( - - - - - 系统角色权限 - - - 设置系统中不同角色的访问权限和操作边界 - - - {can("sys_role:create") && ( - } onClick={openCreate}> - 添加角色 - - )} - - - - {data.map((role) => ( - - - - - - {can("sys_role:update") && ( - } - onClick={() => openEdit(role)} - /> - )} + + + {/* Left: Role List */} + + } onClick={openCreate}>新增} + > + + } + value={searchText} + onChange={e => setSearchText(e.target.value)} + allowClear + /> - - - {role.roleName} - {`ID: ${role.roleCode || role.roleId}`} + + ({ + onClick: () => selectRole(record), + className: `cursor-pointer role-row ${selectedRole?.roleId === record.roleId ? 'role-row-selected' : ''}` + })} + columns={[ + { + title: '角色', + render: (_, record) => ( + + + {record.roleName} + {record.roleCode} + + + {can("sys_role:update") && } onClick={e => openEditBasic(e, record)} />} + {can("sys_role:delete") && record.roleCode !== 'ADMIN' && ( + handleRemove(e!, record.roleId)}> + } onClick={e => e.stopPropagation()} /> + + )} + + + ) + } + ]} + /> + + - {renderRolePermissions(role)} - - - 最后同步 - {role.updatedAt ? "刚刚" : "刚刚"} - - - ))} - {!data.length && !loading && ( - 暂无角色 - )} - - - - - - - - - {editing ? "编辑角色" : "创建新角色"} - - - - } - footer={ - - - 取消更改 - - + {selectedRole ? ( + + + {selectedRole.roleName} + {selectedRole.roleCode} + + } + extra={ + } + loading={saving} + onClick={savePermissions} + disabled={!can("sys_role:permission:save")} + > + 保存权限配置 + + } > - 确认并同步到系统 - + + 功能权限} + key="permissions" + > + + { + 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 + /> + + + 关联用户 ({roleUsers.length})} + key="users" + > + ( + + + + {r.displayName} + @{r.username} + + + ) + }, + { title: '手机号', dataIndex: 'phone' }, + { title: '邮箱', dataIndex: 'email' }, + { + title: '状态', + dataIndex: 'status', + render: s => {s === 1 ? '正常' : '禁用'} + } + ]} + /> + + + + ) : ( + + + + )} + + + + {/* Basic Info Drawer */} + setDrawerOpen(false)} + width={400} + destroyOnClose + footer={ + + setDrawerOpen(false)}>取消 + 提交 } > - - - + + + - - + + + + + + + + - - - - - - - 权限选择 - - - { - 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 - /> - - ); } + +import { Select } from "antd"; diff --git a/frontend/src/routes/routes.tsx b/frontend/src/routes/routes.tsx index 35400b2..3b36c3f 100644 --- a/frontend/src/routes/routes.tsx +++ b/frontend/src/routes/routes.tsx @@ -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: , perm: "menu:roles" }, { path: "/permissions", label: "权限管理", element: , perm: "menu:permissions" }, { path: "/dictionaries", label: "字典管理", element: , perm: "menu:dict" }, + { path: "/logs", label: "日志管理", element: , perm: "menu:logs" }, { path: "/devices", label: "设备管理", element: , perm: "menu:devices" }, { path: "/user-roles", label: "用户角色绑定", element: , perm: "menu:user-roles" }, { path: "/role-permissions", label: "角色权限绑定", element: , perm: "menu:role-permissions" }