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

@@ -2,7 +2,7 @@
> 版本3.0 > 版本3.0
> 日期2026-07-20 > 日期2026-07-20
> 状态:**P0 已完成2026-07-22 验收)+ P1 进行中;架构审计完成 + 重设计方案定稿** > 状态:**P0 已完成 + P1-1 已完成2026-07-22 验收)+ P1 进行中;架构审计完成 + 重设计方案定稿**
> 本文档地位:**portal-shell 前端工作的唯一权威指导文档**。所有后续 AI/人工在此模块的工作必须先读本文件,以其为准。 > 本文档地位:**portal-shell 前端工作的唯一权威指导文档**。所有后续 AI/人工在此模块的工作必须先读本文件,以其为准。
> >
> 关联文档(按效力排序): > 关联文档(按效力排序):
@@ -757,20 +757,34 @@ export default async function ExamsPage(): Promise<React.ReactElement> {
- **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-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) - **P0-8**`git status` 干净commit `cfb7b00``.env.local``tsconfig.tsbuildinfo` 已在 [.gitignore](file:///e:/Desktop/Edu/apps/portal-shell/.gitignore)
### P1 · 框架与数据源接通12 周) ### P1 · 框架与数据源接通12 周)— 进行中P1-1 ✅ 2026-07-22 验收)
**目标**AppFrame + 导航 + 真实数据仪表盘 + 页面模板 + MSW + i18n页面迁移的"流水线"建成。 **目标**AppFrame + 导航 + 真实数据仪表盘 + 页面模板 + MSW + i18n页面迁移的"流水线"建成。
| # | 任务 | 验收 | | # | 任务 | 验收 | 状态 |
| ---- | -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | ---- | -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ---- |
| P1-1 | `src/app/shell/layout.tsx` AppFrameTopBar 静态挂载 + Sidebar 导航菜单 `navigation.ts` + 权限过滤) | 4 角色各见各菜单;导航项与 route-permissions 表一致(单测) | | P1-1 | `src/app/shell/layout.tsx` AppFrameTopBar 静态挂载 + Sidebar 导航菜单 `navigation.ts` + 权限过滤) | 4 角色各见各菜单;导航项与 route-permissions 表一致(单测) | ✅ |
| P1-2 | 仪表盘改接真实查询:`teacherDashboard`/`studentDashboard`/`parentDashboard`/`adminDashboard` + `warnings`/`errorBookStats` | 起全栈4 角色仪表盘显示真实聚合数据截图widget 假契约查询grades/homeworks/schedule/attendance/exams/announcements全部下线或改造 | | 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-3 | 页面模板四件套list/detail/form/workbench+ 三态规范 | Storybook 或示例页 4 张(/shell/_dev/templates/*,仅 dev 可见) | ⏳ |
| P1-4 | next-intl 接入 + messages 合并迁移 | 切换 locale 页面文案切换(截图);`useT` 自造函数删除 | | P1-4 | next-intl 接入 + messages 合并迁移 | 切换 locale 页面文案切换(截图);`useT` 自造函数删除 | ⏳ |
| P1-5 | MSW 兜底层(迁移旧 handlers覆盖 dashboard/users/exams/grades 四域起步) | `NEXT_PUBLIC_MSW=1` 无后端启动,仪表盘 + users 页有数据(截图);生产构建 bundle 无 mocks | | 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-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-operationsconfig + data-ana 两域先行关闭 skipDocumentsValidation | 生成操作级类型lib/api 对应域删除手写 interfacetypecheck 通过 | | P1-7 | codegen 恢复 typescript-operationsconfig + data-ana 两域先行关闭 skipDocumentsValidation | 生成操作级类型lib/api 对应域删除手写 interfacetypecheck 通过 | ⏳ |
| P1-8 | CI 增补:路由表一致性脚本 + 页面计数 + codegen diff 检查 | CI 对预埋违规报红(附 pipeline 链接) | | 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 · 教师域页面23 周,可与 P3 部分并行) ### P2 · 教师域页面23 周,可与 P3 部分并行)

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"], requiredRoles: ["admin"],
anyOfPermissions: ["INVITATION_CODE_MANAGE", "INVITATION_CODE_CREATE"], 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 专属 ────────────────────────────────────────── // ── teacher 专属 ──────────────────────────────────────────
"/shell/teacher/lesson-plans": { "/shell/teacher/lesson-plans": {
@@ -120,6 +130,28 @@ export const EXACT_ROUTE_PERMISSIONS: Record<string, RoutePermissionConfig> = {
requiredRoles: ["teacher", "admin"], requiredRoles: ["teacher", "admin"],
anyOfPermissions: ["SCHEDULE_AUTO", "SCHEDULE_ADJUST", "SCHEDULE_MANAGE"], 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 专属 ────────────────────────────────────────── // ── student 专属 ──────────────────────────────────────────
"/shell/student/error-book": { "/shell/student/error-book": {