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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user