hku-class/frontend/src/app/(app)/admin/members/page.tsx
2026-04-12 18:15:38 +08:00

550 lines
20 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 { useEffect, useState, useRef } from "react";
import { useAuth } from "@/hooks/use-auth";
import { useActiveClass } from "@/hooks/use-active-class";
import { fetchAPI, putAPI, postAPI, deleteAPI, uploadAPI } from "@/lib/api";
import { Card, CardContent } from "@/components/ui/card";
import { ErrorState } from "@/components/error-state";
import { Pagination } from "@/components/pagination";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { ConfirmDialog } from "@/components/confirm-dialog";
import { toast } from "sonner";
import type { UserListItem, RosterEntry } from "@/lib/types";
import { USER_STATUS, ROLES } from "@/lib/constants";
export default function MembersPage() {
const { user } = useAuth();
const { activeClassId } = useActiveClass();
// Tab state
const [activeTab, setActiveTab] = useState<"members" | "roster">("roster");
// Members state
const [members, setMembers] = useState<UserListItem[]>([]);
const [membersLoading, setMembersLoading] = useState(true);
const [membersError, setMembersError] = useState<string | null>(null);
const [membersPage, setMembersPage] = useState(1);
const [membersTotalPages, setMembersTotalPages] = useState(1);
const [filter, setFilter] = useState("all");
// Roster state
const [roster, setRoster] = useState<RosterEntry[]>([]);
const [rosterLoading, setRosterLoading] = useState(true);
const [rosterError, setRosterError] = useState<string | null>(null);
const [rosterPage, setRosterPage] = useState(1);
const [rosterTotalPages, setRosterTotalPages] = useState(1);
const [inviteCode, setInviteCode] = useState("");
const [importOpen, setImportOpen] = useState(false);
const [importText, setImportText] = useState("");
const [importing, setImporting] = useState(false);
const [clearTarget, setClearTarget] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const isSuperAdmin = user?.role === "super_admin";
// Load members
const loadMembers = async () => {
if (!activeClassId) {
setMembersLoading(false);
return;
}
setMembersLoading(true);
setMembersError(null);
try {
const res = await fetchAPI<any>(`/api/classes/${activeClassId}/members`, {
page: String(membersPage),
page_size: "20",
});
setMembers(res.items || []);
setMembersTotalPages(res.total_pages || 1);
} catch (err: any) {
setMembersError(err.message || "加载失败");
} finally {
setMembersLoading(false);
}
};
// Load roster
const loadRoster = async () => {
if (!activeClassId) {
setRosterLoading(false);
return;
}
setRosterLoading(true);
setRosterError(null);
try {
const [rosterRes, codeRes] = await Promise.all([
fetchAPI<any>(`/api/classes/${activeClassId}/roster`, {
page: String(rosterPage),
page_size: "50",
}),
fetchAPI<any>(`/api/classes/${activeClassId}/invite-code`),
]);
setRoster(rosterRes.items || []);
setRosterTotalPages(rosterRes.total_pages || 1);
setInviteCode(codeRes.invite_code || "");
} catch (err: any) {
setRosterError(err.message || "加载失败");
} finally {
setRosterLoading(false);
}
};
useEffect(() => {
if (!activeClassId) return;
if (activeTab === "members") loadMembers();
else loadRoster();
}, [activeClassId, activeTab, membersPage, rosterPage]);
// Member actions
const handleStatusChange = async (
userId: number,
newStatus: string,
role?: string
) => {
try {
await putAPI(`/api/users/${userId}/status`, {
status: newStatus,
role: role || undefined,
});
toast.success(
`已更新状态为 ${USER_STATUS[newStatus as keyof typeof USER_STATUS] || newStatus}`
);
loadMembers();
} catch (err: any) {
toast.error(err.message || "操作失败");
}
};
const handleRoleChange = async (userId: number, newRole: string) => {
try {
await putAPI(`/api/users/${userId}/status`, {
status: "approved",
role: newRole,
});
toast.success(
`角色已更新为 ${ROLES[newRole as keyof typeof ROLES] || newRole}`
);
loadMembers();
} catch (err: any) {
toast.error(err.message || "操作失败");
}
};
const getStatusBadge = (status: string) => {
const variants: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
approved: "default",
disabled: "secondary",
};
return (
<Badge variant={variants[status] || "secondary"}>
{USER_STATUS[status as keyof typeof USER_STATUS] || status}
</Badge>
);
};
// Roster actions
const handleCopyCode = () => {
navigator.clipboard.writeText(inviteCode);
toast.success("邀请码已复制");
};
const handleRegenerateCode = async () => {
try {
const res = await postAPI<any>(`/api/classes/${activeClassId}/invite-code/regenerate`);
setInviteCode(res.invite_code);
toast.success("邀请码已重新生成");
} catch (err: any) {
toast.error(err.message || "操作失败");
}
};
const handleTextImport = async () => {
if (!importText.trim()) return;
setImporting(true);
try {
// Parse text: each line is "学号 姓名" or "学号,姓名" or "学号\t姓名"
const entries: { student_id: string; name: string }[] = [];
const lines = importText.trim().split("\n");
for (const line of lines) {
const parts = line.trim().split(/[,\t\s]+/);
if (parts.length >= 2) {
entries.push({ student_id: parts[0], name: parts.slice(1).join("") });
}
}
if (entries.length === 0) {
toast.error("未解析到有效数据,每行格式:学号 姓名");
return;
}
const res = await postAPI<any>(`/api/classes/${activeClassId}/roster/import`, {
entries,
});
toast.success(res.message);
setImportOpen(false);
setImportText("");
loadRoster();
} catch (err: any) {
toast.error(err.message || "导入失败");
} finally {
setImporting(false);
}
};
const handleFileUpload = async () => {
const fileInput = fileInputRef.current;
if (!fileInput?.files?.length) return;
setImporting(true);
try {
const formData = new FormData();
formData.append("file", fileInput.files[0]);
const res = await uploadAPI(`/api/classes/${activeClassId}/roster/upload`, formData);
toast.success((res as any).message);
setImportOpen(false);
if (fileInput) fileInput.value = "";
loadRoster();
} catch (err: any) {
toast.error(err.message || "上传失败");
} finally {
setImporting(false);
}
};
const handleDeleteRoster = async (rosterId: number) => {
try {
await deleteAPI(`/api/classes/${activeClassId}/roster/${rosterId}`);
toast.success("已删除");
loadRoster();
} catch (err: any) {
toast.error(err.message || "删除失败");
}
};
const handleClearRoster = async () => {
try {
const res = await postAPI<any>(`/api/classes/${activeClassId}/roster/clear`);
toast.success(res.message);
setClearTarget(false);
loadRoster();
} catch (err: any) {
toast.error(err.message || "操作失败");
}
};
const filteredMembers =
filter === "all" ? members : members.filter((m) => m.status === filter);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold"></h1>
<p className="text-gray-500 mt-1"></p>
</div>
{!activeClassId ? (
<div className="text-center py-12 text-gray-400">
{isSuperAdmin ? "请在顶部选择一个班级" : "您尚未分配班级"}
</div>
) : (
<>
{/* Tab switcher */}
<div className="flex gap-2 border-b pb-2">
<Button
variant={activeTab === "roster" ? "default" : "ghost"}
size="sm"
onClick={() => setActiveTab("roster")}
>
</Button>
<Button
variant={activeTab === "members" ? "default" : "ghost"}
size="sm"
onClick={() => setActiveTab("members")}
>
</Button>
</div>
{activeTab === "roster" && (
<div className="space-y-4">
{/* Invite code section */}
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500"></p>
<p className="text-2xl font-mono font-bold tracking-widest mt-1">
{inviteCode || "—"}
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={handleCopyCode}>
</Button>
<Button variant="outline" size="sm" onClick={handleRegenerateCode}>
</Button>
</div>
</div>
<p className="text-xs text-gray-400 mt-2">
+
</p>
</CardContent>
</Card>
{/* Import actions */}
<div className="flex gap-2">
<Dialog open={importOpen} onOpenChange={(open) => { setImportOpen(open); if (!open) setImportText(""); }}>
<DialogTrigger>
<Button></Button>
</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="space-y-4 pt-2">
<div>
<Label> </Label>
<Textarea
placeholder={"24001 张三\n24002 李四\n24003 王五"}
value={importText}
onChange={(e) => setImportText(e.target.value)}
rows={6}
className="mt-2 font-mono text-sm"
/>
<Button
onClick={handleTextImport}
disabled={importing || !importText.trim()}
className="mt-2 w-full"
>
{importing ? "导入中..." : "从文本导入"}
</Button>
</div>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-white px-2 text-gray-400"></span>
</div>
</div>
<div>
<Label> CSV / Excel </Label>
<p className="text-xs text-gray-400 mt-1">
CSV student_id() name() Excel
</p>
<input
ref={fileInputRef}
type="file"
accept=".csv,.xlsx,.xls"
className="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded file:border-0 file:text-sm file:font-medium file:bg-gray-100 file:text-gray-700 hover:file:bg-gray-200 mt-2"
/>
<Button
onClick={handleFileUpload}
disabled={importing}
className="mt-2 w-full"
variant="outline"
>
{importing ? "上传中..." : "上传文件"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
<Button
variant="outline"
onClick={() => setClearTarget(true)}
disabled={roster.filter((r) => r.status === "unregistered").length === 0}
>
</Button>
</div>
{/* Roster list */}
{rosterLoading ? (
<div className="animate-pulse space-y-2">
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardContent className="p-4">
<div className="h-12 bg-gray-200 rounded" />
</CardContent>
</Card>
))}
</div>
) : rosterError ? (
<ErrorState message={rosterError} onRetry={loadRoster} />
) : roster.length === 0 ? (
<div className="text-center py-12 text-gray-400">
</div>
) : (
<div className="space-y-2">
{roster.map((r) => (
<Card key={r.id}>
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="font-medium">{r.name}</p>
<p className="text-sm text-gray-500">{r.student_id}</p>
</div>
<div className="flex items-center gap-2">
<Badge variant={r.status === "registered" ? "default" : "outline"}>
{r.status === "registered" ? "已注册" : "未注册"}
</Badge>
{r.status === "unregistered" && (
<Button
variant="ghost"
size="sm"
className="text-red-500"
onClick={() => handleDeleteRoster(r.id)}
>
</Button>
)}
</div>
</CardContent>
</Card>
))}
</div>
)}
<Pagination
page={rosterPage}
totalPages={rosterTotalPages}
onPageChange={setRosterPage}
/>
</div>
)}
{activeTab === "members" && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold"></h2>
<Select value={filter} onValueChange={(v) => v && setFilter(v)}>
<SelectTrigger className="w-32">
<SelectValue>
{filter === "all"
? "全部"
: USER_STATUS[filter as keyof typeof USER_STATUS] || filter}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="approved"></SelectItem>
<SelectItem value="disabled"></SelectItem>
</SelectContent>
</Select>
</div>
{membersLoading ? (
<div className="animate-pulse space-y-2">
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardContent className="p-4">
<div className="h-12 bg-gray-200 rounded" />
</CardContent>
</Card>
))}
</div>
) : membersError ? (
<ErrorState message={membersError} onRetry={loadMembers} />
) : filteredMembers.length === 0 ? (
<div className="text-center py-8 text-gray-400"></div>
) : (
<div className="space-y-2">
{filteredMembers.map((m) => (
<Card key={m.id}>
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="font-medium">{m.name}</p>
<p className="text-sm text-gray-500">
{m.email}
{m.student_id ? ` · ${m.student_id}` : ""}
{m.company ? ` · ${m.company}` : ""}
</p>
</div>
<div className="flex items-center gap-2">
<Select
value={m.role}
onValueChange={(v) => v && handleRoleChange(m.id, v)}
>
<SelectTrigger className="w-28 h-7 text-xs">
<SelectValue>
{ROLES[m.role as keyof typeof ROLES] || m.role}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="student"></SelectItem>
<SelectItem value="class_admin"></SelectItem>
{isSuperAdmin && (
<SelectItem value="super_admin"></SelectItem>
)}
</SelectContent>
</Select>
{getStatusBadge(m.status)}
{m.status === "approved" && (
<Button
variant="ghost"
size="sm"
className="text-red-500"
onClick={() => handleStatusChange(m.id, "disabled")}
>
</Button>
)}
{m.status === "disabled" && (
<Button
variant="ghost"
size="sm"
onClick={() => handleStatusChange(m.id, "approved")}
>
</Button>
)}
</div>
</CardContent>
</Card>
))}
</div>
)}
<Pagination
page={membersPage}
totalPages={membersTotalPages}
onPageChange={setMembersPage}
/>
</div>
)}
</>
)}
<ConfirmDialog
open={clearTarget}
onOpenChange={(open) => { if (!open) setClearTarget(false); }}
title="清除未注册花名册"
description="确定清除所有未注册的花名册条目?此操作不可恢复。"
confirmText="清除"
variant="destructive"
onConfirm={handleClearRoster}
/>
</div>
);
}