"use client"; /** * AppShell - MF Shell 布局容器 * * 职责(02-architecture-design.md §1.2): * 1. 左侧导航树(viewports GraphQL query,ARB-001 §1.2) * 2. 主内容区(children slot) * 3. 路由守卫(未登录跳转 /login) * 4. 用户信息 + 登出 * 5. 按 requiredPermission 过滤视口(F7 权限点) * * 数据来源(ARB-002 §2.4 ai13 执行项 #3): * - viewports: GraphQL ViewportsQuery(替代 REST /api/v1/teacher/viewports) * - user: GraphQL MeQuery(在 DashboardPage 已加载,AppShell 从 localStorage 兜底) * * 维护者:ai13(teacher-portal) */ import { useEffect, useMemo } from "react"; import { useRouter, usePathname } from "next/navigation"; import Link from "next/link"; import { useQuery } from "urql"; import { ErrorBoundary, Loading } from "@edu/ui-components"; import { usePermission } from "@edu/hooks"; import { ViewportsQuery, MeQuery } from "@/lib/graphql"; import type { ViewportItem, User } from "@/lib/graphql"; import { getToken, getUser, logout } from "@/lib/auth"; import { useCrossTabSync, broadcastCrossTabEvent, } from "@/hooks/use-cross-tab-sync"; import { LocaleSwitcher } from "@/app/locale-switcher"; /** * 权限上下文:从 localStorage 读取(登录时由 iam 返回并存储)。 * P3+ 改为 GraphQL me query 实时拉取(usePermission hook 接受 context props)。 */ function buildPermissionContext(user: User | null) { if (!user) return null; // localStorage UserInfo 包含 permissions 字段(iam /iam/permissions/effective 返回) const stored = getUser(); return { permissions: stored?.permissions ?? [], roles: user.roles, dataScope: user.dataScope, }; } export default function AppShell({ children }: { children: React.ReactNode }) { const router = useRouter(); const pathname = usePathname(); // 路由守卫:未登录跳转 /login useEffect(() => { const token = getToken(); if (!token) { router.replace("/login"); } }, [router]); // 多 Tab 会话同步:监听 logout / role-change / token-refresh 事件 useCrossTabSync(); // GraphQL: viewports query(替代 REST /api/v1/teacher/viewports) const [viewportsResult] = useQuery({ query: ViewportsQuery, }); // GraphQL: me query(实时获取当前用户信息,替代 localStorage 读取) const [meResult] = useQuery({ query: MeQuery, }); const user = meResult.data?.me ?? null; const permissionContext = useMemo( () => buildPermissionContext(user ?? null), [user], ); const { hasPermission } = usePermission({ context: permissionContext }); // 按权限过滤视口(F7: requiredPermission 为 null 表示公开视口) const visibleViewports = useMemo(() => { const list: ViewportItem[] = viewportsResult.data?.viewports ?? []; return list.filter( (vp) => !vp.requiredPermission || hasPermission(vp.requiredPermission), ); }, [viewportsResult, hasPermission]); // 视口加载中:占位(避免导航闪烁) if (viewportsResult.fetching && visibleViewports.length === 0) { return (
); } // 视口加载错误:ErrorBoundary 兜底(不阻塞主内容) const viewportError = viewportsResult.error ? (

导航加载失败:{viewportsResult.error.message}

) : null; // 退出登录:先广播给其他 Tab,再执行本地登出 const handleLogout = () => { broadcastCrossTabEvent("logout"); logout(); }; return (
{/* 左侧栏:导航树 */} {/* 中间:内容区(纸面) */}
(

页面出错了

{error.message}

)} > {children}
); }