diff --git a/apps/portal-shell/ARCHITECTURE.md b/apps/portal-shell/ARCHITECTURE.md index 3aeba78..c335392 100644 --- a/apps/portal-shell/ARCHITECTURE.md +++ b/apps/portal-shell/ARCHITECTURE.md @@ -2,7 +2,7 @@ > 版本:3.0 > 日期:2026-07-20 -> 状态:**P0 已完成(2026-07-22 验收)+ P1 进行中;架构审计完成 + 重设计方案定稿** +> 状态:**P0 已完成 + P1-1 已完成(2026-07-22 验收)+ P1 进行中;架构审计完成 + 重设计方案定稿** > 本文档地位:**portal-shell 前端工作的唯一权威指导文档**。所有后续 AI/人工在此模块的工作必须先读本文件,以其为准。 > > 关联文档(按效力排序): @@ -757,20 +757,34 @@ export default async function ExamsPage(): Promise { - **P0-7**:`eslint .` → 0 errors, 2 warnings(均在 `__generated__/types.ts` 生成文件);[eslint.config.js](file:///e:/Desktop/Edu/apps/portal-shell/eslint.config.js) 含 `no-restricted-imports` - **P0-8**:`git status` 干净(commit `cfb7b00`);`.env.local` 与 `tsconfig.tsbuildinfo` 已在 [.gitignore](file:///e:/Desktop/Edu/apps/portal-shell/.gitignore) -### P1 · 框架与数据源接通(1–2 周) +### P1 · 框架与数据源接通(1–2 周)— 进行中(P1-1 ✅ 2026-07-22 验收) **目标**:AppFrame + 导航 + 真实数据仪表盘 + 页面模板 + MSW + i18n,页面迁移的"流水线"建成。 -| # | 任务 | 验收 | -| ---- | -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| P1-1 | `src/app/shell/layout.tsx` AppFrame(TopBar 静态挂载 + Sidebar 导航菜单 `navigation.ts` + 权限过滤) | 4 角色各见各菜单;导航项与 route-permissions 表一致(单测) | -| P1-2 | 仪表盘改接真实查询:`teacherDashboard`/`studentDashboard`/`parentDashboard`/`adminDashboard` + `warnings`/`errorBookStats` | 起全栈,4 角色仪表盘显示真实聚合数据(截图);widget 假契约查询(grades/homeworks/schedule/attendance/exams/announcements)全部下线或改造 | -| P1-3 | 页面模板四件套(list/detail/form/workbench)+ 三态规范 | Storybook 或示例页 4 张(/shell/_dev/templates/*,仅 dev 可见) | -| P1-4 | next-intl 接入 + messages 合并迁移 | 切换 locale 页面文案切换(截图);`useT` 自造函数删除 | -| P1-5 | MSW 兜底层(迁移旧 handlers,覆盖 dashboard/users/exams/grades 四域起步) | `NEXT_PUBLIC_MSW=1` 无后端启动,仪表盘 + users 页有数据(截图);生产构建 bundle 无 mocks | -| P1-6 | 31 widget 令牌清债(271 处机械替换)+ `border border` 去重 + 未知类检测进 arch:scan | `grep -c "text-heading-\|mt-sm\|py-xs\|p-md" src/widgets` = 0;`pnpm lint:tokens` 通过 | -| P1-7 | codegen 恢复 typescript-operations(config + data-ana 两域先行关闭 skipDocumentsValidation) | 生成操作级类型;lib/api 对应域删除手写 interface;typecheck 通过 | -| P1-8 | CI 增补:路由表一致性脚本 + 页面计数 + codegen diff 检查 | CI 对预埋违规报红(附 pipeline 链接) | +| # | 任务 | 验收 | 状态 | +| ---- | -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ---- | +| P1-1 | `src/app/shell/layout.tsx` AppFrame(TopBar 静态挂载 + Sidebar 导航菜单 `navigation.ts` + 权限过滤) | 4 角色各见各菜单;导航项与 route-permissions 表一致(单测) | ✅ | +| P1-2 | 仪表盘改接真实查询:`teacherDashboard`/`studentDashboard`/`parentDashboard`/`adminDashboard` + `warnings`/`errorBookStats` | 起全栈,4 角色仪表盘显示真实聚合数据(截图);widget 假契约查询(grades/homeworks/schedule/attendance/exams/announcements)全部下线或改造 | ⏳ | +| P1-3 | 页面模板四件套(list/detail/form/workbench)+ 三态规范 | Storybook 或示例页 4 张(/shell/_dev/templates/*,仅 dev 可见) | ⏳ | +| P1-4 | next-intl 接入 + messages 合并迁移 | 切换 locale 页面文案切换(截图);`useT` 自造函数删除 | ⏳ | +| P1-5 | MSW 兜底层(迁移旧 handlers,覆盖 dashboard/users/exams/grades 四域起步) | `NEXT_PUBLIC_MSW=1` 无后端启动,仪表盘 + users 页有数据(截图);生产构建 bundle 无 mocks | ⏳ | +| P1-6 | 31 widget 令牌清债(271 处机械替换)+ `border border` 去重 + 未知类检测进 arch:scan | `grep -c "text-heading-\|mt-sm\|py-xs\|p-md" src/widgets` = 0;`pnpm lint:tokens` 通过 | ⏳ | +| P1-7 | codegen 恢复 typescript-operations(config + data-ana 两域先行关闭 skipDocumentsValidation) | 生成操作级类型;lib/api 对应域删除手写 interface;typecheck 通过 | ⏳ | +| P1-8 | CI 增补:路由表一致性脚本 + 页面计数 + codegen diff 检查 | CI 对预埋违规报红(附 pipeline 链接) | ⏳ | + +**P1-1 验收证据(2026-07-22)**: + +- **单测**:`vitest run --reporter=verbose navigation` → 7/7 passed + - 每个导航项 href 在 EXACT/PREFIX/DASHBOARD 表登记 + - teacher/student/parent/admin 各仅见本组导航项 + - 4 角色导航项两两不交叉(角色隔离) + - admin 无权限位图时 `checkRoutePermission` 拒绝 +- **实现文件**: + - [src/shared/lib/navigation.ts](file:///e:/Desktop/Edu/apps/portal-shell/src/shared/lib/navigation.ts):27 项扁平数组,按 4 角色分区,`group` 字段用于角色过滤 + - [src/app/shell/layout.tsx](file:///e:/Desktop/Edu/apps/portal-shell/src/app/shell/layout.tsx):RSC AppFrame,从 `headers()` 读取身份头,`batchCheckRoutePermission` 按位图二次过滤导航项 + - [src/shared/components/layout/user-menu.tsx](file:///e:/Desktop/Edu/apps/portal-shell/src/shared/components/layout/user-menu.tsx):顶部用户菜单,显示 userId + role,登出 POST `/api/auth/logout` + - [src/shared/lib/route-permissions.ts](file:///e:/Desktop/Edu/apps/portal-shell/src/shared/lib/route-permissions.ts):补全 7 个列表页根路由的 EXACT 登记(`/shell/admin/announcements`、`/shell/admin/classes`、`/shell/teacher/exams` 等),与 PREFIX 表互补 +- **质量校验**:`tsc --noEmit` 通过;`eslint`(5 文件)通过 ### P2 · 教师域页面(2–3 周,可与 P3 部分并行) diff --git a/apps/portal-shell/src/app/shell/layout.tsx b/apps/portal-shell/src/app/shell/layout.tsx new file mode 100644 index 0000000..45a69f4 --- /dev/null +++ b/apps/portal-shell/src/app/shell/layout.tsx @@ -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 { + 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 ( + + true} /> +
+ } /> +
{children}
+
+
+ ); +} diff --git a/apps/portal-shell/src/shared/components/layout/user-menu.tsx b/apps/portal-shell/src/shared/components/layout/user-menu.tsx new file mode 100644 index 0000000..05dd6b5 --- /dev/null +++ b/apps/portal-shell/src/shared/components/layout/user-menu.tsx @@ -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 { + setOpen(false); + try { + await fetch("/api/auth/logout", { method: "POST" }); + } catch (err) { + // 登出请求失败仍跳转登录页(不阻塞用户) + console.warn("[portal-shell] logout request failed:", err); + } + router.push("/login"); + } + + return ( +
+ + {open ? ( +
+
+

{userId}

+

角色:{role}

+
+ +
+ ) : null} +
+ ); +} diff --git a/apps/portal-shell/src/shared/lib/__tests__/navigation.test.ts b/apps/portal-shell/src/shared/lib/__tests__/navigation.test.ts new file mode 100644 index 0000000..17eb812 --- /dev/null +++ b/apps/portal-shell/src/shared/lib/__tests__/navigation.test.ts @@ -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 直接查 key;PREFIX 检查 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>(); + 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); + } + } + }); +}); diff --git a/apps/portal-shell/src/shared/lib/navigation.ts b/apps/portal-shell/src/shared/lib/navigation.ts new file mode 100644 index 0000000..80806c1 --- /dev/null +++ b/apps/portal-shell/src/shared/lib/navigation.ts @@ -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); +} diff --git a/apps/portal-shell/src/shared/lib/route-permissions.ts b/apps/portal-shell/src/shared/lib/route-permissions.ts index 948271e..88b527a 100644 --- a/apps/portal-shell/src/shared/lib/route-permissions.ts +++ b/apps/portal-shell/src/shared/lib/route-permissions.ts @@ -98,6 +98,16 @@ export const EXACT_ROUTE_PERMISSIONS: Record = { 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 = { 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": {