Files
Edu/apps/teacher-portal/src/components/AppShell.tsx
SpecialX 1eacd1ed87 feat(teacher-portal): 完整实现 teacher-portal 微前端
包含 settings/students/api、graphql、mocks、ui-tokens 设计令牌等
2026-07-10 19:10:20 +08:00

177 lines
5.8 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";
/**
* 权限上下文:从 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]);
// 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;
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>
)}
<button
onClick={logout}
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>
);
}