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 经验
This commit is contained in:
SpecialX
2026-07-09 00:58:42 +08:00
parent b2c2f6e567
commit 2c7afe59ef
8 changed files with 875 additions and 331 deletions

View File

@@ -0,0 +1,294 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { getToken } from "@/lib/auth";
interface ClassItem {
id: string;
name: string;
gradeId: string;
description?: string;
createdAt: number;
updatedAt: number;
}
interface ApiResponse<T> {
success: boolean;
data?: T;
error?: { code: string; message: string };
}
export default function ClassesPage() {
const [classes, setClasses] = useState<ClassItem[]>([]);
const [loading, setLoading] = useState(false);
const [name, setName] = useState("");
const [gradeId, setGradeId] = useState(
"550e8400-e29b-41d4-a716-446655440000",
);
const [description, setDescription] = useState("");
const [error, setError] = useState<string | null>(null);
const authHeaders = (): Record<string, string> => {
const token = getToken();
return token ? { Authorization: `Bearer ${token}` } : {};
};
const fetchClasses = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch("/api/v1/classes", {
headers: authHeaders(),
});
const json: ApiResponse<ClassItem[]> = await res.json();
if (json.success && json.data) {
setClasses(json.data);
} else {
setError(json.error?.message || "Failed to load");
}
} catch (e) {
setError(e instanceof Error ? e.message : "Network error");
} finally {
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
fetchClasses();
}, [fetchClasses]);
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) return;
try {
const res = await fetch("/api/v1/classes", {
method: "POST",
headers: {
"Content-Type": "application/json",
...authHeaders(),
},
body: JSON.stringify({ name, gradeId, description }),
});
const json = await res.json();
if (!json.success) {
setError(json.error?.message || "Create failed");
return;
}
setName("");
setDescription("");
await fetchClasses();
} catch (e) {
setError(e instanceof Error ? e.message : "Network error");
}
};
const handleDelete = async (id: string) => {
try {
const res = await fetch(`/api/v1/classes/${id}`, {
method: "DELETE",
headers: authHeaders(),
});
const json = await res.json();
if (!json.success) {
setError(json.error?.message || "Delete failed");
return;
}
await fetchClasses();
} catch (e) {
setError(e instanceof Error ? e.message : "Network error");
}
};
return (
<div className="px-10 py-10">
<header className="mb-8">
<h1
className="text-3xl"
style={{
fontFamily: "var(--font-serif)",
color: "var(--color-ink)",
}}
>
</h1>
<p className="mt-1 text-sm" style={{ color: "var(--color-ink-muted)" }}>
classes CRUD · JWT
</p>
</header>
<div className="rule-thin mb-8" />
<div className="grid grid-cols-12 gap-8">
<aside className="col-span-4">
<h2
className="text-xl mb-4"
style={{ fontFamily: "var(--font-serif)" }}
>
</h2>
<div className="rule-thin mb-4" />
<form onSubmit={handleCreate} className="space-y-4">
<div>
<label
className="block text-xs uppercase tracking-wide mb-1"
style={{ color: "var(--color-ink-muted)" }}
>
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 bg-transparent border-b focus:outline-none focus:border-b-2"
style={{
borderColor: "var(--color-rule)",
borderRadius: "6px 6px 0 0",
}}
placeholder="如:高三(1)班"
required
/>
</div>
<div>
<label
className="block text-xs uppercase tracking-wide mb-1"
style={{ color: "var(--color-ink-muted)" }}
>
ID
</label>
<input
type="text"
value={gradeId}
onChange={(e) => setGradeId(e.target.value)}
className="w-full px-3 py-2 bg-transparent border-b text-sm font-mono"
style={{
borderColor: "var(--color-rule)",
borderRadius: "6px 6px 0 0",
}}
/>
</div>
<div>
<label
className="block text-xs uppercase tracking-wide mb-1"
style={{ color: "var(--color-ink-muted)" }}
>
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full px-3 py-2 bg-transparent border-b resize-none"
style={{
borderColor: "var(--color-rule)",
borderRadius: "6px 6px 0 0",
}}
rows={3}
/>
</div>
<button
type="submit"
className="px-4 py-2 text-white text-sm tracking-wide transition-opacity hover:opacity-90"
style={{ background: "var(--color-accent)", borderRadius: "6px" }}
>
</button>
</form>
</aside>
<section className="col-span-8">
<div className="flex items-baseline justify-between mb-4">
<h2 className="text-xl" style={{ fontFamily: "var(--font-serif)" }}>
<span
className="ml-2 text-sm font-sans"
style={{ color: "var(--color-ink-muted)" }}
>
{classes.length}
</span>
</h2>
<button
onClick={fetchClasses}
className="text-xs uppercase tracking-wide hover:opacity-70"
style={{ color: "var(--color-accent)" }}
>
</button>
</div>
<div className="rule-thin mb-6" />
{error && (
<div
className="mark-left mb-4 py-2"
style={{ borderColor: "var(--color-accent)" }}
>
<p
className="text-sm px-3"
style={{ color: "var(--color-accent)" }}
>
{error}
</p>
</div>
)}
{loading ? (
<p className="text-sm" style={{ color: "var(--color-ink-muted)" }}>
...
</p>
) : classes.length === 0 ? (
<p
className="text-sm italic"
style={{ color: "var(--color-ink-muted)" }}
>
</p>
) : (
<ul className="space-y-0">
{classes.map((cls) => (
<li
key={cls.id}
className="py-4 grid grid-cols-12 gap-4 items-baseline"
style={{ borderBottom: "1px solid var(--color-rule)" }}
>
<div className="col-span-7">
<h3
className="text-lg"
style={{
fontFamily: "var(--font-serif)",
color: "var(--color-ink)",
}}
>
{cls.name}
</h3>
{cls.description && (
<p
className="mt-1 text-sm"
style={{ color: "var(--color-ink-muted)" }}
>
{cls.description}
</p>
)}
</div>
<div
className="col-span-3 text-xs font-mono"
style={{ color: "var(--color-ink-muted)" }}
>
{cls.id.slice(0, 8)}...
</div>
<div className="col-span-2 text-right">
<button
onClick={() => handleDelete(cls.id)}
className="text-xs uppercase tracking-wide hover:opacity-70"
style={{ color: "var(--color-ink-muted)" }}
>
</button>
</div>
</li>
))}
</ul>
)}
</section>
</div>
</div>
);
}

