feat(portal-shell): wire AppFrame + navigation + permission filter (P1-1)
实现 ARCHITECTURE.md §10 P1-1:
- 新增 src/shared/lib/navigation.ts:27 项静态导航注册表,
按 teacher/student/parent/admin 四角色分区,group 字段用于角色过滤
- 新增 src/app/shell/layout.tsx:RSC AppFrame,从 headers() 读取
middleware 注入的身份头,batchCheckRoutePermission 按位图二次过滤
- 新增 src/shared/components/layout/user-menu.tsx:顶部用户菜单,
显示 userId + role,登出 POST /api/auth/logout
- 新增 src/shared/lib/__tests__/navigation.test.ts:P1-1 验收单测
(7 用例:href 登记一致性 + 4 角色隔离 + 权限检查)
- 修改 src/shared/lib/route-permissions.ts:补全 7 个列表页根路由
的 EXACT 登记(/shell/admin/announcements、/shell/admin/classes、
/shell/teacher/exams 等),与 PREFIX 表互补避免 catch-all 拒绝
验收:
- vitest run navigation → 7/7 passed
- tsc --noEmit 通过;eslint(5 文件)通过
回填 ARCHITECTURE.md §10 P1-1 状态为 ✅,附验收证据。
关联:ARCHITECTURE.md §7.2 AppFrame / §10 P1-1
This commit is contained in:
58
apps/portal-shell/src/app/shell/layout.tsx
Normal file
58
apps/portal-shell/src/app/shell/layout.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { headers } from "next/headers";
|
||||
|
||||
import type { Role } from "@edu/shared-ts/contracts";
|
||||
import { batchCheckRoutePermission } from "@/shared/lib/route-permissions";
|
||||
import { getNavigationItemsForRole } from "@/shared/lib/navigation";
|
||||
import {
|
||||
AppSidebar,
|
||||
type NavItem,
|
||||
} from "@/shared/components/layout/app-sidebar";
|
||||
import { SidebarProvider } from "@/shared/components/layout/sidebar-provider";
|
||||
import { SiteHeader } from "@/shared/components/layout/site-header";
|
||||
import { UserMenu } from "@/shared/components/layout/user-menu";
|
||||
|
||||
/**
|
||||
* AppFrame - 全站页面框架(RSC,ARCHITECTURE.md §7.2 / §10 P1-1)
|
||||
*
|
||||
* 结构:SidebarProvider > AppSidebar + (SiteHeader + main)
|
||||
* - 从 middleware 注入的请求头读取身份(fail-closed,§11.7 红线 #5)
|
||||
* - batchCheckRoutePermission 按用户位图过滤导航项
|
||||
* - PREFIX 路由以 "/" 结尾,需同时检查 href 和 href+"/"
|
||||
*/
|
||||
export default async function ShellLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): Promise<React.ReactElement> {
|
||||
const headerList = await headers();
|
||||
const userId = headerList.get("x-user-id");
|
||||
const roleHeader = headerList.get("x-user-role");
|
||||
const permsBitmap = headerList.get("x-user-permissions");
|
||||
|
||||
// fail-closed:middleware 必须注入身份头,缺失即异常
|
||||
if (!userId || !roleHeader || !permsBitmap) {
|
||||
throw new Error(
|
||||
"[portal-shell] ShellLayout missing identity headers " +
|
||||
"(middleware must inject x-user-id / x-user-role / x-user-permissions).",
|
||||
);
|
||||
}
|
||||
|
||||
const role = roleHeader as Role;
|
||||
const roleItems = getNavigationItemsForRole(role);
|
||||
// PREFIX 路由以 "/" 结尾,需同时检查 href 和 href+"/"
|
||||
const pathsToCheck = roleItems.flatMap((i) => [i.href, `${i.href}/`]);
|
||||
const permMap = batchCheckRoutePermission(pathsToCheck, permsBitmap, role);
|
||||
const visibleItems: NavItem[] = roleItems
|
||||
.filter((i) => permMap[i.href] === true || permMap[`${i.href}/`] === true)
|
||||
.map((i) => ({ title: i.label, href: i.href, icon: i.icon }));
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar items={visibleItems} hasPermission={() => true} />
|
||||
<div className="flex flex-1 flex-col">
|
||||
<SiteHeader actions={<UserMenu userId={userId} role={role} />} />
|
||||
<main className="flex-1 overflow-y-auto p-4">{children}</main>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user