feat(shared): 添加操作帮助面板组件

- 实现 ActionHelpPanel 组件,提供操作详情和帮助信息展示
- 添加完整的 CSS 样式文件,支持响应式布局和主题适配
- 集成 Ant Design 的 Drawer 和 Collapse 组件
- 支持当前操作和所有可用操作的分类展示
- 实现操作步骤、注意事项、快捷键等功能说明
- 添加图标、标签、权限要求等信息展示
- 支持操作列表点击切换和实时预览功能
master
chenhao 2026-02-11 13:44:31 +08:00
parent bf537d6074
commit 78e77cf260
26 changed files with 681 additions and 168 deletions

View File

@ -87,6 +87,11 @@ src
2. `/auth/login` 2. `/auth/login`
3. `/api/users/me`(获取用户信息与 isAdmin 3. `/api/users/me`(获取用户信息与 isAdmin
### 验证码开关(系统参数)
- 系统参数 `security.captcha.enabled` 控制验证码是否启用true/false
- 系统启动时加载 `sys_param` 到 Redis Hash`sys:param:{paramKey}`字段value/type
- 前端登录页根据系统参数决定是否展示验证码
### 权限菜单渲染 ### 权限菜单渲染
1. `/api/permissions/me` 获取权限列表 1. `/api/permissions/me` 获取权限列表
2. 前端构建树形菜单 2. 前端构建树形菜单
@ -101,4 +106,3 @@ src
- 添加审计日志落库策略 - 添加审计日志落库策略
- 任务管理模块完善 - 任务管理模块完善
- 权限树缓存与增量刷新策略 - 权限树缓存与增量刷新策略

View File

@ -9,9 +9,7 @@ public class DeviceCodeRequest {
private String username; private String username;
@NotBlank @NotBlank
private String password; private String password;
@NotBlank
private String captchaId; private String captchaId;
@NotBlank
private String captchaCode; private String captchaCode;
private String deviceName; private String deviceName;
} }

View File

@ -9,9 +9,7 @@ public class LoginRequest {
private String username; private String username;
@NotBlank @NotBlank
private String password; private String password;
@NotBlank
private String captchaId; private String captchaId;
@NotBlank
private String captchaCode; private String captchaCode;
private String deviceCode; private String deviceCode;
} }

View File

