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