ClassLens / chatkit /frontend /src /components /admin /AdminDashboard.tsx
chih.yikuan
✨ Add database management: Supabase persistence, teacher profiles, admin dashboard
9f97e28
Raw
History Blame
10.1 kB
import { useEffect, useState } from "react";
import { apiGet, apiPut, apiDelete } from "../../lib/api";
type Tab = "overview" | "teachers" | "sessions" | "students";
interface Stats {
teachers: number;
students: number;
quizzes: number;
reports: number;
student_results: number;
prompts: number;
schema_version: string | null;
}
interface TeacherRow {
id: number;
email: string;
full_name: string;
school: string;
is_admin: boolean;
created_at: string;
quiz_count: number;
student_count: number;
}
interface SessionRow {
id: number;
title: string;
subject: string | null;
status: string;
teacher_email: string;
report_count: number;
created_at: string;
}
interface StudentRow {
id: number;
name: string;
teacher_email: string;
result_count: number;
avg_score: number | null;
}
interface TimelineEntry {
quiz_title: string;
quiz_subject: string | null;
quiz_created_at: string;
total_questions: number;
correct_count: number;
score: number;
}
const TABS: { key: Tab; label: string }[] = [
{ key: "overview", label: "總覽" },
{ key: "teachers", label: "教師" },
{ key: "sessions", label: "測驗" },
{ key: "students", label: "學生" },
];
export function AdminDashboard() {
const [tab, setTab] = useState<Tab>("overview");
return (
<main className="max-w-6xl mx-auto px-4 pb-12">
<div className="text-center mb-6">
<h2 className="font-display text-2xl font-bold text-[var(--color-text)]">管理後台</h2>
<p className="text-[var(--color-text-muted)] mt-1">資料庫管理與使用分析</p>
</div>
<div className="flex justify-center gap-2 mb-6">
{TABS.map((t) => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
tab === t.key
? "bg-[var(--color-primary)] text-white"
: "text-[var(--color-text-muted)] hover:text-[var(--color-text)] border border-[var(--color-border)]"
}`}
>
{t.label}
</button>
))}
</div>
{tab === "overview" && <Overview />}
{tab === "teachers" && <Teachers />}
{tab === "sessions" && <Sessions />}
{tab === "students" && <Students />}
</main>
);
}
function useFetch<T>(path: string, deps: unknown[] = []) {
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState("");
const [loading, setLoading] = useState(true);
const [nonce, setNonce] = useState(0);
const reload = () => setNonce((n) => n + 1);
useEffect(() => {
let active = true;
setLoading(true);
apiGet<T>(path)
.then((d) => active && setData(d))
.catch((e) => active && setError(e instanceof Error ? e.message : "Error"))
.finally(() => active && setLoading(false));
return () => {
active = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [path, nonce, ...deps]);
return { data, error, loading, reload };
}
function StatCard({ label, value }: { label: string; value: number | string }) {
return (
<div className="card p-6 text-center">
<div className="text-3xl font-bold text-[var(--color-primary)]">{value}</div>
<div className="text-sm text-[var(--color-text-muted)] mt-1">{label}</div>
</div>
);
}
function Overview() {
const { data, loading, error } = useFetch<Stats>("/api/admin/stats");
if (loading) return <Spinner />;
if (error || !data) return <ErrorBox msg={error} />;
return (
<div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
<StatCard label="教師" value={data.teachers} />
<StatCard label="學生" value={data.students} />
<StatCard label="測驗" value={data.quizzes} />
<StatCard label="已生成報告" value={data.reports} />
<StatCard label="學生成績紀錄" value={data.student_results} />
<StatCard label="提示詞" value={data.prompts} />
</div>
<p className="text-center text-xs text-[var(--color-text-muted)] mt-6">
資料庫結構版本:{data.schema_version ?? "未知"}
</p>
</div>
);
}
function Teachers() {
const { data, loading, error, reload } = useFetch<{ users: TeacherRow[] }>("/api/admin/users");
if (loading) return <Spinner />;
if (error || !data) return <ErrorBox msg={error} />;
const toggleAdmin = async (u: TeacherRow) => {
await apiPut(`/api/admin/users/${u.id}/admin`, { is_admin: !u.is_admin });
reload();
};
const remove = async (u: TeacherRow) => {
if (!confirm(`刪除教師 ${u.email}?此操作會一併刪除其所有測驗與報告。`)) return;
await apiDelete(`/api/admin/users/${u.id}`);
reload();
};
return (
<Table headers={["Email", "姓名", "學校", "測驗", "學生", "管理員", ""]}>
{data.users.map((u) => (
<tr key={u.id} className="border-t border-[var(--color-border)]">
<Td>{u.email}</Td>
<Td>{u.full_name || "—"}</Td>
<Td>{u.school || "—"}</Td>
<Td>{u.quiz_count}</Td>
<Td>{u.student_count}</Td>
<Td>
<button onClick={() => toggleAdmin(u)} className="text-sm underline text-[var(--color-primary)]">
{u.is_admin ? "是" : "否"}
</button>
</Td>
<Td>
<button onClick={() => remove(u)} className="text-sm text-red-400 hover:underline">
刪除
</button>
</Td>
</tr>
))}
</Table>
);
}
function Sessions() {
const { data, loading, error, reload } = useFetch<{ sessions: SessionRow[] }>("/api/admin/sessions");
if (loading) return <Spinner />;
if (error || !data) return <ErrorBox msg={error} />;
const remove = async (s: SessionRow) => {
if (!confirm(`刪除測驗「${s.title}」?`)) return;
await apiDelete(`/api/admin/sessions/${s.id}`);
reload();
};
return (
<Table headers={["標題", "科目", "教師", "狀態", "報告", "建立時間", ""]}>
{data.sessions.map((s) => (
<tr key={s.id} className="border-t border-[var(--color-border)]">
<Td>{s.title}</Td>
<Td>{s.subject || "—"}</Td>
<Td>{s.teacher_email}</Td>
<Td>{s.status}</Td>
<Td>{s.report_count}</Td>
<Td>{new Date(s.created_at).toLocaleDateString()}</Td>
<Td>
<button onClick={() => remove(s)} className="text-sm text-red-400 hover:underline">
刪除
</button>
</Td>
</tr>
))}
</Table>
);
}
function Students() {
const { data, loading, error } = useFetch<{ students: StudentRow[] }>("/api/admin/students");
const [selected, setSelected] = useState<number | null>(null);
if (loading) return <Spinner />;
if (error || !data) return <ErrorBox msg={error} />;
if (selected !== null) {
return <StudentDetail studentId={selected} onBack={() => setSelected(null)} />;
}
return (
<Table headers={["姓名", "教師", "測驗次數", "平均分數", ""]}>
{data.students.map((s) => (
<tr key={s.id} className="border-t border-[var(--color-border)]">
<Td>{s.name}</Td>
<Td>{s.teacher_email}</Td>
<Td>{s.result_count}</Td>
<Td>{s.avg_score ?? "—"}</Td>
<Td>
<button onClick={() => setSelected(s.id)} className="text-sm underline text-[var(--color-primary)]">
查看歷程
</button>
</Td>
</tr>
))}
</Table>
);
}
function StudentDetail({ studentId, onBack }: { studentId: number; onBack: () => void }) {
const { data, loading, error } = useFetch<{
student: { name: string };
timeline: TimelineEntry[];
}>(`/api/admin/students/${studentId}`);
if (loading) return <Spinner />;
if (error || !data) return <ErrorBox msg={error} />;
return (
<div>
<button onClick={onBack} className="text-sm text-[var(--color-text-muted)] hover:underline mb-4">
← 返回學生列表
</button>
<h3 className="font-display text-xl font-semibold mb-4 text-[var(--color-text)]">
{data.student.name} 的跨測驗歷程
</h3>
{data.timeline.length === 0 ? (
<p className="text-[var(--color-text-muted)]">尚無成績紀錄</p>
) : (
<Table headers={["測驗", "科目", "日期", "答對", "總題數", "分數"]}>
{data.timeline.map((t, i) => (
<tr key={i} className="border-t border-[var(--color-border)]">
<Td>{t.quiz_title}</Td>
<Td>{t.quiz_subject || "—"}</Td>
<Td>{new Date(t.quiz_created_at).toLocaleDateString()}</Td>
<Td>{t.correct_count}</Td>
<Td>{t.total_questions}</Td>
<Td>
<span className="font-semibold text-[var(--color-primary)]">{t.score}</span>
</Td>
</tr>
))}
</Table>
)}
</div>
);
}
function Table({ headers, children }: { headers: string[]; children: React.ReactNode }) {
return (
<div className="card overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-[var(--color-text-muted)]">
{headers.map((h, i) => (
<th key={i} className="px-4 py-3 font-medium">
{h}
</th>
))}
</tr>
</thead>
<tbody className="text-[var(--color-text)]">{children}</tbody>
</table>
</div>
);
}
function Td({ children }: { children: React.ReactNode }) {
return <td className="px-4 py-3">{children}</td>;
}
function Spinner() {
return (
<div className="flex justify-center py-12">
<div className="w-6 h-6 border-2 border-[var(--color-primary)] border-t-transparent rounded-full animate-spin" />
</div>
);
}
function ErrorBox({ msg }: { msg: string }) {
return <div className="card p-6 text-center text-red-400">{msg || "載入失敗"}</div>;
}