@ -14,4 +14,11 @@ public final class RedisKeys {
public static String refreshTokenKey(Long userId, String deviceCode) { public static String refreshTokenKey(Long userId, String deviceCode) {
return "refresh:" + userId + ":" + 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";
} }

View File

@ -26,6 +26,7 @@ public class SecurityConfig {
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/**").permitAll() .requestMatchers("/auth/**").permitAll()
.requestMatchers("/api/params/value").permitAll()
.anyRequest().authenticated() .anyRequest().authenticated()
) )
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

View File

@ -8,6 +8,8 @@ import com.imeeting.auth.dto.RefreshRequest;
import com.imeeting.auth.dto.TokenResponse; import com.imeeting.auth.dto.TokenResponse;
import com.imeeting.common.ApiResponse; import com.imeeting.common.ApiResponse;
import com.imeeting.common.RedisKeys; import com.imeeting.common.RedisKeys;
import com.imeeting.common.SysParamKeys;
import com.imeeting.service.SysParamService;
import com.imeeting.service.AuthService; import com.imeeting.service.AuthService;
import com.wf.captcha.SpecCaptcha; import com.wf.captcha.SpecCaptcha;
import jakarta.validation.Valid; import jakarta.validation.Valid;
@ -24,18 +26,24 @@ public class AuthController {
private final AuthService authService; private final AuthService authService;
private final StringRedisTemplate stringRedisTemplate; private final StringRedisTemplate stringRedisTemplate;
private final JwtTokenProvider jwtTokenProvider; private final JwtTokenProvider jwtTokenProvider;
private final SysParamService sysParamService;
@Value("${app.captcha.ttl-seconds:120}") @Value("${app.captcha.ttl-seconds:120}")
private long captchaTtlSeconds; 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.authService = authService;
this.stringRedisTemplate = stringRedisTemplate; this.stringRedisTemplate = stringRedisTemplate;
this.jwtTokenProvider = jwtTokenProvider; this.jwtTokenProvider = jwtTokenProvider;
this.sysParamService = sysParamService;
} }
@GetMapping("/captcha") @GetMapping("/captcha")
public ApiResponse<CaptchaResponse> captcha() { public ApiResponse<CaptchaResponse> captcha() {
if (!isCaptchaEnabled()) {
return ApiResponse.error("Captcha disabled");
}
SpecCaptcha captcha = new SpecCaptcha(130, 48, 4); SpecCaptcha captcha = new SpecCaptcha(130, 48, 4);
String code = captcha.text(); String code = captcha.text();
String imageBase64 = captcha.toBase64(); String imageBase64 = captcha.toBase64();
@ -75,4 +83,9 @@ public class AuthController {
authService.logout(userId, deviceCode); authService.logout(userId, deviceCode);
return ApiResponse.ok(null); return ApiResponse.ok(null);
} }
private boolean isCaptchaEnabled() {
String value = sysParamService.getCachedParamValue(SysParamKeys.CAPTCHA_ENABLED, "true");
return Boolean.parseBoolean(value);
}
} }

View File

@ -1,19 +1,25 @@
package com.imeeting.controller; package com.imeeting.controller;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.imeeting.common.ApiResponse; import com.imeeting.common.ApiResponse;
import com.imeeting.entity.SysRole; import com.imeeting.entity.SysRole;
import com.imeeting.entity.SysRolePermission;
import com.imeeting.mapper.SysRolePermissionMapper;
import com.imeeting.service.SysRoleService; import com.imeeting.service.SysRoleService;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
import java.util.List; import java.util.List;
@RestController @RestController
@RequestMapping("/api/roles") @RequestMapping("/api/roles")
public class RoleController { public class RoleController {
private final SysRoleService sysRoleService; private final SysRoleService sysRoleService;
private final SysRolePermissionMapper sysRolePermissionMapper;
public RoleController(SysRoleService sysRoleService) { public RoleController(SysRoleService sysRoleService, SysRolePermissionMapper sysRolePermissionMapper) {
this.sysRoleService = sysRoleService; this.sysRoleService = sysRoleService;
this.sysRolePermissionMapper = sysRolePermissionMapper;
} }
@GetMapping @GetMapping
@ -41,4 +47,49 @@ public class RoleController {
public ApiResponse<Boolean> delete(@PathVariable Long id) { public ApiResponse<Boolean> delete(@PathVariable Long id) {
return ApiResponse.ok(sysRoleService.removeById(id)); return ApiResponse.ok(sysRoleService.removeById(id));
} }
@GetMapping("/{id}/permissions")
public ApiResponse<List<Long>> listRolePermissions(@PathVariable Long id) {
List<SysRolePermission> rows = sysRolePermissionMapper.selectList(
new QueryWrapper<SysRolePermission>().eq("role_id", id)
);
List<Long> permIds = new ArrayList<>();
for (SysRolePermission row : rows) {
if (row.getPermId() != null) {
permIds.add(row.getPermId());
}
}
return ApiResponse.ok(permIds);
}
@PostMapping("/{id}/permissions")
public ApiResponse<Boolean> saveRolePermissions(@PathVariable Long id, @RequestBody PermissionBindingPayload payload) {
List<Long> permIds = payload == null ? null : payload.getPermIds();
sysRolePermissionMapper.delete(new QueryWrapper<SysRolePermission>().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<Long> permIds;
public List<Long> getPermIds() {
return permIds;
}
public void setPermIds(List<Long> permIds) {
this.permIds = permIds;
}
}
} }

View File

@ -28,17 +28,36 @@ public class SysParamController {
@PostMapping @PostMapping
public ApiResponse<Boolean> create(@RequestBody SysParam param) { public ApiResponse<Boolean> 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}") @PutMapping("/{id}")
public ApiResponse<Boolean> update(@PathVariable Long id, @RequestBody SysParam param) { public ApiResponse<Boolean> update(@PathVariable Long id, @RequestBody SysParam param) {
param.setParamId(id); 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}") @DeleteMapping("/{id}")
public ApiResponse<Boolean> delete(@PathVariable Long id) { public ApiResponse<Boolean> 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<String> getValue(@RequestParam("key") String key,
@RequestParam(value = "defaultValue", required = false) String defaultValue) {
return ApiResponse.ok(sysParamService.getCachedParamValue(key, defaultValue));
} }
} }

View File

@ -3,12 +3,16 @@ package com.imeeting.controller;
import com.imeeting.auth.JwtTokenProvider; import com.imeeting.auth.JwtTokenProvider;
import com.imeeting.common.ApiResponse; import com.imeeting.common.ApiResponse;
import com.imeeting.dto.UserProfile; import com.imeeting.dto.UserProfile;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.imeeting.entity.SysUser; import com.imeeting.entity.SysUser;
import com.imeeting.entity.SysUserRole;
import com.imeeting.mapper.SysUserRoleMapper;
import com.imeeting.service.SysUserService; import com.imeeting.service.SysUserService;
import io.jsonwebtoken.Claims; import io.jsonwebtoken.Claims;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
import java.util.List; import java.util.List;
@RestController @RestController
@ -17,11 +21,13 @@ public class UserController {
private final SysUserService sysUserService; private final SysUserService sysUserService;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final JwtTokenProvider jwtTokenProvider; 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.sysUserService = sysUserService;
this.passwordEncoder = passwordEncoder; this.passwordEncoder = passwordEncoder;
this.jwtTokenProvider = jwtTokenProvider; this.jwtTokenProvider = jwtTokenProvider;
this.sysUserRoleMapper = sysUserRoleMapper;
} }
@GetMapping @GetMapping
@ -77,6 +83,39 @@ public class UserController {
return ApiResponse.ok(sysUserService.removeById(id)); return ApiResponse.ok(sysUserService.removeById(id));
} }
@GetMapping("/{id}/roles")
public ApiResponse<List<Long>> listUserRoles(@PathVariable Long id) {
List<SysUserRole> rows = sysUserRoleMapper.selectList(
new QueryWrapper<SysUserRole>().eq("user_id", id)
);
List<Long> roleIds = new ArrayList<>();
for (SysUserRole row : rows) {
if (row.getRoleId() != null) {
roleIds.add(row.getRoleId());
}
}
return ApiResponse.ok(roleIds);
}
@PostMapping("/{id}/roles")
public ApiResponse<Boolean> saveUserRoles(@PathVariable Long id, @RequestBody RoleBindingPayload payload) {
List<Long> roleIds = payload == null ? null : payload.getRoleIds();
sysUserRoleMapper.delete(new QueryWrapper<SysUserRole>().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) { private Long resolveUserId(String authorization) {
if (authorization == null || !authorization.startsWith("Bearer ")) { if (authorization == null || !authorization.startsWith("Bearer ")) {
return null; return null;
@ -85,4 +124,16 @@ public class UserController {
Claims claims = jwtTokenProvider.parseToken(token); Claims claims = jwtTokenProvider.parseToken(token);
return claims.get("userId", Long.class); return claims.get("userId", Long.class);
} }
public static class RoleBindingPayload {
private List<Long> roleIds;
public List<Long> getRoleIds() {
return roleIds;
}
public void setRoleIds(List<Long> roleIds) {
this.roleIds = roleIds;
}
}
} }

View File

@ -1,5 +1,6 @@
package com.imeeting.dto; package com.imeeting.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data; import lombok.Data;
@Data @Data
@ -10,5 +11,6 @@ public class UserProfile {
private String email; private String email;
private String phone; private String phone;
private Integer status; private Integer status;
@JsonProperty("isAdmin")
private boolean isAdmin; private boolean isAdmin;
} }

View File

@ -1,15 +1,25 @@
package com.imeeting.entity; package com.imeeting.entity;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data; import lombok.Data;
import java.time.LocalDateTime;
@Data @Data
@TableName("sys_role_permission") @TableName("sys_role_permission")
public class SysRolePermission extends BaseEntity { public class SysRolePermission {
@TableId(value = "id", type = IdType.AUTO) @TableId(value = "id", type = IdType.AUTO)
private Long id; private Long id;
private Long roleId; private Long roleId;
private Long permId; private Long permId;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createdAt;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updatedAt;
} }

View File

@ -1,15 +1,25 @@
package com.imeeting.entity; package com.imeeting.entity;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data; import lombok.Data;
import java.time.LocalDateTime;
@Data @Data
@TableName("sys_user_role") @TableName("sys_user_role")
public class SysUserRole extends BaseEntity { public class SysUserRole {
@TableId(value = "id", type = IdType.AUTO) @TableId(value = "id", type = IdType.AUTO)
private Long id; private Long id;
private Long userId; private Long userId;
private Long roleId; private Long roleId;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createdAt;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updatedAt;
} }

View File

@ -5,4 +5,12 @@ import com.imeeting.entity.SysParam;
public interface SysParamService extends IService<SysParam> { public interface SysParamService extends IService<SysParam> {
String getParamValue(String key, String defaultValue); String getParamValue(String key, String defaultValue);
String getCachedParamValue(String key, String defaultValue);
void syncParamToCache(SysParam param);
void deleteParamCache(String key);
void syncAllToCache();
} }

View File

@ -5,6 +5,7 @@ import com.imeeting.auth.JwtTokenProvider;
import com.imeeting.auth.dto.LoginRequest; import com.imeeting.auth.dto.LoginRequest;
import com.imeeting.auth.dto.TokenResponse; import com.imeeting.auth.dto.TokenResponse;
import com.imeeting.common.RedisKeys; import com.imeeting.common.RedisKeys;
import com.imeeting.common.SysParamKeys;
import com.imeeting.entity.Device; import com.imeeting.entity.Device;
import com.imeeting.entity.SysUser; import com.imeeting.entity.SysUser;
import com.imeeting.service.AuthService; import com.imeeting.service.AuthService;
@ -51,7 +52,9 @@ public class AuthServiceImpl implements AuthService {
@Override @Override
public TokenResponse login(LoginRequest request) { public TokenResponse login(LoginRequest request) {
validateCaptcha(request.getCaptchaId(), request.getCaptchaCode()); if (isCaptchaEnabled()) {
validateCaptcha(request.getCaptchaId(), request.getCaptchaCode());
}
SysUser user = sysUserService.getOne(new LambdaQueryWrapper<SysUser>() SysUser user = sysUserService.getOne(new LambdaQueryWrapper<SysUser>()
.eq(SysUser::getUsername, request.getUsername()) .eq(SysUser::getUsername, request.getUsername())
@ -118,7 +121,9 @@ public class AuthServiceImpl implements AuthService {
@Override @Override
public String createDeviceCode(LoginRequest request, String deviceName) { 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<SysUser>() SysUser user = sysUserService.getOne(new LambdaQueryWrapper<SysUser>()
.eq(SysUser::getUsername, request.getUsername()) .eq(SysUser::getUsername, request.getUsername())
@ -138,6 +143,12 @@ public class AuthServiceImpl implements AuthService {
} }
private void validateCaptcha(String captchaId, String captchaCode) { 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 key = RedisKeys.captchaKey(captchaId);
String stored = stringRedisTemplate.opsForValue().get(key); String stored = stringRedisTemplate.opsForValue().get(key);
if (stored == null) { if (stored == null) {
@ -164,6 +175,11 @@ public class AuthServiceImpl implements AuthService {
stringRedisTemplate.delete(attemptsKey); 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) { private TokenResponse issueTokens(SysUser user, String deviceCode, long accessMinutes, long refreshDays) {
Map<String, Object> accessClaims = new HashMap<>(); Map<String, Object> accessClaims = new HashMap<>();
accessClaims.put("tokenType", "access"); accessClaims.put("tokenType", "access");

View File

@ -2,16 +2,67 @@ package com.imeeting.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.imeeting.common.RedisKeys;
import com.imeeting.entity.SysParam; import com.imeeting.entity.SysParam;
import com.imeeting.mapper.SysParamMapper; import com.imeeting.mapper.SysParamMapper;
import com.imeeting.service.SysParamService; import com.imeeting.service.SysParamService;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Service @Service
public class SysParamServiceImpl extends ServiceImpl<SysParamMapper, SysParam> implements SysParamService { public class SysParamServiceImpl extends ServiceImpl<SysParamMapper, SysParam> implements SysParamService {
private final StringRedisTemplate stringRedisTemplate;
public SysParamServiceImpl(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
@Override @Override
public String getParamValue(String key, String defaultValue) { public String getParamValue(String key, String defaultValue) {
SysParam param = getOne(new LambdaQueryWrapper<SysParam>().eq(SysParam::getParamKey, key)); SysParam param = getOne(new LambdaQueryWrapper<SysParam>().eq(SysParam::getParamKey, key));
return param == null ? defaultValue : param.getParamValue(); 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<String, String> 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<SysParam> params = list();
for (SysParam param : params) {
syncParamToCache(param);
}
}
} }

View File

@ -11,6 +11,7 @@ spring:
host: 10.100.51.51 host: 10.100.51.51
port: 6379 port: 6379
password: Unis@123 password: Unis@123
database: 15
mybatis-plus: mybatis-plus:
configuration: configuration:

View File

@ -35,3 +35,20 @@ Tests:
Status: Status:
- In Progress - 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

View File

@ -167,6 +167,7 @@
## 开发规范 ## 开发规范
- **新增操作**:默认使用**右侧抽屉**打开表单进行创建(除非需求明确允许其他方式)
- **组件命名**PascalCase - **组件命名**PascalCase
- **文件命名**:与组件同名 - **文件命名**:与组件同名
- **样式类命名**kebab-case - **样式类命名**kebab-case

View File

@ -15,16 +15,16 @@ export interface TokenResponse {
export interface LoginPayload { export interface LoginPayload {
username: string; username: string;
password: string; password: string;
captchaId: string; captchaId?: string;
captchaCode: string; captchaCode?: string;
deviceCode?: string; deviceCode?: string;
} }
export interface DeviceCodePayload { export interface DeviceCodePayload {
username: string; username: string;
password: string; password: string;
captchaId: string; captchaId?: string;
captchaCode: string; captchaCode?: string;
deviceName?: string; deviceName?: string;
} }

View File

@ -47,6 +47,11 @@ export async function listPermissions() {
return resp.data.data as SysPermission[]; 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() { export async function listMyPermissions() {
const resp = await http.get("/api/permissions/me"); const resp = await http.get("/api/permissions/me");
return resp.data.data as SysPermission[]; return resp.data.data as SysPermission[];
@ -92,3 +97,23 @@ export async function deleteDevice(id: number) {
return resp.data.data as boolean; 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;
}

View File

@ -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 { useEffect, useMemo, useState } from "react";
import { createDevice, deleteDevice, listDevices, updateDevice } from "../api"; import { createDevice, deleteDevice, listDevices, updateDevice } from "../api";
import type { DeviceInfo } from "../types"; import type { DeviceInfo } from "../types";
@ -135,12 +135,18 @@ export default function Devices() {
]} ]}
/> />
<Modal <Drawer
title={editing ? "编辑设备" : "新增设备"} title={editing ? "编辑设备" : "新增设备"}
open={open} open={open}
onOk={submit} onClose={() => setOpen(false)}
onCancel={() => setOpen(false)} width={420}
destroyOnClose destroyOnClose
footer={
<Space style={{ width: "100%", justifyContent: "flex-end" }}>
<Button onClick={() => setOpen(false)}></Button>
<Button type="primary" onClick={submit}></Button>
</Space>
}
> >
<Form form={form} layout="vertical"> <Form form={form} layout="vertical">
<Form.Item label="用户ID" name="userId" rules={[{ required: true }]}> <Form.Item label="用户ID" name="userId" rules={[{ required: true }]}>
@ -156,7 +162,7 @@ export default function Devices() {
<Select options={[{ value: 1, label: "启用" }, { value: 0, label: "禁用" }]} /> <Select options={[{ value: 1, label: "启用" }, { value: 0, label: "禁用" }]} />
</Form.Item> </Form.Item>
</Form> </Form>
</Modal> </Drawer>
</div> </div>
); );
} }

View File

@ -1,17 +1,21 @@
import { Button, Checkbox, Form, Input, message, Typography } from "antd"; import { Button, Checkbox, Form, Input, message, Typography } from "antd";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { fetchCaptcha, login, type CaptchaResponse } from "../api/auth"; import { fetchCaptcha, login, type CaptchaResponse } from "../api/auth";
import { getCurrentUser } from "../api"; import { getCurrentUser, getSystemParamValue } from "../api";
import "./Login.css"; import "./Login.css";
const { Title, Text, Link } = Typography; const { Title, Text, Link } = Typography;
export default function Login() { export default function Login() {
const [captcha, setCaptcha] = useState<CaptchaResponse | null>(null); const [captcha, setCaptcha] = useState<CaptchaResponse | null>(null);
const [captchaEnabled, setCaptchaEnabled] = useState(true);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [form] = Form.useForm(); const [form] = Form.useForm();
const loadCaptcha = async () => { const loadCaptcha = async () => {
if (!captchaEnabled) {
return;
}
try { try {
const data = await fetchCaptcha(); const data = await fetchCaptcha();
setCaptcha(data); setCaptcha(data);
@ -21,7 +25,20 @@ export default function Login() {
}; };
useEffect(() => { useEffect(() => {
loadCaptcha(); const init = async () => {
try {
const value = await getSystemParamValue("security.captcha.enabled", "true");
const enabled = value !== "false";
setCaptchaEnabled(enabled);
if (enabled) {
loadCaptcha();
}
} catch (e) {
setCaptchaEnabled(true);
loadCaptcha();
}
};
init();
}, []); }, []);
const onFinish = async (values: any) => { const onFinish = async (values: any) => {
@ -30,8 +47,8 @@ export default function Login() {
const data = await login({ const data = await login({
username: values.username, username: values.username,
password: values.password, password: values.password,
captchaId: captcha?.captchaId || "", captchaId: captchaEnabled ? captcha?.captchaId : undefined,
captchaCode: values.captchaCode captchaCode: captchaEnabled ? values.captchaCode : undefined
}); });
localStorage.setItem("accessToken", data.accessToken); localStorage.setItem("accessToken", data.accessToken);
localStorage.setItem("refreshToken", data.refreshToken); localStorage.setItem("refreshToken", data.refreshToken);
@ -46,7 +63,9 @@ export default function Login() {
window.location.href = "/"; window.location.href = "/";
} catch (e: any) { } catch (e: any) {
message.error(e.message || "登录失败"); message.error(e.message || "登录失败");
loadCaptcha(); if (captchaEnabled) {
loadCaptcha();
}
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -109,22 +128,24 @@ export default function Login() {
<Input.Password size="large" placeholder="请输入密码" /> <Input.Password size="large" placeholder="请输入密码" />
</Form.Item> </Form.Item>
<Form.Item {captchaEnabled && (
label="验证码" <Form.Item
name="captchaCode" label="验证码"
rules={[{ required: true, message: "请输入验证码" }]} name="captchaCode"
> rules={[{ required: true, message: "请输入验证码" }]}
<div className="captcha-wrapper"> >
<Input size="large" placeholder="验证码" /> <div className="captcha-wrapper">
<div className="captcha-image-container" onClick={loadCaptcha}> <Input size="large" placeholder="验证码" />
{captcha ? ( <div className="captcha-image-container" onClick={loadCaptcha}>
<img src={captcha.imageBase64} alt="captcha" /> {captcha ? (
) : ( <img src={captcha.imageBase64} alt="captcha" />
<div className="captcha-placeholder" /> ) : (
)} <div className="captcha-placeholder" />
)}
</div>
</div> </div>
</div> </Form.Item>
</Form.Item> )}
<div className="login-extra"> <div className="login-extra">
<Form.Item name="remember" valuePropName="checked" noStyle> <Form.Item name="remember" valuePropName="checked" noStyle>

View File

@ -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 { useEffect, useMemo, useState } from "react";
import { createPermission, deletePermission, listMyPermissions, updatePermission } from "../api"; import { createPermission, deletePermission, listMyPermissions, updatePermission } from "../api";
import type { SysPermission } from "../types"; import type { SysPermission } from "../types";
@ -188,12 +188,18 @@ export default function Permissions() {
]} ]}
/> />
<Modal <Drawer
title={editing ? "编辑权限" : "新增权限"} title={editing ? "编辑权限" : "新增权限"}
open={open} open={open}
onOk={submit} onClose={() => setOpen(false)}
onCancel={() => setOpen(false)} width={520}
destroyOnClose destroyOnClose
footer={
<Space style={{ width: "100%", justifyContent: "flex-end" }}>
<Button onClick={() => setOpen(false)}></Button>
<Button type="primary" onClick={submit}></Button>
</Space>
}
> >
<Form <Form
form={form} form={form}
@ -268,7 +274,7 @@ export default function Permissions() {
<Input.TextArea rows={3} /> <Input.TextArea rows={3} />
</Form.Item> </Form.Item>
</Form> </Form>
</Modal> </Drawer>
</div> </div>
); );
} }

View File

@ -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 { useEffect, useMemo, useState } from "react";
import { createRole, deleteRole, listRoles, updateRole } from "../api"; import {
import type { SysRole } from "../types"; createRole,
listPermissions,
listRolePermissions,
listRoles,
saveRolePermissions,
updateRole
} from "../api";
import type { SysPermission, SysRole } from "../types";
import { usePermission } from "../hooks/usePermission"; 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<number, PermissionNode>();
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: (
<span className="role-permission-node">
<span>{node.name}</span>
{node.permType === "button" && <Tag color="blue"></Tag>}
</span>
),
children: node.children ? toTreeData(node.children) : undefined
}));
const generateRoleCode = () => `ROLE-${Date.now().toString(36).toUpperCase()}`;
export default function Roles() { export default function Roles() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [data, setData] = useState<SysRole[]>([]); const [data, setData] = useState<SysRole[]>([]);
const [query, setQuery] = useState({ roleCode: "", roleName: "" }); const [permissions, setPermissions] = useState<SysPermission[]>([]);
const [pagination, setPagination] = useState({ current: 1, pageSize: 10 }); const [rolePermMap, setRolePermMap] = useState<Record<number, number[]>>({});
const [open, setOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [editing, setEditing] = useState<SysRole | null>(null); const [editing, setEditing] = useState<SysRole | null>(null);
const [selectedPermIds, setSelectedPermIds] = useState<number[]>([]);
const [form] = Form.useForm(); const [form] = Form.useForm();
const { can } = usePermission(); const { can } = usePermission();
const load = async () => { const permissionMap = useMemo(() => {
const map = new Map<number, SysPermission>();
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); setLoading(true);
try { try {
const list = await listRoles(); const list = await listRoles();
setData(list || []); const roles = list || [];
setData(roles);
await loadPermissions();
await loadRolePermissions(roles);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
useEffect(() => { 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 = () => { const openCreate = () => {
setEditing(null); setEditing(null);
setSelectedPermIds([]);
form.resetFields(); form.resetFields();
setOpen(true); setDrawerOpen(true);
}; };
const openEdit = (record: SysRole) => { const openEdit = (record: SysRole) => {
setEditing(record); setEditing(record);
form.setFieldsValue(record); setSelectedPermIds(rolePermMap[record.roleId] || []);
setOpen(true); 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 submit = async () => {
const values = await form.validateFields(); try {
const payload: Partial<SysRole> = { const values = await form.validateFields();
roleCode: values.roleCode, setSaving(true);
roleName: values.roleName, const payload: Partial<SysRole> = {
remark: values.remark, roleCode: editing?.roleCode || generateRoleCode(),
status: values.status roleName: values.roleName,
}; remark: values.remark,
if (editing) { status: editing?.status ?? DEFAULT_STATUS
await updateRole(editing.roleId, payload); };
} else { let roleId = editing?.roleId;
await createRole(payload); 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) => { const renderRolePermissions = (role: SysRole) => {
await deleteRole(id); const permIds = rolePermMap[role.roleId] || [];
load(); 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>
</>
);
}; };
return ( return (
<div> <div className="roles-page">
<Space style={{ marginBottom: 16 }}> <div className="roles-header">
<Input <div>
placeholder="角色编码" <Title level={4} className="roles-title">
value={query.roleCode}
onChange={(e) => setQuery({ ...query, roleCode: e.target.value })} </Title>
/> <Text type="secondary" className="roles-subtitle">
<Input 访
placeholder="角色名称" </Text>
value={query.roleName} </div>
onChange={(e) => setQuery({ ...query, roleName: e.target.value })}
/>
{can("sys_role:create") && ( {can("sys_role:create") && (
<Button type="primary" onClick={openCreate}></Button> <Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
</Button>
)} )}
</Space> </div>
<Table <div className="roles-grid">
rowKey="roleId" {data.map((role) => (
loading={loading} <div key={role.roleId} className="role-card">
dataSource={pageData} <div className="role-card-header">
pagination={{ <div className="role-icon">
current: pagination.current, <SafetyCertificateOutlined />
pageSize: pagination.pageSize, </div>
total: filtered.length, {can("sys_role:update") && (
onChange: (current, pageSize) => setPagination({ current, pageSize }) <Button
}} type="text"
columns={[ className="role-edit-btn"
{ title: "ID", dataIndex: "roleId" }, icon={<EditOutlined />}
{ title: "编码", dataIndex: "roleCode" }, onClick={() => openEdit(role)}
{ title: "名称", dataIndex: "roleName" }, />
{ title: "备注", dataIndex: "remark" }, )}
{ </div>
title: "状态",
dataIndex: "status",
render: (v) => (v === 1 ? <Tag color="green"></Tag> : <Tag color="red"></Tag>)
},
{
title: "操作",
render: (_, record) => (
<Space>
{can("sys_role:update") && <Button onClick={() => openEdit(record)}></Button>}
{can("sys_role:delete") && (
<Popconfirm title="确认删除?" onConfirm={() => remove(record.roleId)}>
<Button danger></Button>
</Popconfirm>
)}
</Space>
)
}
]}
/>
<Modal <div className="role-main">
title={editing ? "编辑角色" : "新增角色"} <div className="role-name">{role.roleName}</div>
open={open} <div className="role-id">{`ID: ${role.roleCode || role.roleId}`}</div>
onOk={submit} </div>
onCancel={() => setOpen(false)}
destroyOnClose {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}
>
</Button>
</div>
}
> >
<Form form={form} layout="vertical"> <Form form={form} layout="vertical" className="role-form">
<Form.Item label="角色编码" name="roleCode" rules={[{ required: true }]}> <Form.Item
<Input disabled={!!editing} /> label="角色名称"
name="roleName"
rules={[{ required: true, message: "请输入角色名称" }]}
>
<Input placeholder="例如Auditor" />
</Form.Item> </Form.Item>
<Form.Item label="角色名称" name="roleName" rules={[{ required: true }]}> <Form.Item label="描述" name="remark">
<Input /> <Input placeholder="该角色的职责描述..." />
</Form.Item>
<Form.Item label="备注" name="remark">
<Input.TextArea rows={3} />
</Form.Item>
<Form.Item label="状态" name="status" initialValue={1}>
<Select
options={[
{ value: 1, label: "启用" },
{ value: 0, label: "禁用" }
]}
/>
</Form.Item> </Form.Item>
</Form> </Form>
</Modal>
<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) => {
const raw = Array.isArray(keys) ? keys : keys.checked;
const normalized = (raw as Array<string | number>).map((k) => Number(k));
setSelectedPermIds(normalized.filter((id) => !Number.isNaN(id)));
}}
defaultExpandAll
/>
</div>
</div>
</Drawer>
</div> </div>
); );
} }

View File

@ -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 { useMemo, useState, useEffect } from "react";
import { createUser, deleteUser, listUsers, updateUser } from "../api"; import { createUser, deleteUser, listUsers, updateUser } from "../api";
import type { SysUser } from "../types"; import type { SysUser } from "../types";
@ -140,12 +140,18 @@ export default function Users() {
]} ]}
/> />
<Modal <Drawer
title={editing ? "编辑用户" : "新增用户"} title={editing ? "编辑用户" : "新增用户"}
open={open} open={open}
onOk={submit} onClose={() => setOpen(false)}
onCancel={() => setOpen(false)} width={420}
destroyOnClose destroyOnClose
footer={
<Space style={{ width: "100%", justifyContent: "flex-end" }}>
<Button onClick={() => setOpen(false)}></Button>
<Button type="primary" onClick={submit}></Button>
</Space>
}
> >
<Form form={form} layout="vertical"> <Form form={form} layout="vertical">
<Form.Item label="用户名" name="username" rules={[{ required: true }]}> <Form.Item label="用户名" name="username" rules={[{ required: true }]}>
@ -172,7 +178,7 @@ export default function Users() {
/> />
</Form.Item> </Form.Item>
</Form> </Form>
</Modal> </Drawer>
</div> </div>
); );
} }

View File

@ -3,6 +3,8 @@ import Users from "../pages/Users";
import Roles from "../pages/Roles"; import Roles from "../pages/Roles";
import Permissions from "../pages/Permissions"; import Permissions from "../pages/Permissions";
import Devices from "../pages/Devices"; import Devices from "../pages/Devices";
import UserRoleBinding from "../pages/UserRoleBinding";
import RolePermissionBinding from "../pages/RolePermissionBinding";
import type { MenuRoute } from "../types"; import type { MenuRoute } from "../types";
@ -11,5 +13,7 @@ export const menuRoutes: MenuRoute[] = [
{ path: "/users", label: "用户管理", element: <Users />, perm: "menu:users" }, { path: "/users", label: "用户管理", element: <Users />, perm: "menu:users" },
{ path: "/roles", label: "角色管理", element: <Roles />, perm: "menu:roles" }, { path: "/roles", label: "角色管理", element: <Roles />, perm: "menu:roles" },
{ path: "/permissions", label: "权限管理", element: <Permissions />, perm: "menu:permissions" }, { path: "/permissions", label: "权限管理", element: <Permissions />, perm: "menu:permissions" },
{ path: "/devices", label: "设备管理", element: <Devices />, perm: "menu:devices" } { path: "/devices", label: "设备管理", element: <Devices />, perm: "menu:devices" },
{ path: "/user-roles", label: "用户角色绑定", element: <UserRoleBinding />, perm: "menu:user-roles" },
{ path: "/role-permissions", label: "角色权限绑定", element: <RolePermissionBinding />, perm: "menu:role-permissions" }
]; ];