Files
Edu/apps/teacher-portal/src/app/(app)/classes/page.tsx
SpecialX 566060fade feat(infra): p6 hardening - observability and deploy compose
- 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
2026-07-09 10:21:06 +08:00

294 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}