View File

@@ -0,0 +1,139 @@
"use client";
import { useEffect, useState } from "react";
import { getToken, getUser, type UserInfo } from "@/lib/auth";
interface DashboardData {
user: { success: boolean; data?: { user: UserInfo } };
classes: { success: boolean; data?: unknown[] };
}
export default function DashboardPage() {
const [data, setData] = useState<DashboardData | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const user = getUser();
useEffect(() => {
const token = getToken();
if (!token) return;
(async () => {
try {
const res = await fetch("/api/v1/teacher/dashboard", {
headers: { Authorization: `Bearer ${token}` },
});
const json = await res.json();
if (json.success) {
setData(json.data);
} else {
setError(json.error?.message || "加载失败");
}
} catch (e) {
setError(e instanceof Error ? e.message : "网络错误");
} finally {
setLoading(false);
}
})();
}, []);
const classesCount = Array.isArray(data?.classes?.data)
? data!.classes.data.length
: 0;
return (
<div className="px-10 py-10">
<header className="mb-8">
<h1
className="text-3xl"
style={{ fontFamily: "var(--font-serif)", color: "var(--color-ink)" }}
>
{user?.name || "老师"}
</h1>
<p className="mt-1 text-sm" style={{ color: "var(--color-ink-muted)" }}>
{user?.email} · {user?.roles.join(", ") || "无"} ·
{user?.dataScope || "-"}
</p>
</header>
<div className="rule-thin mb-8" />
{loading ? (
<p className="text-sm" style={{ color: "var(--color-ink-muted)" }}>
...
</p>
) : error ? (
<div
className="mark-left py-2 mb-4"
style={{ borderColor: "var(--color-accent)" }}
>
<p className="text-sm px-3" style={{ color: "var(--color-accent)" }}>
{error}
</p>
</div>
) : (
<section className="grid grid-cols-3 gap-6">
<div
className="p-6 border"
style={{ borderColor: "var(--color-rule)" }}
>
<p
className="text-xs uppercase tracking-wide"
style={{ color: "var(--color-ink-muted)" }}
>
</p>
<p
className="mt-3 text-4xl"
style={{
fontFamily: "var(--font-serif)",
color: "var(--color-ink)",
}}
>
{classesCount}
</p>
</div>
<div
className="p-6 border"
style={{ borderColor: "var(--color-rule)" }}
>
<p
className="text-xs uppercase tracking-wide"
style={{ color: "var(--color-ink-muted)" }}
>
</p>
<p
className="mt-3 text-4xl"
style={{
fontFamily: "var(--font-serif)",
color: "var(--color-ink)",
}}
>
{user?.permissions.length ?? 0}
</p>
</div>
<div
className="p-6 border"
style={{ borderColor: "var(--color-rule)" }}
>
<p
className="text-xs uppercase tracking-wide"
style={{ color: "var(--color-ink-muted)" }}
>
</p>
<p
className="mt-3 text-2xl"
style={{
fontFamily: "var(--font-serif)",
color: "var(--color-ink)",
}}
>
{user?.dataScope || "-"}
</p>
</div>
</section>
)}
</div>
);
}

