Files
Edu/apps/student-portal/src/components/app-shell.tsx
SpecialX 74474a2d04 feat(student-portal): 完整实现 student-portal 微前端
包含 src 全部实现、Dockerfile、配置文件、contracts 包等
2026-07-10 19:10:36 +08:00

317 lines
9.7 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 — 学生端应用壳ai14
*
* 职责:
* - 左侧导航栏:学生端 11 项菜单(含 P3-P5 阶段)
* - 顶栏:学生姓名 + 头像 + 退出登录
* - 主内容区:渲染 children
* - 响应式:移动端 (<lg) 折叠为 hamburger 抽屉
*
* 设计依据01-understanding.md §7L1 导航菜单)/ §18.1(响应式断点)
* 纸感设计:使用 --color-rule 分隔线 + .card-paper / mark-left 标记
*/
import { useState, useCallback, useEffect, type ReactNode } from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
// 学生端导航菜单01-understanding.md §7 L1 视口)
interface NavItem {
label: string;
route: string;
}
const NAV_ITEMS: NavItem[] = [
{ label: "Dashboard", route: "/dashboard" },
{ label: "我的班级", route: "/my-classes" },
{ label: "我的考试", route: "/my-exams" },
{ label: "我的作业", route: "/my-homework" },
{ label: "我的成绩", route: "/my-grades" },
{ label: "我的考勤", route: "/my-attendance" },
{ label: "学习路径", route: "/learning-path" },
{ label: "学情诊断", route: "/diagnostic" },
{ label: "教材", route: "/textbooks" },
{ label: "通知中心", route: "/notifications" },
{ label: "AI辅学", route: "/ai-tutor" },
];
export default function AppShell({
children,
}: {
children: ReactNode;
}): JSX.Element {
const router = useRouter();
const pathname = usePathname();
const [drawerOpen, setDrawerOpen] = useState(false);
const [studentName, setStudentName] = useState<string | null>(null);
// 从 localStorage 拉取当前学生信息BFF currentUser 已注入)
const loadStudentInfo = useCallback(async (): Promise<void> => {
try {
const raw = window.localStorage.getItem("student_user");
if (!raw) return;
const parsed = JSON.parse(raw) as {
name?: string;
avatar?: string | null;
};
setStudentName(parsed.name ?? null);
} catch {
// 容错localStorage 异常时不阻断渲染
}
}, []);
useEffect(() => {
void loadStudentInfo();
}, [loadStudentInfo]);
// 路由切换后关闭抽屉
useEffect(() => {
setDrawerOpen(false);
}, [pathname]);
// ESC 关闭抽屉A11y 键盘支持)
useEffect(() => {
if (!drawerOpen) return;
const onKey = (e: KeyboardEvent): void => {
if (e.key === "Escape") setDrawerOpen(false);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [drawerOpen]);
const handleLogout = useCallback((): void => {
window.localStorage.removeItem("student_token");
window.localStorage.removeItem("student_user");
void router.push("/login");
}, [router]);
const isItemActive = (route: string): boolean =>
pathname === route || pathname.startsWith(`${route}/`);
// 渲染左侧导航(抽屉与桌面共用)
const renderNav = (): JSX.Element => (
<nav aria-label="主导航" className="mt-4 px-3">
<ul className="space-y-1">
{NAV_ITEMS.map((item) => {
const active = isItemActive(item.route);
return (
<li key={item.route}>
<Link
href={item.route}
aria-current={active ? "page" : undefined}
className="block px-3 py-2 text-sm rounded-sm transition-colors motion-safe"
style={{
color: active ? "var(--color-accent)" : "var(--color-ink)",
borderLeft: active
? "2px solid var(--color-accent)"
: "2px solid transparent",
fontFamily: active ? "var(--font-serif)" : "var(--font-sans)",
background: active
? "var(--color-accent-muted)"
: "transparent",
}}
>
{item.label}
</Link>
</li>
);
})}
</ul>
</nav>
);
// 渲染顶部用户区(抽屉与桌面共用)
const renderUserBar = (): JSX.Element => (
<div
className="mt-auto px-6 py-4 border-t"
style={{ borderColor: "var(--color-rule)" }}
>
<div className="mb-3 flex items-center gap-3">
<span
className="inline-flex w-8 h-8 items-center justify-center rounded-full text-xs font-serif"
style={{
background: "var(--color-accent-muted)",
color: "var(--color-accent)",
}}
aria-hidden="true"
>
{studentName?.slice(0, 1) ?? "S"}
</span>
<div className="min-w-0 flex-1">
<p
className="text-sm text-truncate"
style={{ color: "var(--color-ink)" }}
>
{studentName ?? "同学"}
</p>
<p
className="text-xs text-truncate"
style={{ color: "var(--color-ink-muted)" }}
>
</p>
</div>
</div>
<button
type="button"
onClick={handleLogout}
className="text-xs uppercase tracking-wide hover:opacity-70 transition-opacity"
style={{ color: "var(--color-ink-muted)" }}
>
退
</button>
</div>
);
return (
<div
className="min-h-screen flex bg-paper"
style={{ background: "var(--color-paper)" }}
>
{/* 移动端遮罩A11yrole=dialog + aria-modal*/}
{drawerOpen && (
<div
role="button"
tabIndex={0}
aria-label="关闭抽屉"
onClick={() => setDrawerOpen(false)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") setDrawerOpen(false);
}}
className="lg:hidden fixed inset-0 z-30"
style={{ background: "rgba(0,0,0,0.4)" }}
/>
)}
{/* 移动端抽屉 */}
<aside
className={`lg:hidden fixed inset-y-0 left-0 z-40 w-64 flex flex-col transform transition-transform duration-200 motion-safe ${
drawerOpen ? "translate-x-0" : "-translate-x-full"
}`}
style={{
background: "var(--color-paper)",
borderRight: "1px solid var(--color-rule)",
}}
aria-label="侧边导航"
aria-hidden={!drawerOpen}
>
<div className="px-6 py-6">
<h1
className="text-xl font-serif"
style={{ color: "var(--color-ink)" }}
>
Edu
</h1>
<p
className="text-xs mt-1"
style={{ color: "var(--color-ink-muted)" }}
>
</p>
</div>
<div className="rule-thin mx-6" />
{renderNav()}
{renderUserBar()}
</aside>
{/* 桌面端固定侧栏 */}
<aside
className="hidden lg:flex w-56 flex-shrink-0 flex-col relative border-r"
style={{
borderColor: "var(--color-rule)",
background: "var(--color-paper)",
}}
aria-label="侧边导航"
>
<div className="px-6 py-6">
<h1
className="text-xl font-serif"
style={{ color: "var(--color-ink)" }}
>
Edu
</h1>
<p
className="text-xs mt-1"
style={{ color: "var(--color-ink-muted)" }}
>
</p>
</div>
<div className="rule-thin mx-6" />
{renderNav()}
{renderUserBar()}
</aside>
{/* 主区域:顶栏 + 内容 */}
<div className="flex-1 flex flex-col min-w-0">
{/* 顶栏:移动端 hamburger + 标题 */}
<header
className="flex items-center gap-3 px-4 lg:px-8 py-4 border-b sticky top-0 z-20"
style={{
borderColor: "var(--color-rule)",
background: "var(--color-paper)",
}}
>
<button
type="button"
onClick={() => setDrawerOpen((v) => !v)}
className="lg:hidden p-2 rounded-sm"
aria-label={drawerOpen ? "关闭菜单" : "打开菜单"}
aria-expanded={drawerOpen}
aria-controls="mobile-drawer"
style={{ color: "var(--color-ink)" }}
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
aria-hidden="true"
>
{drawerOpen ? (
<path d="M18 6 6 18M6 6l12 12" />
) : (
<path d="M3 6h18M3 12h18M3 18h18" />
)}
</svg>
</button>
<h2
className="text-lg font-serif flex-1 text-truncate"
style={{ color: "var(--color-ink)" }}
>
{NAV_ITEMS.find((i) => isItemActive(i.route))?.label ??
"Edu 学生端"}
</h2>
{/* 桌面端:右侧显示当前学生 */}
<div className="hidden lg:flex items-center gap-3">
<span
className="inline-flex w-8 h-8 items-center justify-center rounded-full text-xs font-serif"
style={{
background: "var(--color-accent-muted)",
color: "var(--color-accent)",
}}
aria-hidden="true"
>
{studentName?.slice(0, 1) ?? "S"}
</span>
<span className="text-sm" style={{ color: "var(--color-ink)" }}>
{studentName ?? "同学"}
</span>
</div>
</header>
{/* 内容区 */}
<main className="flex-1 overflow-auto" role="main" id="main-content">
{children}
</main>
</div>
</div>
);
}