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`
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
- 添加审计日志落库策略
- 任务管理模块完善
- 权限树缓存与增量刷新策略

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<Boolean> delete(@PathVariable Long 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
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}")
public ApiResponse<Boolean> 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<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.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<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) {
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<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;
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;
}

View File

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

View File

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

View File

@ -5,4 +5,12 @@ import com.imeeting.entity.SysParam;
public interface SysParamService extends IService<SysParam> {
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.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<SysUser>()
.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<SysUser>()
.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<String, Object> accessClaims = new HashMap<>();
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.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<SysParamMapper, SysParam> 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<SysParam>().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<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
port: 6379
password: Unis@123
database: 15
mybatis-plus:
configuration:

View File

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

View File

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

View File

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

View File

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

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

View File

@ -1,17 +1,21 @@
import { Button, Checkbox, Form, Input, message, Typography } from "antd";
import { useEffect, useState } from "react";
import { fetchCaptcha, login, type CaptchaResponse } from "../api/auth";
import { getCurrentUser } from "../api";
import { getCurrentUser, getSystemParamValue } from "../api";
import "./Login.css";
const { Title, Text, Link } = Typography;
export default function Login() {
const [captcha, setCaptcha] = useState<CaptchaResponse | null>(null);
const [captchaEnabled, setCaptchaEnabled] = useState(true);
const [loading, setLoading] = useState(false);
const [form] = Form.useForm();
const loadCaptcha = async () => {
if (!captchaEnabled) {
return;
}
try {
const data = await fetchCaptcha();
setCaptcha(data);
@ -21,7 +25,20 @@ export default function Login() {
};
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) => {
@ -30,8 +47,8 @@ export default function Login() {
const data = await login({
username: values.username,
password: values.password,
captchaId: captcha?.captchaId || "",
captchaCode: values.captchaCode
captchaId: captchaEnabled ? captcha?.captchaId : undefined,
captchaCode: captchaEnabled ? values.captchaCode : undefined
});
localStorage.setItem("accessToken", data.accessToken);
localStorage.setItem("refreshToken", data.refreshToken);
@ -46,7 +63,9 @@ export default function Login() {
window.location.href = "/";
} catch (e: any) {
message.error(e.message || "登录失败");
loadCaptcha();
if (captchaEnabled) {
loadCaptcha();
}
} finally {
setLoading(false);
}
@ -109,22 +128,24 @@ export default function Login() {
<Input.Password size="large" placeholder="请输入密码" />
</Form.Item>
<Form.Item
label="验证码"
name="captchaCode"
rules={[{ required: true, message: "请输入验证码" }]}
>
<div className="captcha-wrapper">
<Input size="large" placeholder="验证码" />
<div className="captcha-image-container" onClick={loadCaptcha}>
{captcha ? (
<img src={captcha.imageBase64} alt="captcha" />
) : (
<div className="captcha-placeholder" />
)}
{captchaEnabled && (
<Form.Item
label="验证码"
name="captchaCode"
rules={[{ required: true, message: "请输入验证码" }]}
>
<div className="captcha-wrapper">
<Input size="large" placeholder="验证码" />
<div className="captcha-image-container" onClick={loadCaptcha}>
{captcha ? (
<img src={captcha.imageBase64} alt="captcha" />
) : (
<div className="captcha-placeholder" />
)}
</div>
</div>
</div>
</Form.Item>
</Form.Item>
)}
<div className="login-extra">
<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 { createPermission, deletePermission, listMyPermissions, updatePermission } from "../api";
import type { SysPermission } from "../types";
@ -188,12 +188,18 @@ export default function Permissions() {
]}
/>
<Modal
<Drawer
title={editing ? "编辑权限" : "新增权限"}
open={open}
onOk={submit}
onCancel={() => setOpen(false)}
onClose={() => setOpen(false)}
width={520}
destroyOnClose
footer={
<Space style={{ width: "100%", justifyContent: "flex-end" }}>
<Button onClick={() => setOpen(false)}></Button>
<Button type="primary" onClick={submit}></Button>
</Space>
}
>
<Form
form={form}
@ -268,7 +274,7 @@ export default function Permissions() {
<Input.TextArea rows={3} />
</Form.Item>
</Form>
</Modal>
</Drawer>
</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 { 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<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() {
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [data, setData] = useState<SysRole[]>([]);
const [query, setQuery] = useState({ roleCode: "", roleName: "" });
const [pagination, setPagination] = useState({ current: 1, pageSize: 10 });
const [open, setOpen] = useState(false);
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 [selectedPermIds, setSelectedPermIds] = useState<number[]>([]);
const [form] = Form.useForm();
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);
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<SysRole> = {
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<SysRole> = {
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 (
<>
<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 (
<div>
<Space style={{ marginBottom: 16 }}>
<Input
placeholder="角色编码"
value={query.roleCode}
onChange={(e) => setQuery({ ...query, roleCode: e.target.value })}
/>
<Input
placeholder="角色名称"
value={query.roleName}
onChange={(e) => setQuery({ ...query, roleName: e.target.value })}
/>
<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" onClick={openCreate}></Button>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
</Button>
)}
</Space>
</div>
<Table
rowKey="roleId"
loading={loading}
dataSource={pageData}
pagination={{
current: pagination.current,
pageSize: pagination.pageSize,
total: filtered.length,
onChange: (current, pageSize) => setPagination({ current, pageSize })
}}
columns={[
{ title: "ID", dataIndex: "roleId" },
{ title: "编码", dataIndex: "roleCode" },
{ title: "名称", dataIndex: "roleName" },
{ title: "备注", dataIndex: "remark" },
{
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>
)
}
]}
/>
<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") && (
<Button
type="text"
className="role-edit-btn"
icon={<EditOutlined />}
onClick={() => openEdit(role)}
/>
)}
</div>
<Modal
title={editing ? "编辑角色" : "新增角色"}
open={open}
onOk={submit}
onCancel={() => setOpen(false)}
destroyOnClose
<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}
>
</Button>
</div>
}
>
<Form form={form} layout="vertical">
<Form.Item label="角色编码" name="roleCode" rules={[{ required: true }]}>
<Input disabled={!!editing} />
<Form form={form} layout="vertical" className="role-form">
<Form.Item
label="角色名称"
name="roleName"
rules={[{ required: true, message: "请输入角色名称" }]}
>
<Input placeholder="例如Auditor" />
</Form.Item>
<Form.Item label="角色名称" name="roleName" rules={[{ required: true }]}>
<Input />
</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 label="描述" name="remark">
<Input placeholder="该角色的职责描述..." />
</Form.Item>
</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>
);
}

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 { createUser, deleteUser, listUsers, updateUser } from "../api";
import type { SysUser } from "../types";
@ -140,12 +140,18 @@ export default function Users() {
]}
/>
<Modal
<Drawer
title={editing ? "编辑用户" : "新增用户"}
open={open}
onOk={submit}
onCancel={() => setOpen(false)}
onClose={() => setOpen(false)}
width={420}
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.Item label="用户名" name="username" rules={[{ required: true }]}>
@ -172,7 +178,7 @@ export default function Users() {
/>
</Form.Item>
</Form>
</Modal>
</Drawer>
</div>
);
}

View File

@ -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: <Users />, perm: "menu:users" },
{ path: "/roles", label: "角色管理", element: <Roles />, perm: "menu:roles" },
{ 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" }
];