feat(admin-ui): add RuoYi layout menu primitives

This commit is contained in:
duanshuwen
2026-08-26 20:40:07 +08:00
parent 0b106a3dd2
commit 3c8c6490fd
3 changed files with 243 additions and 0 deletions

View File

@@ -0,0 +1,46 @@
<script setup lang="ts">
import type { LayoutIconName } from "@/lib/menu-display";
withDefaults(defineProps<{
name: LayoutIconName;
size?: number | string;
title?: string;
}>(), {
size: 24,
});
</script>
<template>
<svg
:width="size"
:height="size"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
focusable="false"
:aria-hidden="title ? undefined : true"
:aria-label="title"
role="img"
>
<title v-if="title">{{ title }}</title>
<path v-if="name === 'Menu'" d="M4 6h16M4 12h16M4 18h16" />
<path v-else-if="name === 'Grid'" d="M4 4h6v6H4zM14 4h6v6h-6zM4 14h6v6H4zM14 14h6v6h-6z" />
<path v-else-if="name === 'Route'" d="M5 5h.01M5 19h.01M19 5h.01M7 5h5a3 3 0 0 1 3 3v8a3 3 0 0 0 3 3h1M5 7v10" />
<path v-else-if="name === 'User'" d="M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8ZM4 21a8 8 0 0 1 16 0" />
<path v-else-if="name === 'Collection'" d="M4 5h16v14H4zM8 5v14M8 9h12M8 14h12" />
<path v-else-if="name === 'Setting'" d="m12 3 1.2 2.4 2.6.4.9 2.5 2.2 1.5-.9 2.5.9 2.5-2.2 1.5-.9 2.5-2.6.4L12 21l-1.2-2.4-2.6-.4-.9-2.5-2.2-1.5.9-2.5-.9-2.5 2.2-1.5.9-2.5 2.6-.4zM12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z" />
<path v-else-if="name === 'Search'" d="m20 20-4.5-4.5M10.5 17a6.5 6.5 0 1 1 0-13 6.5 6.5 0 0 1 0 13Z" />
<path v-else-if="name === 'FullScreen'" d="M8 4H4v4M16 4h4v4M20 16v4h-4M4 16v4h4" />
<path v-else-if="name === 'Moon'" d="M20 15.5A8.5 8.5 0 0 1 8.5 4 8.5 8.5 0 1 0 20 15.5Z" />
<path v-else-if="name === 'Sunny'" d="M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6 7 7M17 17l1.4 1.4M18.4 5.6 17 7M7 17l-1.4 1.4M16 12a4 4 0 1 1-8 0 4 4 0 0 1 8 0Z" />
<path v-else-if="name === 'Refresh'" d="M20 11a8 8 0 0 0-14.8-3L3 11M3 5v6h6M4 13a8 8 0 0 0 14.8 3L21 13M21 19v-6h-6" />
<path v-else-if="name === 'Close'" d="m6 6 12 12M18 6 6 18" />
<path v-else-if="name === 'More'" d="M5 12h.01M12 12h.01M19 12h.01" stroke-width="3" />
<path v-else-if="name === 'Back'" d="m15 18-6-6 6-6M9 12h10" />
<path v-else-if="name === 'Right'" d="m9 6 6 6-6 6M5 12h10" />
<path v-else-if="name === 'Chevron'" d="m8 9 4 4 4-4" />
</svg>
</template>

View File

@@ -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<AdminMenu> & Pick<AdminMenu, "id" | "name" | "type">): 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('<title v-if="title">');
expect(source).toContain(':aria-hidden="title ? undefined : true"');
expect(source).not.toMatch(/emoji|external|lucide|element-plus/i);
});
});

View File

@@ -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";
}