Files
Edu/apps/teacher-portal/src/components/AppShell.tsx
SpecialX 0b42302a64 docs(admin-portal): 新增 nextstep-v2.md 记录下游核查结果
v1 声称完成的下游工作经核查实际未完成:
- api-gateway: /api/admin/graphql 路由未注册,go vet 编译失败
- teacher-bff: resolver 已完成但 schema 未同步(命名空间 vs 扁平)
- iam: proto 缺 BatchGetUsers rpc 声明

v2 记录详细核查证据和修复要求
2026-07-14 08:26:27 +08:00

194 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
/**
* AppShell - MF Shell 布局容器
*
* 职责02-architecture-design.md §1.2
* 1. 左侧导航树viewports GraphQL queryARB-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 兜底)
*
* 维护者ai13teacher-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>
);
}