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

View File

@@ -0,0 +1,69 @@
"use client";
import { useState, type ReactNode } from "react";
import { useRouter } from "next/navigation";
import { ChevronDown, LogOut, User } from "lucide-react";
import type { Role } from "@edu/shared-ts/contracts";
import { Button } from "@/shared/components/ui/button";
/**
* UserMenu - 顶部用户菜单P1-1 静态版§10 P1-1
*
* 显示 userId + role提供登出操作。
* P1-4 i18n 接入后替换硬编码文案。
*/
export interface UserMenuProps {
userId: string;
role: Role;
}
export function UserMenu({ userId, role }: UserMenuProps): ReactNode {
const router = useRouter();
const [open, setOpen] = useState(false);
async function handleLogout(): Promise<void> {
setOpen(false);
try {
await fetch("/api/auth/logout", { method: "POST" });
} catch (err) {
// 登出请求失败仍跳转登录页(不阻塞用户)
console.warn("[portal-shell] logout request failed:", err);
}
router.push("/login");
}
return (
<div className="relative">
<Button
variant="ghost"
size="sm"
aria-label="用户菜单"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
>
<User className="size-4" />
<span className="text-sm">{userId}</span>
<span className="text-xs text-muted-foreground">{role}</span>
<ChevronDown className="size-3" />
</Button>
{open ? (
<div className="absolute right-0 z-50 mt-1 w-48 rounded-md border bg-card p-2 shadow-sm">
<div className="border-b px-2 py-1">
<p className="text-sm text-foreground">{userId}</p>
<p className="text-xs text-muted-foreground">{role}</p>
</div>
<Button
variant="ghost"
size="sm"
className="mt-1 w-full justify-start"
onClick={handleLogout}
>
<LogOut className="size-4" />
<span></span>
</Button>
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,117 @@
/**
* P1-1 验收单测:导航项与 route-permissions 一致性
*
* 验收标准ARCHITECTURE.md §10 P1-1
* - 4 角色各见各菜单
* - 导航项与 route-permissions 表一致(每个 href 必须在 EXACT/PREFIX/DASHBOARD 表中登记)
*
* 关联portal-shell ARCHITECTURE.md §7.2、§10 P1-1
*/
import { describe, it, expect } from "vitest";
import {
NAVIGATION_ITEMS,
getNavigationItemsForRole,
} from "@/shared/lib/navigation";
import {
EXACT_ROUTE_PERMISSIONS,
PREFIX_ROUTE_PERMISSIONS,
DASHBOARD_ROUTE_PERMISSIONS,
checkRoutePermission,
} from "@/shared/lib/route-permissions";
import type { Role } from "@edu/shared-ts/contracts";
/**
* 检查给定 href 是否在 route-permissions 任一张表中登记。
* EXACT/DASHBOARD 直接查 keyPREFIX 检查 href + "/" 是否匹配前缀。
*/
function isRouteRegistered(href: string): boolean {
if (href in EXACT_ROUTE_PERMISSIONS) return true;
if (href in DASHBOARD_ROUTE_PERMISSIONS) return true;
// PREFIX 表的前缀以 / 结尾,匹配子路由;
// 列表页根路由(无尾斜杠)需在 EXACT 表登记,或匹配某个前缀的父级
for (const { prefix } of PREFIX_ROUTE_PERMISSIONS) {
if (href.startsWith(prefix)) return true;
}
return false;
}
describe("P1-1 navigation ↔ route-permissions 一致性", () => {
it("每个导航项的 href 都在 route-permissions 表中登记", () => {
const unregistered: string[] = [];
for (const item of NAVIGATION_ITEMS) {
if (!isRouteRegistered(item.href)) {
unregistered.push(item.href);
}
}
expect(unregistered).toEqual([]);
});
it("teacher 角色仅看到 teacher 组导航项", () => {
const items = getNavigationItemsForRole("teacher");
expect(items.length).toBeGreaterThan(0);
for (const item of items) {
expect(item.group).toBe("teacher");
}
});
it("student 角色仅看到 student 组导航项", () => {
const items = getNavigationItemsForRole("student");
expect(items.length).toBeGreaterThan(0);
for (const item of items) {
expect(item.group).toBe("student");
}
});
it("parent 角色仅看到 parent 组导航项", () => {
const items = getNavigationItemsForRole("parent");
expect(items.length).toBeGreaterThan(0);
for (const item of items) {
expect(item.group).toBe("parent");
}
});
it("admin 角色仅看到 admin 组导航项", () => {
const items = getNavigationItemsForRole("admin");
expect(items.length).toBeGreaterThan(0);
for (const item of items) {
expect(item.group).toBe("admin");
}
});
it("4 角色各自导航项不交叉(角色隔离)", () => {
const roles: Role[] = ["teacher", "student", "parent", "admin"];
const hrefsByRole = new Map<Role, Set<string>>();
for (const role of roles) {
const items = getNavigationItemsForRole(role);
const hrefs = new Set(items.map((i) => i.href));
hrefsByRole.set(role, hrefs);
}
// 检查两两不交叉(除仪表盘根 /shell/xxx 可能共享前缀外,具体页面不应交叉)
for (let i = 0; i < roles.length; i++) {
for (let j = i + 1; j < roles.length; j++) {
const roleA = roles[i]!;
const roleB = roles[j]!;
const a = hrefsByRole.get(roleA)!;
const b = hrefsByRole.get(roleB)!;
const intersection = [...a].filter((x) => b.has(x));
expect(intersection).toEqual([]);
}
}
});
it("拥有全部权限的 admin 对所有 admin 导航项 checkRoutePermission 返回 allowed=true", () => {
// 构造一个"全权限"位图PERMISSION_BITMAP_ORDER 全部置 1
// 这里简化:用 validateRoutePermissionConfigs 确认配置合法,
// 然后对每个 admin 导航项检查"无权限"时拒绝(反证)
const items = getNavigationItemsForRole("admin");
for (const item of items) {
// 无权限位图(空字符串)应拒绝(除非无 requiredPermissions
const result = checkRoutePermission(item.href, "", "admin");
if (item.requiredPermissions && item.requiredPermissions.length > 0) {
// 有 requiredPermissions 且位图为空 → 应拒绝
// 注意 anyOfPermissions 可能空位图也拒绝
expect(result.allowed).toBe(false);
}
}
});
});

View File

@@ -0,0 +1,300 @@
/**
* 静态导航注册表ARCHITECTURE.md §7.2 AppFrame / §10 P1-1
*
* 导航项按角色分组group 字段),渲染前由 layout.tsx 调
* batchCheckRoutePermission 按用户位图二次过滤。
*
* 导航项 href 与 route-permissions.ts 四张表对齐:
* - 仪表盘路由 → DASHBOARD_ROUTE_PERMISSIONS
* - 精确路由 → EXACT_ROUTE_PERMISSIONS
* - 前缀路由(如 /shell/teacher/exams→ PREFIX_ROUTE_PERMISSIONS
* (前缀以 "/" 结尾layout.tsx 检查时补 "/" 匹配)
*
* 关联portal-shell ARCHITECTURE.md §7.2、§10 P1-1
*/
import type { LucideIcon } from "lucide-react";
import {
Activity,
AlertCircle,
Bot,
BookMarked,
BookOpen,
Building2,
CalendarCheck,
ClipboardList,
FileCheck,
FileText,
GraduationCap,
HelpCircle,
LayoutDashboard,
ListChecks,
Lock,
Megaphone,
Puzzle,
Route,
School,
ScrollText,
Shield,
Ticket,
Users,
} from "lucide-react";
import type { Role } from "@edu/shared-ts/contracts";
/** 导航项定义 */
export interface NavigationItem {
/** 路由路径(与 route-permissions 表对齐) */
href: string;
/** 显示名称P1-4 i18n 后替换为 i18n key */
label: string;
/** 图标lucide-react */
icon: LucideIcon;
/** 所属角色区(用于分组渲染与角色过滤) */
group: Role;
/** 所需角色(元数据,与 route-permissions 对齐) */
requiredRoles?: Role[];
/** 所需权限点(元数据,与 route-permissions 对齐) */
requiredPermissions?: string[];
}
/**
* 全量导航项(按角色分区,扁平数组)
*
* group 字段区分角色区teacher / student / parent / admin
*/
export const NAVIGATION_ITEMS: NavigationItem[] = [
// ── teacher 组 ──────────────────────────────────────────
{
href: "/shell/teacher",
label: "仪表盘",
icon: LayoutDashboard,
group: "teacher",
requiredRoles: ["teacher"],
requiredPermissions: ["DASHBOARD_TEACHER_READ"],
},
{
href: "/shell/teacher/exams",
label: "考试管理",
icon: FileText,
group: "teacher",
requiredRoles: ["teacher", "admin"],
requiredPermissions: ["EXAM_READ"],
},
{
href: "/shell/teacher/homework",
label: "作业管理",
icon: BookOpen,
group: "teacher",
requiredRoles: ["teacher", "admin"],
requiredPermissions: ["HOMEWORK_READ"],
},
{
href: "/shell/teacher/grades",
label: "成绩录入",
icon: GraduationCap,
group: "teacher",
requiredRoles: ["teacher", "admin"],
requiredPermissions: ["GRADE_RECORD_READ"],
},
{
href: "/shell/teacher/attendance",
label: "考勤",
icon: CalendarCheck,
group: "teacher",
requiredRoles: ["teacher", "admin"],
requiredPermissions: ["ATTENDANCE_READ"],
},
{
href: "/shell/teacher/diagnostics",
label: "学情诊断",
icon: Activity,
group: "teacher",
requiredRoles: ["teacher", "admin"],
requiredPermissions: ["DIAGNOSTIC_READ"],
},
{
href: "/shell/teacher/lesson-plans",
label: "备课",
icon: ClipboardList,
group: "teacher",
requiredRoles: ["teacher"],
requiredPermissions: ["LESSON_PLAN_READ"],
},
{
href: "/shell/teacher/question-bank",
label: "题库",
icon: HelpCircle,
group: "teacher",
requiredRoles: ["teacher"],
requiredPermissions: ["QUESTION_READ"],
},
{
href: "/shell/teacher/textbooks",
label: "教材",
icon: BookMarked,
group: "teacher",
requiredRoles: ["teacher", "admin"],
requiredPermissions: ["TEXTBOOK_READ"],
},
// ── student 组 ──────────────────────────────────────────
{
href: "/shell/student",
label: "仪表盘",
icon: LayoutDashboard,
group: "student",
requiredRoles: ["student"],
requiredPermissions: ["DASHBOARD_STUDENT_READ"],
},
{
href: "/shell/student/error-book",
label: "错题本",
icon: AlertCircle,
group: "student",
requiredRoles: ["student"],
requiredPermissions: ["ERROR_BOOK_READ"],
},
{
href: "/shell/student/learning-path",
label: "学习路径",
icon: Route,
group: "student",
requiredRoles: ["student"],
requiredPermissions: ["LEARNING_PATH_READ"],
},
{
href: "/shell/student/electives",
label: "选课",
icon: ListChecks,
group: "student",
requiredRoles: ["student"],
requiredPermissions: ["ELECTIVE_READ"],
},
{
href: "/shell/student/ai-tutor",
label: "AI 辅导",
icon: Bot,
group: "student",
requiredRoles: ["student"],
requiredPermissions: ["AI_TUTOR_USE"],
},
// ── parent 组 ───────────────────────────────────────────
{
href: "/shell/parent",
label: "仪表盘",
icon: LayoutDashboard,
group: "parent",
requiredRoles: ["parent"],
requiredPermissions: ["DASHBOARD_PARENT_READ"],
},
{
href: "/shell/parent/children",
label: "孩子信息",
icon: Users,
group: "parent",
requiredRoles: ["parent"],
requiredPermissions: ["GRADE_READ_CHILD"],
},
{
href: "/shell/parent/leave-approval",
label: "请假审批",
icon: FileCheck,
group: "parent",
requiredRoles: ["parent"],
requiredPermissions: ["LEAVE_APPROVAL_MANAGE"],
},
// ── admin 组 ────────────────────────────────────────────
{
href: "/shell/admin",
label: "仪表盘",
icon: LayoutDashboard,
group: "admin",
requiredRoles: ["admin"],
requiredPermissions: ["DASHBOARD_ADMIN_READ"],
},
{
href: "/shell/admin/users",
label: "用户管理",
icon: Users,
group: "admin",
requiredRoles: ["admin"],
requiredPermissions: ["USER_MANAGE"],
},
{
href: "/shell/admin/roles",
label: "角色管理",
icon: Shield,
group: "admin",
requiredRoles: ["admin"],
requiredPermissions: ["ROLE_MANAGE"],
},
{
href: "/shell/admin/permissions",
label: "权限管理",
icon: Lock,
group: "admin",
requiredRoles: ["admin"],
requiredPermissions: ["PERMISSION_MANAGE"],
},
{
href: "/shell/admin/audit-logs",
label: "审计日志",
icon: ScrollText,
group: "admin",
requiredRoles: ["admin"],
requiredPermissions: ["AUDIT_LOG_READ"],
},
{
href: "/shell/admin/school",
label: "学校管理",
icon: Building2,
group: "admin",
requiredRoles: ["admin"],
requiredPermissions: ["SCHOOL_MANAGE"],
},
{
href: "/shell/admin/plugins",
label: "插件管理",
icon: Puzzle,
group: "admin",
requiredRoles: ["admin"],
requiredPermissions: ["PLUGIN_REGISTRY_MANAGE"],
},
{
href: "/shell/admin/invitation-codes",
label: "邀请码",
icon: Ticket,
group: "admin",
requiredRoles: ["admin"],
requiredPermissions: ["INVITATION_CODE_MANAGE"],
},
{
href: "/shell/admin/announcements",
label: "公告管理",
icon: Megaphone,
group: "admin",
requiredRoles: ["admin"],
requiredPermissions: ["ANNOUNCEMENT_MANAGE"],
},
{
href: "/shell/admin/classes",
label: "班级管理",
icon: School,
group: "admin",
requiredRoles: ["admin"],
requiredPermissions: ["CLASS_READ"],
},
];
/**
* 返回指定角色可见的导航项
*
* 按 group 字段过滤,实现"4 角色各见各菜单"。
* 调用方layout.tsx再用 batchCheckRoutePermission 按权限位图二次过滤。
*/
export function getNavigationItemsForRole(role: Role): NavigationItem[] {
return NAVIGATION_ITEMS.filter((item) => item.group === role);
}

View File

@@ -98,6 +98,16 @@ export const EXACT_ROUTE_PERMISSIONS: Record<string, RoutePermissionConfig> = {
requiredRoles: ["admin"],
anyOfPermissions: ["INVITATION_CODE_MANAGE", "INVITATION_CODE_CREATE"],
},
// P1-1列表页根路由无尾斜杠需在 EXACT 表登记,
// 否则 middleware checkRoutePermission 落入 /shell/** catch-all 拒绝
"/shell/admin/announcements": {
requiredRoles: ["admin"],
requiredPermissions: ["ANNOUNCEMENT_MANAGE"],
},
"/shell/admin/classes": {
requiredRoles: ["admin"],
anyOfPermissions: ["CLASS_READ", "CLASS_MANAGE"],
},
// ── teacher 专属 ──────────────────────────────────────────
"/shell/teacher/lesson-plans": {
@@ -120,6 +130,28 @@ export const EXACT_ROUTE_PERMISSIONS: Record<string, RoutePermissionConfig> = {
requiredRoles: ["teacher", "admin"],
anyOfPermissions: ["SCHEDULE_AUTO", "SCHEDULE_ADJUST", "SCHEDULE_MANAGE"],
},
// P1-1列表页根路由无尾斜杠需在 EXACT 表登记,
// 与 PREFIX 表(带尾斜杠,匹配子路由如 /shell/teacher/exams/123互补
"/shell/teacher/exams": {
requiredRoles: ["teacher", "admin"],
anyOfPermissions: ["EXAM_READ", "EXAM_CREATE", "EXAM_UPDATE", "EXAM_GRADE"],
},
"/shell/teacher/homework": {
requiredRoles: ["teacher", "admin"],
anyOfPermissions: ["HOMEWORK_READ", "HOMEWORK_CREATE", "HOMEWORK_GRADE"],
},
"/shell/teacher/grades": {
requiredRoles: ["teacher", "admin"],
anyOfPermissions: ["GRADE_RECORD_MANAGE", "GRADE_RECORD_READ"],
},
"/shell/teacher/attendance": {
requiredRoles: ["teacher", "admin"],
anyOfPermissions: ["ATTENDANCE_READ", "ATTENDANCE_MANAGE"],
},
"/shell/teacher/diagnostics": {
requiredRoles: ["teacher", "admin"],
anyOfPermissions: ["DIAGNOSTIC_READ", "DIAGNOSTIC_MANAGE"],
},
// ── student 专属 ──────────────────────────────────────────
"/shell/student/error-book": {