feat(admin): 实现动态路由菜单并修复rbac菜单树构建问题

- 修复rbac.py中菜单树构建逻辑,将`elif not menu.parentId`改为`else`,避免父菜单不存在时子菜单丢失
- 新增后端`/api/admin/system/routers`接口,作为动态路由菜单的API
- 前端新增`getRouters` API并整合到用户信息加载流程
- 添加菜单扁平化工具函数,更新路由注册逻辑以支持嵌套路由
- 新增相关测试用例并更新API和集成文档
This commit is contained in:
duanshuwen committed 2026-08-27 00:02:33 +08:00
1 parent f087663b5b
commit 2c8c327de7
12 files changed
+108 -5

No files matched your search

@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
createSystemDept,
getRouters,
getSystemDepts,
getSystemMenus,
getSystemRoles,
@@ -49,6 +50,16 @@ describe("system RBAC client", () => {
["/api/admin/system/depts", "POST"],
]);
});
it("loads the RuoYi-compatible dynamic router tree from the admin API", async () => {
const fetchMock = vi.fn().mockResolvedValueOnce(jsonResponse(200, [{ id: "dashboard", name: "仪表盘", type: "page" }]));
vi.stubGlobal("fetch", fetchMock);
const routers = await getRouters();
expect(routers).toEqual([{ id: "dashboard", name: "仪表盘", type: "page" }]);
expect(fetchMock).toHaveBeenCalledWith("/api/admin/system/routers", expect.anything());
});
});
function jsonResponse(code: number, data: unknown) {
+3
View File
@@ -2,6 +2,7 @@ import type {
ApiErrorResponse,
ApiResponse,
AuthPayload,
AdminMenu,
AdminProfile,
ConciergeAdvisorCreate,
ConciergeAdvisorPatch,
@@ -140,6 +141,8 @@ export async function request<T>(path: string, options: RequestInit = {}, retry
export const login = (email: string, password: string) => request<AuthPayload>("/api/admin/auth/login", { method: "POST", body: JSON.stringify({ email, password }) }, false);
export const getProfile = () => request<AdminProfile>("/api/admin/system/profile");
/** Load the authenticated user's visible menu tree for dynamic route registration. */
export const getRouters = () => request<AdminMenu[]>("/api/admin/system/routers");
export const logout = () => request<{ ok: boolean }>("/api/admin/auth/logout", { method: "POST" }, false);
export const getDashboard = () => request<Dashboard>("/api/admin/dashboard");
export const getSiteConfig = () => request<SiteConfig>("/api/admin/site-config");
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { isStaticRouteComponent } from "./menu-routes";
import { flattenMenuTree, isStaticRouteComponent } from "./menu-routes";
describe("admin menu route registration", () => {
it("keeps RBAC resource pages on the static parameter route", () => {
@@ -10,3 +10,18 @@ describe("admin menu route registration", () => {
expect(isStaticRouteComponent("Dashboard")).toBe(false);
});
});
describe("dynamic menu routes", () => {
it("flattens nested router menus so child pages can be registered", () => {
const menus = [
{
id: "system",
name: "系统管理",
type: "directory" as const,
children: [{ id: "users", name: "用户管理", type: "page" as const, path: "/system/users" }],
},
];
expect(flattenMenuTree(menus).map((item) => item.id)).toEqual(["system", "users"]);
});
});
@@ -1,5 +1,11 @@
import type { AdminMenu } from "@/types";
const staticRouteComponents = new Set(["SystemUsers", "SystemRoles", "SystemMenus", "SystemDepts"]);
export function isStaticRouteComponent(component: string | null | undefined) {
return typeof component === "string" && staticRouteComponents.has(component);
}
export function flattenMenuTree(menus: readonly AdminMenu[]): AdminMenu[] {
return menus.flatMap((menu) => [menu, ...flattenMenuTree(menu.children ?? [])]);
}
+2 -2
View File
@@ -10,7 +10,7 @@ import LeadsPage from "@/pages/LeadsPage.vue";
import MediaPage from "@/pages/MediaPage.vue";
import SystemResourcePage from "@/pages/SystemResourcePage.vue";
import NotFoundPage from "@/pages/NotFoundPage.vue";
import { isStaticRouteComponent } from "@/lib/menu-routes";
import { flattenMenuTree, isStaticRouteComponent } from "@/lib/menu-routes";
import { adminIndexRoute } from "./route-config";
const componentRegistry = {
@@ -54,7 +54,7 @@ let dynamicRoutesRegistered = false;
function registerDynamicRoutes() {
if (dynamicRoutesRegistered) return;
const auth = useAuthStore();
for (const menu of auth.menus) {
for (const menu of flattenMenuTree(auth.menus)) {
if (menu.type !== "page" || !menu.path || !menu.component || isStaticRouteComponent(menu.component) || !(menu.component in componentRegistry)) continue;
const path = menu.path.replace(/^\/+/, "");
if (router.getRoutes().some((route) => route.path === `/${path}`)) continue;
+3 -1
View File
@@ -18,7 +18,9 @@ export const useAuthStore = defineStore("auth", () => {
}
async function loadProfile() {
applyProfile(await api.getProfile());
const profile = await api.getProfile();
applyProfile(profile);
menus.value = await api.getRouters();
}
async function bootstrap() {