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:
294
apps/teacher-portal/src/app/(app)/classes/page.tsx
Normal file
294
apps/teacher-portal/src/app/(app)/classes/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
139
apps/teacher-portal/src/app/(app)/dashboard/page.tsx
Normal file
139
apps/teacher-portal/src/app/(app)/dashboard/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
apps/teacher-portal/src/app/(app)/layout.tsx
Normal file
5
apps/teacher-portal/src/app/(app)/layout.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import AppShell from "@/components/AppShell";
|
||||
|
||||
export default function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
return <AppShell>{children}</AppShell>;
|
||||
}
|
||||
133
apps/teacher-portal/src/app/login/page.tsx
Normal file
133
apps/teacher-portal/src/app/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
161
apps/teacher-portal/src/components/AppShell.tsx
Normal file
161
apps/teacher-portal/src/components/AppShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
77
apps/teacher-portal/src/lib/auth.ts
Normal file
77
apps/teacher-portal/src/lib/auth.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user