feat(auth): 添加登录日志记录功能并配置软删除逻辑

- 在application.yml中配置MyBatis-Plus逻辑删除字段
- 在BaseEntity中添加@TableLogic注解实现软删除
- 在AuthServiceImpl中注入SysLogService和HttpServletRequest
- 实现登录成功和失败的日志记录功能
- 添加LoginUser类用于安全认证
- 创建Log注解和LogAspect切面实现操作日志记录
- 添加PermissionService用于权限验证
- 在RoleController中添加用户查询接口和日志注解
- 在前端添加Logs页面展示操作日志
- 更新Roles页面UI并添加相关API调用
- 添加AOP依赖并在启动类启用异步支持
master
chenhao 2026-02-12 10:41:59 +08:00
parent e379a228a3
commit a1db81892c
24 changed files with 1037 additions and 465 deletions

View File

@ -43,6 +43,10 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>

View File

@ -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);

View File

@ -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 ""; // 资源类型/模块名
}

View File

@ -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]";
}
}
}

View File

@ -2,10 +2,13 @@ package com.imeeting.controller;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.imeeting.common.ApiResponse;
import com.imeeting.common.annotation.Log;
import com.imeeting.entity.SysRole;
import com.imeeting.entity.SysRolePermission;
import com.imeeting.entity.SysUser;
import com.imeeting.mapper.SysRolePermissionMapper;
import com.imeeting.service.SysRoleService;
import com.imeeting.service.SysUserService;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
@ -16,10 +19,12 @@ import java.util.List;
@RequestMapping("/api/roles")
public class RoleController {
private final SysRoleService sysRoleService;
private final SysUserService sysUserService;
private final SysRolePermissionMapper sysRolePermissionMapper;
public RoleController(SysRoleService sysRoleService, SysRolePermissionMapper sysRolePermissionMapper) {
public RoleController(SysRoleService sysRoleService, SysUserService sysUserService, SysRolePermissionMapper sysRolePermissionMapper) {
this.sysRoleService = sysRoleService;
this.sysUserService = sysUserService;
this.sysRolePermissionMapper = sysRolePermissionMapper;
}
@ -29,6 +34,12 @@ public class RoleController {
return ApiResponse.ok(sysRoleService.list());
}
@GetMapping("/{id}/users")
@PreAuthorize("@ss.hasPermi('sys_role:query')")
public ApiResponse<List<SysUser>> listUsers(@PathVariable Long id) {
return ApiResponse.ok(sysUserService.listUsersByRoleId(id));
}
@GetMapping("/{id}")
@PreAuthorize("@ss.hasPermi('sys_role:query')")
public ApiResponse<SysRole> get(@PathVariable Long id) {
@ -37,12 +48,14 @@ public class RoleController {
@PostMapping
@PreAuthorize("@ss.hasPermi('sys_role:create')")
@com.imeeting.common.annotation.Log(value = "新增角色", type = "角色管理")
public ApiResponse<Boolean> create(@RequestBody SysRole role) {
return ApiResponse.ok(sysRoleService.save(role));
}
@PutMapping("/{id}")
@PreAuthorize("@ss.hasPermi('sys_role:update')")
@com.imeeting.common.annotation.Log(value = "修改角色", type = "角色管理")
public ApiResponse<Boolean> update(@PathVariable Long id, @RequestBody SysRole role) {
role.setRoleId(id);
return ApiResponse.ok(sysRoleService.updateById(role));
@ -50,6 +63,7 @@ public class RoleController {
@DeleteMapping("/{id}")
@PreAuthorize("@ss.hasPermi('sys_role:delete')")
@Log(value = "删除角色", type = "角色管理")
public ApiResponse<Boolean> delete(@PathVariable Long id) {
return ApiResponse.ok(sysRoleService.removeById(id));
}

View File

@ -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));
}
}

View File

@ -72,6 +72,7 @@ public class UserController {
@PostMapping
@PreAuthorize("@ss.hasPermi('sys_user:create')")
@com.imeeting.common.annotation.Log(value = "新增用户", type = "用户管理")
public ApiResponse<Boolean> create(@RequestBody SysUser user) {
if (user.getPasswordHash() != null && !user.getPasswordHash().isEmpty()) {
user.setPasswordHash(passwordEncoder.encode(user.getPasswordHash()));
@ -81,6 +82,7 @@ public class UserController {
@PutMapping("/{id}")
@PreAuthorize("@ss.hasPermi('sys_user:update')")
@com.imeeting.common.annotation.Log(value = "修改用户", type = "用户管理")
public ApiResponse<Boolean> update(@PathVariable Long id, @RequestBody SysUser user) {
user.setUserId(id);
if (user.getPasswordHash() != null && !user.getPasswordHash().isEmpty()) {
@ -91,6 +93,7 @@ public class UserController {
@DeleteMapping("/{id}")
@PreAuthorize("@ss.hasPermi('sys_user:delete')")
@com.imeeting.common.annotation.Log(value = "删除用户", type = "用户管理")
public ApiResponse<Boolean> delete(@PathVariable Long id) {
return ApiResponse.ok(sysUserService.removeById(id));
}

View File

@ -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)

View File

@ -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;
}

View File

@ -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> {
}

View File

@ -3,6 +3,12 @@ package com.imeeting.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.imeeting.entity.SysUser;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface SysUserMapper extends BaseMapper<SysUser> {}
public interface SysUserMapper extends BaseMapper<SysUser> {
@Select("SELECT u.* FROM sys_user u JOIN sys_user_role ur ON u.user_id = ur.user_id WHERE ur.role_id = #{roleId}")
List<SysUser> selectUsersByRoleId(@Param("roleId") Long roleId);
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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);
}

View File

@ -1,6 +1,17 @@
package com.imeeting.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.imeeting.entity.SysUser;
public interface SysUserService extends IService<SysUser> {}
import java.util.List;
public interface SysUserService extends IService<SysUser> {
List<SysUser> listUsersByRoleId(Long roleId);
}

View File

@ -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<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("设备码无效");
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<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("用户名或密码错误");
}
if (deviceCode == null || deviceCode.isEmpty()) {
deviceCode = "default";
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",
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

View File

@ -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);
}
}

View File

@ -1,10 +1,31 @@
package com.imeeting.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.imeeting.entity.SysUser;
import com.imeeting.mapper.SysUserMapper;
import com.imeeting.service.SysUserService;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> implements SysUserService {}
public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> implements SysUserService {
@Override
public List<SysUser> listUsersByRoleId(Long roleId) {
return baseMapper.selectUsersByRoleId(roleId);
}
}

View File

@ -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:

View File

@ -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";

View File

@ -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>
);
}

View File

@ -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;
}

