v1 声称完成的下游工作经核查实际未完成: - api-gateway: /api/admin/graphql 路由未注册,go vet 编译失败 - teacher-bff: resolver 已完成但 schema 未同步(命名空间 vs 扁平) - iam: proto 缺 BatchGetUsers rpc 声明 v2 记录详细核查证据和修复要求
194 lines
6.3 KiB
TypeScript
194 lines
6.3 KiB
TypeScript
"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 (
|
||
<div className="min-h-screen bg-paper">
|
||
<Loading lines={5} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 视口加载错误:ErrorBoundary 兜底(不阻塞主内容)
|
||
const viewportError = viewportsResult.error ? (
|
||
<p className="px-3 py-2 text-tiny text-danger">
|
||
导航加载失败:{viewportsResult.error.message}
|
||
</p>
|
||
) : null;
|
||
|
||
// 退出登录:先广播给其他 Tab,再执行本地登出
|
||
const handleLogout = () => {
|
||
broadcastCrossTabEvent("logout");
|
||
logout();
|
||
};
|
||
|
||
return (
|
||
<div className="min-h-screen flex bg-paper">
|
||
{/* 左侧栏:导航树 */}
|
||
<aside className="w-56 flex-shrink-0 border-r border-rule bg-paper flex flex-col">
|
||
<div className="px-6 py-6">
|
||
<h1 className="text-xl font-serif text-ink">Edu</h1>
|
||
<p className="mt-1 text-tiny text-ink-muted">教师端</p>
|
||
</div>
|
||
<div className="rule-thin mx-6" />
|
||
|
||
<nav className="mt-4 px-3">
|
||
{viewportError}
|
||
{visibleViewports.length === 0 && !viewportError ? (
|
||
<p className="px-3 py-2 text-tiny text-ink-muted">暂无可用视口</p>
|
||
) : (
|
||
visibleViewports.map((vp) => {
|
||
const active = pathname === vp.route;
|
||
return (
|
||
<Link
|
||
key={vp.key}
|
||
href={vp.route}
|
||
className={`block px-3 py-2 text-sm transition-colors ${
|
||
active
|
||
? "text-accent border-l-2 border-accent font-serif"
|
||
: "text-ink border-l-2 border-transparent font-sans hover:text-accent"
|
||
}`}
|
||
>
|
||
{vp.label}
|
||
</Link>
|
||
);
|
||
})
|
||
)}
|
||
</nav>
|
||
|
||
{/* 底部:用户信息 + 语言切换 + 登出 */}
|
||
<div className="mt-auto px-6 py-4 border-t border-rule">
|
||
{user && (
|
||
<div className="mb-2">
|
||
<p className="text-sm text-ink">{user.name}</p>
|
||
<p className="text-tiny text-ink-muted">
|
||
{user.roles.join(", ") || "无角色"}
|
||
</p>
|
||
</div>
|
||
)}
|
||
<div className="mb-3">
|
||
<LocaleSwitcher />
|
||
</div>
|
||
<button
|
||
onClick={handleLogout}
|
||
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
|
||
>
|
||
退出登录
|
||
</button>
|
||
</div>
|
||
</aside>
|
||
|
||
{/* 中间:内容区(纸面) */}
|
||
<main className="flex-1 overflow-auto">
|
||
<ErrorBoundary
|
||
fallback={(error, reset) => (
|
||
<div
|
||
role="alert"
|
||
className="flex min-h-[200px] flex-col items-center justify-center gap-4 p-8"
|
||
>
|
||
<h2 className="text-lg font-serif text-ink">页面出错了</h2>
|
||
<p className="text-sm text-ink-muted">{error.message}</p>
|
||
<button
|
||
type="button"
|
||
onClick={reset}
|
||
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover"
|
||
>
|
||
重试
|
||
</button>
|
||
</div>
|
||
)}
|
||
>
|
||
{children}
|
||
</ErrorBoundary>
|
||
</main>
|
||
</div>
|
||
);
|
||
}
|