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:
@@ -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 · 框架与数据源接通(1–2 周)
|
### P1 · 框架与数据源接通(1–2 周)— 进行中(P1-1 ✅ 2026-07-22 验收)
|
||||||
|
|
||||||
**目标**:AppFrame + 导航 + 真实数据仪表盘 + 页面模板 + MSW + i18n,页面迁移的"流水线"建成。
|
**目标**:AppFrame + 导航 + 真实数据仪表盘 + 页面模板 + MSW + i18n,页面迁移的"流水线"建成。
|
||||||
|
|
||||||
| # | 任务 | 验收 |
|
| # | 任务 | 验收 | 状态 |
|
||||||
| ---- | -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
| ---- | -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ---- |
|
||||||
| P1-1 | `src/app/shell/layout.tsx` AppFrame(TopBar 静态挂载 + Sidebar 导航菜单 `navigation.ts` + 权限过滤) | 4 角色各见各菜单;导航项与 route-permissions 表一致(单测) |
|
| 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-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-operations(config + data-ana 两域先行关闭 skipDocumentsValidation) | 生成操作级类型;lib/api 对应域删除手写 interface;typecheck 通过 |
|
| P1-7 | codegen 恢复 typescript-operations(config + data-ana 两域先行关闭 skipDocumentsValidation) | 生成操作级类型;lib/api 对应域删除手写 interface;typecheck 通过 | ⏳ |
|
||||||
| 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 · 教师域页面(2–3 周,可与 P3 部分并行)
|
### P2 · 教师域页面(2–3 周,可与 P3 部分并行)
|
||||||
|
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
apps/portal-shell/src/shared/components/layout/user-menu.tsx
Normal file
69
apps/portal-shell/src/shared/components/layout/user-menu.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
117
apps/portal-shell/src/shared/lib/__tests__/navigation.test.ts
Normal file
117
apps/portal-shell/src/shared/lib/__tests__/navigation.test.ts
Normal 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 直接查 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<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
300
apps/portal-shell/src/shared/lib/navigation.ts
Normal file
300
apps/portal-shell/src/shared/lib/navigation.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -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": {
|
||||||
|
|||||||
Reference in New Issue
Block a user