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:
SpecialX
2026-07-22 12:17:16 +08:00
parent dc13a2afb4
commit f92fdf8efe
6 changed files with 602 additions and 12 deletions

View 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 - 全站页面框架RSCARCHITECTURE.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-closedmiddleware 必须注入身份头,缺失即异常
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>
);
}