diff --git a/backend/pom.xml b/backend/pom.xml index c499ad8..0fe2850 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -39,6 +39,10 @@ org.springframework.boot spring-boot-starter-data-redis + + org.springframework.boot + spring-boot-starter-cache + com.baomidou mybatis-plus-spring-boot3-starter diff --git a/backend/src/main/java/com/imeeting/common/RedisKeys.java b/backend/src/main/java/com/imeeting/common/RedisKeys.java index 6027ebb..a9b1614 100644 --- a/backend/src/main/java/com/imeeting/common/RedisKeys.java +++ b/backend/src/main/java/com/imeeting/common/RedisKeys.java @@ -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"; } diff --git a/backend/src/main/java/com/imeeting/common/SysParamKeys.java b/backend/src/main/java/com/imeeting/common/SysParamKeys.java new file mode 100644 index 0000000..3617864 --- /dev/null +++ b/backend/src/main/java/com/imeeting/common/SysParamKeys.java @@ -0,0 +1,7 @@ +package com.imeeting.common; + +public final class SysParamKeys { + private SysParamKeys() {} + + public static final String CAPTCHA_ENABLED = "security.captcha.enabled"; +} diff --git a/backend/src/main/java/com/imeeting/config/CacheConfig.java b/backend/src/main/java/com/imeeting/config/CacheConfig.java new file mode 100644 index 0000000..08cf9f3 --- /dev/null +++ b/backend/src/main/java/com/imeeting/config/CacheConfig.java @@ -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(); + } +} diff --git a/backend/src/main/java/com/imeeting/config/SysParamCacheInitializer.java b/backend/src/main/java/com/imeeting/config/SysParamCacheInitializer.java new file mode 100644 index 0000000..d73a664 --- /dev/null +++ b/backend/src/main/java/com/imeeting/config/SysParamCacheInitializer.java @@ -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(); + } +} diff --git a/backend/src/main/java/com/imeeting/controller/DictItemController.java b/backend/src/main/java/com/imeeting/controller/DictItemController.java new file mode 100644 index 0000000..8a45be3 --- /dev/null +++ b/backend/src/main/java/com/imeeting/controller/DictItemController.java @@ -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(@RequestParam(required = false) String typeCode) { + LambdaQueryWrapper 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 get(@PathVariable Long id) { + return ApiResponse.ok(sysDictItemService.getById(id)); + } + + @PostMapping + public ApiResponse create(@RequestBody SysDictItem dictItem) { + return ApiResponse.ok(sysDictItemService.save(dictItem)); + } + + @PutMapping("/{id}") + public ApiResponse update(@PathVariable Long id, @RequestBody SysDictItem dictItem) { + dictItem.setDictItemId(id); + return ApiResponse.ok(sysDictItemService.updateById(dictItem)); + } + + @DeleteMapping("/{id}") + public ApiResponse delete(@PathVariable Long id) { + return ApiResponse.ok(sysDictItemService.removeById(id)); + } + + @GetMapping("/type/{typeCode}") + public ApiResponse> getByType(@PathVariable String typeCode) { + return ApiResponse.ok(sysDictItemService.getItemsByTypeCode(typeCode)); + } +} diff --git a/backend/src/main/java/com/imeeting/controller/DictTypeController.java b/backend/src/main/java/com/imeeting/controller/DictTypeController.java new file mode 100644 index 0000000..a13d0ca --- /dev/null +++ b/backend/src/main/java/com/imeeting/controller/DictTypeController.java @@ -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() { + return ApiResponse.ok(sysDictTypeService.list()); + } + + @GetMapping("/{id}") + public ApiResponse get(@PathVariable Long id) { + return ApiResponse.ok(sysDictTypeService.getById(id)); + } + + @PostMapping + public ApiResponse create(@RequestBody SysDictType dictType) { + return ApiResponse.ok(sysDictTypeService.save(dictType)); + } + + @PutMapping("/{id}") + public ApiResponse update(@PathVariable Long id, @RequestBody SysDictType dictType) { + dictType.setDictTypeId(id); + return ApiResponse.ok(sysDictTypeService.updateById(dictType)); + } + + @DeleteMapping("/{id}") + public ApiResponse delete(@PathVariable Long id) { + return ApiResponse.ok(sysDictTypeService.removeById(id)); + } +} diff --git a/backend/src/main/java/com/imeeting/entity/SysDictItem.java b/backend/src/main/java/com/imeeting/entity/SysDictItem.java new file mode 100644 index 0000000..47da67b --- /dev/null +++ b/backend/src/main/java/com/imeeting/entity/SysDictItem.java @@ -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; +} diff --git a/backend/src/main/java/com/imeeting/entity/SysDictType.java b/backend/src/main/java/com/imeeting/entity/SysDictType.java new file mode 100644 index 0000000..9600d2e --- /dev/null +++ b/backend/src/main/java/com/imeeting/entity/SysDictType.java @@ -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; +} diff --git a/backend/src/main/java/com/imeeting/mapper/SysDictItemMapper.java b/backend/src/main/java/com/imeeting/mapper/SysDictItemMapper.java new file mode 100644 index 0000000..7cb3786 --- /dev/null +++ b/backend/src/main/java/com/imeeting/mapper/SysDictItemMapper.java @@ -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 { +} diff --git a/backend/src/main/java/com/imeeting/mapper/SysDictTypeMapper.java b/backend/src/main/java/com/imeeting/mapper/SysDictTypeMapper.java new file mode 100644 index 0000000..0dad502 --- /dev/null +++ b/backend/src/main/java/com/imeeting/mapper/SysDictTypeMapper.java @@ -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 { +} diff --git a/backend/src/main/java/com/imeeting/service/SysDictItemService.java b/backend/src/main/java/com/imeeting/service/SysDictItemService.java new file mode 100644 index 0000000..fe012ac --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/SysDictItemService.java @@ -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 { + List getItemsByTypeCode(String typeCode); +} diff --git a/backend/src/main/java/com/imeeting/service/SysDictTypeService.java b/backend/src/main/java/com/imeeting/service/SysDictTypeService.java new file mode 100644 index 0000000..734ee0f --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/SysDictTypeService.java @@ -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 { +} diff --git a/backend/src/main/java/com/imeeting/service/impl/SysDictItemServiceImpl.java b/backend/src/main/java/com/imeeting/service/impl/SysDictItemServiceImpl.java new file mode 100644 index 0000000..944162a --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/impl/SysDictItemServiceImpl.java @@ -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 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 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>() {}); + } + } catch (Exception e) { + log.error("Redis error for key {}: {}", key, e.getMessage()); + } + + List items = list(new LambdaQueryWrapper() + .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)); + } + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/imeeting/service/impl/SysDictTypeServiceImpl.java b/backend/src/main/java/com/imeeting/service/impl/SysDictTypeServiceImpl.java new file mode 100644 index 0000000..9d90cc4 --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/impl/SysDictTypeServiceImpl.java @@ -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 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; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/imeeting/service/impl/SysParamServiceImpl.java b/backend/src/main/java/com/imeeting/service/impl/SysParamServiceImpl.java index 9cd81dc..751350c 100644 --- a/backend/src/main/java/com/imeeting/service/impl/SysParamServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/impl/SysParamServiceImpl.java @@ -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 implements SysParamService { - private final StringRedisTemplate stringRedisTemplate; + private final StringRedisTemplate redisTemplate; - public SysParamServiceImpl(StringRedisTemplate stringRedisTemplate) { - this.stringRedisTemplate = stringRedisTemplate; - } - - @Override - public String getParamValue(String key, String defaultValue) { - SysParam param = getOne(new LambdaQueryWrapper().eq(SysParam::getParamKey, key)); - return param == null ? defaultValue : param.getParamValue(); - } - - @Override - public String getCachedParamValue(String key, String defaultValue) { - if (key == null || key.isEmpty()) { - return defaultValue; + public SysParamServiceImpl(StringRedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; } - Object value = stringRedisTemplate.opsForHash().get(RedisKeys.sysParamKey(key), RedisKeys.SYS_PARAM_FIELD_VALUE); - if (value != null) { - return String.valueOf(value); - } - return getParamValue(key, defaultValue); - } - @Override - public void syncParamToCache(SysParam param) { - if (param == null || param.getParamKey() == null || param.getParamKey().isEmpty()) { - return; - } - Map data = new HashMap<>(); - data.put(RedisKeys.SYS_PARAM_FIELD_VALUE, param.getParamValue() == null ? "" : param.getParamValue()); - data.put(RedisKeys.SYS_PARAM_FIELD_TYPE, param.getParamType() == null ? "" : param.getParamType()); - stringRedisTemplate.opsForHash().putAll(RedisKeys.sysParamKey(param.getParamKey()), data); - } + @Override + public String getParamValue(String key, String defaultValue) { + if (key == null || key.isEmpty()) { + return defaultValue; + } - @Override - public void deleteParamCache(String key) { - if (key == null || key.isEmpty()) { - return; - } - stringRedisTemplate.delete(RedisKeys.sysParamKey(key)); - } + String redisKey = RedisKeys.sysParamKey(key); + try { + // 1. 尝试从 Redis 获取 + String cachedValue = redisTemplate.opsForValue().get(redisKey); + if (cachedValue != null) { + // 如果是空标记,返回默认值 + if (RedisKeys.CACHE_EMPTY_MARKER.equals(cachedValue)) { + return defaultValue; + } + return cachedValue; + } + } catch (Exception e) { + log.error("Redis read error for key {}: {}", redisKey, e.getMessage()); + } - @Override - public void syncAllToCache() { - List params = list(); - for (SysParam param : params) { - syncParamToCache(param); + // 2. Redis 未命中,查数据库 + log.info("Cache miss for param key: {}, fetching from DB", key); + SysParam param = getOne(new LambdaQueryWrapper().eq(SysParam::getParamKey, key)); + + if (param != null) { + String val = param.getParamValue(); + // 3. 回写 Redis + try { + redisTemplate.opsForValue().set(redisKey, val == null ? "" : val, Duration.ofHours(24)); + } catch (Exception e) { + log.error("Redis write error for key {}: {}", redisKey, e.getMessage()); + } + return val; + } else { + // 4. 数据库也无数据,设置空标记防止穿透 + try { + redisTemplate.opsForValue().set(redisKey, RedisKeys.CACHE_EMPTY_MARKER, Duration.ofMinutes(5)); + } catch (Exception e) { + log.error("Redis write empty marker error for key {}: {}", redisKey, e.getMessage()); + } + return defaultValue; + } } - } -} + + @Override + public String getCachedParamValue(String key, String defaultValue) { + return getParamValue(key, defaultValue); + } + + @Override + public void syncParamToCache(SysParam param) { + if (param != null && param.getParamKey() != null) { + redisTemplate.opsForValue().set(RedisKeys.sysParamKey(param.getParamKey()), + param.getParamValue() == null ? "" : param.getParamValue(), Duration.ofHours(24)); + } + } + + @Override + public void deleteParamCache(String key) { + if (key != null) { + redisTemplate.delete(RedisKeys.sysParamKey(key)); + } + } + + @Override + public void syncAllToCache() { + log.info("Syncing all system parameters to Redis"); + List 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; + } +} \ No newline at end of file diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 8e29a38..65178d3 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -12,6 +12,8 @@ spring: port: 6379 password: Unis@123 database: 15 + cache: + type: redis mybatis-plus: configuration: diff --git a/frontend/src/api/dict.ts b/frontend/src/api/dict.ts new file mode 100644 index 0000000..4dc5bfb --- /dev/null +++ b/frontend/src/api/dict.ts @@ -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) { + const resp = await http.post("/api/dict-types", data); + return resp.data.data as boolean; +} + +export async function updateDictType(id: number, data: Partial) { + 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) { + const resp = await http.post("/api/dict-items", data); + return resp.data.data as boolean; +} + +export async function updateDictItem(id: number, data: Partial) { + 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[]; +} \ No newline at end of file diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 6db0fc6..49b3d9c 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -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"; + diff --git a/frontend/src/components/AppLayout.tsx b/frontend/src/components/AppLayout.tsx index 42a8273..d077c8f 100644 --- a/frontend/src/components/AppLayout.tsx +++ b/frontend/src/components/AppLayout.tsx @@ -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 = { + 'home': , + 'user': , + 'role': , + 'permission': , + 'dict': , + 'device': , + 'setting': +}; export default function AppLayout() { const location = useLocation(); const navigate = useNavigate(); + const [menuTree, setMenuTree] = useState([]); + 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] || : 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 ? {node.name} : node.name + }; + }); + }; + + const menuItems = [ + { key: "/", label: 总览, icon: }, + ...renderMenuItems(menuTree), + { + key: "logout", + label: 退出登录, + icon: , + danger: true, + style: { marginTop: 'auto' } + } + ]; + return ( - +
logo MeetingAI @@ -22,22 +88,21 @@ export default function AppLayout() { theme="dark" mode="inline" selectedKeys={[location.pathname]} - items={[ - { key: "/", label: 总览 }, - { key: "/users", label: 用户管理 }, - { key: "/roles", label: 权限角色 }, - { key: "/permissions", label: 权限菜单 }, - { key: "/devices", label: 设备管理 }, - { key: "logout", label: 退出 } - ]} + items={menuItems} + loading={loading} + style={{ height: 'calc(100% - 64px)', display: 'flex', flexDirection: 'column' }} /> - - + + + + + + ); -} +} \ No newline at end of file diff --git a/frontend/src/pages/Dictionaries.tsx b/frontend/src/pages/Dictionaries.tsx new file mode 100644 index 0000000..3ac59dd --- /dev/null +++ b/frontend/src/pages/Dictionaries.tsx @@ -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([]); + const [items, setItems] = useState([]); + const [selectedType, setSelectedType] = useState(null); + const [loadingTypes, setLoadingTypes] = useState(false); + const [loadingItems, setLoadingItems] = useState(false); + + // Type Drawer + const [typeDrawerVisible, setTypeDrawerVisible] = useState(false); + const [editingType, setEditingType] = useState(null); + const [typeForm] = Form.useForm(); + + // Item Drawer + const [itemDrawerVisible, setItemDrawerVisible] = useState(false); + const [editingItem, setEditingItem] = useState(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 ( +
+ 字典管理 + + + } onClick={handleAddType}> + 新增 + + ) + } + > + ({ + 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) => ( + + {can("sys_dict:type:update") && ( + + } onClick={handleAddItem} disabled={!selectedType}> + 新增 + + ) + } + > +
(v === 1 ? 启用 : 禁用) + }, + { + title: "操作", + width: 120, + render: (_, record) => ( + + {can("sys_dict:item:update") && ( + + + + } + > +
+ + + + + + + + + + + + + {/* Item Drawer */} + setItemDrawerVisible(false)} + width={400} + destroyOnClose + footer={ +
+ + +
+ } + > +
+ + + + + + + + + + + + + +
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 ? 启用 : 禁用) + } + ]} + /> + + + + + {selectedRole ? `当前角色:${selectedRole.roleName}` : "未选择角色"} + + } + > + setCheckedPermIds(keys as number[])} + defaultExpandAll + /> + {!permissions.length && !loadingPerms && ( +
+ 暂无权限数据 +
+ )} +
+ + + + ); +} diff --git a/frontend/src/pages/Roles.css b/frontend/src/pages/Roles.css new file mode 100644 index 0000000..980a24f --- /dev/null +++ b/frontend/src/pages/Roles.css @@ -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; +} diff --git a/frontend/src/pages/Roles.tsx b/frontend/src/pages/Roles.tsx index e6d6715..a2046aa 100644 --- a/frontend/src/pages/Roles.tsx +++ b/frontend/src/pages/Roles.tsx @@ -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(); 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(null); const [selectedPermIds, setSelectedPermIds] = useState([]); + const [halfCheckedIds, setHalfCheckedIds] = useState([]); 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).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 /> diff --git a/frontend/src/pages/UserRoleBinding.tsx b/frontend/src/pages/UserRoleBinding.tsx new file mode 100644 index 0000000..5ec0289 --- /dev/null +++ b/frontend/src/pages/UserRoleBinding.tsx @@ -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([]); + const [roles, setRoles] = useState([]); + const [loadingUsers, setLoadingUsers] = useState(false); + const [loadingRoles, setLoadingRoles] = useState(false); + const [saving, setSaving] = useState(false); + const [selectedUserId, setSelectedUserId] = useState(null); + const [checkedRoleIds, setCheckedRoleIds] = useState([]); + + 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 ( +
+
+
+ 用户角色绑定 + 为用户分配一个或多个角色 +
+ +
+ + +
+ +
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 ? 启用 : 禁用) + } + ]} + /> + + + + + {selectedUser ? `当前用户:${selectedUser.displayName || selectedUser.username}` : "未选择用户"} + + } + > + + setCheckedRoleIds(values as number[])} + disabled={loadingRoles} + > + + {roles.map((role) => ( + + + + {role.roleName} + + {role.roleCode} + + + + + ))} + + + {!roles.length && !loadingRoles && ( + 暂无角色数据 + )} + + + + + + ); +} diff --git a/frontend/src/pages/Users.css b/frontend/src/pages/Users.css new file mode 100644 index 0000000..ac60247 --- /dev/null +++ b/frontend/src/pages/Users.css @@ -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; +} diff --git a/frontend/src/pages/Users.tsx b/frontend/src/pages/Users.tsx index 887c341..adb9520 100644 --- a/frontend/src/pages/Users.tsx +++ b/frontend/src/pages/Users.tsx @@ -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([]); - const [query, setQuery] = useState({ username: "", displayName: "", phone: "" }); - const [pagination, setPagination] = useState({ current: 1, pageSize: 10 }); - const [open, setOpen] = useState(false); + const [roles, setRoles] = useState([]); + + // Search state + const [searchText, setSearchText] = useState(""); + + // Drawer state + const [drawerOpen, setDrawerOpen] = useState(false); const [editing, setEditing] = useState(null); const [form] = Form.useForm(); - const { can } = usePermission(); - const load = async () => { + const loadData = async () => { setLoading(true); try { - const list = await listUsers(); - setData(list || []); + const [usersList, rolesList] = await Promise.all([listUsers(), listRoles()]); + setData(usersList || []); + setRoles(rolesList || []); + } catch (e) { + message.error("加载数据失败"); } finally { setLoading(false); } }; useEffect(() => { - load(); + loadData(); }, []); - const filtered = useMemo(() => { - return data.filter((u) => { - const hitUsername = query.username ? u.username?.includes(query.username) : true; - const hitDisplay = query.displayName ? u.displayName?.includes(query.displayName) : true; - const hitPhone = query.phone ? (u.phone || "").includes(query.phone) : true; - return hitUsername && hitDisplay && hitPhone; - }); - }, [data, query]); - - const pageData = useMemo(() => { - const start = (pagination.current - 1) * pagination.pageSize; - return filtered.slice(start, start + pagination.pageSize); - }, [filtered, pagination]); + const filteredData = useMemo(() => { + if (!searchText) return data; + const lower = searchText.toLowerCase(); + return data.filter( + (u) => + u.username.toLowerCase().includes(lower) || + u.displayName.toLowerCase().includes(lower) || + (u.email && u.email.toLowerCase().includes(lower)) || + (u.phone && u.phone.includes(lower)) + ); + }, [data, searchText]); const openCreate = () => { setEditing(null); form.resetFields(); - setOpen(true); + form.setFieldsValue({ status: 1, roleIds: [] }); + setDrawerOpen(true); }; - const openEdit = (record: SysUser) => { + const openEdit = async (record: SysUser) => { setEditing(record); - form.setFieldsValue(record); - setOpen(true); + try { + const roleIds = await listUserRoles(record.userId); + form.setFieldsValue({ + ...record, + roleIds: roleIds || [], + password: "" // Clear password field + }); + setDrawerOpen(true); + } catch (e) { + message.error("获取用户角色失败"); + } + }; + + const handleDelete = async (id: number) => { + try { + await deleteUser(id); + message.success("用户已删除"); + loadData(); + } catch (e) { + message.error("删除失败"); + } }; const submit = async () => { - const values = await form.validateFields(); - const payload: Partial = { - username: values.username, - displayName: values.displayName, - email: values.email, - phone: values.phone, - status: values.status - }; - if (values.password) { - payload.passwordHash = values.password; + try { + const values = await form.validateFields(); + setSaving(true); + + const userPayload: Partial = { + username: values.username, + displayName: values.displayName, + email: values.email, + phone: values.phone, + status: values.status, + }; + + if (values.password) { + userPayload.passwordHash = values.password; + } + + let userId = editing?.userId; + if (editing) { + await updateUser(editing.userId, userPayload); + } else { + // We need the new user ID to save roles. + // Our API returns boolean, so we might need to find the user after creation if backend doesn't return ID. + // However, looking at the list request after create is common. + await createUser(userPayload); + // Refresh list to find the newly created user (by username) + const updatedList = await listUsers(); + const newUser = updatedList.find(u => u.username === userPayload.username); + userId = newUser?.userId; + } + + if (userId) { + await saveUserRoles(userId, values.roleIds || []); + } + + message.success(editing ? "用户信息已更新" : "用户已创建"); + setDrawerOpen(false); + loadData(); + } catch (e) { + if (e instanceof Error && e.message) { + message.error(e.message); + } + } finally { + setSaving(false); } - if (editing) { - await updateUser(editing.userId, payload); - } else { - await createUser(payload); - } - setOpen(false); - load(); }; - const remove = async (id: number) => { - await deleteUser(id); - load(); - }; + const columns = [ + { + title: "用户信息", + key: "user", + render: (_: any, record: SysUser) => ( + +
+ +
+
+
{record.displayName}
+
@{record.username}
+
+
+ ), + }, + { + title: "联系方式", + key: "contact", + render: (_: any, record: SysUser) => ( +
+
{record.email || "-"}
+
{record.phone || "-"}
+
+ ), + }, + { + title: "状态", + dataIndex: "status", + width: 100, + render: (status: number) => ( + + {status === 1 ? "正常" : "禁用"} + + ), + }, + { + title: "创建时间", + dataIndex: "createdAt", + width: 180, + render: (text: string) => {text?.replace('T', ' ').substring(0, 19)} + }, + { + title: "操作", + key: "action", + width: 120, + fixed: "right" as const, + render: (_: any, record: SysUser) => ( + + {can("sys_user:update") && ( + + )} - + -
setPagination({ current, pageSize }) - }} - columns={[ - { title: "ID", dataIndex: "userId" }, - { title: "用户名", dataIndex: "username" }, - { title: "显示名", dataIndex: "displayName" }, - { title: "邮箱", dataIndex: "email" }, - { title: "手机", dataIndex: "phone" }, - { - title: "状态", - dataIndex: "status", - render: (v) => (v === 1 ? 启用 : 禁用) - }, - { - title: "操作", - render: (_, record) => ( - - {can("sys_user:update") && } - {can("sys_user:delete") && ( - remove(record.userId)}> - - - )} - - ) - } - ]} - /> + +
+ } + className="users-search-input" + value={searchText} + onChange={(e) => setSearchText(e.target.value)} + allowClear + /> +
+ +
`共 ${total} 条数据`, + pageSize: 10, + }} + /> + setOpen(false)} - width={420} + title={ +
+ + {editing ? "编辑用户信息" : "创建新用户"} +
+ } + open={drawerOpen} + onClose={() => setDrawerOpen(false)} + width={480} destroyOnClose footer={ - - - - +
+ + +
} > - - - + + + - - + + + - - + + +
+ + + + + + + + + + + + + - - + + + @@ -181,4 +346,4 @@ export default function Users() { ); -} +} \ No newline at end of file diff --git a/frontend/src/routes/routes.tsx b/frontend/src/routes/routes.tsx index bd38daf..35400b2 100644 --- a/frontend/src/routes/routes.tsx +++ b/frontend/src/routes/routes.tsx @@ -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: , perm: "menu:users" }, { path: "/roles", label: "角色管理", element: , perm: "menu:roles" }, { path: "/permissions", label: "权限管理", element: , perm: "menu:permissions" }, + { path: "/dictionaries", label: "字典管理", element: , perm: "menu:dict" }, { path: "/devices", label: "设备管理", element: , perm: "menu:devices" }, { path: "/user-roles", label: "用户角色绑定", element: , perm: "menu:user-roles" }, { path: "/role-permissions", label: "角色权限绑定", element: , perm: "menu:role-permissions" } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 710821a..ede5fd6 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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 {