- 5 NestJS services add /metrics endpoint via app.getHttpAdapter() - prometheus.yml scales to 8 services with rule_files and alertmanager - monitoring compose replaces blackbox with Loki+Promtail - Grafana datasource adds Loki - docker-compose.deploy.yml scales to 11 services - deploy.env.example completes Neo4j/ES/ClickHouse/LLM vars - teacher-bff adds health.controller - CI removes continue-on-error on lint step - teacher-portal lint script changed to eslint src
294 lines
8.9 KiB
TypeScript
294 lines
8.9 KiB
TypeScript
"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);
|
||
}
|
||
}, []);
|
||
|
||
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>
|
||
);
|
||
}
|