feat(app): 实现动态菜单树和字典管理功能

- 集成后端Redis缓存配置和依赖
- 实现前端AppLayout组件动态加载菜单树结构
- 添加字典类型和字典项的完整CRUD功能
- 创建字典管理页面支持类型和项的增删改查
- 优化角色权限绑定界面的权限树展示
- 更新角色管理页面的权限分配逻辑
- 添加权限节点类型定义和菜单渲染逻辑
- 实现用户登出功能的布局调整和图标优化
master
chenhao 2026-02-11 15:44:49 +08:00
parent 78e77cf260
commit ef262e7a43
29 changed files with 2054 additions and 203 deletions

View File

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

View File

@ -19,6 +19,11 @@ public final class RedisKeys {
return "sys:param:" + paramKey;
}
public static String sysDictKey(String typeCode) {
return "sys:dict:" + typeCode;
}
public static final String CACHE_EMPTY_MARKER = "EMPTY_MARKER";
public static final String SYS_PARAM_FIELD_VALUE = "value";
public static final String SYS_PARAM_FIELD_TYPE = "type";
}

View File

@ -0,0 +1,7 @@
package com.imeeting.common;
public final class SysParamKeys {
private SysParamKeys() {}
public static final String CAPTCHA_ENABLED = "security.captcha.enabled";
}

View File

@ -0,0 +1,29 @@
package com.imeeting.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import java.time.Duration;
@Configuration
public class CacheConfig {
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofHours(1)) // Default TTL
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))
.disableCachingNullValues();
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(config)
.build();
}
}

View File

@ -0,0 +1,20 @@
package com.imeeting.config;
import com.imeeting.service.SysParamService;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
@Component
public class SysParamCacheInitializer implements ApplicationRunner {
private final SysParamService sysParamService;
public SysParamCacheInitializer(SysParamService sysParamService) {
this.sysParamService = sysParamService;
}
@Override
public void run(ApplicationArguments args) {
sysParamService.syncAllToCache();
}
}

View File

@ -0,0 +1,55 @@
package com.imeeting.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.imeeting.common.ApiResponse;
import com.imeeting.entity.SysDictItem;
import com.imeeting.service.SysDictItemService;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/dict-items")
public class DictItemController {
private final SysDictItemService sysDictItemService;
public DictItemController(SysDictItemService sysDictItemService) {
this.sysDictItemService = sysDictItemService;
}
@GetMapping
public ApiResponse<List<SysDictItem>> list(@RequestParam(required = false) String typeCode) {
LambdaQueryWrapper<SysDictItem> queryWrapper = new LambdaQueryWrapper<>();
if (typeCode != null && !typeCode.isEmpty()) {
queryWrapper.eq(SysDictItem::getTypeCode, typeCode);
}
queryWrapper.orderByAsc(SysDictItem::getSortOrder);
return ApiResponse.ok(sysDictItemService.list(queryWrapper));
}
@GetMapping("/{id}")
public ApiResponse<SysDictItem> get(@PathVariable Long id) {
return ApiResponse.ok(sysDictItemService.getById(id));
}
@PostMapping
public ApiResponse<Boolean> create(@RequestBody SysDictItem dictItem) {
return ApiResponse.ok(sysDictItemService.save(dictItem));
}
@PutMapping("/{id}")
public ApiResponse<Boolean> update(@PathVariable Long id, @RequestBody SysDictItem dictItem) {
dictItem.setDictItemId(id);
return ApiResponse.ok(sysDictItemService.updateById(dictItem));
}
@DeleteMapping("/{id}")
public ApiResponse<Boolean> delete(@PathVariable Long id) {
return ApiResponse.ok(sysDictItemService.removeById(id));
}
@GetMapping("/type/{typeCode}")
public ApiResponse<List<SysDictItem>> getByType(@PathVariable String typeCode) {
return ApiResponse.ok(sysDictItemService.getItemsByTypeCode(typeCode));
}
}

View File

@ -0,0 +1,44 @@
package com.imeeting.controller;
import com.imeeting.common.ApiResponse;
import com.imeeting.entity.SysDictType;
import com.imeeting.service.SysDictTypeService;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/dict-types")
public class DictTypeController {
private final SysDictTypeService sysDictTypeService;
public DictTypeController(SysDictTypeService sysDictTypeService) {
this.sysDictTypeService = sysDictTypeService;
}
@GetMapping
public ApiResponse<List<SysDictType>> list() {
return ApiResponse.ok(sysDictTypeService.list());
}
@GetMapping("/{id}")
public ApiResponse<SysDictType> get(@PathVariable Long id) {
return ApiResponse.ok(sysDictTypeService.getById(id));
}
@PostMapping
public ApiResponse<Boolean> create(@RequestBody SysDictType dictType) {
return ApiResponse.ok(sysDictTypeService.save(dictType));
}
@PutMapping("/{id}")
public ApiResponse<Boolean> update(@PathVariable Long id, @RequestBody SysDictType dictType) {
dictType.setDictTypeId(id);
return ApiResponse.ok(sysDictTypeService.updateById(dictType));
}
@DeleteMapping("/{id}")
public ApiResponse<Boolean> delete(@PathVariable Long id) {
return ApiResponse.ok(sysDictTypeService.removeById(id));
}
}

View File

@ -0,0 +1,27 @@
package com.imeeting.entity;
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 lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("sys_dict_item")
public class SysDictItem extends BaseEntity {
@TableId(value = "dict_item_id", type = IdType.AUTO)
private Long dictItemId;
private String typeCode;
private String itemLabel;
private String itemValue;
private Integer sortOrder;
private String remark;
@TableField(exist = false)
private Long tenantId;
@TableField(exist = false)
private Integer isDeleted;
}

View File

@ -0,0 +1,25 @@
package com.imeeting.entity;
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 lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("sys_dict_type")
public class SysDictType extends BaseEntity {
@TableId(value = "dict_type_id", type = IdType.AUTO)
private Long dictTypeId;
private String typeCode;
private String typeName;
private String remark;
@TableField(exist = false)
private Long tenantId;
@TableField(exist = false)
private Integer isDeleted;
}

View File

@ -0,0 +1,9 @@
package com.imeeting.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.imeeting.entity.SysDictItem;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SysDictItemMapper extends BaseMapper<SysDictItem> {
}

View File

@ -0,0 +1,9 @@
package com.imeeting.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.imeeting.entity.SysDictType;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SysDictTypeMapper extends BaseMapper<SysDictType> {
}

View File

@ -0,0 +1,10 @@
package com.imeeting.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.imeeting.entity.SysDictItem;
import java.util.List;
public interface SysDictItemService extends IService<SysDictItem> {
List<SysDictItem> getItemsByTypeCode(String typeCode);
}

View File

@ -0,0 +1,7 @@
package com.imeeting.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.imeeting.entity.SysDictType;
public interface SysDictTypeService extends IService<SysDictType> {
}

View File

