326 lines
10 KiB
TypeScript
326 lines
10 KiB
TypeScript
"use client";
|
||
|
||
/**
|
||
* AppShell — 学生端应用壳(ai14)
|
||
*
|
||
* 职责:
|
||
* - 左侧导航栏:学生端 13 项菜单(含 P3-P5 阶段)
|
||
* - 顶栏:学生姓名 + 头像 + 退出登录
|
||
* - 主内容区:渲染 children
|
||
* - 响应式:移动端 (<lg) 折叠为 hamburger 抽屉
|
||
*
|
||
* 设计依据:01-understanding.md §7(L1 导航菜单)/ §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: "/schedule" },
|
||
{ label: "选课中心", route: "/elective" },
|
||
{ label: "我的作业", route: "/my-homework" },
|
||
{ label: "我的考试", route: "/my-exams" },
|
||
{ label: "我的成绩", route: "/my-grades" },
|
||
{ label: "我的考勤", route: "/my-attendance" },
|
||
{ label: "错题本", route: "/error-book" },
|
||
{ label: "在线请假", route: "/leave" },
|
||
{ label: "学习路径", route: "/learning-path" },
|
||
{ label: "自适应练习", route: "/practice" },
|
||
{ label: "学情诊断", route: "/dashboard/weakness" },
|
||
{ label: "课案查看", route: "/lesson-plans" },
|
||
{ label: "课程计划", route: "/course-plans" },
|
||
{ label: "教材", route: "/textbooks" },
|
||
{ label: "公告", route: "/announcements" },
|
||
{ label: "通知中心", route: "/notifications" },
|
||
{ label: "AI辅学", route: "/ai-tutor" },
|
||
{ label: "系统设置", route: "/settings" },
|
||
];
|
||
|
||
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)" }}
|
||
>
|
||
{/* 移动端遮罩(A11y:role=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>
|
||
);
|
||
}
|