View File

@@ -0,0 +1,5 @@
import AppShell from "@/components/AppShell";
export default function AppLayout({ children }: { children: React.ReactNode }) {
return <AppShell>{children}</AppShell>;
}

View File

@@ -0,0 +1,133 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { login, isAuthenticated } from "@/lib/auth";
export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState("teacher2@edu.test");
const [password, setPassword] = useState("Teacher@123");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (isAuthenticated()) {
router.replace("/dashboard");
}
}, [router]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError(null);
try {
await login(email, password);
router.replace("/dashboard");
} catch (err) {
setError(err instanceof Error ? err.message : "登录失败");
} finally {
setLoading(false);
}
};
return (
<div
className="min-h-screen flex items-center justify-center"
style={{ background: "var(--bg-paper)" }}
>
<div className="w-full max-w-sm px-8">
<div className="text-center mb-8">
<h1
className="text-3xl"
style={{
fontFamily: "var(--font-serif)",
color: "var(--color-ink)",
}}
>
Edu
</h1>
<p
className="mt-2 text-sm"
style={{ color: "var(--color-ink-muted)" }}
>
</p>
</div>
<div className="rule-thin mb-6" />
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label
className="block text-xs uppercase tracking-wide mb-1"
style={{ color: "var(--color-ink-muted)" }}
>
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-3 py-2 bg-transparent border-b focus:outline-none focus:border-b-2"
style={{
borderColor: "var(--color-rule)",
borderRadius: "6px 6px 0 0",
}}
required
/>
</div>
<div>
<label
className="block text-xs uppercase tracking-wide mb-1"
style={{ color: "var(--color-ink-muted)" }}
>
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2 bg-transparent border-b focus:outline-none focus:border-b-2"
style={{
borderColor: "var(--color-rule)",
borderRadius: "6px 6px 0 0",
}}
required
/>
</div>
{error && (
<div
className="mark-left py-2"
style={{ borderColor: "var(--color-accent)" }}
>
<p
className="text-sm px-3"
style={{ color: "var(--color-accent)" }}
>
{error}
</p>
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full px-4 py-2 text-white text-sm tracking-wide transition-opacity hover:opacity-90 disabled:opacity-50"
style={{ background: "var(--color-accent)", borderRadius: "6px" }}
>
{loading ? "登录中..." : "登录"}
</button>
</form>
<p
className="mt-6 text-center text-xs"
style={{ color: "var(--color-ink-muted)" }}
>
P2 · JWT + RBAC +
</p>
</div>
</div>
);
}

View File

@@ -1,289 +1,13 @@
"use client";
import { useState, useEffect, useCallback } from "react";
interface ClassItem {
id: string;
name: string;
gradeId: string;
description?: string;
createdAt: number;
updatedAt: number;
}
interface ApiResponse<T> {
success: boolean;
data?: T;
error?: { code: string; message: string };
}
export default function HomePage() {
const [classes, setClasses] = useState<ClassItem[]>([]);
const [loading, setLoading] = useState(false);
const [name, setName] = useState("");
const [gradeId, setGradeId] = useState(
"550e8400-e29b-41d4-a716-446655440000",
);
const [description, setDescription] = useState("");
const [error, setError] = useState<string | null>(null);
const fetchClasses = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch("/api/v1/classes", {
headers: { Authorization: "Bearer dev-token" },
});
const json: ApiResponse<ClassItem[]> = await res.json();
if (json.success && json.data) {
setClasses(json.data);
} else {
setError(json.error?.message || "Failed to load");
}
} catch (e) {
setError(e instanceof Error ? e.message : "Network error");
} finally {
setLoading(false);
}
}, []);
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { isAuthenticated } from "@/lib/auth";
export default function Home() {
const router = useRouter();
useEffect(() => {
fetchClasses();
}, [fetchClasses]);
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) return;
try {
const res = await fetch("/api/v1/classes", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer dev-token",
},
body: JSON.stringify({ name, gradeId, description }),
});
const json = await res.json();
if (!json.success) {
setError(json.error?.message || "Create failed");
return;
}
setName("");
setDescription("");
await fetchClasses();
} catch (e) {
setError(e instanceof Error ? e.message : "Network error");
}
};
const handleDelete = async (id: string) => {
try {
const res = await fetch(`/api/v1/classes/${id}`, {
method: "DELETE",
headers: { Authorization: "Bearer dev-token" },
});
const json = await res.json();
if (!json.success) {
setError(json.error?.message || "Delete failed");
return;
}
await fetchClasses();
} catch (e) {
setError(e instanceof Error ? e.message : "Network error");
}
};
return (
<div className="min-h-screen" style={{ background: "var(--bg-paper)" }}>
<header className="border-b" style={{ borderColor: "var(--color-rule)" }}>
<div className="max-w-6xl mx-auto px-8 py-6">
<h1
className="text-3xl"
style={{
fontFamily: "var(--font-serif)",
color: "var(--color-ink)",
}}
>
</h1>
<p
className="mt-1 text-sm"
style={{ color: "var(--color-ink-muted)" }}
>
P1 - classes CRUD
</p>
</div>
</header>
<main className="max-w-6xl mx-auto px-8 py-8 grid grid-cols-12 gap-8">
{/* 左侧:创建表单 */}
<aside className="col-span-4">
<h2
className="text-xl mb-4"
style={{ fontFamily: "var(--font-serif)" }}
>
</h2>
<div className="rule-thin mb-4" />
<form onSubmit={handleCreate} className="space-y-4">
<div>
<label
className="block text-xs uppercase tracking-wide mb-1"
style={{ color: "var(--color-ink-muted)" }}
>
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 bg-transparent border-b focus:outline-none focus:border-b-2"
style={{
borderColor: "var(--color-rule)",
borderRadius: "6px 6px 0 0",
}}
placeholder="如:高三(1)班"
required
/>
</div>
<div>
<label
className="block text-xs uppercase tracking-wide mb-1"
style={{ color: "var(--color-ink-muted)" }}
>
ID
</label>
<input
type="text"
value={gradeId}
onChange={(e) => setGradeId(e.target.value)}
className="w-full px-3 py-2 bg-transparent border-b text-sm font-mono"
style={{
borderColor: "var(--color-rule)",
borderRadius: "6px 6px 0 0",
}}
/>
</div>
<div>
<label
className="block text-xs uppercase tracking-wide mb-1"
style={{ color: "var(--color-ink-muted)" }}
>
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full px-3 py-2 bg-transparent border-b resize-none"
style={{
borderColor: "var(--color-rule)",
borderRadius: "6px 6px 0 0",
}}
rows={3}
/>
</div>
<button
type="submit"
className="px-4 py-2 text-white text-sm tracking-wide transition-opacity hover:opacity-90"
style={{ background: "var(--color-accent)", borderRadius: "6px" }}
>
</button>
</form>
</aside>
{/* 中间:班级列表(纸面)*/}
<section className="col-span-8">
<div className="flex items-baseline justify-between mb-4">
<h2 className="text-xl" style={{ fontFamily: "var(--font-serif)" }}>
<span
className="ml-2 text-sm font-sans"
style={{ color: "var(--color-ink-muted)" }}
>
{classes.length}
</span>
</h2>
<button
onClick={fetchClasses}
className="text-xs uppercase tracking-wide hover:opacity-70"
style={{ color: "var(--color-accent)" }}
>
</button>
</div>
<div className="rule-thin mb-6" />
{error && (
<div
className="mark-left mb-4 py-2"
style={{ borderColor: "var(--color-accent)" }}
>
<p className="text-sm" style={{ color: "var(--color-accent)" }}>
{error}
</p>
</div>
)}
{loading ? (
<p className="text-sm" style={{ color: "var(--color-ink-muted)" }}>
...
</p>
) : classes.length === 0 ? (
<p
className="text-sm italic"
style={{ color: "var(--color-ink-muted)" }}
>
</p>
) : (
<ul className="space-y-0">
{classes.map((cls) => (
<li
key={cls.id}
className="py-4 grid grid-cols-12 gap-4 items-baseline"
style={{ borderBottom: "1px solid var(--color-rule)" }}
>
<div className="col-span-7">
<h3
className="text-lg"
style={{
fontFamily: "var(--font-serif)",
color: "var(--color-ink)",
}}
>
{cls.name}
</h3>
{cls.description && (
<p
className="mt-1 text-sm"
style={{ color: "var(--color-ink-muted)" }}
>
{cls.description}
</p>
)}
</div>
<div
className="col-span-3 text-xs font-mono"
style={{ color: "var(--color-ink-muted)" }}
>
{cls.id.slice(0, 8)}...
</div>
<div className="col-span-2 text-right">
<button
onClick={() => handleDelete(cls.id)}
className="text-xs uppercase tracking-wide hover:opacity-70"
style={{ color: "var(--color-ink-muted)" }}
>
</button>
</div>
</li>
))}
</ul>
)}
</section>
</main>
</div>
);
router.replace(isAuthenticated() ? "/dashboard" : "/login");
}, [router]);
return null;
}

