feat(app): 实现动态菜单树和字典管理功能
- 集成后端Redis缓存配置和依赖 - 实现前端AppLayout组件动态加载菜单树结构 - 添加字典类型和字典项的完整CRUD功能 - 创建字典管理页面支持类型和项的增删改查 - 优化角色权限绑定界面的权限树展示 - 更新角色管理页面的权限分配逻辑 - 添加权限节点类型定义和菜单渲染逻辑 - 实现用户登出功能的布局调整和图标优化master
parent
78e77cf260
commit
ef262e7a43
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
package com.imeeting.common;
|
||||
|
||||
public final class SysParamKeys {
|
||||
private SysParamKeys() {}
|
||||
|
||||
public static final String CAPTCHA_ENABLED = "security.captcha.enabled";
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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> {
|
||||
}
|
||||
|
|
@ -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> {
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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> {
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
public SysParamServiceImpl(StringRedisTemplate redisTemplate) {
|
||||
this.redisTemplate = redisTemplate;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getParamValue(String key, String defaultValue) {
|
||||
if (key == null || key.isEmpty()) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
// 2. Redis 未命中,查数据库
|
||||
log.info("Cache miss for param key: {}, fetching from DB", key);
|
||||
SysParam param = getOne(new LambdaQueryWrapper<SysParam>().eq(SysParam::getParamKey, key));
|
||||
return param == null ? defaultValue : param.getParamValue();
|
||||
|
||||
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) {
|
||||
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;
|
||||
if (param != null && param.getParamKey() != null) {
|
||||
redisTemplate.opsForValue().set(RedisKeys.sysParamKey(param.getParamKey()),
|
||||
param.getParamValue() == null ? "" : param.getParamValue(), Duration.ofHours(24));
|
||||
}
|
||||
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;
|
||||
if (key != null) {
|
||||
redisTemplate.delete(RedisKeys.sysParamKey(key));
|
||||
}
|
||||
stringRedisTemplate.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;
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,8 @@ spring:
|
|||
port: 6379
|
||||
password: Unis@123
|
||||
database: 15
|
||||
cache:
|
||||
type: redis
|
||||
|
||||
mybatis-plus:
|
||||
configuration:
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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,19 +88,18 @@ 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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const payload: Partial<SysUser> = {
|
||||
setSaving(true);
|
||||
|
||||
const userPayload: Partial<SysUser> = {
|
||||
username: values.username,
|
||||
displayName: values.displayName,
|
||||
email: values.email,
|
||||
phone: values.phone,
|
||||
status: values.status
|
||||
status: values.status,
|
||||
};
|
||||
|
||||
if (values.password) {
|
||||
payload.passwordHash = values.password;
|
||||
userPayload.passwordHash = values.password;
|
||||
}
|
||||
|
||||
let userId = editing?.userId;
|
||||
if (editing) {
|
||||
await updateUser(editing.userId, payload);
|
||||
await updateUser(editing.userId, userPayload);
|
||||
} else {
|
||||
await createUser(payload);
|
||||
// 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);
|
||||
}
|
||||
setOpen(false);
|
||||
load();
|
||||
};
|
||||
|
||||
const remove = async (id: number) => {
|
||||
await deleteUser(id);
|
||||
load();
|
||||
};
|
||||
|
||||
return (
|
||||
const columns = [
|
||||
{
|
||||
title: "用户信息",
|
||||
key: "user",
|
||||
render: (_: any, record: SysUser) => (
|
||||
<Space>
|
||||
<div className="user-avatar-placeholder">
|
||||
<UserOutlined />
|
||||
</div>
|
||||
<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 })}
|
||||
/>
|
||||
{can("sys_user:create") && (
|
||||
<Button type="primary" onClick={openCreate}>新增</Button>
|
||||
)}
|
||||
<div className="user-display-name">{record.displayName}</div>
|
||||
<div className="user-username">@{record.username}</div>
|
||||
</div>
|
||||
</Space>
|
||||
|
||||
<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: "联系方式",
|
||||
key: "contact",
|
||||
render: (_: any, record: SysUser) => (
|
||||
<div>
|
||||
<div>{record.email || "-"}</div>
|
||||
<div className="user-phone">{record.phone || "-"}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
dataIndex: "status",
|
||||
render: (v) => (v === 1 ? <Tag color="green">启用</Tag> : <Tag color="red">禁用</Tag>)
|
||||
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: "操作",
|
||||
render: (_, record) => (
|
||||
key: "action",
|
||||
width: 120,
|
||||
fixed: "right" as const,
|
||||
render: (_: any, record: SysUser) => (
|
||||
<Space>
|
||||
{can("sys_user:update") && <Button onClick={() => openEdit(record)}>编辑</Button>}
|
||||
{can("sys_user:delete") && (
|
||||
<Popconfirm title="确认删除?" onConfirm={() => remove(record.userId)}>
|
||||
<Button danger>删除</Button>
|
||||
{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 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" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
新增用户
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<Form.Item label="手机号" name="phone">
|
||||
<Input />
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="手机号码" name="phone">
|
||||
<Input placeholder="联系电话" />
|
||||
</Form.Item>
|
||||
<Form.Item label="密码" name="password">
|
||||
<Input.Password placeholder={editing ? "留空表示不修改" : "设置初始密码"} />
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item
|
||||
label="登录密码"
|
||||
name="password"
|
||||
rules={[{ required: !editing, message: "请输入初始密码" }]}
|
||||
>
|
||||
<Input.Password placeholder={editing ? "留空表示不修改密码" : "设置初始登录密码"} />
|
||||
</Form.Item>
|
||||
<Form.Item label="状态" name="status" initialValue={1}>
|
||||
|
||||
<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="status" initialValue={1}>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 1, label: "启用" },
|
||||
{ value: 0, label: "禁用" }
|
||||
{ label: "正常启用", value: 1 },
|
||||
{ label: "禁用账号", value: 0 },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue