Files
Edu/apps/teacher-portal/src/components/AppShell.tsx
SpecialX 2c7afe59ef feat(teacher-portal): 实现登录页侧边栏路由组与真实JWT集成
- lib/auth.ts: localStorage token 存储 + login/logout

- app/login: 登录表单页

- components/AppShell: 视口驱动侧边栏 + 路由保护

- app/(app)/layout: 路由组布局套 AppShell

- app/(app)/dashboard: 聚合统计卡片页

- app/(app)/classes: 迁移 CRUD 改用真实 JWT

- app/page.tsx: 根路径重定向

- known-issues.md: 记录 P2 经验
2026-07-09 00:58:42 +08:00

162 lines
4.3 KiB
TypeScript

"use client";
import { useState, useEffect, useCallback } from "react";
import { useRouter, usePathname } from "next/navigation";
import Link from "next/link";
import { getToken, getUser, logout } from "@/lib/auth";
interface ViewportItem {
key: string;
label: string;
route: string;
icon: string | null;
sortOrder: string;
requiredPermission: string | null;
}
export default function AppShell({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const [viewports, setViewports] = useState<ViewportItem[]>([]);
const [loading, setLoading] = useState(true);
const [mounted, setMounted] = useState(false);
const fetchViewports = useCallback(async () => {
const token = getToken();
if (!token) {
router.replace("/login");
return;
}
try {
const res = await fetch("/api/v1/teacher/viewports", {
headers: { Authorization: `Bearer ${token}` },
});
if (res.status === 401) {
logout();
return;
}
const json = await res.json();
if (json.success && json.data) {
setViewports(json.data);
}
} catch {
// 网络错误,保持空视口
} finally {
setLoading(false);
}
}, [router]);
useEffect(() => {
setMounted(true);
const token = getToken();
if (!token) {
router.replace("/login");
return;
}
fetchViewports();
}, [router, fetchViewports]);
// 防止 SSR 闪烁
if (!mounted) return null;
const user = getUser();
return (
<div
className="min-h-screen flex"
style={{ background: "var(--bg-paper)" }}
>
{/* 左侧栏:导航树 */}
<aside
className="w-56 flex-shrink-0 border-r relative flex flex-col"
style={{
borderColor: "var(--color-rule)",
background: "var(--bg-paper)",
}}
>
<div className="px-6 py-6">
<h1
className="text-xl"
style={{
fontFamily: "var(--font-serif)",
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" />
<nav className="mt-4 px-3">
{loading ? (
<p
className="text-xs px-3 py-2"
style={{ color: "var(--color-ink-muted)" }}
>
...
</p>
) : (
viewports.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"
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-inter)",
}}
>
{vp.label}
</Link>
);
})
)}
</nav>
{/* 底部:用户信息 + 登出 */}
<div
className="mt-auto px-6 py-4 border-t"
style={{ borderColor: "var(--color-rule)" }}
>
{user && (
<div className="mb-2">
<p className="text-sm" style={{ color: "var(--color-ink)" }}>
{user.name}
</p>
<p
className="text-xs"
style={{ color: "var(--color-ink-muted)" }}
>
{user.roles.join(", ") || "无角色"}
</p>
</div>
)}
<button
onClick={logout}
className="text-xs uppercase tracking-wide hover:opacity-70"
style={{ color: "var(--color-ink-muted)" }}
>
退
</button>
</div>
</aside>
{/* 中间:内容区(纸面) */}
<main className="flex-1 overflow-auto">{children}</main>
</div>
);
}