View File

@@ -0,0 +1,161 @@
"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>
);
}

View File

@@ -0,0 +1,77 @@
// 认证工具token 存储 + 路由保护
const TOKEN_KEY = "edu_access_token";
const USER_KEY = "edu_user_info";
export interface UserInfo {
id: string;
email: string;
name: string;
roles: string[];
permissions: string[];
dataScope: string;
}
export function getToken(): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem(TOKEN_KEY);
}
export function setToken(token: string): void {
if (typeof window === "undefined") return;
localStorage.setItem(TOKEN_KEY, token);
}
export function clearToken(): void {
if (typeof window === "undefined") return;
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
}
export function getUser(): UserInfo | null {
if (typeof window === "undefined") return null;
const raw = localStorage.getItem(USER_KEY);
if (!raw) return null;
try {
return JSON.parse(raw) as UserInfo;
} catch {
return null;
}
}
export function setUser(user: UserInfo): void {
if (typeof window === "undefined") return;
localStorage.setItem(USER_KEY, JSON.stringify(user));
}
export function isAuthenticated(): boolean {
return getToken() !== null;
}
// 调用 Gateway 登录接口
export async function login(
email: string,
password: string,
): Promise<{ user: UserInfo; token: string }> {
const res = await fetch("/api/v1/iam/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
const json = await res.json();
if (!json.success) {
throw new Error(json.error?.message || "登录失败");
}
const user = json.data.user as UserInfo;
const token = json.data.tokens.accessToken as string;
setToken(token);
setUser(user);
return { user, token };
}
export function logout(): void {
clearToken();
if (typeof window !== "undefined") {
window.location.href = "/login";
}
}

View File

@@ -11,7 +11,7 @@
### 1.1 多语言 monorepo 配置
| 场景 | 技术/规则 |
| ------------------------ | ------------------------------------------------------------------------------------------ |
| ------------------------ | ------------------------------------------------------------------------------------------------------------- |
| 多语言 workspace | pnpm workspaceTS+ go.workGo+ pyproject.toml/uv workspacePython三套并存 |
| 根 package.json scripts | 封装多语言命令入口:`pnpm dev` / `pnpm lint` / `pnpm test` / `pnpm build` |
| pnpm-workspace.yaml | 仅声明 TS 包路径packages/_、services/classes、bff/_、apps/_、scripts/_Go/Python 不入 |
@@ -21,6 +21,8 @@
| tsx 执行 TS 脚本 | arch-scan 等工具脚本用 `tsx` 直接运行,无需编译 |
| husky + commitlint | pre-commit 跑 eslint+prettiercommit-msg 校验 Conventional Commits |
| .editorconfig 多语言缩进 | Go 用 tabPython 用 4 空格TS/默认用 2 空格 |
| ESLint 9 flat config | 升级到 ESLint 9 后 `eslint src --ext .ts` 报错,需 `eslint.config.(js\|mjs)` flat configP6 硬化阶段统一配置 |
| next lint 交互式初始化 | teacher-portal 无 `.eslintrc.json``next lint` 触发 Strict/Base 选择提示CI 中需预置配置或改 `eslint src` |
### 1.2 Docker Compose 基础设施
@@ -186,7 +188,7 @@
### 2.3 iamTS/NestJSP2
| 场景 | 技术/规则 |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------ |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------ |
| 认证 | 登录/登出/JWT/2FARS256 非对称签名 |
| RBAC | 角色/权限/角色-权限 CRUD + `getEffectivePermissions(userId)` API |
| 视口配置 | 4 层模型(导航/路由/组件/数据),`role_viewports` 表 |
@@ -200,6 +202,9 @@
| 健康检查依赖 | `readyz` 用 `db.execute(sql\`SELECT 1\`)` 校验连接,不要依赖 typeorm DataSourceIAM 用 Drizzle无 typeorm |
| Gateway 身份传递 | Controller 直接读 `req.headers['x-user-id']` / `x-user-roles`,不要依赖未注册的 AuthMiddleware 的 `AuthenticatedRequest` |
| DEV_MODE 登录 | DEV_MODE=true 时 Gateway 接受 `Bearer dev-token` 注入固定身份IAM 仍支持真实 JWTHS256P2 应改 RS256 |
| P2 公开路径白名单 | Gateway `publicPaths` map 含 `/iam/register`/`/iam/login`/`/iam/refresh`AuthMiddleware 跳过鉴权避免死锁 |
| P2 视口过滤 | `getUserViewports` 按 `requiredPermission` 过滤 + `sortOrder` 字典序排序,无权限要求的视口全员可见 |
| P2 JWT payload | HS256 签名含 `sub/email/roles/dataScope/type`register 自动分配 teacher 角色TEACHER_ROLE_ID 固定 UUID |
### 2.4 core-eduTS/NestJSP3
@@ -285,11 +290,16 @@
### 2.12 teacher-portal微前端宿主P1 测试页)
| 场景 | 技术/规则 |
| -------------------- | ------------------------------------------------------------------------- |
| -------------------- | --------------------------------------------------------------------------------------- |
| P1 测试页 | 单一 Next.js 应用,验证 classes CRUD 端到端链路 |
| API 调用 | `fetch(${API_BASE}/api/v1/classes)` + `Authorization: Bearer ${TEST_JWT}` |
| P1 测试 JWT | 开发工具生成 HS256 tokenP2 起由 IAM 签发 RS256 |
| P2 Module Federation | next.config.js 配置,按场景域分 4 个稳定 portal |
| P2 路由组 + AppShell | `app/(app)/layout.tsx` 用 AppShell 包裹受保护页;`/login` 与 `/` 不套壳 |
| P2 真实 JWT | 登录后 token 存 localStorage`authHeaders()` 读 `Bearer ${getToken()}` |
| P2 视口驱动侧边栏 | AppShell fetch `/teacher/viewports` 渲染左侧导航active 路由高亮 |
| P2 根路径重定向 | `app/page.tsx` 客户端组件 `router.replace(isAuthenticated() ? '/dashboard' : '/login')` |
| fetch headers 类型 | `authHeaders(): Record<string, string>` 显式标注,避免 `{}` 与 `HeadersInit` 不兼容 |
---
@@ -298,7 +308,8 @@
> 按时间倒序50 条上限。AI 发现更好方案时可更新本节。
| 日期 | 时间 | 模块 | 做了什么 + 学到什么 |
| ---------- | ---- | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| ---------- | ---- | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 2026-07-09 | 上午 | iam/teacher-bff/teacher-portal | **P2 身份阶段完整实现**(1) Gateway 公开路径白名单register/login/refresh解决无 token 死锁。(2) IAM schema 扩展users 加 dataScope新增 role_viewports 表。(3) RBAC 端点 4 个 GET。(4) 视口按 requiredPermission 过滤 + sortOrder 排序getEffectivePermissions 用 Set 去重。(5) JWT payload 含 dataScoperegister 自动分配 teacher 角色。(6) 种子数据 7 权限+12 映射+7 视口。(7) Teacher BFF 视口聚合。(8) 前端lib/auth.ts + login + AppShell + (app) 路由组 + dashboard + classes真实 JWT+ 根重定向。(9) E2E 全链路通过。**学到**Next.js 路由组 (app) 不影响 URL/login 与 /dashboard 共存只后者套壳fetch headers 函数返回 Record<string,string> 避免 TS2769ESLint 9 需 flat config 留 P6AppShell aside 用 flex flex-col + mt-auto 比 absolute 稳健。 |
| 2026-07-08 | 晚上 | iam/classes/api-gateway | **P1 端到端链路验证 + IAM 服务修复**:验证 register → JWT → Gateway /iam/me → Gateway /classes CRUD → teacher-portal 前端渲染全链路打通。(1) IAM 服务 14 个 TS 编译错误修复:移除 typeorm/ioredis/kafkajs 依赖IAM 用 Drizzlehealth.controller.ts 改用 `db.execute(sql\`SELECT 1\`)`lifecycle.service.ts 简化为只关闭 Drizzle 连接池Drizzle API 修正(`r.roles`→`r.iam_roles``.in()`→`inArray()`)。(2) NestJS ESM DI 修复iam.module.ts 简化 providers 为 `[IamService, IamRepository]`iam.service.ts 构造器加 `@Inject(IamRepository)`(参考 classes 黄金模板),修复运行时 `Cannot read properties of undefined (reading 'findUserByEmail')`。(3) Gateway /iam/me 404 修复iam.controller.ts 直接读 `req.headers['x-user-id']`替代未注册的`AuthenticatedRequest`。(4) 创建 `scripts/iam-init.sql`建 6 张 IAM 表 + 种子数据。(5) E2E 验证iam:3002 注册/登录 → Gateway /iam/me 200 → Gateway GET /classes 200 → Gateway POST /classes合法 UUID gradeId201 → teacher-portal:3000 首页渲染 200 + 含"班级管理" → Next.js rewrites 透传 dev-token 到 Gateway 全链路通。**学到**NestJS ESM 模式下 DI 无法通过类型推断解析 token必须显式`@Inject(Token)`Drizzle select 返回字段名按 schema 定义而非表名classes.dto.ts 的 gradeId 要求 UUID 格式,测试数据不能用 "grade-12" 这类字符串PowerShell 控制台中文显示为 `?`是编码问题数据库实际存储正确DEV_MODE 下前端用`Bearer dev-token` 即可走通链路,无需真实 JWT。 |
| 2026-07-08 | 下午 | 全局 | **CI/CD 完整配置 + 多AI协作规范入规则**(1) project_rules.md 新增 §14 多 AI 协作规范(角色权限矩阵/分支命名/PR合并规则/跨模块变更顺序/冲突处理/AI 身份标注/敏感文件保护)+ §15 CI/CD 规范(流水线阶段/触发条件/镜像规范/部署策略/Secrets 管理/必需 CI 文件)。(2) 优化现有 4 个 ci-*.ymlci-ts.yml 加 arch-scan + docker-build jobci-go.yml 去掉 golangci-lintlint-staged 预存问题),加 docker-buildci-proto.yml 修复 buf breaking URL从 github.com 改为 .git 本地比较)。(3) 新增 `docker.yml`main/tag 触发,构建推送 3 服务镜像到 Gitea Container Registrygit.eazygame.cn/xiner/edu/<service>:latest + sha tag + version tag用 GITHUB_TOKEN 自动认证。(4) 新增 `deploy.yml`workflow_run 触发 + 手动 dispatchRunner 直接执行 docker compose pull && up -d10 次健康检查轮询,失败输出日志。(5) 新增 `infra/docker-compose.deploy.yml`(部署用,镜像来自 Gitea registry连接服务器已有 MySQL/Redis 通过 edu-shared 外部网络)+ `infra/deploy.env.example`(部署环境变量模板)。(6) 编写 `docs/standards/cicd-runbook.md`CI/CD 使用手册,含架构总览/一次性配置/日常使用/镜像管理/部署验证/回滚/常见问题/排查命令/安全注意事项)。**学到**Docker Compose 不支持 `restart_policy`(是 swarm 字段),用 `restart: unless-stopped` 替代Gitea Actions 兼容 GitHub Actions 语法但 `workflow_run` 触发可能不完整,备选手动 dispatch应用容器访问宿主机已有 MySQL/Redis 需通过共享外部网络(`docker network create edu-shared` + `docker network connect`)而非 `host.docker.internal`。 |
| 2026-07-08 | 下午 | api-gateway | **重定向循环修复 + 生产模式部署准备 + 多AI协作文档**(1) 修复 `ERR_TOO_MANY_REDIRECTS`Gin 默认 `RedirectTrailingSlash=true` 导致 `/api/v1/classes` → 301 → `/classes/`Next.js rewrites 代理时形成循环。**修复**`r.RedirectTrailingSlash=false` + 同时注册无尾斜杠路由(`/classes`)与通配符路由(`/classes/*path`)。(2) 新增 DEV_MODE 旁路:`config.go` 加 `DevMode` 字段,`auth.go` 在 `DEV_MODE=true` 时接受 `dev-token` 注入固定身份(生产必须 false。(3) 生产 Docker 化:新建 `apps/teacher-portal/Dockerfile`(多阶段 Next.js build+ `services/api-gateway/Dockerfile`(多阶段 Go 静态编译)+ `infra/docker-compose.prod.yml`(三服务编排,强制 DEV_MODE=false。(4) 编写 `docs/standards/local-dev-runbook.md`(本地启动手册,含端口表/开发模式/生产模式/常见问题)+ `docs/standards/multi-ai-collaboration.md`多AI协作文档含模块分工矩阵/分支命名/PR流程/合并策略/冲突处理/权限矩阵)。**学到**Gin `RedirectTrailingSlash=false` 后需显式注册无尾斜杠路由(`Any("/classes")` + `Any("/classes/*path")`),否则 404Next.js rewrites 代理会透传 301 给浏览器形成循环,开发模式旁路应通过环境变量控制而非硬编码。 |