From 78e77cf26011233a1f65ec88060d6ef113460396 Mon Sep 17 00:00:00 2001 From: chenhao Date: Wed, 11 Feb 2026 13:44:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(shared):=20=E6=B7=BB=E5=8A=A0=E6=93=8D?= =?UTF-8?q?=E4=BD=9C=E5=B8=AE=E5=8A=A9=E9=9D=A2=E6=9D=BF=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现 ActionHelpPanel 组件,提供操作详情和帮助信息展示 - 添加完整的 CSS 样式文件,支持响应式布局和主题适配 - 集成 Ant Design 的 Drawer 和 Collapse 组件 - 支持当前操作和所有可用操作的分类展示 - 实现操作步骤、注意事项、快捷键等功能说明 - 添加图标、标签、权限要求等信息展示 - 支持操作列表点击切换和实时预览功能 --- backend/design/project_design.md | 6 +- .../imeeting/auth/dto/DeviceCodeRequest.java | 2 - .../com/imeeting/auth/dto/LoginRequest.java | 2 - .../java/com/imeeting/common/RedisKeys.java | 7 + .../com/imeeting/config/SecurityConfig.java | 1 + .../imeeting/controller/AuthController.java | 15 +- .../imeeting/controller/RoleController.java | 53 ++- .../controller/SysParamController.java | 25 +- .../imeeting/controller/UserController.java | 53 ++- .../java/com/imeeting/dto/UserProfile.java | 2 + .../imeeting/entity/SysRolePermission.java | 12 +- .../java/com/imeeting/entity/SysUserRole.java | 12 +- .../com/imeeting/service/SysParamService.java | 8 + .../service/impl/AuthServiceImpl.java | 20 +- .../service/impl/SysParamServiceImpl.java | 51 +++ backend/src/main/resources/application.yml | 1 + frontend/IMPLEMENTATION_PLAN.md | 17 + frontend/design/开发规范.md | 1 + frontend/src/api/auth.ts | 8 +- frontend/src/api/index.ts | 25 ++ frontend/src/pages/Devices.tsx | 16 +- frontend/src/pages/Login.tsx | 61 ++- frontend/src/pages/Permissions.tsx | 16 +- frontend/src/pages/Roles.tsx | 413 +++++++++++++----- frontend/src/pages/Users.tsx | 16 +- frontend/src/routes/routes.tsx | 6 +- 26 files changed, 681 insertions(+), 168 deletions(-) diff --git a/backend/design/project_design.md b/backend/design/project_design.md index eaa86be..815c98b 100644 --- a/backend/design/project_design.md +++ b/backend/design/project_design.md @@ -87,6 +87,11 @@ src 2. `/auth/login` 3. `/api/users/me`(获取用户信息与 isAdmin) +### 验证码开关(系统参数) +- 系统参数 `security.captcha.enabled` 控制验证码是否启用(true/false) +- 系统启动时加载 `sys_param` 到 Redis Hash:`sys:param:{paramKey}`(字段:value/type) +- 前端登录页根据系统参数决定是否展示验证码 + ### 权限菜单渲染 1. `/api/permissions/me` 获取权限列表 2. 前端构建树形菜单 @@ -101,4 +106,3 @@ src - 添加审计日志落库策略 - 任务管理模块完善 - 权限树缓存与增量刷新策略 - diff --git a/backend/src/main/java/com/imeeting/auth/dto/DeviceCodeRequest.java b/backend/src/main/java/com/imeeting/auth/dto/DeviceCodeRequest.java index 29aaf62..0bc531d 100644 --- a/backend/src/main/java/com/imeeting/auth/dto/DeviceCodeRequest.java +++ b/backend/src/main/java/com/imeeting/auth/dto/DeviceCodeRequest.java @@ -9,9 +9,7 @@ public class DeviceCodeRequest { private String username; @NotBlank private String password; - @NotBlank private String captchaId; - @NotBlank private String captchaCode; private String deviceName; } diff --git a/backend/src/main/java/com/imeeting/auth/dto/LoginRequest.java b/backend/src/main/java/com/imeeting/auth/dto/LoginRequest.java index 5b3f712..9162a45 100644 --- a/backend/src/main/java/com/imeeting/auth/dto/LoginRequest.java +++ b/backend/src/main/java/com/imeeting/auth/dto/LoginRequest.java @@ -9,9 +9,7 @@ public class LoginRequest { private String username; @NotBlank private String password; - @NotBlank private String captchaId; - @NotBlank private String captchaCode; private String deviceCode; } diff --git a/backend/src/main/java/com/imeeting/common/RedisKeys.java b/backend/src/main/java/com/imeeting/common/RedisKeys.java index 8cc3388..6027ebb 100644 --- a/backend/src/main/java/com/imeeting/common/RedisKeys.java +++ b/backend/src/main/java/com/imeeting/common/RedisKeys.java @@ -14,4 +14,11 @@ public final class RedisKeys { public static String refreshTokenKey(Long userId, String deviceCode) { return "refresh:" + userId + ":" + deviceCode; } + + public static String sysParamKey(String paramKey) { + return "sys:param:" + paramKey; + } + + public static final String SYS_PARAM_FIELD_VALUE = "value"; + public static final String SYS_PARAM_FIELD_TYPE = "type"; } diff --git a/backend/src/main/java/com/imeeting/config/SecurityConfig.java b/backend/src/main/java/com/imeeting/config/SecurityConfig.java index 0fb6bcf..d3619e7 100644 --- a/backend/src/main/java/com/imeeting/config/SecurityConfig.java +++ b/backend/src/main/java/com/imeeting/config/SecurityConfig.java @@ -26,6 +26,7 @@ public class SecurityConfig { .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers("/auth/**").permitAll() + .requestMatchers("/api/params/value").permitAll() .anyRequest().authenticated() ) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); diff --git a/backend/src/main/java/com/imeeting/controller/AuthController.java b/backend/src/main/java/com/imeeting/controller/AuthController.java index 104951e..d178563 100644 --- a/backend/src/main/java/com/imeeting/controller/AuthController.java +++ b/backend/src/main/java/com/imeeting/controller/AuthController.java @@ -8,6 +8,8 @@ import com.imeeting.auth.dto.RefreshRequest; import com.imeeting.auth.dto.TokenResponse; import com.imeeting.common.ApiResponse; import com.imeeting.common.RedisKeys; +import com.imeeting.common.SysParamKeys; +import com.imeeting.service.SysParamService; import com.imeeting.service.AuthService; import com.wf.captcha.SpecCaptcha; import jakarta.validation.Valid; @@ -24,18 +26,24 @@ public class AuthController { private final AuthService authService; private final StringRedisTemplate stringRedisTemplate; private final JwtTokenProvider jwtTokenProvider; + private final SysParamService sysParamService; @Value("${app.captcha.ttl-seconds:120}") private long captchaTtlSeconds; - public AuthController(AuthService authService, StringRedisTemplate stringRedisTemplate, JwtTokenProvider jwtTokenProvider) { + public AuthController(AuthService authService, StringRedisTemplate stringRedisTemplate, + JwtTokenProvider jwtTokenProvider, SysParamService sysParamService) { this.authService = authService; this.stringRedisTemplate = stringRedisTemplate; this.jwtTokenProvider = jwtTokenProvider; + this.sysParamService = sysParamService; } @GetMapping("/captcha") public ApiResponse captcha() { + if (!isCaptchaEnabled()) { + return ApiResponse.error("Captcha disabled"); + } SpecCaptcha captcha = new SpecCaptcha(130, 48, 4); String code = captcha.text(); String imageBase64 = captcha.toBase64(); @@ -75,4 +83,9 @@ public class AuthController { authService.logout(userId, deviceCode); return ApiResponse.ok(null); } + + private boolean isCaptchaEnabled() { + String value = sysParamService.getCachedParamValue(SysParamKeys.CAPTCHA_ENABLED, "true"); + return Boolean.parseBoolean(value); + } } diff --git a/backend/src/main/java/com/imeeting/controller/RoleController.java b/backend/src/main/java/com/imeeting/controller/RoleController.java index 796e482..846de26 100644 --- a/backend/src/main/java/com/imeeting/controller/RoleController.java +++ b/backend/src/main/java/com/imeeting/controller/RoleController.java @@ -1,19 +1,25 @@ package com.imeeting.controller; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.imeeting.common.ApiResponse; import com.imeeting.entity.SysRole; +import com.imeeting.entity.SysRolePermission; +import com.imeeting.mapper.SysRolePermissionMapper; import com.imeeting.service.SysRoleService; import org.springframework.web.bind.annotation.*; +import java.util.ArrayList; import java.util.List; @RestController @RequestMapping("/api/roles") public class RoleController { private final SysRoleService sysRoleService; + private final SysRolePermissionMapper sysRolePermissionMapper; - public RoleController(SysRoleService sysRoleService) { + public RoleController(SysRoleService sysRoleService, SysRolePermissionMapper sysRolePermissionMapper) { this.sysRoleService = sysRoleService; + this.sysRolePermissionMapper = sysRolePermissionMapper; } @GetMapping @@ -41,4 +47,49 @@ public class RoleController { public ApiResponse delete(@PathVariable Long id) { return ApiResponse.ok(sysRoleService.removeById(id)); } + + @GetMapping("/{id}/permissions") + public ApiResponse> listRolePermissions(@PathVariable Long id) { + List rows = sysRolePermissionMapper.selectList( + new QueryWrapper().eq("role_id", id) + ); + List permIds = new ArrayList<>(); + for (SysRolePermission row : rows) { + if (row.getPermId() != null) { + permIds.add(row.getPermId()); + } + } + return ApiResponse.ok(permIds); + } + + @PostMapping("/{id}/permissions") + public ApiResponse saveRolePermissions(@PathVariable Long id, @RequestBody PermissionBindingPayload payload) { + List permIds = payload == null ? null : payload.getPermIds(); + sysRolePermissionMapper.delete(new QueryWrapper().eq("role_id", id)); + if (permIds == null || permIds.isEmpty()) { + return ApiResponse.ok(true); + } + for (Long permId : permIds) { + if (permId == null) { + continue; + } + SysRolePermission item = new SysRolePermission(); + item.setRoleId(id); + item.setPermId(permId); + sysRolePermissionMapper.insert(item); + } + return ApiResponse.ok(true); + } + + public static class PermissionBindingPayload { + private List permIds; + + public List getPermIds() { + return permIds; + } + + public void setPermIds(List permIds) { + this.permIds = permIds; + } + } } diff --git a/backend/src/main/java/com/imeeting/controller/SysParamController.java b/backend/src/main/java/com/imeeting/controller/SysParamController.java index 7f6ee1e..d4ce1c7 100644 --- a/backend/src/main/java/com/imeeting/controller/SysParamController.java +++ b/backend/src/main/java/com/imeeting/controller/SysParamController.java @@ -28,17 +28,36 @@ public class SysParamController { @PostMapping public ApiResponse create(@RequestBody SysParam param) { - return ApiResponse.ok(sysParamService.save(param)); + boolean saved = sysParamService.save(param); + if (saved) { + sysParamService.syncParamToCache(param); + } + return ApiResponse.ok(saved); } @PutMapping("/{id}") public ApiResponse update(@PathVariable Long id, @RequestBody SysParam param) { param.setParamId(id); - return ApiResponse.ok(sysParamService.updateById(param)); + boolean updated = sysParamService.updateById(param); + if (updated) { + sysParamService.syncParamToCache(param); + } + return ApiResponse.ok(updated); } @DeleteMapping("/{id}") public ApiResponse delete(@PathVariable Long id) { - return ApiResponse.ok(sysParamService.removeById(id)); + SysParam param = sysParamService.getById(id); + boolean removed = sysParamService.removeById(id); + if (removed && param != null) { + sysParamService.deleteParamCache(param.getParamKey()); + } + return ApiResponse.ok(removed); + } + + @GetMapping("/value") + public ApiResponse getValue(@RequestParam("key") String key, + @RequestParam(value = "defaultValue", required = false) String defaultValue) { + return ApiResponse.ok(sysParamService.getCachedParamValue(key, defaultValue)); } } diff --git a/backend/src/main/java/com/imeeting/controller/UserController.java b/backend/src/main/java/com/imeeting/controller/UserController.java index e97c53c..8aa82b6 100644 --- a/backend/src/main/java/com/imeeting/controller/UserController.java +++ b/backend/src/main/java/com/imeeting/controller/UserController.java @@ -3,12 +3,16 @@ package com.imeeting.controller; import com.imeeting.auth.JwtTokenProvider; import com.imeeting.common.ApiResponse; import com.imeeting.dto.UserProfile; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.imeeting.entity.SysUser; +import com.imeeting.entity.SysUserRole; +import com.imeeting.mapper.SysUserRoleMapper; import com.imeeting.service.SysUserService; import io.jsonwebtoken.Claims; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.web.bind.annotation.*; +import java.util.ArrayList; import java.util.List; @RestController @@ -17,11 +21,13 @@ public class UserController { private final SysUserService sysUserService; private final PasswordEncoder passwordEncoder; private final JwtTokenProvider jwtTokenProvider; + private final SysUserRoleMapper sysUserRoleMapper; - public UserController(SysUserService sysUserService, PasswordEncoder passwordEncoder, JwtTokenProvider jwtTokenProvider) { + public UserController(SysUserService sysUserService, PasswordEncoder passwordEncoder, JwtTokenProvider jwtTokenProvider, SysUserRoleMapper sysUserRoleMapper) { this.sysUserService = sysUserService; this.passwordEncoder = passwordEncoder; this.jwtTokenProvider = jwtTokenProvider; + this.sysUserRoleMapper = sysUserRoleMapper; } @GetMapping @@ -77,6 +83,39 @@ public class UserController { return ApiResponse.ok(sysUserService.removeById(id)); } + @GetMapping("/{id}/roles") + public ApiResponse> listUserRoles(@PathVariable Long id) { + List rows = sysUserRoleMapper.selectList( + new QueryWrapper().eq("user_id", id) + ); + List roleIds = new ArrayList<>(); + for (SysUserRole row : rows) { + if (row.getRoleId() != null) { + roleIds.add(row.getRoleId()); + } + } + return ApiResponse.ok(roleIds); + } + + @PostMapping("/{id}/roles") + public ApiResponse saveUserRoles(@PathVariable Long id, @RequestBody RoleBindingPayload payload) { + List roleIds = payload == null ? null : payload.getRoleIds(); + sysUserRoleMapper.delete(new QueryWrapper().eq("user_id", id)); + if (roleIds == null || roleIds.isEmpty()) { + return ApiResponse.ok(true); + } + for (Long roleId : roleIds) { + if (roleId == null) { + continue; + } + SysUserRole item = new SysUserRole(); + item.setUserId(id); + item.setRoleId(roleId); + sysUserRoleMapper.insert(item); + } + return ApiResponse.ok(true); + } + private Long resolveUserId(String authorization) { if (authorization == null || !authorization.startsWith("Bearer ")) { return null; @@ -85,4 +124,16 @@ public class UserController { Claims claims = jwtTokenProvider.parseToken(token); return claims.get("userId", Long.class); } + + public static class RoleBindingPayload { + private List roleIds; + + public List getRoleIds() { + return roleIds; + } + + public void setRoleIds(List roleIds) { + this.roleIds = roleIds; + } + } } diff --git a/backend/src/main/java/com/imeeting/dto/UserProfile.java b/backend/src/main/java/com/imeeting/dto/UserProfile.java index b2fc6a6..55f3b4d 100644 --- a/backend/src/main/java/com/imeeting/dto/UserProfile.java +++ b/backend/src/main/java/com/imeeting/dto/UserProfile.java @@ -1,5 +1,6 @@ package com.imeeting.dto; +import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Data; @Data @@ -10,5 +11,6 @@ public class UserProfile { private String email; private String phone; private Integer status; + @JsonProperty("isAdmin") private boolean isAdmin; } diff --git a/backend/src/main/java/com/imeeting/entity/SysRolePermission.java b/backend/src/main/java/com/imeeting/entity/SysRolePermission.java index 28633ca..09fd594 100644 --- a/backend/src/main/java/com/imeeting/entity/SysRolePermission.java +++ b/backend/src/main/java/com/imeeting/entity/SysRolePermission.java @@ -1,15 +1,25 @@ package com.imeeting.entity; +import com.baomidou.mybatisplus.annotation.FieldFill; import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; +import java.time.LocalDateTime; + @Data @TableName("sys_role_permission") -public class SysRolePermission extends BaseEntity { +public class SysRolePermission { @TableId(value = "id", type = IdType.AUTO) private Long id; private Long roleId; private Long permId; + + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createdAt; + + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updatedAt; } diff --git a/backend/src/main/java/com/imeeting/entity/SysUserRole.java b/backend/src/main/java/com/imeeting/entity/SysUserRole.java index 9fb8e79..75cb834 100644 --- a/backend/src/main/java/com/imeeting/entity/SysUserRole.java +++ b/backend/src/main/java/com/imeeting/entity/SysUserRole.java @@ -1,15 +1,25 @@ package com.imeeting.entity; +import com.baomidou.mybatisplus.annotation.FieldFill; import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; +import java.time.LocalDateTime; + @Data @TableName("sys_user_role") -public class SysUserRole extends BaseEntity { +public class SysUserRole { @TableId(value = "id", type = IdType.AUTO) private Long id; private Long userId; private Long roleId; + + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createdAt; + + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updatedAt; } diff --git a/backend/src/main/java/com/imeeting/service/SysParamService.java b/backend/src/main/java/com/imeeting/service/SysParamService.java index 6d84ba6..7428f91 100644 --- a/backend/src/main/java/com/imeeting/service/SysParamService.java +++ b/backend/src/main/java/com/imeeting/service/SysParamService.java @@ -5,4 +5,12 @@ import com.imeeting.entity.SysParam; public interface SysParamService extends IService { String getParamValue(String key, String defaultValue); + + String getCachedParamValue(String key, String defaultValue); + + void syncParamToCache(SysParam param); + + void deleteParamCache(String key); + + void syncAllToCache(); } 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 2960bda..fc0c828 100644 --- a/backend/src/main/java/com/imeeting/service/impl/AuthServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/impl/AuthServiceImpl.java @@ -5,6 +5,7 @@ import com.imeeting.auth.JwtTokenProvider; import com.imeeting.auth.dto.LoginRequest; 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.SysUser; import com.imeeting.service.AuthService; @@ -51,7 +52,9 @@ public class AuthServiceImpl implements AuthService { @Override public TokenResponse login(LoginRequest request) { - validateCaptcha(request.getCaptchaId(), request.getCaptchaCode()); + if (isCaptchaEnabled()) { + validateCaptcha(request.getCaptchaId(), request.getCaptchaCode()); + } SysUser user = sysUserService.getOne(new LambdaQueryWrapper() .eq(SysUser::getUsername, request.getUsername()) @@ -118,7 +121,9 @@ public class AuthServiceImpl implements AuthService { @Override public String createDeviceCode(LoginRequest request, String deviceName) { - validateCaptcha(request.getCaptchaId(), request.getCaptchaCode()); + if (isCaptchaEnabled()) { + validateCaptcha(request.getCaptchaId(), request.getCaptchaCode()); + } SysUser user = sysUserService.getOne(new LambdaQueryWrapper() .eq(SysUser::getUsername, request.getUsername()) @@ -138,6 +143,12 @@ public class AuthServiceImpl implements AuthService { } private void validateCaptcha(String captchaId, String captchaCode) { + if (captchaId == null || captchaId.isEmpty()) { + throw new IllegalArgumentException("验证码不能为空"); + } + if (captchaCode == null || captchaCode.isEmpty()) { + throw new IllegalArgumentException("验证码不能为空"); + } String key = RedisKeys.captchaKey(captchaId); String stored = stringRedisTemplate.opsForValue().get(key); if (stored == null) { @@ -164,6 +175,11 @@ public class AuthServiceImpl implements AuthService { stringRedisTemplate.delete(attemptsKey); } + private boolean isCaptchaEnabled() { + String value = sysParamService.getCachedParamValue(SysParamKeys.CAPTCHA_ENABLED, "true"); + return Boolean.parseBoolean(value); + } + private TokenResponse issueTokens(SysUser user, String deviceCode, long accessMinutes, long refreshDays) { Map accessClaims = new HashMap<>(); accessClaims.put("tokenType", "access"); diff --git a/backend/src/main/java/com/imeeting/service/impl/SysParamServiceImpl.java b/backend/src/main/java/com/imeeting/service/impl/SysParamServiceImpl.java index 328d42d..9cd81dc 100644 --- a/backend/src/main/java/com/imeeting/service/impl/SysParamServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/impl/SysParamServiceImpl.java @@ -2,16 +2,67 @@ package com.imeeting.service.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.imeeting.common.RedisKeys; import com.imeeting.entity.SysParam; import com.imeeting.mapper.SysParamMapper; import com.imeeting.service.SysParamService; +import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + @Service public class SysParamServiceImpl extends ServiceImpl implements SysParamService { + private final StringRedisTemplate stringRedisTemplate; + + public SysParamServiceImpl(StringRedisTemplate stringRedisTemplate) { + this.stringRedisTemplate = stringRedisTemplate; + } + @Override public String getParamValue(String key, String defaultValue) { SysParam param = getOne(new LambdaQueryWrapper().eq(SysParam::getParamKey, key)); return param == null ? defaultValue : param.getParamValue(); } + + @Override + public String getCachedParamValue(String key, String defaultValue) { + if (key == null || key.isEmpty()) { + return defaultValue; + } + Object value = stringRedisTemplate.opsForHash().get(RedisKeys.sysParamKey(key), RedisKeys.SYS_PARAM_FIELD_VALUE); + if (value != null) { + return String.valueOf(value); + } + return getParamValue(key, defaultValue); + } + + @Override + public void syncParamToCache(SysParam param) { + if (param == null || param.getParamKey() == null || param.getParamKey().isEmpty()) { + return; + } + Map data = new HashMap<>(); + data.put(RedisKeys.SYS_PARAM_FIELD_VALUE, param.getParamValue() == null ? "" : param.getParamValue()); + data.put(RedisKeys.SYS_PARAM_FIELD_TYPE, param.getParamType() == null ? "" : param.getParamType()); + stringRedisTemplate.opsForHash().putAll(RedisKeys.sysParamKey(param.getParamKey()), data); + } + + @Override + public void deleteParamCache(String key) { + if (key == null || key.isEmpty()) { + return; + } + stringRedisTemplate.delete(RedisKeys.sysParamKey(key)); + } + + @Override + public void syncAllToCache() { + List params = list(); + for (SysParam param : params) { + syncParamToCache(param); + } + } } diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 6e268b3..8e29a38 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -11,6 +11,7 @@ spring: host: 10.100.51.51 port: 6379 password: Unis@123 + database: 15 mybatis-plus: configuration: diff --git a/frontend/IMPLEMENTATION_PLAN.md b/frontend/IMPLEMENTATION_PLAN.md index a1d2270..cfd2e35 100644 --- a/frontend/IMPLEMENTATION_PLAN.md +++ b/frontend/IMPLEMENTATION_PLAN.md @@ -35,3 +35,20 @@ Tests: Status: - In Progress + +## Stage 3: User-Role and Role-Permission Binding Pages + +Goal: +- Add admin pages for binding users to roles and roles to permissions. + +Success Criteria: +- Two new pages available at /user-roles and /role-permissions. +- Role and permission selection UI with save actions wired to API endpoints. + +Tests: +- Select user and roles, then save. +- Select role and permissions, then save. +- Verify behavior when APIs are missing or return errors. + +Status: +- In Progress diff --git a/frontend/design/开发规范.md b/frontend/design/开发规范.md index cf1748e..a10f2d5 100644 --- a/frontend/design/开发规范.md +++ b/frontend/design/开发规范.md @@ -167,6 +167,7 @@ ## 开发规范 +- **新增操作**:默认使用**右侧抽屉**打开表单进行创建(除非需求明确允许其他方式) - **组件命名**:PascalCase - **文件命名**:与组件同名 - **样式类命名**:kebab-case diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 8f61203..1b00f2b 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -15,16 +15,16 @@ export interface TokenResponse { export interface LoginPayload { username: string; password: string; - captchaId: string; - captchaCode: string; + captchaId?: string; + captchaCode?: string; deviceCode?: string; } export interface DeviceCodePayload { username: string; password: string; - captchaId: string; - captchaCode: string; + captchaId?: string; + captchaCode?: string; deviceName?: string; } diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 240ffa7..6db0fc6 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -47,6 +47,11 @@ export async function listPermissions() { return resp.data.data as SysPermission[]; } +export async function getSystemParamValue(key: string, defaultValue?: string) { + const resp = await http.get("/api/params/value", { params: { key, defaultValue } }); + return resp.data.data as string; +} + export async function listMyPermissions() { const resp = await http.get("/api/permissions/me"); return resp.data.data as SysPermission[]; @@ -92,3 +97,23 @@ export async function deleteDevice(id: number) { return resp.data.data as boolean; } +export async function listUserRoles(userId: number) { + const resp = await http.get(`/api/users/${userId}/roles`); + return resp.data.data as number[]; +} + +export async function saveUserRoles(userId: number, roleIds: number[]) { + const resp = await http.post(`/api/users/${userId}/roles`, { roleIds }); + return resp.data.data as boolean; +} + +export async function listRolePermissions(roleId: number) { + const resp = await http.get(`/api/roles/${roleId}/permissions`); + return resp.data.data as number[]; +} + +export async function saveRolePermissions(roleId: number, permIds: number[]) { + const resp = await http.post(`/api/roles/${roleId}/permissions`, { permIds }); + return resp.data.data as boolean; +} + diff --git a/frontend/src/pages/Devices.tsx b/frontend/src/pages/Devices.tsx index 23130cd..73c423b 100644 --- a/frontend/src/pages/Devices.tsx +++ b/frontend/src/pages/Devices.tsx @@ -1,4 +1,4 @@ -import { Button, Form, Input, Modal, Popconfirm, Space, Table, Tag, Select, InputNumber } from "antd"; +import { Button, Form, Input, Drawer, Popconfirm, Space, Table, Tag, Select, InputNumber } from "antd"; import { useEffect, useMemo, useState } from "react"; import { createDevice, deleteDevice, listDevices, updateDevice } from "../api"; import type { DeviceInfo } from "../types"; @@ -135,12 +135,18 @@ export default function Devices() { ]} /> - setOpen(false)} + onClose={() => setOpen(false)} + width={420} destroyOnClose + footer={ + + + + + } >
@@ -156,7 +162,7 @@ export default function Devices() { -
- {captcha ? ( - captcha - ) : ( -
- )} + {captchaEnabled && ( + +
+ +
+ {captcha ? ( + captcha + ) : ( +
+ )} +
-
-
+ + )}
diff --git a/frontend/src/pages/Permissions.tsx b/frontend/src/pages/Permissions.tsx index 77f178f..9abbaeb 100644 --- a/frontend/src/pages/Permissions.tsx +++ b/frontend/src/pages/Permissions.tsx @@ -1,4 +1,4 @@ -import { Button, Form, Input, Modal, Popconfirm, Space, Table, Tag, Select, InputNumber } from "antd"; +import { Button, Form, Input, Drawer, Popconfirm, Space, Table, Tag, Select, InputNumber } from "antd"; import { useEffect, useMemo, useState } from "react"; import { createPermission, deletePermission, listMyPermissions, updatePermission } from "../api"; import type { SysPermission } from "../types"; @@ -188,12 +188,18 @@ export default function Permissions() { ]} /> - setOpen(false)} + onClose={() => setOpen(false)} + width={520} destroyOnClose + footer={ + + + + + } > - +
); } diff --git a/frontend/src/pages/Roles.tsx b/frontend/src/pages/Roles.tsx index c39daed..e6d6715 100644 --- a/frontend/src/pages/Roles.tsx +++ b/frontend/src/pages/Roles.tsx @@ -1,161 +1,348 @@ -import { Button, Form, Input, Modal, Popconfirm, Space, Table, Tag, Select } from "antd"; +import { Button, Drawer, Form, Input, message, Tag, Typography, Tree } from "antd"; +import type { DataNode } from "antd/es/tree"; import { useEffect, useMemo, useState } from "react"; -import { createRole, deleteRole, listRoles, updateRole } from "../api"; -import type { SysRole } from "../types"; +import { + createRole, + listPermissions, + listRolePermissions, + listRoles, + saveRolePermissions, + updateRole +} from "../api"; +import type { SysPermission, SysRole } from "../types"; import { usePermission } from "../hooks/usePermission"; +import { EditOutlined, PlusOutlined, SafetyCertificateOutlined } from "@ant-design/icons"; +import "./Roles.css"; + +const { Title, Text } = Typography; + +const DEFAULT_STATUS = 1; + +type PermissionNode = SysPermission & { key: number; children?: PermissionNode[] }; + +const buildPermissionTree = (list: SysPermission[]): PermissionNode[] => { + const active = list.filter((p) => p.status !== 0); + const map = new Map(); + const roots: PermissionNode[] = []; + + active.forEach((item) => { + map.set(item.permId, { ...item, key: item.permId, children: [] }); + }); + + map.forEach((node) => { + if (node.parentId && map.has(node.parentId)) { + map.get(node.parentId)!.children!.push(node); + } else { + roots.push(node); + } + }); + + const sortNodes = (nodes: PermissionNode[]) => { + nodes.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0)); + nodes.forEach((n) => n.children && sortNodes(n.children)); + }; + sortNodes(roots); + return roots; +}; + +const toTreeData = (nodes: PermissionNode[]): DataNode[] => + nodes.map((node) => ({ + key: node.permId, + title: ( + + {node.name} + {node.permType === "button" && 按钮} + + ), + children: node.children ? toTreeData(node.children) : undefined + })); + +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 [query, setQuery] = useState({ roleCode: "", roleName: "" }); - const [pagination, setPagination] = useState({ current: 1, pageSize: 10 }); - const [open, setOpen] = useState(false); + const [permissions, setPermissions] = useState([]); + const [rolePermMap, setRolePermMap] = useState>({}); + const [drawerOpen, setDrawerOpen] = useState(false); const [editing, setEditing] = useState(null); + const [selectedPermIds, setSelectedPermIds] = useState([]); const [form] = Form.useForm(); const { can } = usePermission(); - const load = async () => { + const permissionMap = useMemo(() => { + const map = new Map(); + permissions.forEach((p) => map.set(p.permId, p)); + return map; + }, [permissions]); + + const permissionTreeData = useMemo( + () => toTreeData(buildPermissionTree(permissions)), + [permissions] + ); + + const loadPermissions = async () => { + try { + const list = await listPermissions(); + 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(); - setData(list || []); + const roles = list || []; + setData(roles); + await loadPermissions(); + await loadRolePermissions(roles); } finally { setLoading(false); } }; useEffect(() => { - load(); + loadRoles(); }, []); - const filtered = useMemo(() => { - return data.filter((r) => { - const hitCode = query.roleCode ? r.roleCode?.includes(query.roleCode) : true; - const hitName = query.roleName ? r.roleName?.includes(query.roleName) : true; - return hitCode && hitName; - }); - }, [data, query]); - - const pageData = useMemo(() => { - const start = (pagination.current - 1) * pagination.pageSize; - return filtered.slice(start, start + pagination.pageSize); - }, [filtered, pagination]); - const openCreate = () => { setEditing(null); + setSelectedPermIds([]); form.resetFields(); - setOpen(true); + setDrawerOpen(true); }; const openEdit = (record: SysRole) => { setEditing(record); - form.setFieldsValue(record); - setOpen(true); + setSelectedPermIds(rolePermMap[record.roleId] || []); + form.setFieldsValue({ + roleName: record.roleName, + remark: record.remark + }); + setDrawerOpen(true); + }; + + useEffect(() => { + if (editing) { + setSelectedPermIds(rolePermMap[editing.roleId] || []); + } + }, [editing, rolePermMap]); + + const handleClose = () => { + setDrawerOpen(false); }; const submit = async () => { - const values = await form.validateFields(); - const payload: Partial = { - roleCode: values.roleCode, - roleName: values.roleName, - remark: values.remark, - status: values.status - }; - if (editing) { - await updateRole(editing.roleId, payload); - } else { - await createRole(payload); + try { + const values = await form.validateFields(); + setSaving(true); + const payload: Partial = { + roleCode: editing?.roleCode || generateRoleCode(), + roleName: values.roleName, + remark: values.remark, + status: editing?.status ?? DEFAULT_STATUS + }; + let roleId = editing?.roleId; + if (editing) { + await updateRole(editing.roleId, payload); + } else { + await createRole(payload); + } + + const list = await listRoles(); + const roles = list || []; + setData(roles); + if (!roleId) { + roleId = roles.find((r) => r.roleCode === payload.roleCode)?.roleId; + } + if (roleId) { + await saveRolePermissions(roleId, selectedPermIds); + } + await loadRolePermissions(roles); + setDrawerOpen(false); + message.success(editing ? "角色已更新" : "角色已创建"); + } catch (e) { + if (e instanceof Error && e.message) { + message.error(e.message); + } + } finally { + setSaving(false); } - setOpen(false); - load(); }; - const remove = async (id: number) => { - await deleteRole(id); - load(); + 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 ? "已选权限" : "暂无权限"} + + )} +
+ + ); }; return ( -
- - setQuery({ ...query, roleCode: e.target.value })} - /> - setQuery({ ...query, roleName: e.target.value })} - /> +
+
+
+ + 系统角色权限 + + + 设置系统中不同角色的访问权限和操作边界 + +
{can("sys_role:create") && ( - + )} - +
- setPagination({ current, pageSize }) - }} - columns={[ - { title: "ID", dataIndex: "roleId" }, - { title: "编码", dataIndex: "roleCode" }, - { title: "名称", dataIndex: "roleName" }, - { title: "备注", dataIndex: "remark" }, - { - title: "状态", - dataIndex: "status", - render: (v) => (v === 1 ? 启用 : 禁用) - }, - { - title: "操作", - render: (_, record) => ( - - {can("sys_role:update") && } - {can("sys_role:delete") && ( - remove(record.roleId)}> - - - )} - - ) - } - ]} - /> +
+ {data.map((role) => ( +
+
+
+ +
+ {can("sys_role:update") && ( +
- setOpen(false)} - destroyOnClose +
+
{role.roleName}
+
{`ID: ${role.roleCode || role.roleId}`}
+
+ + {renderRolePermissions(role)} + +
+ 最后同步 + {role.updatedAt ? "刚刚" : "刚刚"} +
+
+ ))} + {!data.length && !loading && ( +
暂无角色
+ )} +
+ + +
+ +
+
+
+ {editing ? "编辑角色" : "创建新角色"} +
+
+ + } + footer={ +
+ + +
+ } > -
- - + + + - - - - - - - - - + +
+
+ + + + 权限选择 +
+
+ { + const raw = Array.isArray(keys) ? keys : keys.checked; + const normalized = (raw as Array).map((k) => Number(k)); + setSelectedPermIds(normalized.filter((id) => !Number.isNaN(id))); + }} + defaultExpandAll + /> +
+
+
); } diff --git a/frontend/src/pages/Users.tsx b/frontend/src/pages/Users.tsx index 99ee6f3..887c341 100644 --- a/frontend/src/pages/Users.tsx +++ b/frontend/src/pages/Users.tsx @@ -1,4 +1,4 @@ -import { Button, Form, Input, Modal, Popconfirm, Space, Table, Tag, Select } from "antd"; +import { Button, Drawer, Form, Input, Popconfirm, Space, Table, Tag, Select } from "antd"; import { useMemo, useState, useEffect } from "react"; import { createUser, deleteUser, listUsers, updateUser } from "../api"; import type { SysUser } from "../types"; @@ -140,12 +140,18 @@ export default function Users() { ]} /> - setOpen(false)} + onClose={() => setOpen(false)} + width={420} destroyOnClose + footer={ + + + + + } >
@@ -172,7 +178,7 @@ export default function Users() { /> -
+ ); } diff --git a/frontend/src/routes/routes.tsx b/frontend/src/routes/routes.tsx index 47db222..bd38daf 100644 --- a/frontend/src/routes/routes.tsx +++ b/frontend/src/routes/routes.tsx @@ -3,6 +3,8 @@ import Users from "../pages/Users"; import Roles from "../pages/Roles"; import Permissions from "../pages/Permissions"; import Devices from "../pages/Devices"; +import UserRoleBinding from "../pages/UserRoleBinding"; +import RolePermissionBinding from "../pages/RolePermissionBinding"; import type { MenuRoute } from "../types"; @@ -11,5 +13,7 @@ export const menuRoutes: MenuRoute[] = [ { path: "/users", label: "用户管理", element: , perm: "menu:users" }, { path: "/roles", label: "角色管理", element: , perm: "menu:roles" }, { path: "/permissions", label: "权限管理", element: , perm: "menu:permissions" }, - { path: "/devices", label: "设备管理", element: , perm: "menu:devices" } + { 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" } ];