- 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 经验
162 lines
4.3 KiB
TypeScript
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>
|
|
);
|
|
}
|