diff --git a/rdms-system/rdms-system-api/src/main/java/com/njcn/rdms/module/system/enums/ErrorCodeConstants.java b/rdms-system/rdms-system-api/src/main/java/com/njcn/rdms/module/system/enums/ErrorCodeConstants.java index 9b99ab2..265e383 100644 --- a/rdms-system/rdms-system-api/src/main/java/com/njcn/rdms/module/system/enums/ErrorCodeConstants.java +++ b/rdms-system/rdms-system-api/src/main/java/com/njcn/rdms/module/system/enums/ErrorCodeConstants.java @@ -27,6 +27,9 @@ public interface ErrorCodeConstants { ErrorCode MENU_PARENT_NOT_DIR_OR_MENU = new ErrorCode(1_002_001_005, "父菜单的类型必须是目录或者菜单"); ErrorCode MENU_COMPONENT_NAME_DUPLICATE = new ErrorCode(1_002_001_006, "已经存在该组件名的菜单"); ErrorCode MENU_NOT_ENABLE = new ErrorCode(1_002_001_007, "名字为【{}】的菜单已被禁用"); + ErrorCode MENU_ROUTE_KIND_INVALID = new ErrorCode(1_002_001_008, "路由类型不合法"); + ErrorCode MENU_ROUTE_PROPS_JSON_INVALID = new ErrorCode(1_002_001_009, "路由 props JSON 不合法"); + ErrorCode MENU_ROUTE_IFRAME_URL_REQUIRED = new ErrorCode(1_002_001_010, "iframe 路由必须配置 props.url"); // ========== 角色模块 1-002-002-000 ========== ErrorCode ROLE_NOT_EXISTS = new ErrorCode(1_002_002_000, "角色不存在"); diff --git a/rdms-system/rdms-system-api/src/main/java/com/njcn/rdms/module/system/enums/permission/MenuRouteKindEnum.java b/rdms-system/rdms-system-api/src/main/java/com/njcn/rdms/module/system/enums/permission/MenuRouteKindEnum.java new file mode 100644 index 0000000..3c61e13 --- /dev/null +++ b/rdms-system/rdms-system-api/src/main/java/com/njcn/rdms/module/system/enums/permission/MenuRouteKindEnum.java @@ -0,0 +1,38 @@ +package com.njcn.rdms.module.system.enums.permission; + +import cn.hutool.core.util.StrUtil; +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 菜单路由类型枚举 + */ +@Getter +@AllArgsConstructor +public enum MenuRouteKindEnum { + + DIR("dir"), // 目录路由 + VIEW("view"), // 普通页面 + SINGLE("single"), // 顶级单页 + IFRAME("iframe"), // iframe 页面 + EXTERNAL("external"), // 外链页面 + REDIRECT("redirect"); // 重定向路由 + + /** + * 路由类型值 + */ + private final String kind; + + public static MenuRouteKindEnum valueOfKind(String kind) { + if (StrUtil.isBlank(kind)) { + return null; + } + for (MenuRouteKindEnum value : values()) { + if (StrUtil.equalsIgnoreCase(value.getKind(), StrUtil.trim(kind))) { + return value; + } + } + return null; + } + +} diff --git a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/permission/vo/menu/MenuRespVO.java b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/permission/vo/menu/MenuRespVO.java index 7e8737d..31b19c1 100644 --- a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/permission/vo/menu/MenuRespVO.java +++ b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/permission/vo/menu/MenuRespVO.java @@ -50,6 +50,12 @@ public class MenuRespVO { @Schema(description = "组件名", example = "SystemUser") private String componentName; + @Schema(description = "路由类型,例如 dir/view/single/iframe", example = "view") + private String routeKind; + + @Schema(description = "路由 props JSON 字符串", example = "{\"url\":\"https://cn.vuejs.org/\"}") + private String routePropsJson; + @Schema(description = "状态,见 CommonStatusEnum 枚举", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") @NotNull(message = "状态不能为空") private Integer status; diff --git a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/permission/vo/menu/MenuSaveVO.java b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/permission/vo/menu/MenuSaveVO.java index 10e1d0e..73cd826 100644 --- a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/permission/vo/menu/MenuSaveVO.java +++ b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/permission/vo/menu/MenuSaveVO.java @@ -48,6 +48,13 @@ public class MenuSaveVO { @Schema(description = "组件名", example = "SystemUser") private String componentName; + @Schema(description = "路由类型,例如 dir/view/single/iframe", example = "view") + @Size(max = 32, message = "路由类型长度不能超过32个字符") + private String routeKind; + + @Schema(description = "路由 props JSON 字符串", example = "{\"url\":\"https://cn.vuejs.org/\"}") + private String routePropsJson; + @Schema(description = "状态,见 CommonStatusEnum 枚举", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") @NotNull(message = "状态不能为空") private Integer status; diff --git a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/convert/auth/AuthConvert.java b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/convert/auth/AuthConvert.java index c10cad7..fb75993 100644 --- a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/convert/auth/AuthConvert.java +++ b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/convert/auth/AuthConvert.java @@ -3,6 +3,8 @@ package com.njcn.rdms.module.system.convert.auth; import cn.hutool.core.collection.CollUtil; import cn.hutool.core.util.ObjUtil; import cn.hutool.core.util.StrUtil; +import com.fasterxml.jackson.core.type.TypeReference; +import com.njcn.rdms.framework.common.util.json.JsonUtils; import com.njcn.rdms.framework.common.util.object.BeanUtils; import com.njcn.rdms.module.system.controller.admin.auth.vo.AuthRouteMetaRespVO; import com.njcn.rdms.module.system.controller.admin.auth.vo.AuthRouteNodeRespVO; @@ -12,6 +14,7 @@ import com.njcn.rdms.module.system.controller.admin.auth.vo.AuthUserRouteRespVO; import com.njcn.rdms.module.system.dal.dataobject.permission.MenuDO; import com.njcn.rdms.module.system.dal.dataobject.permission.RoleDO; import com.njcn.rdms.module.system.dal.dataobject.user.AdminUserDO; +import com.njcn.rdms.module.system.enums.permission.MenuRouteKindEnum; import com.njcn.rdms.module.system.enums.permission.MenuTypeEnum; import org.mapstruct.Mapper; import org.mapstruct.factory.Mappers; @@ -208,7 +211,7 @@ public interface AuthConvert { Set usedNames = new HashSet<>(); menus.forEach(menu -> { String fullPath = fullPathCache.get(menu.getId()); - String baseName = normalizeRouteName(fullPath); + String baseName = resolveBaseRouteName(menu, fullPath); if (StrUtil.isBlank(baseName)) { baseName = "route_" + menu.getId(); } @@ -222,12 +225,21 @@ public interface AuthConvert { return routeNameMap; } + default String resolveBaseRouteName(MenuDO menu, String fullPath) { + if (StrUtil.isNotBlank(menu.getComponentName())) { + return StrUtil.trim(menu.getComponentName()); + } + return normalizeRouteName(fullPath); + } + default AuthRouteNodeRespVO buildRouteNode(MenuDO menu, String fullPath, String routeName, boolean hasChildren) { + MenuRouteKindEnum routeKind = resolveRouteKind(menu, hasChildren); return AuthRouteNodeRespVO.builder() .id(String.valueOf(menu.getId())) .name(routeName) .path(fullPath) - .component(resolveComponentKey(menu, routeName, hasChildren)) + .component(resolveComponentKey(menu, routeKind, routeName)) + .props(resolveRouteProps(menu.getRoutePropsJson())) .meta(buildRouteMeta(menu)) .build(); } @@ -238,17 +250,60 @@ public interface AuthConvert { .icon(resolveIcon(menu.getIcon())) .order(menu.getSort()) .keepAlive(menu.getKeepAlive()) + .hideInMenu(Boolean.FALSE.equals(menu.getVisible())) .build(); } - default String resolveComponentKey(MenuDO menu, String routeName, boolean hasChildren) { + default MenuRouteKindEnum resolveRouteKind(MenuDO menu, boolean hasChildren) { if (hasChildren) { - return "layout.base"; + return MenuRouteKindEnum.DIR; + } + MenuRouteKindEnum routeKind = MenuRouteKindEnum.valueOfKind(menu.getRouteKind()); + if (routeKind != null) { + return routeKind; + } + if (MenuTypeEnum.DIR.getType().equals(menu.getType())) { + return MenuRouteKindEnum.DIR; + } + if (isExternalPath(menu.getPath())) { + return MenuRouteKindEnum.EXTERNAL; } if (ID_ROOT.equals(menu.getParentId())) { + return MenuRouteKindEnum.SINGLE; + } + return MenuRouteKindEnum.VIEW; + } + + default String resolveComponentKey(MenuDO menu, MenuRouteKindEnum routeKind, String routeName) { + if (StrUtil.isNotBlank(menu.getComponent())) { + return StrUtil.trim(menu.getComponent()); + } + if (MenuRouteKindEnum.DIR.equals(routeKind)) { + return "layout.base"; + } + if (MenuRouteKindEnum.IFRAME.equals(routeKind)) { + return "view.iframe-page"; + } + if (MenuRouteKindEnum.SINGLE.equals(routeKind)) { return "layout.base$view." + routeName; } - return "view." + routeName; + if (MenuRouteKindEnum.VIEW.equals(routeKind)) { + return "view." + routeName; + } + return null; + } + + default Object resolveRouteProps(String routePropsJson) { + if (StrUtil.isBlank(routePropsJson)) { + return null; + } + Object routeProps = JsonUtils.parseObjectQuietly(routePropsJson, new TypeReference() { + }); + if (routeProps != null) { + return routeProps; + } + LoggerFactory.getLogger(getClass()).warn("[resolveRouteProps][routePropsJson({}) 解析失败]", routePropsJson); + return null; } default String resolveIcon(String icon) { @@ -278,10 +333,15 @@ public interface AuthConvert { if (StrUtil.isBlank(fullPath)) { return ""; } - return fullPath.replaceAll("^/+", "") + return fullPath + .replaceAll("([A-Z]+)([A-Z][a-z])", "$1_$2") + .replaceAll("([a-z\\d])([A-Z])", "$1_$2") + .replaceAll("^/+", "") .replaceAll("/+$", "") - .replaceAll("[/\\-.]+", "_") + .replaceAll("[/\\\\.\\-\\s]+", "_") .replaceAll("_+", "_") + .replaceAll("^_+", "") + .replaceAll("_+$", "") .toLowerCase(); } diff --git a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/dal/dataobject/permission/MenuDO.java b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/dal/dataobject/permission/MenuDO.java index d7df247..e3032cb 100644 --- a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/dal/dataobject/permission/MenuDO.java +++ b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/dal/dataobject/permission/MenuDO.java @@ -77,6 +77,14 @@ public class MenuDO extends BaseDO { * 组件名 */ private String componentName; + /** + * 路由类型 + */ + private String routeKind; + /** + * 路由 props JSON + */ + private String routePropsJson; /** * 状态 * diff --git a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/permission/MenuServiceImpl.java b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/permission/MenuServiceImpl.java index 9ecda2e..fd123c1 100644 --- a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/permission/MenuServiceImpl.java +++ b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/permission/MenuServiceImpl.java @@ -3,13 +3,16 @@ package com.njcn.rdms.module.system.service.permission; import cn.hutool.core.collection.CollUtil; import cn.hutool.core.util.ObjUtil; import cn.hutool.core.util.StrUtil; +import com.fasterxml.jackson.core.type.TypeReference; import com.njcn.rdms.framework.common.enums.CommonStatusEnum; +import com.njcn.rdms.framework.common.util.json.JsonUtils; import com.njcn.rdms.framework.common.util.object.BeanUtils; import com.njcn.rdms.module.system.controller.admin.permission.vo.menu.MenuListReqVO; import com.njcn.rdms.module.system.controller.admin.permission.vo.menu.MenuSaveVO; import com.njcn.rdms.module.system.dal.dataobject.permission.MenuDO; import com.njcn.rdms.module.system.dal.mysql.permission.MenuMapper; import com.njcn.rdms.module.system.dal.redis.RedisKeyConstants; +import com.njcn.rdms.module.system.enums.permission.MenuRouteKindEnum; import com.njcn.rdms.module.system.enums.permission.MenuTypeEnum; import com.google.common.annotations.VisibleForTesting; @@ -53,6 +56,7 @@ public class MenuServiceImpl implements MenuService { // 校验菜单(自己) validateMenuName(createReqVO.getParentId(), createReqVO.getName(), null); validateMenuComponentName(createReqVO.getComponentName(), null); + validateMenuRoute(createReqVO); // 插入数据库 MenuDO menu = BeanUtils.toBean(createReqVO, MenuDO.class); @@ -75,6 +79,7 @@ public class MenuServiceImpl implements MenuService { // 校验菜单(自己) validateMenuName(updateReqVO.getParentId(), updateReqVO.getName(), updateReqVO.getId()); validateMenuComponentName(updateReqVO.getComponentName(), updateReqVO.getId()); + validateMenuRoute(updateReqVO); // 更新到数据库 MenuDO updateObj = BeanUtils.toBean(updateReqVO, MenuDO.class); @@ -296,6 +301,43 @@ public class MenuServiceImpl implements MenuService { } } + @VisibleForTesting + void validateMenuRoute(MenuSaveVO reqVO) { + if (reqVO == null || MenuTypeEnum.BUTTON.getType().equals(reqVO.getType())) { + return; + } + + MenuRouteKindEnum routeKind = MenuRouteKindEnum.valueOfKind(reqVO.getRouteKind()); + if (StrUtil.isNotBlank(reqVO.getRouteKind()) && routeKind == null) { + throw exception(MENU_ROUTE_KIND_INVALID); + } + + String routePropsJson = StrUtil.trim(reqVO.getRoutePropsJson()); + if (StrUtil.isBlank(routePropsJson)) { + if (MenuRouteKindEnum.IFRAME.equals(routeKind)) { + throw exception(MENU_ROUTE_IFRAME_URL_REQUIRED); + } + return; + } + + Object routeProps = JsonUtils.parseObjectQuietly(routePropsJson, new TypeReference() { + }); + if (routeProps == null) { + throw exception(MENU_ROUTE_PROPS_JSON_INVALID); + } + if (!MenuRouteKindEnum.IFRAME.equals(routeKind)) { + return; + } + + if (!(routeProps instanceof Map routePropsMap)) { + throw exception(MENU_ROUTE_PROPS_JSON_INVALID); + } + Object url = routePropsMap.get("url"); + if (!(url instanceof String urlString) || StrUtil.isBlank(urlString)) { + throw exception(MENU_ROUTE_IFRAME_URL_REQUIRED); + } + } + /** * 初始化菜单的通用属性。 *

@@ -304,13 +346,22 @@ public class MenuServiceImpl implements MenuService { * @param menu 菜单 */ private void initMenuProperty(MenuDO menu) { + menu.setRouteKind(normalizeRouteKind(menu.getRouteKind())); + menu.setRoutePropsJson(StrUtil.blankToDefault(StrUtil.trim(menu.getRoutePropsJson()), null)); // 菜单为按钮类型时,无需 component、icon、path 属性,进行置空 if (MenuTypeEnum.BUTTON.getType().equals(menu.getType())) { menu.setComponent(""); menu.setComponentName(""); menu.setIcon(""); menu.setPath(""); + menu.setRouteKind(null); + menu.setRoutePropsJson(null); } } + private String normalizeRouteKind(String routeKind) { + MenuRouteKindEnum routeKindEnum = MenuRouteKindEnum.valueOfKind(routeKind); + return routeKindEnum != null ? routeKindEnum.getKind() : null; + } + } diff --git a/rdms-system/rdms-system-boot/src/main/resources/sql/permission-v2/system_dept.sql b/rdms-system/rdms-system-boot/src/main/resources/sql/permission-v2/system_dept.sql index c752020..4684409 100644 --- a/rdms-system/rdms-system-boot/src/main/resources/sql/permission-v2/system_dept.sql +++ b/rdms-system/rdms-system-boot/src/main/resources/sql/permission-v2/system_dept.sql @@ -646,4 +646,60 @@ CREATE TABLE `system_users` ( -- ---------------------------- INSERT INTO `system_users` VALUES (1, 'admin', '$2a$04$KljJDa/LK7QfDm0lF5OhuePhlPfjRH3tB2Wu351Uidz.oQGJXevPi', '灿能源码', '管理员', 103, 2, NULL, '[1,2]', '11aoteman@126.com', '18818260272', 2, 'http://test.rdms.iocoder.cn/20250921/avatar_1758423875594.png', 0, '192.168.2.125', '2026-03-18 16:18:02', 'admin', '2021-01-05 17:03:47', 'system', '2026-03-19 13:59:04', b'0'); +-- ---------------------------- +-- system_menu 路由扩展字段 +-- ---------------------------- +ALTER TABLE `system_menu` + ADD COLUMN `route_kind` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '路由类型:dir/view/single/iframe/external/redirect' AFTER `component_name`, + ADD COLUMN `route_props_json` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '路由 props JSON' AFTER `route_kind`; + +UPDATE `system_menu` +SET `component` = 'view.iframe-page', + `component_name` = 'document_antd', + `route_kind` = 'iframe', + `route_props_json` = '{\"url\":\"https://www.antdv.com/\"}' +WHERE `id` = 900010; + +UPDATE `system_menu` +SET `component` = 'view.iframe-page', + `component_name` = 'document_naive', + `route_kind` = 'iframe', + `route_props_json` = '{\"url\":\"https://www.naiveui.com/\"}' +WHERE `id` = 900011; + +UPDATE `system_menu` +SET `component` = 'view.iframe-page', + `component_name` = 'document_element_plus', + `route_kind` = 'iframe', + `route_props_json` = '{\"url\":\"https://cn.element-plus.org/\"}' +WHERE `id` = 900012; + +UPDATE `system_menu` +SET `component` = 'view.iframe-page', + `component_name` = 'document_alova', + `route_kind` = 'iframe', + `route_props_json` = '{\"url\":\"https://alova.js.org/\"}' +WHERE `id` = 900013; + +UPDATE `system_menu` +SET `component` = 'view.iframe-page', + `component_name` = 'document_unocss', + `route_kind` = 'iframe', + `route_props_json` = '{\"url\":\"https://unocss.dev/\"}' +WHERE `id` = 900014; + +UPDATE `system_menu` +SET `component` = 'view.iframe-page', + `component_name` = 'document_vite', + `route_kind` = 'iframe', + `route_props_json` = '{\"url\":\"https://vite.dev/\"}' +WHERE `id` = 900015; + +UPDATE `system_menu` +SET `component` = 'view.iframe-page', + `component_name` = 'document_vue', + `route_kind` = 'iframe', + `route_props_json` = '{\"url\":\"https://cn.vuejs.org/\"}' +WHERE `id` = 900016; + SET FOREIGN_KEY_CHECKS = 1;