@ -0,0 +1,130 @@
package com.imeeting.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.imeeting.common.RedisKeys;
import com.imeeting.entity.SysDictItem;
import com.imeeting.mapper.SysDictItemMapper;
import com.imeeting.service.SysDictItemService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.io.Serializable;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Random;
@Slf4j
@Service
public class SysDictItemServiceImpl extends ServiceImpl<SysDictItemMapper, SysDictItem> implements SysDictItemService {
private final StringRedisTemplate redisTemplate;
private final ObjectMapper objectMapper;
private final Random random = new Random();
@Autowired
public SysDictItemServiceImpl(StringRedisTemplate redisTemplate, ObjectMapper objectMapper) {
this.redisTemplate = redisTemplate;
this.objectMapper = objectMapper;
}
@Override
public List<SysDictItem> getItemsByTypeCode(String typeCode) {
String key = RedisKeys.sysDictKey(typeCode);
try {
String cached = redisTemplate.opsForValue().get(key);
if (RedisKeys.CACHE_EMPTY_MARKER.equals(cached)) {
return new ArrayList<>();
}
if (cached != null) {
return objectMapper.readValue(cached, new TypeReference<List<SysDictItem>>() {});
}
} catch (Exception e) {
log.error("Redis error for key {}: {}", key, e.getMessage());
}
List<SysDictItem> items = list(new LambdaQueryWrapper<SysDictItem>()
.eq(SysDictItem::getTypeCode, typeCode)
.eq(SysDictItem::getStatus, 1)
.orderByAsc(SysDictItem::getSortOrder));
try {
if (items == null || items.isEmpty()) {
redisTemplate.opsForValue().set(key, RedisKeys.CACHE_EMPTY_MARKER, Duration.ofMinutes(5));
} else {
int jitter = random.nextInt(120);
redisTemplate.opsForValue().set(key, objectMapper.writeValueAsString(items), Duration.ofMinutes(1440 + jitter));
}
} catch (Exception e) {
log.error("Failed to cache dictionary items for {}: {}", typeCode, e.getMessage());
}
return items;
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean save(SysDictItem entity) {
boolean success = super.save(entity);
if (success && entity != null) {
deleteCache(entity.getTypeCode());
}
return success;
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean updateById(SysDictItem entity) {
if (entity == null || entity.getDictItemId() == null) {
return super.updateById(entity);
}
SysDictItem old = getById(entity.getDictItemId());
boolean success = super.updateById(entity);
if (success && old != null) {
deleteCache(old.getTypeCode());
if (entity.getTypeCode() != null && !old.getTypeCode().equals(entity.getTypeCode())) {
deleteCache(entity.getTypeCode());
}
}
return success;
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean removeById(Serializable id) {
SysDictItem old = getById(id);
boolean success = super.removeById(id);
if (success && old != null) {
deleteCache(old.getTypeCode());
}
return success;
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean removeByIds(Collection<?> list) {
if (list == null || list.isEmpty()) {
return false;
}
boolean allSuccess = true;
for (Object id : list) {
if (!removeById((Serializable) id)) {
allSuccess = false;
}
}
return allSuccess;
}
private void deleteCache(String typeCode) {
if (typeCode != null) {
redisTemplate.delete(RedisKeys.sysDictKey(typeCode));
}
}
}

View File

@ -0,0 +1,68 @@
package com.imeeting.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.imeeting.common.RedisKeys;
import com.imeeting.entity.SysDictType;
import com.imeeting.mapper.SysDictTypeMapper;
import com.imeeting.service.SysDictTypeService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.io.Serializable;
import java.util.Collection;
@Service
public class SysDictTypeServiceImpl extends ServiceImpl<SysDictTypeMapper, SysDictType> implements SysDictTypeService {
private final StringRedisTemplate redisTemplate;
@Autowired
public SysDictTypeServiceImpl(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean updateById(SysDictType entity) {
if (entity == null || entity.getDictTypeId() == null) {
return super.updateById(entity);
}
SysDictType old = getById(entity.getDictTypeId());
boolean success = super.updateById(entity);
if (success && old != null) {
redisTemplate.delete(RedisKeys.sysDictKey(old.getTypeCode()));
if (entity.getTypeCode() != null && !old.getTypeCode().equals(entity.getTypeCode())) {
redisTemplate.delete(RedisKeys.sysDictKey(entity.getTypeCode()));
}
}
return success;
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean removeById(Serializable id) {
SysDictType old = getById(id);
boolean success = super.removeById(id);
if (success && old != null) {
redisTemplate.delete(RedisKeys.sysDictKey(old.getTypeCode()));
}
return success;
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean removeByIds(Collection<?> list) {
if (list == null || list.isEmpty()) {
return false;
}
boolean allSuccess = true;
for (Object id : list) {
if (!removeById((Serializable) id)) {
allSuccess = false;
}
}
return allSuccess;
}
}

View File

@ -6,63 +6,131 @@ import com.imeeting.common.RedisKeys;
import com.imeeting.entity.SysParam;
import com.imeeting.mapper.SysParamMapper;
import com.imeeting.service.SysParamService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.HashMap;
import java.io.Serializable;
import java.time.Duration;
import java.util.List;
import java.util.Map;
@Slf4j
@Service
public class SysParamServiceImpl extends ServiceImpl<SysParamMapper, SysParam> implements SysParamService {
private final StringRedisTemplate stringRedisTemplate;
private final StringRedisTemplate redisTemplate;
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;
public SysParamServiceImpl(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
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 String getParamValue(String key, String defaultValue) {
if (key == null || key.isEmpty()) {
return defaultValue;
}
@Override
public void deleteParamCache(String key) {
if (key == null || key.isEmpty()) {
return;
}
stringRedisTemplate.delete(RedisKeys.sysParamKey(key));
}
String redisKey = RedisKeys.sysParamKey(key);
try {
// 1. 尝试从 Redis 获取
String cachedValue = redisTemplate.opsForValue().get(redisKey);
if (cachedValue != null) {
// 如果是空标记,返回默认值
if (RedisKeys.CACHE_EMPTY_MARKER.equals(cachedValue)) {
return defaultValue;
}
return cachedValue;
}
} catch (Exception e) {
log.error("Redis read error for key {}: {}", redisKey, e.getMessage());
}
@Override
public void syncAllToCache() {
List<SysParam> params = list();
for (SysParam param : params) {
syncParamToCache(param);
// 2. Redis 未命中,查数据库
log.info("Cache miss for param key: {}, fetching from DB", key);
SysParam param = getOne(new LambdaQueryWrapper<SysParam>().eq(SysParam::getParamKey, key));
if (param != null) {
String val = param.getParamValue();
// 3. 回写 Redis
try {
redisTemplate.opsForValue().set(redisKey, val == null ? "" : val, Duration.ofHours(24));
} catch (Exception e) {
log.error("Redis write error for key {}: {}", redisKey, e.getMessage());
}
return val;
} else {
// 4. 数据库也无数据,设置空标记防止穿透
try {
redisTemplate.opsForValue().set(redisKey, RedisKeys.CACHE_EMPTY_MARKER, Duration.ofMinutes(5));
} catch (Exception e) {
log.error("Redis write empty marker error for key {}: {}", redisKey, e.getMessage());
}
return defaultValue;
}
}
}
}
@Override
public String getCachedParamValue(String key, String defaultValue) {
return getParamValue(key, defaultValue);
}
@Override
public void syncParamToCache(SysParam param) {
if (param != null && param.getParamKey() != null) {
redisTemplate.opsForValue().set(RedisKeys.sysParamKey(param.getParamKey()),
param.getParamValue() == null ? "" : param.getParamValue(), Duration.ofHours(24));
}
}
@Override
public void deleteParamCache(String key) {
if (key != null) {
redisTemplate.delete(RedisKeys.sysParamKey(key));
}
}
@Override
public void syncAllToCache() {
log.info("Syncing all system parameters to Redis");
List<SysParam> params = list();
for (SysParam param : params) {
syncParamToCache(param);
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean save(SysParam entity) {
boolean success = super.save(entity);
if (success && entity.getParamKey() != null) {
deleteParamCache(entity.getParamKey());
}
return success;
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean updateById(SysParam entity) {
// 先查出旧的 Key 确保缓存被清理
SysParam old = getById(entity.getParamId());
boolean success = super.updateById(entity);
if (success && old != null) {
deleteParamCache(old.getParamKey());
if (entity.getParamKey() != null && !entity.getParamKey().equals(old.getParamKey())) {
deleteParamCache(entity.getParamKey());
}
}
return success;
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean removeById(Serializable id) {
SysParam old = getById(id);
boolean success = super.removeById(id);
if (success && old != null) {
deleteParamCache(old.getParamKey());
}
return success;
}
}

View File

@ -12,6 +12,8 @@ spring:
port: 6379
password: Unis@123
database: 15
cache:
type: redis
mybatis-plus:
configuration:

View File

@ -0,0 +1,49 @@
import http from "./http";
import { SysDictType, SysDictItem } from "../types";
// Dictionary Type APIs
export async function fetchDictTypes() {
const resp = await http.get("/api/dict-types");
return resp.data.data as SysDictType[];
}
export async function createDictType(data: Partial<SysDictType>) {
const resp = await http.post("/api/dict-types", data);
return resp.data.data as boolean;
}
export async function updateDictType(id: number, data: Partial<SysDictType>) {
const resp = await http.put(`/api/dict-types/${id}`, data);
return resp.data.data as boolean;
}
export async function deleteDictType(id: number) {
const resp = await http.delete(`/api/dict-types/${id}`);
return resp.data.data as boolean;
}
// Dictionary Item APIs
export async function fetchDictItems(typeCode?: string) {
const resp = await http.get("/api/dict-items", { params: { typeCode } });
return resp.data.data as SysDictItem[];
}
export async function createDictItem(data: Partial<SysDictItem>) {
const resp = await http.post("/api/dict-items", data);
return resp.data.data as boolean;
}
export async function updateDictItem(id: number, data: Partial<SysDictItem>) {
const resp = await http.put(`/api/dict-items/${id}`, data);
return resp.data.data as boolean;
}
export async function deleteDictItem(id: number) {
const resp = await http.delete(`/api/dict-items/${id}`);
return resp.data.data as boolean;
}
export async function fetchDictItemsByTypeCode(typeCode: string) {
const resp = await http.get(`/api/dict-items/type/${typeCode}`);
return resp.data.data as SysDictItem[];
}

View File

@ -57,6 +57,11 @@ export async function listMyPermissions() {
return resp.data.data as SysPermission[];
}
export async function fetchMyMenuTree() {
const resp = await http.get("/api/permissions/tree/me");
return resp.data.data as PermissionNode[];
}
export async function getCurrentUser() {
const resp = await http.get("/api/users/me");
return resp.data.data as UserProfile;
@ -117,3 +122,5 @@ export async function saveRolePermissions(roleId: number, permIds: number[]) {
return resp.data.data as boolean;
}
export * from "./dict";

View File

@ -1,9 +1,40 @@
import { Layout, Menu } from "antd";
import { Layout, Menu, Space, Button } from "antd";
import { Link, Outlet, useLocation, useNavigate } from "react-router-dom";
import { useEffect, useState } from "react";
import { fetchMyMenuTree } from "../api";
import { PermissionNode } from "../types";
import { LogoutOutlined, HomeOutlined, SettingOutlined, UserOutlined, SafetyOutlined, ClusterOutlined, BookOutlined, DesktopOutlined } from "@ant-design/icons";
const iconMap: Record<string, any> = {
'home': <HomeOutlined />,
'user': <UserOutlined />,
'role': <SafetyOutlined />,
'permission': <ClusterOutlined />,
'dict': <BookOutlined />,
'device': <DesktopOutlined />,
'setting': <SettingOutlined />
};
export default function AppLayout() {
const location = useLocation();
const navigate = useNavigate();
const [menuTree, setMenuTree] = useState<PermissionNode[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
const loadMenu = async () => {
setLoading(true);
try {
const tree = await fetchMyMenuTree();
setMenuTree(tree || []);
} catch (e) {
console.error("Failed to load menu tree", e);
} finally {
setLoading(false);
}
};
loadMenu();
}, []);
const handleLogout = () => {
localStorage.removeItem("accessToken");
@ -11,9 +42,44 @@ export default function AppLayout() {
navigate("/login");
};
const renderMenuItems = (nodes: PermissionNode[]): any[] => {
return nodes
.filter(node => node.isVisible !== 0 && node.status !== 0)
.map(node => {
const icon = node.icon ? iconMap[node.icon] || <SettingOutlined /> : undefined;
if (node.children && node.children.length > 0) {
return {
key: node.path || `parent-${node.permId}`,
icon: icon,
label: node.name,
children: renderMenuItems(node.children)
};
}
return {
key: node.path,
icon: icon,
label: node.path ? <Link to={node.path}>{node.name}</Link> : node.name
};
});
};
const menuItems = [
{ key: "/", label: <Link to="/"></Link>, icon: <HomeOutlined /> },
...renderMenuItems(menuTree),
{
key: "logout",
label: <span onClick={handleLogout}>退</span>,
icon: <LogoutOutlined />,
danger: true,
style: { marginTop: 'auto' }
}
];
return (
<Layout style={{ minHeight: "100vh" }}>
<Layout.Sider collapsible>
<Layout.Sider collapsible width={220}>
<div style={{ padding: '16px', display: 'flex', alignItems: 'center', gap: '8px', overflow: 'hidden' }}>
<img src="/logo.svg" alt="logo" style={{ width: 28, height: 28, flexShrink: 0 }} />
<span style={{ color: "#fff", fontWeight: 700, fontSize: '18px', whiteSpace: 'nowrap' }}>MeetingAI</span>
@ -22,22 +88,21 @@ export default function AppLayout() {
theme="dark"
mode="inline"
selectedKeys={[location.pathname]}
items={[
{ key: "/", label: <Link to="/"></Link> },
{ key: "/users", label: <Link to="/users"></Link> },
{ key: "/roles", label: <Link to="/roles"></Link> },
{ key: "/permissions", label: <Link to="/permissions"></Link> },
{ key: "/devices", label: <Link to="/devices"></Link> },
{ key: "logout", label: <span onClick={handleLogout}>退</span> }
]}
items={menuItems}
loading={loading}
style={{ height: 'calc(100% - 64px)', display: 'flex', flexDirection: 'column' }}
/>
</Layout.Sider>
<Layout>
<Layout.Header style={{ background: "#fff" }} />
<Layout.Content style={{ padding: 24 }}>
<Layout.Header style={{ background: "#fff", padding: '0 24px', display: 'flex', alignItems: 'center', justifyContent: 'flex-end' }}>
<Space>
<Button type="text" icon={<LogoutOutlined />} onClick={handleLogout}>退</Button>
</Space>
</Layout.Header>
<Layout.Content style={{ padding: 24, background: '#f0f2f5', overflowY: 'auto' }}>
<Outlet />
</Layout.Content>
</Layout>
</Layout>
);
}
}

View File

@ -0,0 +1,345 @@
import {
Button,
Card,
Col,
Drawer,
Form,
Input,
InputNumber,
message,
Popconfirm,
Row,
Select,
Space,
Table,
Tag,
Typography
} from "antd";
import { useEffect, useState } from "react";
import {
createDictItem,
createDictType,
deleteDictItem,
deleteDictType,
fetchDictItems,
fetchDictTypes,
updateDictItem,
updateDictType
} from "../api";
import { usePermission } from "../hooks/usePermission";
import { PlusOutlined, EditOutlined, DeleteOutlined } from "@ant-design/icons";
import type { SysDictItem, SysDictType } from "../types";
const { Title } = Typography;
export default function Dictionaries() {
const { can } = usePermission();
const [types, setTypes] = useState<SysDictType[]>([]);
const [items, setItems] = useState<SysDictItem[]>([]);
const [selectedType, setSelectedType] = useState<SysDictType | null>(null);
const [loadingTypes, setLoadingTypes] = useState(false);
const [loadingItems, setLoadingItems] = useState(false);
// Type Drawer
const [typeDrawerVisible, setTypeDrawerVisible] = useState(false);
const [editingType, setEditingType] = useState<SysDictType | null>(null);
const [typeForm] = Form.useForm();
// Item Drawer
const [itemDrawerVisible, setItemDrawerVisible] = useState(false);
const [editingItem, setEditingItem] = useState<SysDictItem | null>(null);
const [itemForm] = Form.useForm();
const loadTypes = async () => {
setLoadingTypes(true);
try {
const data = await fetchDictTypes();
setTypes(data || []);
if (data && data.length > 0 && !selectedType) {
setSelectedType(data[0]);
}
} finally {
setLoadingTypes(false);
}
};
const loadItems = async (typeCode: string) => {
setLoadingItems(true);
try {
const data = await fetchDictItems(typeCode);
setItems(data || []);
} finally {
setLoadingItems(false);
}
};
useEffect(() => {
loadTypes();
}, []);
useEffect(() => {
if (selectedType) {
loadItems(selectedType.typeCode);
} else {
setItems([]);
}
}, [selectedType]);
// Type Actions
const handleAddType = () => {
setEditingType(null);
typeForm.resetFields();
setTypeDrawerVisible(true);
};
const handleEditType = (record: SysDictType) => {
setEditingType(record);
typeForm.setFieldsValue(record);
setTypeDrawerVisible(true);
};
const handleDeleteType = async (id: number) => {
await deleteDictType(id);
message.success("删除成功");
loadTypes();
};
const handleTypeSubmit = async () => {
const values = await typeForm.validateFields();
if (editingType) {
await updateDictType(editingType.dictTypeId, values);
} else {
await createDictType(values);
}
message.success(editingType ? "更新成功" : "创建成功");
setTypeDrawerVisible(false);
loadTypes();
};
// Item Actions
const handleAddItem = () => {
if (!selectedType) {
message.warning("请先选择一个字典类型");
return;
}
setEditingItem(null);
itemForm.resetFields();
itemForm.setFieldsValue({ typeCode: selectedType.typeCode, sortOrder: 0, status: 1 });
setItemDrawerVisible(true);
};
const handleEditItem = (record: SysDictItem) => {
setEditingItem(record);
itemForm.setFieldsValue(record);
setItemDrawerVisible(true);
};
const handleDeleteItem = async (id: number) => {
await deleteDictItem(id);
message.success("删除成功");
if (selectedType) loadItems(selectedType.typeCode);
};
const handleItemSubmit = async () => {
const values = await itemForm.validateFields();
if (editingItem) {
await updateDictItem(editingItem.dictItemId, values);
} else {
await createDictItem(values);
}
message.success(editingItem ? "更新成功" : "创建成功");
setItemDrawerVisible(false);
if (selectedType) loadItems(selectedType.typeCode);
};
return (
<div className="p-6">
<Title level={4} className="mb-6"></Title>
<Row gutter={16}>
<Col span={8}>
<Card
title="字典类型"
extra={
can("sys_dict:type:create") && (
<Button type="primary" icon={<PlusOutlined />} onClick={handleAddType}>
</Button>
)
}
>
<Table
rowKey="dictTypeId"
loading={loadingTypes}
dataSource={types}
pagination={false}
size="small"
onRow={(record) => ({
onClick: () => setSelectedType(record),
className: `cursor-pointer ${selectedType?.dictTypeId === record.dictTypeId ? "ant-table-row-selected" : ""}`
})}
columns={[
{ title: "类型名称", dataIndex: "typeName" },
{ title: "编码", dataIndex: "typeCode" },
{
title: "操作",
width: 100,
render: (_, record) => (
<Space>
{can("sys_dict:type:update") && (
<Button
type="text"
size="small"
icon={<EditOutlined />}
onClick={(e) => {
e.stopPropagation();
handleEditType(record);
}}
/>
)}
{can("sys_dict:type:delete") && (
<Popconfirm
title="删除类型会影响关联的项,确认删除?"
onConfirm={(e) => {
e?.stopPropagation();
handleDeleteType(record.dictTypeId);
}}
>
<Button
type="text"
size="small"
danger
icon={<DeleteOutlined />}
onClick={(e) => e.stopPropagation()}
/>
</Popconfirm>
)}
</Space>
)
}
]}
/>
</Card>
</Col>
<Col span={16}>
<Card
title={`字典项 - ${selectedType?.typeName || "未选择"}`}
extra={
can("sys_dict:item:create") && (
<Button type="primary" icon={<PlusOutlined />} onClick={handleAddItem} disabled={!selectedType}>
</Button>
)
}
>
<Table
rowKey="dictItemId"
loading={loadingItems}
dataSource={items}
pagination={false}
columns={[
{ title: "标签", dataIndex: "itemLabel" },
{ title: "数值", dataIndex: "itemValue" },
{ title: "排序", dataIndex: "sortOrder", width: 80 },
{
title: "状态",
dataIndex: "status",
width: 80,
render: (v) => (v === 1 ? <Tag color="green"></Tag> : <Tag color="red"></Tag>)
},
{
title: "操作",
width: 120,
render: (_, record) => (
<Space>
{can("sys_dict:item:update") && (
<Button
type="text"
size="small"
icon={<EditOutlined />}
onClick={() => handleEditItem(record)}
/>
)}
{can("sys_dict:item:delete") && (
<Popconfirm title="确认删除该项?" onConfirm={() => handleDeleteItem(record.dictItemId)}>
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
)}
</Space>
)
}
]}
/>
</Card>
</Col>
</Row>
{/* Type Drawer */}
<Drawer
title={editingType ? "编辑字典类型" : "新增字典类型"}
open={typeDrawerVisible}
onClose={() => setTypeDrawerVisible(false)}
width={400}
destroyOnClose
footer={
<div className="flex justify-end space-x-2">
<Button onClick={() => setTypeDrawerVisible(false)}></Button>
<Button type="primary" onClick={handleTypeSubmit}></Button>
</div>
}
>
<Form form={typeForm} layout="vertical">
<Form.Item label="类型编码" name="typeCode" rules={[{ required: true }]}>
<Input disabled={!!editingType} placeholder="例如user_status" />
</Form.Item>
<Form.Item label="类型名称" name="typeName" rules={[{ required: true }]}>
<Input placeholder="例如:用户状态" />
</Form.Item>
<Form.Item label="备注" name="remark">
<Input.TextArea />
</Form.Item>
</Form>
</Drawer>
{/* Item Drawer */}
<Drawer
title={editingItem ? "编辑字典项" : "新增字典项"}
open={itemDrawerVisible}
onClose={() => setItemDrawerVisible(false)}
width={400}
destroyOnClose
footer={
<div className="flex justify-end space-x-2">
<Button onClick={() => setItemDrawerVisible(false)}></Button>
<Button type="primary" onClick={handleItemSubmit}></Button>
</div>
}
>
<Form form={itemForm} layout="vertical">
<Form.Item label="所属类型" name="typeCode">
<Input disabled />
</Form.Item>
<Form.Item label="标签" name="itemLabel" rules={[{ required: true }]}>
<Input placeholder="例如:启用" />
</Form.Item>
<Form.Item label="数值" name="itemValue" rules={[{ required: true }]}>
<Input placeholder="例如1" />
</Form.Item>
<Form.Item label="排序" name="sortOrder" initialValue={0}>
<InputNumber className="w-full" />
</Form.Item>
<Form.Item label="状态" name="status" initialValue={1}>
<Select
options={[
{ label: "启用", value: 1 },
{ label: "禁用", value: 0 }
]}
/>
</Form.Item>
<Form.Item label="备注" name="remark">
<Input.TextArea />
</Form.Item>
</Form>
</Drawer>
</div>
);
}

View File

@ -0,0 +1,194 @@
import { Button, Card, Col, message, Row, Space, Table, Tag, Tree, Typography } from "antd";
import type { DataNode } from "antd/es/tree";
import { useEffect, useMemo, useState } from "react";
import { listPermissions, listRolePermissions, listRoles, saveRolePermissions } from "../api";
import type { SysPermission, SysRole } from "../types";
const { Title, Text } = Typography;
type PermissionNode = SysPermission & { key: number; children?: PermissionNode[] };
function buildPermissionTree(list: SysPermission[]): PermissionNode[] {
const map = new Map<number, PermissionNode>();
const roots: PermissionNode[] = [];
list.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;
}
function toTreeData(nodes: PermissionNode[]): DataNode[] {
return nodes.map((node) => ({
key: node.permId,
title: (
<Space>
<span>{node.name}</span>
{node.permType === "button" && <Tag color="blue"></Tag>}
</Space>
),
children: node.children ? toTreeData(node.children) : undefined
}));
}
export default function RolePermissionBinding() {
const [roles, setRoles] = useState<SysRole[]>([]);
const [permissions, setPermissions] = useState<SysPermission[]>([]);
const [loadingRoles, setLoadingRoles] = useState(false);
const [loadingPerms, setLoadingPerms] = useState(false);
const [saving, setSaving] = useState(false);
const [selectedRoleId, setSelectedRoleId] = useState<number | null>(null);
const [checkedPermIds, setCheckedPermIds] = useState<number[]>([]);
const selectedRole = useMemo(
() => roles.find((r) => r.roleId === selectedRoleId) || null,
[roles, selectedRoleId]
);
const loadRoles = async () => {
setLoadingRoles(true);
try {
const list = await listRoles();
setRoles(list || []);
} finally {
setLoadingRoles(false);
}
};
const loadPermissions = async () => {
setLoadingPerms(true);
try {
const list = await listPermissions();
setPermissions(list || []);
} catch (e) {
message.error("加载权限失败,请确认接口已实现");
} finally {
setLoadingPerms(false);
}
};
const loadRolePermissions = async (roleId: number) => {
try {
const list = await listRolePermissions(roleId);
setCheckedPermIds(list || []);
} catch (e) {
setCheckedPermIds([]);
message.error("加载角色权限失败,请确认接口已实现");
}
};
useEffect(() => {
loadRoles();
loadPermissions();
}, []);
useEffect(() => {
if (selectedRoleId) {
loadRolePermissions(selectedRoleId);
} else {
setCheckedPermIds([]);
}
}, [selectedRoleId]);
const treeData = useMemo(() => toTreeData(buildPermissionTree(permissions)), [permissions]);
const handleSave = async () => {
if (!selectedRoleId) {
message.warning("请先选择角色");
return;
}
setSaving(true);
try {
await saveRolePermissions(selectedRoleId, checkedPermIds);
message.success("角色权限绑定已保存");
} catch (e) {
message.error("保存失败,请确认接口已实现");
} finally {
setSaving(false);
}
};
return (
<div className="page-shell">
<div className="page-header">
<div>
<Title level={4} className="page-title"></Title>
<Text type="secondary" className="page-subtitle"></Text>
</div>
<Button type="primary" onClick={handleSave} loading={saving} disabled={!selectedRoleId}>
</Button>
</div>
<Row gutter={[24, 24]}>
<Col xs={24} lg={10}>
<Card title="选择角色" bordered={false} className="surface-card">
<Table
rowKey="roleId"
size="middle"
loading={loadingRoles}
dataSource={roles}
rowSelection={{
type: "radio",
selectedRowKeys: selectedRoleId ? [selectedRoleId] : [],
onChange: (keys) => setSelectedRoleId(keys[0] as number)
}}
pagination={{ pageSize: 8 }}
columns={[
{ title: "ID", dataIndex: "roleId", width: 80 },
{ title: "角色编码", dataIndex: "roleCode" },
{ title: "角色名称", dataIndex: "roleName" },
{
title: "状态",
dataIndex: "status",
width: 90,
render: (v) => (v === 1 ? <Tag color="green"></Tag> : <Tag color="red"></Tag>)
}
]}
/>
</Card>
</Col>
<Col xs={24} lg={14}>
<Card
title="配置权限"
bordered={false}
className="surface-card"
extra={
<Text type="secondary">
{selectedRole ? `当前角色:${selectedRole.roleName}` : "未选择角色"}
</Text>
}
>
<Tree
checkable
selectable={false}
treeData={treeData}
checkedKeys={checkedPermIds}
onCheck={(keys) => setCheckedPermIds(keys as number[])}
defaultExpandAll
/>
{!permissions.length && !loadingPerms && (
<div style={{ marginTop: 12 }}>
<Text type="secondary"></Text>
</div>
)}
</Card>
</Col>
</Row>
</div>
);
}

View File

@ -0,0 +1,224 @@
.roles-page {
display: flex;
flex-direction: column;
gap: 24px;
}
.roles-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.roles-title {
margin-bottom: 4px !important;
}
.roles-subtitle {
font-size: 13px;
}
.roles-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 24px;
}
.roles-empty {
color: #94a3b8;
font-size: 14px;
}
.role-card {
background: #fff;
border: 1px solid #eef0f5;
border-radius: 16px;
padding: 20px;
box-shadow: 0 10px 20px rgba(15, 23, 42, 0.04);
display: flex;
flex-direction: column;
min-height: 230px;
}
.role-card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.role-icon {
width: 40px;
height: 40px;
border-radius: 12px;
background: #eef4ff;
color: #3b82f6;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
}
.role-edit-btn {
color: #94a3b8;
background: #f1f5f9;
border-radius: 10px;
width: 32px;
height: 32px;
}
.role-edit-btn:hover {
color: #2563eb !important;
}
.role-main {
margin-top: 16px;
}
.role-name {
font-size: 16px;
font-weight: 600;
color: #0f172a;
}
.role-id {
margin-top: 4px;
font-size: 12px;
color: #94a3b8;
}
.role-permission-summary {
margin-top: 16px;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 13px;
color: #475569;
}
.role-permission-badge {
background: #e8f1ff;
color: #2563eb;
border-radius: 999px;
padding: 2px 8px;
font-size: 12px;
}
.role-permission-tags {
margin-top: 10px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.role-permission-tag {
border: none;
background: #f1f5ff;
color: #2563eb;
border-radius: 999px;
font-size: 12px;
padding: 2px 10px;
}
.role-footer {
margin-top: auto;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
color: #94a3b8;
}
.role-drawer-title {
display: flex;
align-items: center;
gap: 12px;
}
.role-drawer-icon {
width: 36px;
height: 36px;
border-radius: 12px;
background: #e8f1ff;
color: #2563eb;
display: flex;
align-items: center;
justify-content: center;
}
.role-drawer-heading {
font-size: 16px;
font-weight: 600;
color: #0f172a;
}
.role-form .ant-form-item {
margin-bottom: 16px;
}
.role-permission-section {
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 12px;
}
.role-permission-group-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
color: #0f172a;
margin-bottom: 8px;
}
.role-permission-group-icon {
width: 20px;
height: 20px;
border-radius: 6px;
background: #e8f1ff;
color: #2563eb;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
}
.role-permission-tree {
padding: 12px;
border: 1px solid #eef0f5;
border-radius: 12px;
background: #fbfcff;
max-height: 520px;
overflow: auto;
}
.role-permission-node {
display: inline-flex;
align-items: center;
gap: 8px;
}
.role-drawer-footer {
display: flex;
align-items: center;
justify-content: space-between;
}
.role-drawer-cancel {
color: #64748b;
}
.role-drawer-submit {
background: #0f172a;
border-color: #0f172a;
border-radius: 10px;
height: 40px;
padding: 0 20px;
}
.role-drawer-submit:hover {
background: #1f2937 !important;
border-color: #1f2937 !important;
}

View File

@ -21,6 +21,7 @@ const DEFAULT_STATUS = 1;
type PermissionNode = SysPermission & { key: number; children?: PermissionNode[] };
const buildPermissionTree = (list: SysPermission[]): PermissionNode[] => {
if (!list || list.length === 0) return [];
const active = list.filter((p) => p.status !== 0);
const map = new Map<number, PermissionNode>();
const roots: PermissionNode[] = [];
@ -30,8 +31,15 @@ const buildPermissionTree = (list: SysPermission[]): PermissionNode[] => {
});
map.forEach((node) => {
if (node.parentId && map.has(node.parentId)) {
map.get(node.parentId)!.children!.push(node);
if (node.parentId && node.parentId !== 0) {
const parent = map.get(node.parentId);
if (parent) {
parent.children!.push(node);
} else {
// If parent is missing, it's an orphan.
// We don't push it to roots to avoid "submenu becomes root" issue.
console.warn(`Orphan node detected: ${node.name} (ID: ${node.permId}, ParentID: ${node.parentId})`);
}
} else {
roots.push(node);
}
@ -68,6 +76,7 @@ export default function Roles() {
const [drawerOpen, setDrawerOpen] = useState(false);
const [editing, setEditing] = useState<SysRole | null>(null);
const [selectedPermIds, setSelectedPermIds] = useState<number[]>([]);
const [halfCheckedIds, setHalfCheckedIds] = useState<number[]>([]);
const [form] = Form.useForm();
const { can } = usePermission();
@ -127,13 +136,23 @@ export default function Roles() {
const openCreate = () => {
setEditing(null);
setSelectedPermIds([]);
setHalfCheckedIds([]);
form.resetFields();
setDrawerOpen(true);
};
const openEdit = (record: SysRole) => {
setEditing(record);
setSelectedPermIds(rolePermMap[record.roleId] || []);
const roleIds = rolePermMap[record.roleId] || [];
// Filter out parent IDs. AntD Tree will re-calculate the checked/half-checked
// status of parents based on the leaf nodes provided to checkedKeys.
const leafIds = roleIds.filter(id => {
return !permissions.some(p => p.parentId === id);
});
setSelectedPermIds(leafIds);
setHalfCheckedIds([]);
form.setFieldsValue({
roleName: record.roleName,
remark: record.remark
@ -141,12 +160,6 @@ export default function Roles() {
setDrawerOpen(true);
};
useEffect(() => {
if (editing) {
setSelectedPermIds(rolePermMap[editing.roleId] || []);
}
}, [editing, rolePermMap]);
const handleClose = () => {
setDrawerOpen(false);
};
@ -175,7 +188,8 @@ export default function Roles() {
roleId = roles.find((r) => r.roleCode === payload.roleCode)?.roleId;
}
if (roleId) {
await saveRolePermissions(roleId, selectedPermIds);
const allPermIds = Array.from(new Set([...selectedPermIds, ...halfCheckedIds]));
await saveRolePermissions(roleId, allPermIds);
}
await loadRolePermissions(roles);
setDrawerOpen(false);
@ -333,10 +347,12 @@ export default function Roles() {
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)));
onCheck={(keys, info) => {
const checked = Array.isArray(keys) ? keys : keys.checked;
const halfChecked = info.halfCheckedKeys || [];
setSelectedPermIds(checked.map(k => Number(k)));
setHalfCheckedIds(halfChecked.map(k => Number(k)));
}}
defaultExpandAll
/>

View File

@ -0,0 +1,163 @@
import { Button, Card, Checkbox, Col, message, Row, Space, Table, Tag, Typography } from "antd";
import { useEffect, useMemo, useState } from "react";
import { listRoles, listUserRoles, listUsers, saveUserRoles } from "../api";
import type { SysRole, SysUser } from "../types";
const { Title, Text } = Typography;
export default function UserRoleBinding() {
const [users, setUsers] = useState<SysUser[]>([]);
const [roles, setRoles] = useState<SysRole[]>([]);
const [loadingUsers, setLoadingUsers] = useState(false);
const [loadingRoles, setLoadingRoles] = useState(false);
const [saving, setSaving] = useState(false);
const [selectedUserId, setSelectedUserId] = useState<number | null>(null);
const [checkedRoleIds, setCheckedRoleIds] = useState<number[]>([]);
const selectedUser = useMemo(
() => users.find((u) => u.userId === selectedUserId) || null,
[users, selectedUserId]
);
const loadUsers = async () => {
setLoadingUsers(true);
try {
const list = await listUsers();
setUsers(list || []);
} finally {
setLoadingUsers(false);
}
};
const loadRoles = async () => {
setLoadingRoles(true);
try {
const list = await listRoles();
setRoles(list || []);
} finally {
setLoadingRoles(false);
}
};
const loadUserRoles = async (userId: number) => {
try {
const list = await listUserRoles(userId);
setCheckedRoleIds(list || []);
} catch (e) {
setCheckedRoleIds([]);
message.error("加载用户角色失败,请确认接口已实现");
}
};
useEffect(() => {
loadUsers();
loadRoles();
}, []);
useEffect(() => {
if (selectedUserId) {
loadUserRoles(selectedUserId);
} else {
setCheckedRoleIds([]);
}
}, [selectedUserId]);
const handleSave = async () => {
if (!selectedUserId) {
message.warning("请先选择用户");
return;
}
setSaving(true);
try {
await saveUserRoles(selectedUserId, checkedRoleIds);
message.success("用户角色绑定已保存");
} catch (e) {
message.error("保存失败,请确认接口已实现");
} finally {
setSaving(false);
}
};
return (
<div className="page-shell">
<div className="page-header">
<div>
<Title level={4} className="page-title"></Title>
<Text type="secondary" className="page-subtitle"></Text>
</div>
<Button type="primary" onClick={handleSave} loading={saving} disabled={!selectedUserId}>
</Button>
</div>
<Row gutter={[24, 24]}>
<Col xs={24} lg={12}>
<Card title="选择用户" bordered={false} className="surface-card">
<Table
rowKey="userId"
size="middle"
loading={loadingUsers}
dataSource={users}
rowSelection={{
type: "radio",
selectedRowKeys: selectedUserId ? [selectedUserId] : [],
onChange: (keys) => setSelectedUserId(keys[0] as number)
}}
pagination={{ pageSize: 8 }}
columns={[
{ title: "ID", dataIndex: "userId", width: 80 },
{ title: "用户名", dataIndex: "username" },
{ title: "显示名", dataIndex: "displayName" },
{
title: "状态",
dataIndex: "status",
width: 90,
render: (v) => (v === 1 ? <Tag color="green"></Tag> : <Tag color="red"></Tag>)
}
]}
/>
</Card>
</Col>
<Col xs={24} lg={12}>
<Card
title="选择角色"
bordered={false}
className="surface-card"
extra={
<Text type="secondary">
{selectedUser ? `当前用户:${selectedUser.displayName || selectedUser.username}` : "未选择用户"}
</Text>
}
>
<Space direction="vertical" size={12} style={{ width: "100%" }}>
<Checkbox.Group
style={{ width: "100%" }}
value={checkedRoleIds}
onChange={(values) => setCheckedRoleIds(values as number[])}
disabled={loadingRoles}
>
<Row gutter={[12, 12]}>
{roles.map((role) => (
<Col key={role.roleId} span={12}>
<Checkbox value={role.roleId}>
<Space direction="vertical" size={0}>
<span>{role.roleName}</span>
<Text type="secondary" style={{ fontSize: 12 }}>
{role.roleCode}
</Text>
</Space>
</Checkbox>
</Col>
))}
</Row>
</Checkbox.Group>
{!roles.length && !loadingRoles && (
<Text type="secondary"></Text>
)}
</Space>
</Card>
</Col>
</Row>
</div>
);
}

View File

@ -0,0 +1,82 @@
.users-page {
padding: 24px;
}
.users-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 24px;
}
.users-title {
margin-bottom: 4px !important;
}
.users-table-card {
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.users-table-toolbar {
margin-bottom: 20px;
}
.users-search-input {
max-width: 400px;
}
.user-avatar-placeholder {
width: 40px;
height: 40px;
background-color: #f0f2f5;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #1890ff;
font-size: 20px;
}
.user-display-name {
font-weight: 600;
color: #262626;
}
.user-username {
font-size: 12px;
color: #8c8c8c;
}
.user-phone {
color: #8c8c8c;
font-size: 13px;
}
.user-drawer-title {
display: flex;
align-items: center;
font-size: 16px;
font-weight: 600;
}
.user-drawer-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 10px 0;
}
.user-form .ant-form-item {
margin-bottom: 20px;
}
/* Custom alignment for Row/Col in Form */
.user-form .ant-row {
margin-left: -8px !important;
margin-right: -8px !important;
}
.user-form .ant-col {
padding-left: 8px !important;
padding-right: 8px !important;
}

View File

@ -1,179 +1,344 @@
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";
import {
Button,
Drawer,
Form,
Input,
message,
Popconfirm,
Select,
Space,
Table,
Tag,
Typography,
Card,
Row,
Col
} from "antd";
import { useEffect, useState, useMemo } from "react";
import {
createUser,
deleteUser,
listRoles,
listUserRoles,
listUsers,
saveUserRoles,
updateUser
} from "../api";
import { usePermission } from "../hooks/usePermission";
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
SearchOutlined,
UserOutlined
} from "@ant-design/icons";
import type { SysRole, SysUser } from "../types";
import "./Users.css";
const { Title, Text } = Typography;
export default function Users() {
const { can } = usePermission();
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [data, setData] = useState<SysUser[]>([]);
const [query, setQuery] = useState({ username: "", displayName: "", phone: "" });
const [pagination, setPagination] = useState({ current: 1, pageSize: 10 });
const [open, setOpen] = useState(false);
const [roles, setRoles] = useState<SysRole[]>([]);
// Search state
const [searchText, setSearchText] = useState("");
// Drawer state
const [drawerOpen, setDrawerOpen] = useState(false);
const [editing, setEditing] = useState<SysUser | null>(null);
const [form] = Form.useForm();
const { can } = usePermission();
const load = async () => {
const loadData = async () => {
setLoading(true);
try {
const list = await listUsers();
setData(list || []);
const [usersList, rolesList] = await Promise.all([listUsers(), listRoles()]);
setData(usersList || []);
setRoles(rolesList || []);
} catch (e) {
message.error("加载数据失败");
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
loadData();
}, []);
const filtered = useMemo(() => {
return data.filter((u) => {
const hitUsername = query.username ? u.username?.includes(query.username) : true;
const hitDisplay = query.displayName ? u.displayName?.includes(query.displayName) : true;
const hitPhone = query.phone ? (u.phone || "").includes(query.phone) : true;
return hitUsername && hitDisplay && hitPhone;
});
}, [data, query]);
const pageData = useMemo(() => {
const start = (pagination.current - 1) * pagination.pageSize;
return filtered.slice(start, start + pagination.pageSize);
}, [filtered, pagination]);
const filteredData = useMemo(() => {
if (!searchText) return data;
const lower = searchText.toLowerCase();
return data.filter(
(u) =>
u.username.toLowerCase().includes(lower) ||
u.displayName.toLowerCase().includes(lower) ||
(u.email && u.email.toLowerCase().includes(lower)) ||
(u.phone && u.phone.includes(lower))
);
}, [data, searchText]);
const openCreate = () => {
setEditing(null);
form.resetFields();
setOpen(true);
form.setFieldsValue({ status: 1, roleIds: [] });
setDrawerOpen(true);
};
const openEdit = (record: SysUser) => {
const openEdit = async (record: SysUser) => {
setEditing(record);
form.setFieldsValue(record);
setOpen(true);
try {
const roleIds = await listUserRoles(record.userId);
form.setFieldsValue({
...record,
roleIds: roleIds || [],
password: "" // Clear password field
});
setDrawerOpen(true);
} catch (e) {
message.error("获取用户角色失败");
}
};
const handleDelete = async (id: number) => {
try {
await deleteUser(id);
message.success("用户已删除");
loadData();
} catch (e) {
message.error("删除失败");
}
};
const submit = async () => {
const values = await form.validateFields();
const payload: Partial<SysUser> = {
username: values.username,
displayName: values.displayName,
email: values.email,
phone: values.phone,
status: values.status
};
if (values.password) {
payload.passwordHash = values.password;
try {
const values = await form.validateFields();
setSaving(true);
const userPayload: Partial<SysUser> = {
username: values.username,
displayName: values.displayName,
email: values.email,
phone: values.phone,
status: values.status,
};
if (values.password) {
userPayload.passwordHash = values.password;
}
let userId = editing?.userId;
if (editing) {
await updateUser(editing.userId, userPayload);
} else {
// We need the new user ID to save roles.
// Our API returns boolean, so we might need to find the user after creation if backend doesn't return ID.
// However, looking at the list request after create is common.
await createUser(userPayload);
// Refresh list to find the newly created user (by username)
const updatedList = await listUsers();
const newUser = updatedList.find(u => u.username === userPayload.username);
userId = newUser?.userId;
}
if (userId) {
await saveUserRoles(userId, values.roleIds || []);
}
message.success(editing ? "用户信息已更新" : "用户已创建");
setDrawerOpen(false);
loadData();
} catch (e) {
if (e instanceof Error && e.message) {
message.error(e.message);
}
} finally {
setSaving(false);
}
if (editing) {
await updateUser(editing.userId, payload);
} else {
await createUser(payload);
}
setOpen(false);
load();
};
const remove = async (id: number) => {
await deleteUser(id);
load();
};
const columns = [
{
title: "用户信息",
key: "user",
render: (_: any, record: SysUser) => (
<Space>
<div className="user-avatar-placeholder">
<UserOutlined />
</div>
<div>
<div className="user-display-name">{record.displayName}</div>
<div className="user-username">@{record.username}</div>
</div>
</Space>
),
},
{
title: "联系方式",
key: "contact",
render: (_: any, record: SysUser) => (
<div>
<div>{record.email || "-"}</div>
<div className="user-phone">{record.phone || "-"}</div>
</div>
),
},
{
title: "状态",
dataIndex: "status",
width: 100,
render: (status: number) => (
<Tag color={status === 1 ? "green" : "red"}>
{status === 1 ? "正常" : "禁用"}
</Tag>
),
},
{
title: "创建时间",
dataIndex: "createdAt",
width: 180,
render: (text: string) => <Text type="secondary">{text?.replace('T', ' ').substring(0, 19)}</Text>
},
{
title: "操作",
key: "action",
width: 120,
fixed: "right" as const,
render: (_: any, record: SysUser) => (
<Space>
{can("sys_user:update") && (
<Button
type="text"
icon={<EditOutlined />}
onClick={() => openEdit(record)}
/>
)}
{can("sys_user:delete") && record.userId !== 1 && (
<Popconfirm title="确定删除该用户吗?" onConfirm={() => handleDelete(record.userId)}>
<Button type="text" danger icon={<DeleteOutlined />} />
</Popconfirm>
)}
</Space>
),
},
];
return (
<div>
<Space style={{ marginBottom: 16 }}>
<Input
placeholder="用户名"
value={query.username}
onChange={(e) => setQuery({ ...query, username: e.target.value })}
/>
<Input
placeholder="显示名"
value={query.displayName}
onChange={(e) => setQuery({ ...query, displayName: e.target.value })}
/>
<Input
placeholder="手机号"
value={query.phone}
onChange={(e) => setQuery({ ...query, phone: e.target.value })}
/>
<div className="users-page">
<div className="users-header">
<div>
<Title level={4} className="users-title"></Title>
<Text type="secondary"></Text>
</div>
{can("sys_user:create") && (
<Button type="primary" onClick={openCreate}></Button>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
</Button>
)}
</Space>
</div>
<Table
rowKey="userId"
loading={loading}
dataSource={pageData}
pagination={{
current: pagination.current,
pageSize: pagination.pageSize,
total: filtered.length,
onChange: (current, pageSize) => setPagination({ current, pageSize })
}}
columns={[
{ title: "ID", dataIndex: "userId" },
{ title: "用户名", dataIndex: "username" },
{ title: "显示名", dataIndex: "displayName" },
{ title: "邮箱", dataIndex: "email" },
{ title: "手机", dataIndex: "phone" },
{
title: "状态",
dataIndex: "status",
render: (v) => (v === 1 ? <Tag color="green"></Tag> : <Tag color="red"></Tag>)
},
{
title: "操作",
render: (_, record) => (
<Space>
{can("sys_user:update") && <Button onClick={() => openEdit(record)}></Button>}
{can("sys_user:delete") && (
<Popconfirm title="确认删除?" onConfirm={() => remove(record.userId)}>
<Button danger></Button>
</Popconfirm>
)}
</Space>
)
}
]}
/>
<Card className="users-table-card">
<div className="users-table-toolbar">
<Input
placeholder="搜索用户名、姓名、邮箱或手机号..."
prefix={<SearchOutlined />}
className="users-search-input"
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
allowClear
/>
</div>
<Table
rowKey="userId"
columns={columns}
dataSource={filteredData}
loading={loading}
pagination={{
showTotal: (total) => `${total} 条数据`,
pageSize: 10,
}}
/>
</Card>
<Drawer
title={editing ? "编辑用户" : "新增用户"}
open={open}
onClose={() => setOpen(false)}
width={420}
title={
<div className="user-drawer-title">
<UserOutlined className="mr-2" />
{editing ? "编辑用户信息" : "创建新用户"}
</div>
}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
width={480}
destroyOnClose
footer={
<Space style={{ width: "100%", justifyContent: "flex-end" }}>
<Button onClick={() => setOpen(false)}></Button>
<Button type="primary" onClick={submit}></Button>
</Space>
<div className="user-drawer-footer">
<Button onClick={() => setDrawerOpen(false)}></Button>
<Button type="primary" loading={saving} onClick={submit}>
</Button>
</div>
}
>
<Form form={form} layout="vertical">
<Form.Item label="用户名" name="username" rules={[{ required: true }]}>
<Input disabled={!!editing} />
<Form form={form} layout="vertical" className="user-form">
<Form.Item
label="用户名"
name="username"
rules={[{ required: true, message: "请输入用户名" }]}
>
<Input placeholder="登录凭证,创建后不可修改" disabled={!!editing} />
</Form.Item>
<Form.Item label="显示名" name="displayName" rules={[{ required: true }]}>
<Input />
<Form.Item
label="显示姓名"
name="displayName"
rules={[{ required: true, message: "请输入显示姓名" }]}
>
<Input placeholder="用户的真实姓名或昵称" />
</Form.Item>
<Form.Item label="邮箱" name="email">
<Input />
<Row gutter={16}>
<Col span={12}>
<Form.Item label="邮箱地址" name="email">
<Input placeholder="example@domain.com" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="手机号码" name="phone">
<Input placeholder="联系电话" />
</Form.Item>
</Col>
</Row>
<Form.Item
label="登录密码"
name="password"
rules={[{ required: !editing, message: "请输入初始密码" }]}
>
<Input.Password placeholder={editing ? "留空表示不修改密码" : "设置初始登录密码"} />
</Form.Item>
<Form.Item label="手机号" name="phone">
<Input />
<Form.Item
label="所属角色"
name="roleIds"
rules={[{ required: true, message: "请至少选择一个角色" }]}
>
<Select
mode="multiple"
placeholder="选择授予该用户的系统角色"
options={roles.map(r => ({ label: r.roleName, value: r.roleId }))}
/>
</Form.Item>
<Form.Item label="密码" name="password">
<Input.Password placeholder={editing ? "留空表示不修改" : "设置初始密码"} />
</Form.Item>
<Form.Item label="状态" name="status" initialValue={1}>
<Form.Item label="账号状态" name="status" initialValue={1}>
<Select
options={[
{ value: 1, label: "启用" },
{ value: 0, label: "禁用" }
{ label: "正常启用", value: 1 },
{ label: "禁用账号", value: 0 },
]}
/>
</Form.Item>
@ -181,4 +346,4 @@ export default function Users() {
</Drawer>
</div>
);
}
}

View File

@ -3,6 +3,7 @@ import Users from "../pages/Users";
import Roles from "../pages/Roles";
import Permissions from "../pages/Permissions";
import Devices from "../pages/Devices";
import Dictionaries from "../pages/Dictionaries";
import UserRoleBinding from "../pages/UserRoleBinding";
import RolePermissionBinding from "../pages/RolePermissionBinding";
@ -13,6 +14,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: "/dictionaries", label: "字典管理", element: <Dictionaries />, perm: "menu:dict" },
{ 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" }

View File

@ -49,6 +49,10 @@ export interface SysPermission extends BaseEntity {
meta?: string;
}
export interface PermissionNode extends SysPermission {
children: PermissionNode[];
}
export interface DeviceInfo extends BaseEntity {
deviceId: number;
userId: number;
@ -56,6 +60,22 @@ export interface DeviceInfo extends BaseEntity {
deviceName?: string;
}
export interface SysDictType extends BaseEntity {
dictTypeId: number;
typeCode: string;
typeName: string;
remark?: string;
}
export interface SysDictItem extends BaseEntity {
dictItemId: number;
typeCode: string;
itemLabel: string;
itemValue: string;
sortOrder: number;
remark?: string;
}
import type { ReactNode } from "react";
export interface MenuRoute {