feat(app): 实现动态菜单树和字典管理功能
- 集成后端Redis缓存配置和依赖 - 实现前端AppLayout组件动态加载菜单树结构 - 添加字典类型和字典项的完整CRUD功能 - 创建字典管理页面支持类型和项的增删改查 - 优化角色权限绑定界面的权限树展示 - 更新角色管理页面的权限分配逻辑 - 添加权限节点类型定义和菜单渲染逻辑 - 实现用户登出功能的布局调整和图标优化master
parent
78e77cf260
commit
ef262e7a43
|
|
@ -39,6 +39,10 @@
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-data-redis</artifactId>
|
<artifactId>spring-boot-starter-data-redis</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-cache</artifactId>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.baomidou</groupId>
|
<groupId>com.baomidou</groupId>
|
||||||
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
|
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,11 @@ public final class RedisKeys {
|
||||||
return "sys:param:" + paramKey;
|
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_VALUE = "value";
|
||||||
public static final String SYS_PARAM_FIELD_TYPE = "type";
|
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.entity.SysParam;
|
||||||
import com.imeeting.mapper.SysParamMapper;
|
import com.imeeting.mapper.SysParamMapper;
|
||||||
import com.imeeting.service.SysParamService;
|
import com.imeeting.service.SysParamService;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
import org.springframework.stereotype.Service;
|
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.List;
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
public class SysParamServiceImpl extends ServiceImpl<SysParamMapper, SysParam> implements SysParamService {
|
public class SysParamServiceImpl extends ServiceImpl<SysParamMapper, SysParam> implements SysParamService {
|
||||||
private final StringRedisTemplate stringRedisTemplate;
|
private final StringRedisTemplate redisTemplate;
|
||||||
|
|
||||||
public SysParamServiceImpl(StringRedisTemplate stringRedisTemplate) {
|
public SysParamServiceImpl(StringRedisTemplate redisTemplate) {
|
||||||
this.stringRedisTemplate = stringRedisTemplate;
|
this.redisTemplate = redisTemplate;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String getParamValue(String key, String defaultValue) {
|
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));
|
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
|
@Override
|
||||||
public String getCachedParamValue(String key, String defaultValue) {
|
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);
|
return getParamValue(key, defaultValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void syncParamToCache(SysParam param) {
|
public void syncParamToCache(SysParam param) {
|
||||||
if (param == null || param.getParamKey() == null || param.getParamKey().isEmpty()) {
|
if (param != null && param.getParamKey() != null) {
|
||||||
return;
|
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
|
@Override
|
||||||
public void deleteParamCache(String key) {
|
public void deleteParamCache(String key) {
|
||||||
if (key == null || key.isEmpty()) {
|
if (key != null) {
|
||||||
return;
|
redisTemplate.delete(RedisKeys.sysParamKey(key));
|
||||||
}
|
}
|
||||||
stringRedisTemplate.delete(RedisKeys.sysParamKey(key));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void syncAllToCache() {
|
public void syncAllToCache() {
|
||||||
|
log.info("Syncing all system parameters to Redis");
|
||||||
List<SysParam> params = list();
|
List<SysParam> params = list();
|
||||||
for (SysParam param : params) {
|
for (SysParam param : params) {
|
||||||
syncParamToCache(param);
|
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
|
port: 6379
|
||||||
password: Unis@123
|
password: Unis@123
|
||||||
database: 15
|
database: 15
|
||||||
|
cache:
|
||||||
|
type: redis
|
||||||
|
|
||||||
mybatis-plus:
|
mybatis-plus:
|
||||||
configuration:
|
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[];
|
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() {
|
export async function getCurrentUser() {
|
||||||
const resp = await http.get("/api/users/me");
|
const resp = await http.get("/api/users/me");
|
||||||
return resp.data.data as UserProfile;
|
return resp.data.data as UserProfile;
|
||||||
|
|
@ -117,3 +122,5 @@ export async function saveRolePermissions(roleId: number, permIds: number[]) {
|
||||||
return resp.data.data as boolean;
|
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 { 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() {
|
export default function AppLayout() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
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 = () => {
|
const handleLogout = () => {
|
||||||
localStorage.removeItem("accessToken");
|
localStorage.removeItem("accessToken");
|
||||||
|
|
@ -11,9 +42,44 @@ export default function AppLayout() {
|
||||||
navigate("/login");
|
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 (
|
return (
|
||||||
<Layout style={{ minHeight: "100vh" }}>
|
<Layout style={{ minHeight: "100vh" }}>
|
||||||
<Layout.Sider collapsible>
|
<Layout.Sider collapsible width={220}>
|
||||||
<div style={{ padding: '16px', display: 'flex', alignItems: 'center', gap: '8px', overflow: 'hidden' }}>
|
<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 }} />
|
<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>
|
<span style={{ color: "#fff", fontWeight: 700, fontSize: '18px', whiteSpace: 'nowrap' }}>MeetingAI</span>
|
||||||
|
|
@ -22,19 +88,18 @@ export default function AppLayout() {
|
||||||
theme="dark"
|
theme="dark"
|
||||||
mode="inline"
|
mode="inline"
|
||||||
selectedKeys={[location.pathname]}
|
selectedKeys={[location.pathname]}
|
||||||
items={[
|
items={menuItems}
|
||||||
{ key: "/", label: <Link to="/">总览</Link> },
|
loading={loading}
|
||||||
{ key: "/users", label: <Link to="/users">用户管理</Link> },
|
style={{ height: 'calc(100% - 64px)', display: 'flex', flexDirection: 'column' }}
|
||||||
{ 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> }
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
</Layout.Sider>
|
</Layout.Sider>
|
||||||
<Layout>
|
<Layout>
|
||||||
<Layout.Header style={{ background: "#fff" }} />
|
<Layout.Header style={{ background: "#fff", padding: '0 24px', display: 'flex', alignItems: 'center', justifyContent: 'flex-end' }}>
|
||||||
<Layout.Content style={{ padding: 24 }}>
|
<Space>
|
||||||
|
<Button type="text" icon={<LogoutOutlined />} onClick={handleLogout}>退出</Button>
|
||||||
|
</Space>
|
||||||
|
</Layout.Header>
|
||||||
|
<Layout.Content style={{ padding: 24, background: '#f0f2f5', overflowY: 'auto' }}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</Layout.Content>
|
</Layout.Content>
|
||||||
</Layout>
|
</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[] };
|
type PermissionNode = SysPermission & { key: number; children?: PermissionNode[] };
|
||||||
|
|
||||||
const buildPermissionTree = (list: SysPermission[]): PermissionNode[] => {
|
const buildPermissionTree = (list: SysPermission[]): PermissionNode[] => {
|
||||||
|
if (!list || list.length === 0) return [];
|
||||||
const active = list.filter((p) => p.status !== 0);
|
const active = list.filter((p) => p.status !== 0);
|
||||||
const map = new Map<number, PermissionNode>();
|
const map = new Map<number, PermissionNode>();
|
||||||
const roots: PermissionNode[] = [];
|
const roots: PermissionNode[] = [];
|
||||||
|
|
@ -30,8 +31,15 @@ const buildPermissionTree = (list: SysPermission[]): PermissionNode[] => {
|
||||||
});
|
});
|
||||||
|
|
||||||
map.forEach((node) => {
|
map.forEach((node) => {
|
||||||
if (node.parentId && map.has(node.parentId)) {
|
if (node.parentId && node.parentId !== 0) {
|
||||||
map.get(node.parentId)!.children!.push(node);
|
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 {
|
} else {
|
||||||
roots.push(node);
|
roots.push(node);
|
||||||
}
|
}
|
||||||
|
|
@ -68,6 +76,7 @@ export default function Roles() {
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState<SysRole | null>(null);
|
const [editing, setEditing] = useState<SysRole | null>(null);
|
||||||
const [selectedPermIds, setSelectedPermIds] = useState<number[]>([]);
|
const [selectedPermIds, setSelectedPermIds] = useState<number[]>([]);
|
||||||
|
const [halfCheckedIds, setHalfCheckedIds] = useState<number[]>([]);
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const { can } = usePermission();
|
const { can } = usePermission();
|
||||||
|
|
||||||
|
|
@ -127,13 +136,23 @@ export default function Roles() {
|
||||||
const openCreate = () => {
|
const openCreate = () => {
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
setSelectedPermIds([]);
|
setSelectedPermIds([]);
|
||||||
|
setHalfCheckedIds([]);
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
setDrawerOpen(true);
|
setDrawerOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEdit = (record: SysRole) => {
|
const openEdit = (record: SysRole) => {
|
||||||
setEditing(record);
|
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({
|
form.setFieldsValue({
|
||||||
roleName: record.roleName,
|
roleName: record.roleName,
|
||||||
remark: record.remark
|
remark: record.remark
|
||||||
|
|
@ -141,12 +160,6 @@ export default function Roles() {
|
||||||
setDrawerOpen(true);
|
setDrawerOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (editing) {
|
|
||||||
setSelectedPermIds(rolePermMap[editing.roleId] || []);
|
|
||||||
}
|
|
||||||
}, [editing, rolePermMap]);
|
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
setDrawerOpen(false);
|
setDrawerOpen(false);
|
||||||
};
|
};
|
||||||
|
|
@ -175,7 +188,8 @@ export default function Roles() {
|
||||||
roleId = roles.find((r) => r.roleCode === payload.roleCode)?.roleId;
|
roleId = roles.find((r) => r.roleCode === payload.roleCode)?.roleId;
|
||||||
}
|
}
|
||||||
if (roleId) {
|
if (roleId) {
|
||||||
await saveRolePermissions(roleId, selectedPermIds);
|
const allPermIds = Array.from(new Set([...selectedPermIds, ...halfCheckedIds]));
|
||||||
|
await saveRolePermissions(roleId, allPermIds);
|
||||||
}
|
}
|
||||||
await loadRolePermissions(roles);
|
await loadRolePermissions(roles);
|
||||||
setDrawerOpen(false);
|
setDrawerOpen(false);
|
||||||
|
|
@ -333,10 +347,12 @@ export default function Roles() {
|
||||||
checkStrictly={false}
|
checkStrictly={false}
|
||||||
treeData={permissionTreeData}
|
treeData={permissionTreeData}
|
||||||
checkedKeys={selectedPermIds}
|
checkedKeys={selectedPermIds}
|
||||||
onCheck={(keys) => {
|
onCheck={(keys, info) => {
|
||||||
const raw = Array.isArray(keys) ? keys : keys.checked;
|
const checked = Array.isArray(keys) ? keys : keys.checked;
|
||||||
const normalized = (raw as Array<string | number>).map((k) => Number(k));
|
const halfChecked = info.halfCheckedKeys || [];
|
||||||
setSelectedPermIds(normalized.filter((id) => !Number.isNaN(id)));
|
|
||||||
|
setSelectedPermIds(checked.map(k => Number(k)));
|
||||||
|
setHalfCheckedIds(halfChecked.map(k => Number(k)));
|
||||||
}}
|
}}
|
||||||
defaultExpandAll
|
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 {
|
||||||
import { useMemo, useState, useEffect } from "react";
|
Button,
|
||||||
import { createUser, deleteUser, listUsers, updateUser } from "../api";
|
Drawer,
|
||||||
import type { SysUser } from "../types";
|
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 { 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() {
|
export default function Users() {
|
||||||
|
const { can } = usePermission();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
const [data, setData] = useState<SysUser[]>([]);
|
const [data, setData] = useState<SysUser[]>([]);
|
||||||
const [query, setQuery] = useState({ username: "", displayName: "", phone: "" });
|
const [roles, setRoles] = useState<SysRole[]>([]);
|
||||||
const [pagination, setPagination] = useState({ current: 1, pageSize: 10 });
|
|
||||||
const [open, setOpen] = useState(false);
|
// Search state
|
||||||
|
const [searchText, setSearchText] = useState("");
|
||||||
|
|
||||||
|
// Drawer state
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState<SysUser | null>(null);
|
const [editing, setEditing] = useState<SysUser | null>(null);
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const { can } = usePermission();
|
|
||||||
|
|
||||||
const load = async () => {
|
const loadData = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const list = await listUsers();
|
const [usersList, rolesList] = await Promise.all([listUsers(), listRoles()]);
|
||||||
setData(list || []);
|
setData(usersList || []);
|
||||||
|
setRoles(rolesList || []);
|
||||||
|
} catch (e) {
|
||||||
|
message.error("加载数据失败");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
loadData();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filteredData = useMemo(() => {
|
||||||
return data.filter((u) => {
|
if (!searchText) return data;
|
||||||
const hitUsername = query.username ? u.username?.includes(query.username) : true;
|
const lower = searchText.toLowerCase();
|
||||||
const hitDisplay = query.displayName ? u.displayName?.includes(query.displayName) : true;
|
return data.filter(
|
||||||
const hitPhone = query.phone ? (u.phone || "").includes(query.phone) : true;
|
(u) =>
|
||||||
return hitUsername && hitDisplay && hitPhone;
|
u.username.toLowerCase().includes(lower) ||
|
||||||
});
|
u.displayName.toLowerCase().includes(lower) ||
|
||||||
}, [data, query]);
|
(u.email && u.email.toLowerCase().includes(lower)) ||
|
||||||
|
(u.phone && u.phone.includes(lower))
|
||||||
const pageData = useMemo(() => {
|
);
|
||||||
const start = (pagination.current - 1) * pagination.pageSize;
|
}, [data, searchText]);
|
||||||
return filtered.slice(start, start + pagination.pageSize);
|
|
||||||
}, [filtered, pagination]);
|
|
||||||
|
|
||||||
const openCreate = () => {
|
const openCreate = () => {
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
setOpen(true);
|
form.setFieldsValue({ status: 1, roleIds: [] });
|
||||||
|
setDrawerOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEdit = (record: SysUser) => {
|
const openEdit = async (record: SysUser) => {
|
||||||
setEditing(record);
|
setEditing(record);
|
||||||
form.setFieldsValue(record);
|
try {
|
||||||
setOpen(true);
|
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 submit = async () => {
|
||||||
|
try {
|
||||||
const values = await form.validateFields();
|
const values = await form.validateFields();
|
||||||
const payload: Partial<SysUser> = {
|
setSaving(true);
|
||||||
|
|
||||||
|
const userPayload: Partial<SysUser> = {
|
||||||
username: values.username,
|
username: values.username,
|
||||||
displayName: values.displayName,
|
displayName: values.displayName,
|
||||||
email: values.email,
|
email: values.email,
|
||||||
phone: values.phone,
|
phone: values.phone,
|
||||||
status: values.status
|
status: values.status,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (values.password) {
|
if (values.password) {
|
||||||
payload.passwordHash = values.password;
|
userPayload.passwordHash = values.password;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let userId = editing?.userId;
|
||||||
if (editing) {
|
if (editing) {
|
||||||
await updateUser(editing.userId, payload);
|
await updateUser(editing.userId, userPayload);
|
||||||
} else {
|
} 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) => {
|
const columns = [
|
||||||
await deleteUser(id);
|
{
|
||||||
load();
|
title: "用户信息",
|
||||||
};
|
key: "user",
|
||||||
|
render: (_: any, record: SysUser) => (
|
||||||
return (
|
<Space>
|
||||||
|
<div className="user-avatar-placeholder">
|
||||||
|
<UserOutlined />
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Space style={{ marginBottom: 16 }}>
|
<div className="user-display-name">{record.displayName}</div>
|
||||||
<Input
|
<div className="user-username">@{record.username}</div>
|
||||||
placeholder="用户名"
|
</div>
|
||||||
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>
|
|
||||||
)}
|
|
||||||
</Space>
|
</Space>
|
||||||
|
),
|
||||||
<Table
|
},
|
||||||
rowKey="userId"
|
{
|
||||||
loading={loading}
|
title: "联系方式",
|
||||||
dataSource={pageData}
|
key: "contact",
|
||||||
pagination={{
|
render: (_: any, record: SysUser) => (
|
||||||
current: pagination.current,
|
<div>
|
||||||
pageSize: pagination.pageSize,
|
<div>{record.email || "-"}</div>
|
||||||
total: filtered.length,
|
<div className="user-phone">{record.phone || "-"}</div>
|
||||||
onChange: (current, pageSize) => setPagination({ current, pageSize })
|
</div>
|
||||||
}}
|
),
|
||||||
columns={[
|
},
|
||||||
{ title: "ID", dataIndex: "userId" },
|
|
||||||
{ title: "用户名", dataIndex: "username" },
|
|
||||||
{ title: "显示名", dataIndex: "displayName" },
|
|
||||||
{ title: "邮箱", dataIndex: "email" },
|
|
||||||
{ title: "手机", dataIndex: "phone" },
|
|
||||||
{
|
{
|
||||||
title: "状态",
|
title: "状态",
|
||||||
dataIndex: "status",
|
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: "操作",
|
title: "操作",
|
||||||
render: (_, record) => (
|
key: "action",
|
||||||
|
width: 120,
|
||||||
|
fixed: "right" as const,
|
||||||
|
render: (_: any, record: SysUser) => (
|
||||||
<Space>
|
<Space>
|
||||||
{can("sys_user:update") && <Button onClick={() => openEdit(record)}>编辑</Button>}
|
{can("sys_user:update") && (
|
||||||
{can("sys_user:delete") && (
|
<Button
|
||||||
<Popconfirm title="确认删除?" onConfirm={() => remove(record.userId)}>
|
type="text"
|
||||||
<Button danger>删除</Button>
|
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>
|
</Popconfirm>
|
||||||
)}
|
)}
|
||||||
</Space>
|
</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
|
<Drawer
|
||||||
title={editing ? "编辑用户" : "新增用户"}
|
title={
|
||||||
open={open}
|
<div className="user-drawer-title">
|
||||||
onClose={() => setOpen(false)}
|
<UserOutlined className="mr-2" />
|
||||||
width={420}
|
{editing ? "编辑用户信息" : "创建新用户"}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
open={drawerOpen}
|
||||||
|
onClose={() => setDrawerOpen(false)}
|
||||||
|
width={480}
|
||||||
destroyOnClose
|
destroyOnClose
|
||||||
footer={
|
footer={
|
||||||
<Space style={{ width: "100%", justifyContent: "flex-end" }}>
|
<div className="user-drawer-footer">
|
||||||
<Button onClick={() => setOpen(false)}>取消</Button>
|
<Button onClick={() => setDrawerOpen(false)}>取消</Button>
|
||||||
<Button type="primary" onClick={submit}>确认</Button>
|
<Button type="primary" loading={saving} onClick={submit}>
|
||||||
</Space>
|
保存更改
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Form form={form} layout="vertical">
|
<Form form={form} layout="vertical" className="user-form">
|
||||||
<Form.Item label="用户名" name="username" rules={[{ required: true }]}>
|
<Form.Item
|
||||||
<Input disabled={!!editing} />
|
label="用户名"
|
||||||
|
name="username"
|
||||||
|
rules={[{ required: true, message: "请输入用户名" }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="登录凭证,创建后不可修改" disabled={!!editing} />
|
||||||
</Form.Item>
|
</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>
|
||||||
<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>
|
||||||
<Form.Item label="手机号" name="phone">
|
</Col>
|
||||||
<Input />
|
<Col span={12}>
|
||||||
|
<Form.Item label="手机号码" name="phone">
|
||||||
|
<Input placeholder="联系电话" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label="密码" name="password">
|
</Col>
|
||||||
<Input.Password placeholder={editing ? "留空表示不修改" : "设置初始密码"} />
|
</Row>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label="登录密码"
|
||||||
|
name="password"
|
||||||
|
rules={[{ required: !editing, message: "请输入初始密码" }]}
|
||||||
|
>
|
||||||
|
<Input.Password placeholder={editing ? "留空表示不修改密码" : "设置初始登录密码"} />
|
||||||
</Form.Item>
|
</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
|
<Select
|
||||||
options={[
|
options={[
|
||||||
{ value: 1, label: "启用" },
|
{ label: "正常启用", value: 1 },
|
||||||
{ value: 0, label: "禁用" }
|
{ label: "禁用账号", value: 0 },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import Users from "../pages/Users";
|
||||||
import Roles from "../pages/Roles";
|
import Roles from "../pages/Roles";
|
||||||
import Permissions from "../pages/Permissions";
|
import Permissions from "../pages/Permissions";
|
||||||
import Devices from "../pages/Devices";
|
import Devices from "../pages/Devices";
|
||||||
|
import Dictionaries from "../pages/Dictionaries";
|
||||||
import UserRoleBinding from "../pages/UserRoleBinding";
|
import UserRoleBinding from "../pages/UserRoleBinding";
|
||||||
import RolePermissionBinding from "../pages/RolePermissionBinding";
|
import RolePermissionBinding from "../pages/RolePermissionBinding";
|
||||||
|
|
||||||
|
|
@ -13,6 +14,7 @@ export const menuRoutes: MenuRoute[] = [
|
||||||
{ path: "/users", label: "用户管理", element: <Users />, perm: "menu:users" },
|
{ path: "/users", label: "用户管理", element: <Users />, perm: "menu:users" },
|
||||||
{ path: "/roles", label: "角色管理", element: <Roles />, perm: "menu:roles" },
|
{ path: "/roles", label: "角色管理", element: <Roles />, perm: "menu:roles" },
|
||||||
{ path: "/permissions", label: "权限管理", element: <Permissions />, perm: "menu:permissions" },
|
{ path: "/permissions", label: "权限管理", element: <Permissions />, perm: "menu:permissions" },
|
||||||
|
{ path: "/dictionaries", label: "字典管理", element: <Dictionaries />, perm: "menu:dict" },
|
||||||
{ path: "/devices", label: "设备管理", element: <Devices />, perm: "menu:devices" },
|
{ path: "/devices", label: "设备管理", element: <Devices />, perm: "menu:devices" },
|
||||||
{ path: "/user-roles", label: "用户角色绑定", element: <UserRoleBinding />, perm: "menu:user-roles" },
|
{ path: "/user-roles", label: "用户角色绑定", element: <UserRoleBinding />, perm: "menu:user-roles" },
|
||||||
{ path: "/role-permissions", label: "角色权限绑定", element: <RolePermissionBinding />, perm: "menu:role-permissions" }
|
{ path: "/role-permissions", label: "角色权限绑定", element: <RolePermissionBinding />, perm: "menu:role-permissions" }
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,10 @@ export interface SysPermission extends BaseEntity {
|
||||||
meta?: string;
|
meta?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PermissionNode extends SysPermission {
|
||||||
|
children: PermissionNode[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface DeviceInfo extends BaseEntity {
|
export interface DeviceInfo extends BaseEntity {
|
||||||
deviceId: number;
|
deviceId: number;
|
||||||
userId: number;
|
userId: number;
|
||||||
|
|
@ -56,6 +60,22 @@ export interface DeviceInfo extends BaseEntity {
|
||||||
deviceName?: string;
|
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";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
export interface MenuRoute {
|
export interface MenuRoute {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue