From 3c8c6490fd9e2639c0e18997e74b4592740ccbb0 Mon Sep 17 00:00:00 2001 From: duanshuwen Date: Wed, 26 Aug 2026 20:40:07 +0800 Subject: [PATCH] feat(admin-ui): add RuoYi layout menu primitives --- .../src/components/layout/LayoutIcon.vue | 46 ++++++++ .../src/lib/menu-display.test.ts | 87 ++++++++++++++ WonderQ-Admin-UI-Vue/src/lib/menu-display.ts | 110 ++++++++++++++++++ 3 files changed, 243 insertions(+) create mode 100644 WonderQ-Admin-UI-Vue/src/components/layout/LayoutIcon.vue create mode 100644 WonderQ-Admin-UI-Vue/src/lib/menu-display.test.ts create mode 100644 WonderQ-Admin-UI-Vue/src/lib/menu-display.ts diff --git a/WonderQ-Admin-UI-Vue/src/components/layout/LayoutIcon.vue b/WonderQ-Admin-UI-Vue/src/components/layout/LayoutIcon.vue new file mode 100644 index 0000000..b322b8e --- /dev/null +++ b/WonderQ-Admin-UI-Vue/src/components/layout/LayoutIcon.vue @@ -0,0 +1,46 @@ + + + diff --git a/WonderQ-Admin-UI-Vue/src/lib/menu-display.test.ts b/WonderQ-Admin-UI-Vue/src/lib/menu-display.test.ts new file mode 100644 index 0000000..1b55f44 --- /dev/null +++ b/WonderQ-Admin-UI-Vue/src/lib/menu-display.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import type { AdminMenu, SystemMenuItem } from "@/types"; +import { findMenuTitle, getMenuIconName, visibleNavigableMenus } from "@/lib/menu-display"; + +const menu = (value: Partial & Pick): AdminMenu => value; + +describe("menu display primitives", () => { + it("recursively removes hidden and button menus while preserving hierarchy and order", () => { + const menus: AdminMenu[] = [ + menu({ id: "home", name: "首页", type: "page", path: "/home" }), + menu({ + id: "system", + name: "系统管理", + type: "directory", + path: "/system", + children: [ + menu({ id: "users", name: "用户", type: "page", path: "/system/users" }), + menu({ id: "hidden", name: "隐藏", type: "page", path: "/system/hidden", isVisible: false } as AdminMenu & { isVisible?: boolean }), + menu({ id: "button", name: "新增", type: "button", path: null }), + ], + }), + menu({ + id: "empty", + name: "空目录", + type: "directory", + children: [menu({ id: "hidden-child", name: "隐藏子项", type: "page", path: "/hidden-child", isVisible: false } as AdminMenu & { isVisible?: boolean })], + }), + ]; + + expect(visibleNavigableMenus(menus)).toEqual([ + { + ...menus[0], + }, + { + ...menus[1], + children: [{ ...menus[1].children?.[0] }], + }, + ]); + }); + + it("returns parent directory and page titles for normalized paths", () => { + const menus: SystemMenuItem[] = [ + { + id: "system", + name: "系统管理", + type: "directory", + path: "/system/", + sortOrder: 1, + isVisible: true, + isActive: true, + }, + { + id: "users", + parentId: "system", + name: "用户管理", + type: "page", + path: "system/users/", + sortOrder: 1, + isVisible: true, + isActive: true, + }, + ]; + + expect(findMenuTitle(menus, "//system/users//")).toEqual(["系统管理", "用户管理"]); + expect(findMenuTitle(menus, "/missing")).toEqual([]); + }); + + it("maps WonderQ paths and components, accepts supported custom icons, and falls back safely", () => { + expect(getMenuIconName(menu({ id: "dashboard", name: "仪表盘", type: "page", path: "/dashboard", icon: "Search" }))).toBe("Search"); + expect(getMenuIconName(menu({ id: "dashboard-default", name: "仪表盘", type: "page", path: "/dashboard" }))).toBe("Grid"); + expect(getMenuIconName(menu({ id: "play", name: "玩法", type: "page", component: "WanfaPage" }))).toBe("Route"); + expect(getMenuIconName(menu({ id: "custom", name: "自定义", type: "page", icon: "Collection" }))).toBe("Collection"); + expect(getMenuIconName(menu({ id: "unknown", name: "未知", type: "page", icon: "NotAnIcon" }))).toBe("Menu"); + }); + + it("keeps the LayoutIcon SFC contract static and dependency-free", () => { + const source = readFileSync(new URL("../components/layout/LayoutIcon.vue", import.meta.url), "utf8"); + expect(source).toContain('viewBox="0 0 24 24"'); + for (const name of ["Menu", "Grid", "Route", "User", "Collection", "Setting", "Search", "FullScreen", "Moon", "Sunny", "Refresh", "Close", "More", "Back", "Right", "Chevron"]) { + expect(source).toContain(`name === '${name}'`); + } + expect(source).toContain(''); + expect(source).toContain(':aria-hidden="title ? undefined : true"'); + expect(source).not.toMatch(/emoji|external|lucide|element-plus/i); + }); +}); diff --git a/WonderQ-Admin-UI-Vue/src/lib/menu-display.ts b/WonderQ-Admin-UI-Vue/src/lib/menu-display.ts new file mode 100644 index 0000000..dc62e3f --- /dev/null +++ b/WonderQ-Admin-UI-Vue/src/lib/menu-display.ts @@ -0,0 +1,110 @@ +import type { AdminMenu, SystemMenuItem } from "@/types"; + +export type MenuDisplayItem = (AdminMenu | SystemMenuItem) & { + isVisible?: boolean; + children?: MenuDisplayItem[]; +}; + +export type LayoutIconName = + | "Menu" + | "Grid" + | "Route" + | "User" + | "Collection" + | "Setting" + | "Search" + | "FullScreen" + | "Moon" + | "Sunny" + | "Refresh" + | "Close" + | "More" + | "Back" + | "Right" + | "Chevron"; + +const supportedIconNames = new Set<LayoutIconName>([ + "Menu", "Grid", "Route", "User", "Collection", "Setting", "Search", "FullScreen", + "Moon", "Sunny", "Refresh", "Close", "More", "Back", "Right", "Chevron", +]); + +const pathIconMap: Array<[string, LayoutIconName]> = [ + ["/dashboard", "Grid"], + ["/home", "Collection"], + ["/play", "Route"], + ["/concierge", "User"], + ["/leads", "Collection"], + ["/media", "Collection"], + ["/tools", "Setting"], + ["/system", "Setting"], +]; + +const componentIconMap: Record<string, LayoutIconName> = { + DashboardPage: "Grid", + HomePage: "Collection", + WanfaPage: "Route", + ConciergePage: "User", + LeadsPage: "Collection", + MediaPage: "Collection", + OperationsToolsPage: "Setting", + SystemResourcePage: "Setting", +}; + +function normalizePath(path: string | null | undefined) { + if (!path) return "/"; + const normalized = `/${path}`.replace(/\/+/g, "/"); + const withLeadingSlash = normalized.startsWith("/") ? normalized : `/${normalized}`; + const trimmed = withLeadingSlash.replace(/\/+$/, ""); + return trimmed || "/"; +} + +export function visibleNavigableMenus<T extends MenuDisplayItem>(menus: readonly T[]): T[] { + return menus.reduce<T[]>((visible, menu) => { + if (menu.type === "button" || menu.isVisible === false) return visible; + + const children = menu.children ? visibleNavigableMenus(menu.children) : undefined; + if (menu.type === "directory" && !menu.path && !children?.length) return visible; + visible.push({ ...menu, ...(children ? { children } : {}) }); + return visible; + }, []); +} + +export function findMenuTitle(menus: readonly MenuDisplayItem[], path: string): string[] { + const targetPath = normalizePath(path); + const entries: Array<{ menu: MenuDisplayItem; parentId?: string | null }> = []; + + function collect(items: readonly MenuDisplayItem[], parentId?: string | null) { + for (const menu of items) { + entries.push({ menu, parentId: menu.parentId ?? parentId }); + if (menu.children) collect(menu.children, menu.id); + } + } + + collect(menus); + const byId = new Map(entries.map((entry) => [entry.menu.id, entry])); + const match = entries.find(({ menu }) => normalizePath(menu.path) === targetPath); + if (!match) return []; + + const titles: string[] = []; + let current: typeof match | undefined = match; + const seen = new Set<string>(); + while (current && !seen.has(current.menu.id)) { + seen.add(current.menu.id); + titles.unshift(current.menu.name); + current = current.parentId ? byId.get(current.parentId) : undefined; + } + return titles; +} + +export function getMenuIconName(menu: Pick<MenuDisplayItem, "path" | "component" | "icon">): LayoutIconName { + const customIcon = menu.icon as LayoutIconName | null | undefined; + if (customIcon && supportedIconNames.has(customIcon)) return customIcon; + + const normalizedPath = normalizePath(menu.path); + const pathMatch = pathIconMap.find(([path]) => normalizedPath === path || normalizedPath.startsWith(`${path}/`)); + if (pathMatch) return pathMatch[1]; + + const componentMatch = menu.component ? componentIconMap[menu.component] : undefined; + if (componentMatch) return componentMatch; + return "Menu"; +}