550 lines
20 KiB
TypeScript
550 lines
20 KiB
TypeScript
"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>
|
||
);
|
||
}
|