View File

@ -1,4 +1,21 @@
import { Button, Drawer, Form, Input, message, Tag, Typography, Tree } from "antd";
import {
Button,
Card,
Drawer,
Form,
Input,
message,
Popconfirm,
Space,
Table,
Tag,
Typography,
Tree,
Row,
Col,
Tabs,
Empty
} from "antd";
import type { DataNode } from "antd/es/tree";
import { useEffect, useMemo, useState } from "react";
import {
@ -7,11 +24,22 @@ import {
listRolePermissions,
listRoles,
saveRolePermissions,
updateRole
updateRole,
deleteRole,
fetchUsersByRoleId
} from "../api";
import type { SysPermission, SysRole } from "../types";
import type { SysPermission, SysRole, SysUser } from "../types";
import { usePermission } from "../hooks/usePermission";
import { EditOutlined, PlusOutlined, SafetyCertificateOutlined } from "@ant-design/icons";
import {
EditOutlined,
PlusOutlined,
SafetyCertificateOutlined,
SearchOutlined,
DeleteOutlined,
KeyOutlined,
UserOutlined,
SaveOutlined
} from "@ant-design/icons";
import "./Roles.css";
const { Title, Text } = Typography;
@ -35,10 +63,6 @@ const buildPermissionTree = (list: SysPermission[]): PermissionNode[] => {
const parent = map.get(node.parentId);
if (parent) {
parent.children!.push(node);
} else {
// If parent is missing, it's an orphan.
// We don't push it to roots to avoid "submenu becomes root" issue.
console.warn(`Orphan node detected: ${node.name} (ID: ${node.permId}, ParentID: ${node.parentId})`);
}
} else {
roots.push(node);
@ -59,32 +83,36 @@ const toTreeData = (nodes: PermissionNode[]): DataNode[] =>
title: (
<span className="role-permission-node">
<span>{node.name}</span>
{node.permType === "button" && <Tag color="blue"></Tag>}
{node.permType === "button" && <Tag color="blue" style={{ marginLeft: 8 }}></Tag>}
</span>
),
children: node.children ? toTreeData(node.children) : undefined
children: node.children && node.children.length > 0 ? toTreeData(node.children) : undefined
}));
const generateRoleCode = () => `ROLE-${Date.now().toString(36).toUpperCase()}`;
const generateRoleCode = () => `ROLE_${Date.now().toString(36).toUpperCase()}`;
export default function Roles() {
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [data, setData] = useState<SysRole[]>([]);
const [permissions, setPermissions] = useState<SysPermission[]>([]);
const [rolePermMap, setRolePermMap] = useState<Record<number, number[]>>({});
const [drawerOpen, setDrawerOpen] = useState(false);
const [editing, setEditing] = useState<SysRole | null>(null);
const [selectedRole, setSelectedRole] = useState<SysRole | null>(null);
// Right side states
const [selectedPermIds, setSelectedPermIds] = useState<number[]>([]);
const [halfCheckedIds, setHalfCheckedIds] = useState<number[]>([]);
const [form] = Form.useForm();
const { can } = usePermission();
const [roleUsers, setRoleUsers] = useState<SysUser[]>([]);
const [loadingUsers, setLoadingUsers] = useState(false);
const permissionMap = useMemo(() => {
const map = new Map<number, SysPermission>();
permissions.forEach((p) => map.set(p.permId, p));
return map;
}, [permissions]);
// Search
const [searchText, setSearchText] = useState("");
// Drawer (Only for Add/Edit basic info)
const [drawerOpen, setDrawerOpen] = useState(false);
const [editing, setEditing] = useState<SysRole | null>(null);
const [form] = Form.useForm();
const { can } = usePermission();
const permissionTreeData = useMemo(
() => toTreeData(buildPermissionTree(permissions)),
@ -97,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<SysRole> = {
roleCode: editing?.roleCode || generateRoleCode(),
roleCode: editing?.roleCode || values.roleCode || generateRoleCode(),
roleName: values.roleName,
remark: values.remark,
status: editing?.status ?? DEFAULT_STATUS
status: values.status ?? DEFAULT_STATUS
};
let roleId = editing?.roleId;
if (editing) {
await updateRole(editing.roleId, payload);
message.success("角色已更新");
} else {
await createRole(payload);
message.success("角色已创建");
}
const list = await listRoles();
const roles = list || [];
setData(roles);
if (!roleId) {
roleId = roles.find((r) => r.roleCode === payload.roleCode)?.roleId;
}
if (roleId) {
const allPermIds = Array.from(new Set([...selectedPermIds, ...halfCheckedIds]));
await saveRolePermissions(roleId, allPermIds);
}
await loadRolePermissions(roles);
setDrawerOpen(false);
message.success(editing ? "角色已更新" : "角色已创建");
loadRoles();
} catch (e) {
if (e instanceof Error && e.message) {
message.error(e.message);
}
if (e instanceof Error && e.message) message.error(e.message);
} finally {
setSaving(false);
}
};
const renderRolePermissions = (role: SysRole) => {
const permIds = rolePermMap[role.roleId] || [];
const perms = permIds
.map((id) => permissionMap.get(id))
.filter((p): p is SysPermission => Boolean(p));
const preview = perms.slice(0, 3);
const totalCount = permIds.length;
return (
<>
<div className="role-permission-summary">
<span></span>
<span className="role-permission-badge">{`${totalCount}个权限`}</span>
</div>
<div className="role-permission-tags">
{preview.length ? (
preview.map((p) => (
<Tag key={p.permId} className="role-permission-tag">
{p.name}
</Tag>
))
) : (
<Tag className="role-permission-tag">
{totalCount ? "已选权限" : "暂无权限"}
</Tag>
)}
</div>
</>
);
const savePermissions = async () => {
if (!selectedRole) return;
setSaving(true);
try {
const allPermIds = Array.from(new Set([...selectedPermIds, ...halfCheckedIds]));
await saveRolePermissions(selectedRole.roleId, allPermIds);
message.success("权限已保存并生效");
} catch (e) {
message.error("保存权限失败");
} finally {
setSaving(false);
}
};
return (
<div className="roles-page">
<div className="roles-header">
<div>
<Title level={4} className="roles-title">
</Title>
<Text type="secondary" className="roles-subtitle">
访
</Text>
</div>
{can("sys_role:create") && (
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
</Button>
)}
</div>
<div className="roles-page-v2">
<Row gutter={24} style={{ height: 'calc(100vh - 120px)' }}>
{/* Left: Role List */}
<Col span={8} style={{ height: '100%' }}>
<Card
title="系统角色"
className="full-height-card"
extra={can("sys_role:create") && <Button type="primary" size="small" icon={<PlusOutlined />} onClick={openCreate}></Button>}
>
<div className="mb-4">
<Input
placeholder="搜索角色..."
prefix={<SearchOutlined />}
value={searchText}
onChange={e => setSearchText(e.target.value)}
allowClear
/>
</div>
<div className="role-list-container">
<Table
rowKey="roleId"
showHeader={false}
dataSource={filteredData}
loading={loading}
pagination={false}
onRow={(record) => ({
onClick: () => selectRole(record),
className: `cursor-pointer role-row ${selectedRole?.roleId === record.roleId ? 'role-row-selected' : ''}`
})}
columns={[
{
title: '角色',
render: (_, record) => (
<div className="role-item-content">
<div className="role-item-main">
<div className="role-item-name">{record.roleName}</div>
<div className="role-item-code">{record.roleCode}</div>
</div>
<div className="role-item-actions">
{can("sys_role:update") && <Button type="text" size="small" icon={<EditOutlined />} onClick={e => openEditBasic(e, record)} />}
{can("sys_role:delete") && record.roleCode !== 'ADMIN' && (
<Popconfirm title="删除角色?" onConfirm={e => handleRemove(e!, record.roleId)}>
<Button type="text" size="small" danger icon={<DeleteOutlined />} onClick={e => e.stopPropagation()} />
</Popconfirm>
)}
</div>
</div>
)
}
]}
/>
</div>
</Card>
</Col>
<div className="roles-grid">
{data.map((role) => (
<div key={role.roleId} className="role-card">
<div className="role-card-header">
<div className="role-icon">
<SafetyCertificateOutlined />
</div>
{can("sys_role:update") && (
{/* Right: Detail Tabs */}
<Col span={16} style={{ height: '100%' }}>
{selectedRole ? (
<Card
className="full-height-card"
title={
<Space>
<SafetyCertificateOutlined style={{ color: '#1890ff' }} />
<span>{selectedRole.roleName}</span>
<Tag color="blue">{selectedRole.roleCode}</Tag>
</Space>
}
extra={
<Button
type="text"
className="role-edit-btn"
icon={<EditOutlined />}
onClick={() => openEdit(role)}
/>
)}
</div>
<div className="role-main">
<div className="role-name">{role.roleName}</div>
<div className="role-id">{`ID: ${role.roleCode || role.roleId}`}</div>
</div>
{renderRolePermissions(role)}
<div className="role-footer">
<span></span>
<span>{role.updatedAt ? "刚刚" : "刚刚"}</span>
</div>
</div>
))}
{!data.length && !loading && (
<div className="roles-empty"></div>
)}
</div>
<Drawer
open={drawerOpen}
onClose={handleClose}
width={420}
closable
title={
<div className="role-drawer-title">
<div className="role-drawer-icon">
<SafetyCertificateOutlined />
</div>
<div>
<div className="role-drawer-heading">
{editing ? "编辑角色" : "创建新角色"}
</div>
</div>
</div>
}
footer={
<div className="role-drawer-footer">
<Button type="link" className="role-drawer-cancel" onClick={handleClose}>
</Button>
<Button
type="primary"
className="role-drawer-submit"
loading={saving}
onClick={submit}
type="primary"
icon={<SaveOutlined />}
loading={saving}
onClick={savePermissions}
disabled={!can("sys_role:permission:save")}
>
</Button>
}
>
</Button>
<Tabs defaultActiveKey="permissions" className="role-tabs">
<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>
}
>
<Form form={form} layout="vertical" className="role-form">
<Form.Item
label="角色名称"
name="roleName"
rules={[{ required: true, message: "请输入角色名称" }]}
>
<Input placeholder="例如Auditor" />
<Form form={form} layout="vertical">
<Form.Item label="角色名称" name="roleName" rules={[{ required: true }]}>
<Input placeholder="输入名称" />
</Form.Item>
<Form.Item label="描述" name="remark">
<Input placeholder="该角色的职责描述..." />
<Form.Item label="角色编码" name="roleCode" rules={[{ required: true }]}>
<Input placeholder="输入唯一编码" disabled={!!editing} />
</Form.Item>
<Form.Item label="状态" name="status" initialValue={1}>
<Select options={[{label: '启用', value: 1}, {label: '禁用', value: 0}]} />
</Form.Item>
<Form.Item label="备注" name="remark">
<Input.TextArea rows={3} />
</Form.Item>
</Form>
<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>
</div>
);
}
import { Select } from "antd";

View File

@ -4,6 +4,7 @@ import Roles from "../pages/Roles";
import Permissions from "../pages/Permissions";
import Devices from "../pages/Devices";
import Dictionaries from "../pages/Dictionaries";
import Logs from "../pages/Logs";
import UserRoleBinding from "../pages/UserRoleBinding";
import RolePermissionBinding from "../pages/RolePermissionBinding";
@ -15,6 +16,7 @@ export const menuRoutes: MenuRoute[] = [
{ path: "/roles", label: "角色管理", element: <Roles />, perm: "menu:roles" },
{ path: "/permissions", label: "权限管理", element: <Permissions />, perm: "menu:permissions" },
{ path: "/dictionaries", label: "字典管理", element: <Dictionaries />, perm: "menu:dict" },
{ path: "/logs", label: "日志管理", element: <Logs />, perm: "menu:logs" },
{ path: "/devices", label: "设备管理", element: <Devices />, perm: "menu:devices" },
{ path: "/user-roles", label: "用户角色绑定", element: <UserRoleBinding />, perm: "menu:user-roles" },
{ path: "/role-permissions", label: "角色权限绑定", element: <RolePermissionBinding />, perm: "menu:role-permissions" }