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

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

View File

@@ -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) {

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");

View File

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

View File

@@ -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 ?? [])]);
}

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;

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() {

View File

@@ -38,7 +38,7 @@ def build_menu_tree(menus: Iterable[AdminMenu], visible_only: bool = True) -> li
node = nodes[menu.id]
if menu.parentId and menu.parentId in nodes:
nodes[menu.parentId]["children"].append(node)
elif not menu.parentId:
else:
roots.append(node)
return roots

View File

@@ -111,6 +111,17 @@ def profile(
}
@router.get("/routers")
def routers(
user: AdminUser = Depends(require_admin),
db: Session = Depends(get_db),
store: AdminSessionStore = Depends(get_admin_session_store),
):
"""Return the authenticated user's visible menu tree for dynamic routing."""
context = build_admin_permission_context(user, db, store)
return {"code": 200, "msg": "success", "data": context["menus"]}
@router.get("/users")
def list_users(_user: AdminUser = Depends(require_permission("system:user:read")), db: Session = Depends(get_db)):
users = list(db.scalars(select(AdminUser).order_by(AdminUser.createdAt.desc())).all())

View File

@@ -220,3 +220,13 @@ def test_dynamic_menu_tree_preserves_parent_order_and_button_permissions():
assert DATA_SCOPE_VALUES == {"all", "dept", "dept_and_children", "custom_dept", "self"}
assert [item["id"] for item in tree] == ["root"]
assert tree[0]["children"][0]["permission"] == "home:edit"
def test_dynamic_menu_tree_keeps_granted_menu_when_parent_is_not_in_scope():
menus = [
AdminMenu(id="page", parentId="missing-parent", name="受限页面", type="page", path="/restricted", sortOrder=10, isVisible=True, isActive=True),
]
tree = build_menu_tree(menus)
assert [item["id"] for item in tree] == ["page"]

View File

@@ -257,6 +257,13 @@ def test_admin_requires_auth_for_protected_endpoint():
assert response.json() == {"code": 401, "msg": "请先登录后台", "data": None}
def test_admin_dynamic_routers_requires_admin_authentication():
response = TestClient(create_app()).get("/api/admin/system/routers")
assert response.status_code == 401
assert response.json() == {"code": 401, "msg": "请先登录后台", "data": None}
def test_admin_login_returns_token_and_user():
admin_user = AdminUser(
id="admin-test",

View File

@@ -31,6 +31,7 @@
| `POST` | `/api/admin/auth/logout` | 撤销当前后台会话 |
| `GET` | `/api/admin/me` | 当前后台用户 |
| `GET` | `/api/admin/system/profile` | 当前用户、角色、权限码和动态菜单 |
| `GET` | `/api/admin/system/routers` | 按当前管理员权限返回 RuoYi 风格动态路由树 |
| `GET` | `/api/admin/system/users` | 查询后台用户 |
| `POST` | `/api/admin/system/users` | 新增后台用户 |
| `PATCH` | `/api/admin/system/users/{userId}` | 更新后台用户 |
@@ -110,6 +111,40 @@ type SiteModule =
`/api/admin/system/profile` 返回 `roles``permissions``menus``dataScopes``deptIds`。菜单只返回启用且可见的目录/页面,按钮菜单保留在页面节点的 `children` 中;前端组件只能从预注册组件白名单加载 `component`
`GET /api/admin/system/routers` 是管理端对应 RuoYi `getRouters` 的独立接口。接口只要求当前管理员会话,不要求调用者拥有 `system:menu:read`,否则普通运营角色会因无法读取菜单管理页面而无法加载自己的导航。响应为:
```json
{
"code": 200,
"msg": "success",
"data": [
{
"id": "menu-id",
"name": "系统管理",
"type": "directory",
"path": "/system",
"component": null,
"permission": null,
"icon": "Setting",
"sortOrder": 60,
"children": [
{
"id": "page-id",
"name": "用户管理",
"type": "page",
"path": "/system/users",
"component": "SystemUsers",
"permission": "system:user:read",
"children": []
}
]
}
]
}
```
后端先按当前管理员角色计算可见菜单,再过滤停用菜单与不可见目录/页面;按钮菜单作为页面节点的 `children` 返回,但不会被前端注册为页面路由。`profile.menus` 继续保留以兼容旧管理端,`routers` 才是 `WonderQ-Admin-UI-Vue` 动态导航和动态路由注册的权威来源。前端只能将 `component` 映射到预注册组件白名单,未知组件不得执行或加载。
角色数据范围使用以下五个编码:`all`(全部)、`dept`(当前部门)、`dept_and_children`(当前部门及子部门)、`custom_dept`(自定义部门)、`self`(本人)。运营资源通过 `deptId``createdById` 归属字段执行查询过滤。
登录按 IP 与账号组合执行 Redis 限流,默认 60 秒最多 5 次;权限菜单缓存默认 300 秒。Redis 故障不能放行权限检查,缓存不可用时只能重新读取数据库,认证会话和限流不可用时返回 `503`

View File

@@ -71,11 +71,14 @@ yarn dev:mp-weixin
- `GET /health` 返回健康状态。
- `GET /api/public/site-config` 返回前台站点配置。
- `POST /api/admin/auth/login` 返回统一包裹的登录结果。
- 管理端登录后依次读取 `/api/admin/system/profile``/api/admin/system/routers`;后者按当前管理员权限提供动态导航树。
- 所有成功响应包含数字 `code``msg: "success"``data`;失败响应的 `data` 必须为 `null`
管理端重点检查:
- 登录后请求带 `Authorization: Bearer <access-token>`Refresh Token 只通过 HttpOnly Cookie 传递。
- 动态路由只注册 `/api/admin/system/routers` 返回的页面菜单;目录用于组织层级,按钮只用于按钮权限,不注册为页面。
- 变更角色、菜单或用户关联后,权限缓存失效时要重新登录或重新加载菜单,确认侧栏、路由和按钮权限同步变化。
- 站点模块、玩法、详情、管家、线索和媒体接口按当前契约返回。
- 新增、更新、删除和排序成功后,页面使用接口返回的数据更新状态,不自行生成 ID 或排序结果。