From 3ccbdb7c0635e4a080bd963337c16a6f03a5f5f4 Mon Sep 17 00:00:00 2001 From: "Ray.Hao" <1490493387@qq.com> Date: Fri, 25 Sep 2026 19:56:52 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20=E9=80=92=E5=A2=9E=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E5=8F=B7=E5=B9=B6=E6=A0=87=E8=AE=B0=E5=81=9C=E6=9B=B4=E7=89=88?= =?UTF-8?q?=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 版本号: 中段递增、末段归零 - README: laravel 与 spring-boot-3 更新状态改为 ❌️ --- README.en-US.md | 4 +- README.md | 4 +- mock/menu.mock.ts | 66 +- src/components/CopyButton/index.vue | 8 +- src/components/SectionTitle/index.vue | 49 + src/main.ts | 2 +- .../{guards/permission.ts => guards.ts} | 8 +- src/router/views.ts | 133 ++ src/stores/permission.ts | 151 +- src/utils/index.ts | 3 + src/utils/route.ts | 19 + src/views/dynamic-form/index.vue | 12 +- src/views/system/menu/index.vue | 1463 ++++++++++------- 13 files changed, 1235 insertions(+), 687 deletions(-) create mode 100644 src/components/SectionTitle/index.vue rename src/router/{guards/permission.ts => guards.ts} (93%) create mode 100644 src/router/views.ts create mode 100644 src/utils/route.ts diff --git a/README.en-US.md b/README.en-US.md index bb2f9106..46a66549 100644 --- a/README.en-US.md +++ b/README.en-US.md @@ -129,10 +129,10 @@ npx skills add https://github.com/youlaitech/youlai-skills --skill vue-admin | [youlai-gin](https://gitee.com/youlaiorg/youlai-gin) | Go + Gorm | Go | ✅️ | | [youlai-django](https://gitee.com/youlaiorg/youlai-django) | Django + DRF | Python | ✅️ | | [youlai-fastapi](https://gitee.com/youlaiorg/youlai-fastapi) | FastAPI + SQLAlchemy | Python | ✅️ | -| [youlai-laravel](https://gitee.com/youlaiorg/youlai-laravel) | Laravel + Eloquent | PHP | ✅️ | | [youlai-think](https://gitee.com/youlaiorg/youlai-think) | ThinkPHP + ThinkORM | PHP | ✅️ | | [youlai-aspnet](https://gitee.com/youlaiorg/youlai-aspnet) | ASP.NET Core + EF Core | C# | ✅️ | | [youlai-axum](https://gitee.com/youlaiorg/youlai-axum) | Axum + SeaORM | Rust | ✅️ | +| [youlai-laravel](https://gitee.com/youlaiorg/youlai-laravel) | Laravel + Eloquent | PHP | ❌️ | > All nine backends share the same **RESTful API** and **database schema**, frontends can switch seamlessly. @@ -145,7 +145,7 @@ npx skills add https://github.com/youlaitech/youlai-skills --skill vue-admin | [youlai-boot-flex](https://gitee.com/youlaiorg/youlai-boot-flex) | youlai-boot | Repo | Switched to MyBatis-Flex | ✅️ | | [youlai-boot (db-pg)](https://gitee.com/youlaiorg/youlai-boot/tree/db-pg) | youlai-boot | Branch | PostgreSQL database branch | ✅️ | | [youlai-boot (multi-module)](https://gitee.com/youlaiorg/youlai-boot/tree/multi-module) | youlai-boot | Branch | Multi-module project split | ✅️ | -| [youlai-boot (spring-boot-3)](https://gitee.com/youlaiorg/youlai-boot/tree/spring-boot-3) | youlai-boot | Branch | Spring Boot 3 compatible branch | ✅️ | +| [youlai-boot (spring-boot-3)](https://gitee.com/youlaiorg/youlai-boot/tree/spring-boot-3) | youlai-boot | Branch | Spring Boot 3 compatible branch | ❌️ | ## Development Guide diff --git a/README.md b/README.md index 7d5d8013..b6533df8 100644 --- a/README.md +++ b/README.md @@ -131,10 +131,10 @@ npx skills add https://github.com/youlaitech/youlai-skills --skill vue-admin | [youlai-gin](https://gitee.com/youlaiorg/youlai-gin) | Go + Gorm | Go | ✅️ | | [youlai-django](https://gitee.com/youlaiorg/youlai-django) | Django + DRF | Python | ✅️ | | [youlai-fastapi](https://gitee.com/youlaiorg/youlai-fastapi) | FastAPI + SQLAlchemy | Python | ✅️ | -| [youlai-laravel](https://gitee.com/youlaiorg/youlai-laravel) | Laravel + Eloquent | PHP | ✅️ | | [youlai-think](https://gitee.com/youlaiorg/youlai-think) | ThinkPHP + ThinkORM | PHP | ✅️ | | [youlai-aspnet](https://gitee.com/youlaiorg/youlai-aspnet) | ASP.NET Core + EF Core | C# | ✅️ | | [youlai-axum](https://gitee.com/youlaiorg/youlai-axum) | Axum + SeaORM | Rust | ✅️ | +| [youlai-laravel](https://gitee.com/youlaiorg/youlai-laravel) | Laravel + Eloquent | PHP | ❌️ | > 九种后端共享同一套 **RESTful API 规范** 和 **数据库结构**,前端可无缝切换。 @@ -147,7 +147,7 @@ npx skills add https://github.com/youlaitech/youlai-skills --skill vue-admin | [youlai-boot-flex](https://gitee.com/youlaiorg/youlai-boot-flex) | youlai-boot | 独立仓库 | 改用 MyBatis-Flex | ✅️ | | [youlai-boot (db-pg)](https://gitee.com/youlaiorg/youlai-boot/tree/db-pg) | youlai-boot | 分支 | PostgreSQL 数据库分支 | ✅️ | | [youlai-boot (multi-module)](https://gitee.com/youlaiorg/youlai-boot/tree/multi-module) | youlai-boot | 分支 | 多模块工程拆分 | ✅️ | -| [youlai-boot (spring-boot-3)](https://gitee.com/youlaiorg/youlai-boot/tree/spring-boot-3) | youlai-boot | 分支 | Spring Boot 3 兼容分支 | ✅️ | +| [youlai-boot (spring-boot-3)](https://gitee.com/youlaiorg/youlai-boot/tree/spring-boot-3) | youlai-boot | 分支 | Spring Boot 3 兼容分支 | ❌️ | ## 开发指南 diff --git a/mock/menu.mock.ts b/mock/menu.mock.ts index e1e00744..e042f3d3 100644 --- a/mock/menu.mock.ts +++ b/mock/menu.mock.ts @@ -577,7 +577,7 @@ export default defineMock([ id: "105", parentId: "2", name: "用户查询", - type: "F", + type: "B", routeName: null, routePath: "", component: null, @@ -592,7 +592,7 @@ export default defineMock([ id: "31", parentId: "2", name: "用户新增", - type: "F", + type: "B", routeName: null, routePath: "", component: null, @@ -607,7 +607,7 @@ export default defineMock([ id: "32", parentId: "2", name: "用户编辑", - type: "F", + type: "B", routeName: null, routePath: "", component: null, @@ -622,7 +622,7 @@ export default defineMock([ id: "33", parentId: "2", name: "用户删除", - type: "F", + type: "B", routeName: null, routePath: "", component: null, @@ -637,7 +637,7 @@ export default defineMock([ id: "88", parentId: "2", name: "重置密码", - type: "F", + type: "B", routeName: null, routePath: "", component: null, @@ -652,7 +652,7 @@ export default defineMock([ id: "106", parentId: "2", name: "用户导入", - type: "F", + type: "B", routeName: null, routePath: "", component: null, @@ -667,7 +667,7 @@ export default defineMock([ id: "107", parentId: "2", name: "用户导出", - type: "F", + type: "B", routeName: null, routePath: "", component: null, @@ -698,7 +698,7 @@ export default defineMock([ id: "70", parentId: "3", name: "角色新增", - type: "F", + type: "B", routeName: null, routePath: "", component: null, @@ -713,7 +713,7 @@ export default defineMock([ id: "71", parentId: "3", name: "角色编辑", - type: "F", + type: "B", routeName: null, routePath: "", component: null, @@ -728,7 +728,7 @@ export default defineMock([ id: "72", parentId: "3", name: "角色删除", - type: "F", + type: "B", routeName: null, routePath: "", component: null, @@ -759,7 +759,7 @@ export default defineMock([ id: "73", parentId: "4", name: "菜单新增", - type: "F", + type: "B", routeName: null, routePath: "", component: null, @@ -774,7 +774,7 @@ export default defineMock([ id: "74", parentId: "4", name: "菜单编辑", - type: "F", + type: "B", routeName: null, routePath: "", component: null, @@ -789,7 +789,7 @@ export default defineMock([ id: "75", parentId: "4", name: "菜单删除", - type: "F", + type: "B", routeName: null, routePath: "", component: null, @@ -820,7 +820,7 @@ export default defineMock([ id: "76", parentId: "5", name: "部门新增", - type: "F", + type: "B", routeName: null, routePath: "", component: null, @@ -835,7 +835,7 @@ export default defineMock([ id: "77", parentId: "5", name: "部门编辑", - type: "F", + type: "B", routeName: null, routePath: "", component: null, @@ -850,7 +850,7 @@ export default defineMock([ id: "78", parentId: "5", name: "部门删除", - type: "F", + type: "B", routeName: null, routePath: "", component: null, @@ -881,7 +881,7 @@ export default defineMock([ id: "79", parentId: "6", name: "字典新增", - type: "F", + type: "B", routeName: null, routePath: "", component: null, @@ -896,7 +896,7 @@ export default defineMock([ id: "81", parentId: "6", name: "字典编辑", - type: "F", + type: "B", routeName: null, routePath: "", component: null, @@ -911,7 +911,7 @@ export default defineMock([ id: "84", parentId: "6", name: "字典删除", - type: "F", + type: "B", routeName: null, routePath: "", component: null, @@ -942,7 +942,7 @@ export default defineMock([ id: "136", parentId: "135", name: "字典项新增", - type: "F", + type: "B", routeName: null, routePath: "", component: null, @@ -957,7 +957,7 @@ export default defineMock([ id: "137", parentId: "135", name: "字典项编辑", - type: "F", + type: "B", routeName: null, routePath: "", component: null, @@ -972,7 +972,7 @@ export default defineMock([ id: "138", parentId: "135", name: "字典项删除", - type: "F", + type: "B", routeName: null, routePath: "", component: null, @@ -1018,7 +1018,7 @@ export default defineMock([ id: "121", parentId: "120", name: "查询系统配置", - type: "F", + type: "B", routeName: null, routePath: "", component: null, @@ -1033,7 +1033,7 @@ export default defineMock([ id: "122", parentId: "120", name: "新增系统配置", - type: "F", + type: "B", routeName: null, routePath: "", component: null, @@ -1048,7 +1048,7 @@ export default defineMock([ id: "123", parentId: "120", name: "修改系统配置", - type: "F", + type: "B", routeName: null, routePath: "", component: null, @@ -1063,7 +1063,7 @@ export default defineMock([ id: "124", parentId: "120", name: "删除系统配置", - type: "F", + type: "B", routeName: null, routePath: "", component: null, @@ -1078,7 +1078,7 @@ export default defineMock([ id: "125", parentId: "120", name: "刷新系统配置", - type: "F", + type: "B", routeName: null, routePath: "", component: null, @@ -1109,7 +1109,7 @@ export default defineMock([ id: "127", parentId: "126", name: "查询", - type: "F", + type: "B", routeName: null, routePath: "", component: null, @@ -1124,7 +1124,7 @@ export default defineMock([ id: "128", parentId: "126", name: "新增", - type: "F", + type: "B", routeName: null, routePath: "", component: null, @@ -1139,7 +1139,7 @@ export default defineMock([ id: "129", parentId: "126", name: "编辑", - type: "F", + type: "B", routeName: null, routePath: "", component: null, @@ -1154,7 +1154,7 @@ export default defineMock([ id: "130", parentId: "126", name: "删除", - type: "F", + type: "B", routeName: null, routePath: "", component: null, @@ -1169,7 +1169,7 @@ export default defineMock([ id: "133", parentId: "126", name: "发布", - type: "F", + type: "B", routeName: null, routePath: "", component: null, @@ -1184,7 +1184,7 @@ export default defineMock([ id: "134", parentId: "126", name: "撤回", - type: "F", + type: "B", routeName: null, routePath: "", component: null, diff --git a/src/components/CopyButton/index.vue b/src/components/CopyButton/index.vue index 1b0035b4..ce17c80f 100644 --- a/src/components/CopyButton/index.vue +++ b/src/components/CopyButton/index.vue @@ -29,10 +29,10 @@ function handleClipboard() { // 使用 Clipboard API navigator.clipboard.writeText(props.text).then( () => { - ElMessage.success("Copy successfully"); + ElMessage.success("复制成功"); }, () => { - ElMessage.warning("Copy failed"); + ElMessage.warning("复制失败"); } ); } else { @@ -47,9 +47,9 @@ function handleClipboard() { const successful = document.execCommand("copy"); if (successful) { - ElMessage.success("Copy successfully!"); + ElMessage.success("复制成功"); } else { - ElMessage.warning("Copy failed!"); + ElMessage.warning("复制失败"); } } finally { document.body.removeChild(input); diff --git a/src/components/SectionTitle/index.vue b/src/components/SectionTitle/index.vue new file mode 100644 index 00000000..1f997833 --- /dev/null +++ b/src/components/SectionTitle/index.vue @@ -0,0 +1,49 @@ + + + + + diff --git a/src/main.ts b/src/main.ts index 463f06a0..9571a3fc 100644 --- a/src/main.ts +++ b/src/main.ts @@ -12,7 +12,7 @@ import { setupStore } from "@/stores"; import { setupI18n } from "@/lang"; import { setupFormCreate } from "@/plugins/form-create"; import * as ElementPlusIcons from "@element-plus/icons-vue"; -import { setupPermissionGuard } from "@/router/guards/permission"; +import { setupPermissionGuard } from "@/router/guards"; import { useSse } from "@/utils/sse"; import { useDictStoreHook } from "@/stores/dict"; diff --git a/src/router/guards/permission.ts b/src/router/guards.ts similarity index 93% rename from src/router/guards/permission.ts rename to src/router/guards.ts index 8eda3974..24d56884 100644 --- a/src/router/guards/permission.ts +++ b/src/router/guards.ts @@ -6,9 +6,9 @@ import { useTenantStoreHook } from "@/stores/tenant"; import { isTenantEnabled } from "@/utils/tenant"; /** - * 路由权限守卫 + * 路由守卫 * - * 处理登录验证、动态路由生成、404检测等 + * 处理登录验证、动态路由生成、404 检测、页面标题与进度条 */ export function setupPermissionGuard() { // 白名单支持前缀匹配:/f 命中所有公开表单分享页 /f/:formKey @@ -84,7 +84,9 @@ export function setupPermissionGuard() { }); } -/** 初始化多租户上下文,未启用或失败时静默跳过 */ +/** + * 初始化多租户上下文,未启用或失败时静默跳过 + */ async function initTenantContext(): Promise { if (!isTenantEnabled()) return; diff --git a/src/router/views.ts b/src/router/views.ts new file mode 100644 index 00000000..a7047046 --- /dev/null +++ b/src/router/views.ts @@ -0,0 +1,133 @@ +/** + * 视图组件注册表 + * + * 维护「菜单 component 字符串 → src/views 视图文件」的映射,路由注册与菜单表单共用。 + * 表单的「页面路径」选项树也由此派生:跳过错误页与页面内部组件,index.vue 归并到目录。 + */ + +/** 全部视图模块,键形如 /src/views/system/user/index.vue */ +export const viewModules = import.meta.glob("/src/views/**/*.vue"); + +const VIEW_PREFIX = "/src/views/"; + +/** 视图文件路径集合,元素形如 system/user/index */ +export const viewPaths = new Set( + Object.keys(viewModules).map((key) => key.slice(VIEW_PREFIX.length).replace(/\.vue$/, "")) +); + +/** + * 规整组件路径:去首尾空白、开头斜杠与 .vue 后缀 + */ +export function normalizeViewPath(componentPath: string): string { + return componentPath + .trim() + .replace(/^\/+/, "") + .replace(/\.vue$/i, ""); +} + +/** + * 解析组件:支持 system/user 与 system/user/index 两种写法,未命中回退 404 + */ +export function resolveComponent(componentPath: string) { + const path = normalizeViewPath(componentPath); + return ( + viewModules[`${VIEW_PREFIX}${path}.vue`] || + viewModules[`${VIEW_PREFIX}${path}/index.vue`] || + viewModules[`${VIEW_PREFIX}error/404.vue`] + ); +} + +/** + * 判断组件路径是否存在对应视图文件,目录写法命中其 index.vue + */ +export function isViewComponent(componentPath?: string): boolean { + if (!componentPath) return false; + const path = normalizeViewPath(componentPath); + return viewPaths.has(path) || viewPaths.has(`${path}/index`); +} + +/** 选项树节点 */ +export interface PageComponentNode { + /** 表单取值:真实文件路径,目录无 index.vue 时不可选 */ + value?: string; + label: string; + children?: PageComponentNode[]; + disabled?: boolean; +} + +function isSelectable(path: string): boolean { + const segments = path.split("/"); + if (segments.includes("components")) return false; + return segments[0] !== "error" && path !== "redirect"; +} + +function buildTree(): PageComponentNode[] { + const tree: PageComponentNode[] = []; + + [...viewPaths].filter(isSelectable).forEach((path) => insertPath(tree, path)); + + sortTree(tree); + markDisabled(tree); + return tree; +} + +/** + * 将视图路径插入选项树,index.vue 归并到所在目录 + */ +function insertPath(tree: PageComponentNode[], path: string): void { + const segments = path.split("/"); + if (segments[segments.length - 1] === "index") segments.pop(); + + let level = tree; + segments.forEach((segment, index) => { + const isLeaf = index === segments.length - 1; + let node = level.find((item) => item.label === segment); + + if (!node) { + node = { label: segment }; + level.push(node); + } + + if (isLeaf) { + // 取值始终是真实文件路径,与历史数据和列表展示一致 + node.value = path; + return; + } + + node.children ??= []; + level = node.children; + }); +} + +/** + * 目录下没有 index.vue 时不可选,只用于展开 + */ +function markDisabled(nodes: PageComponentNode[]): void { + nodes.forEach((node) => { + if (!node.value) node.disabled = true; + if (node.children) markDisabled(node.children); + }); +} + +function sortTree(nodes: PageComponentNode[]): void { + nodes.sort((a, b) => a.label.localeCompare(b.label)); + nodes.forEach((node) => node.children && sortTree(node.children)); +} + +/** 页面组件选项树 */ +export const pageComponentTree = buildTree(); + +/** + * 节点筛选:名称与文件路径都参与匹配,输入 index 可筛出所有主页 + */ +export function filterPageComponent( + query: string, + node: { label?: string; value?: string } +): boolean { + if (!query) return true; + const keyword = query.toLowerCase(); + return ( + (node.label ?? "").toLowerCase().includes(keyword) || + (node.value ?? "").toLowerCase().includes(keyword) + ); +} diff --git a/src/stores/permission.ts b/src/stores/permission.ts index 8cb12d2f..76541c56 100644 --- a/src/stores/permission.ts +++ b/src/stores/permission.ts @@ -1,13 +1,14 @@ import type { RouteRecordRaw } from "vue-router"; import { constantRoutes } from "@/router"; +import { resolveComponent } from "@/router/views"; import { store } from "@/stores"; import router from "@/router"; import { useUserStoreHook } from "@/stores/user"; -import { isExternal } from "@/utils"; +import { isExternal, joinRoutePath } from "@/utils"; import MenuAPI from "@/api/system/menu"; import type { RouteItem } from "@/api/system/menu"; -const modules = import.meta.glob("../views/**/*.vue"); + const Layout = () => import("../layouts/index.vue"); export const usePermissionStore = defineStore("permission", () => { @@ -21,7 +22,7 @@ export const usePermissionStore = defineStore("permission", () => { async function generateRoutes(): Promise { try { const routeData = await MenuAPI.getRoutes(); - const menuRoutes = transformRoutes(routeData); + const menuRoutes = buildRoutes(routeData); const registerRoutes = filterRoutes(menuRoutes); routes.value = [...constantRoutes, ...menuRoutes]; @@ -132,49 +133,115 @@ export const usePermissionStore = defineStore("permission", () => { }; }); -/** - * 将后端路由数据转为 Vue Router 配置 - */ -const transformRoutes = (routes: RouteItem[], isTopLevel: boolean = true): RouteRecordRaw[] => { - return routes.map((route) => { - const { children, ...args } = route; - const componentPath = route.component; - - // 非顶层目录壳去掉 Layout 组件,仅保留路由结构 - const resolvedComponent = isTopLevel || componentPath !== "Layout" ? componentPath : undefined; - - const normalizedRoute = { ...args } as RouteRecordRaw; - - if (!resolvedComponent) { - normalizedRoute.component = undefined; - } else { - normalizedRoute.component = - resolvedComponent === "Layout" ? Layout : resolveComponent(resolvedComponent); - } - - if (children && children.length > 0) { - normalizedRoute.children = transformRoutes(children, false); - } - - return normalizedRoute; - }); -}; +// 目录菜单在 component 上的占位值,表示该级只作路由容器 +const LAYOUT_COMPONENT = "Layout"; /** - * 解析组件 + * 后端菜单树还原为 Vue Router 路由树 * - * 支持 xxx.vue 与 xxx/index.vue 两种写法,未命中时回退到 404 + * 顶层菜单统一由 Layout 承载:目录本身是容器,页面下沉为 path 为空的子路由; + * 路径前缀与默认跳转在此推导,菜单数据只描述业务信息。 */ -function resolveComponent(componentPath: string) { - const normalized = componentPath - .trim() - .replace(/^\/+/, "") - .replace(/\.vue$/i, ""); - return ( - modules[`../views/${normalized}.vue`] || - modules[`../views/${normalized}/index.vue`] || - modules[`../views/error/404.vue`] - ); +const buildRoutes = (menus: RouteItem[]): RouteRecordRaw[] => menus.map(buildTopLevelRoute); + +/** + * 顶层菜单路由:套 Layout 壳,保证页面具备侧边栏与顶栏 + */ +function buildTopLevelRoute(menu: RouteItem): RouteRecordRaw { + // 新标签页外链不注册路由,保留原始数据供侧边栏直接跳转 + if (isExternalLink(menu)) return toExternalRoute(menu, menu.path as string); + + const path = joinRoutePath("", menu.path); + const meta = { ...menu.meta }; + + // 目录:自身就是侧边栏分组,标题与图标保留在壳层 + if (isContainer(menu)) { + return { + path, + name: path, + component: Layout, + meta, + redirect: menu.redirect || firstVisiblePath(menu.children, path), + children: buildChildRoutes(menu.children, path), + }; + } + + // 页面:壳层只承载显示状态,标题与图标下沉到页面子路由,避免面包屑与标签页多出一层 + return { + path, + name: path, + component: Layout, + meta: { hidden: meta.hidden }, + children: [ + { + path: "", + name: menu.name, + component: resolveComponent(menu.component as string), + meta, + children: buildChildRoutes(menu.children, path), + }, + ], + }; +} + +/** + * 子级菜单路由:目录保留为路径分组,页面挂载实际组件 + */ +function buildChildRoutes(menus: RouteItem[] | undefined, basePath: string): RouteRecordRaw[] { + return (menus ?? []).map((menu) => { + const fullPath = joinRoutePath(basePath, menu.path); + + if (isExternalLink(menu)) return toExternalRoute(menu, fullPath); + + if (isContainer(menu)) { + const children = buildChildRoutes(menu.children, fullPath); + return { + path: menu.path ?? "", + meta: { ...menu.meta }, + children, + ...(children.length + ? { redirect: menu.redirect || firstVisiblePath(menu.children, fullPath) } + : {}), + }; + } + + return { + path: menu.path ?? "", + name: menu.name, + component: resolveComponent(menu.component as string), + meta: { ...menu.meta }, + }; + }); +} + +/** + * 新标签页外链路由:不参与路由注册,仅保留侧边栏跳转所需信息 + */ +function toExternalRoute(menu: RouteItem, path: string): RouteRecordRaw { + const { children: _children, ...rest } = menu; + return { ...rest, path } as RouteRecordRaw; +} + +/** + * 目录菜单:没有页面组件,只作路由容器 + */ +function isContainer(menu: RouteItem): boolean { + return !menu.component || menu.component === LAYOUT_COMPONENT; +} + +/** + * 新标签页外链:外链地址直接写在路由路径上且不带组件 + */ +function isExternalLink(menu: RouteItem): boolean { + return !menu.component && isExternal(menu.path ?? ""); +} + +/** + * 默认跳转:第一个可见子菜单的完整路径 + */ +function firstVisiblePath(menus: RouteItem[] | undefined, basePath: string): string | undefined { + const first = (menus ?? []).find((menu) => !menu.meta?.hidden && !isExternal(menu.path ?? "")); + return first ? joinRoutePath(basePath, first.path) : undefined; } /** diff --git a/src/utils/index.ts b/src/utils/index.ts index eed1eae9..c4836c93 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -11,5 +11,8 @@ export { formatGrowthRate, formatFileSize, formatNumber, formatCurrency } from " // 文件下载 export { downloadFile } from "./download"; +// 路由路径 +export { joinRoutePath } from "./route"; + // 本地存储 export { Storage } from "./storage"; diff --git a/src/utils/route.ts b/src/utils/route.ts new file mode 100644 index 00000000..6ad44ca1 --- /dev/null +++ b/src/utils/route.ts @@ -0,0 +1,19 @@ +/** + * 路由路径工具 + */ + +import { isExternal } from "./validate"; + +/** + * 拼接路由路径 + * + * 外链与绝对路径原样返回,相对路径挂到父级之后;顶级相对路径自动补前导斜杠。 + * + * @param basePath 父级完整路径 + * @param path 当前段路径 + */ +export function joinRoutePath(basePath: string, path?: string): string { + if (!path) return basePath || "/"; + if (isExternal(path) || path.startsWith("/")) return path; + return `${basePath.replace(/\/+$/, "")}/${path}`; +} diff --git a/src/views/dynamic-form/index.vue b/src/views/dynamic-form/index.vue index 6b741ec8..fec3443b 100644 --- a/src/views/dynamic-form/index.vue +++ b/src/views/dynamic-form/index.vue @@ -549,12 +549,12 @@ onMounted(() => { gap: 6px; align-items: center; max-width: 100%; +} - &__text { - flex-shrink: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } +.form-name__text { + flex-shrink: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } diff --git a/src/views/system/menu/index.vue b/src/views/system/menu/index.vue index 62c5a93a..bca8c93a 100644 --- a/src/views/system/menu/index.vue +++ b/src/views/system/menu/index.vue @@ -21,8 +21,11 @@
- - 新增 + + 新增菜单 + + + {{ expandButtons ? "收起按钮" : "展开按钮" }}
@@ -41,33 +44,36 @@
- + @@ -80,17 +86,44 @@ 按钮 - + - + - +