feat(admin-ui): add RuoYi layout menu primitives
This commit is contained in:
46
WonderQ-Admin-UI-Vue/src/components/layout/LayoutIcon.vue
Normal file
46
WonderQ-Admin-UI-Vue/src/components/layout/LayoutIcon.vue
Normal 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>
|
||||
87
WonderQ-Admin-UI-Vue/src/lib/menu-display.test.ts
Normal file
87
WonderQ-Admin-UI-Vue/src/lib/menu-display.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
110
WonderQ-Admin-UI-Vue/src/lib/menu-display.ts
Normal file
110
WonderQ-Admin-UI-Vue/src/lib/menu-display.ts
Normal 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";
|
||||
}
|
||||
Reference in New Issue
Block a user