hku-class/frontend/src/app/(app)/admin/members/page.tsx
2026-04-27 09:32:40 +08:00

1220 lines
47 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 { useCallback, useEffect, useRef, useState } from "react";
import { useAuth } from "@/hooks/use-auth";
import { useActiveClass } from "@/hooks/use-active-class";
import {
deleteAPI,
fetchAPI,
getErrorMessage,
postAPI,
putAPI,
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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
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 {
ClassPermission,
InactiveMemberEntry,
PageResponse,
UserListItem,
} from "@/lib/types";
import { CLASS_PERMISSIONS, ROLES } from "@/lib/constants";
import { hasClassPermission } from "@/lib/permissions";
type InviteCodeResponse = {
invite_code: string;
};
type MessageResponse = {
message: string;
};
type TeacherCreateRequest = {
class_id: number;
name: string;
email: string;
password: string;
};
type TeacherCreateResponse = {
message: string;
user: UserListItem;
};
type TeacherAssignRequest = {
class_id: number;
email: string;
};
type TeacherAssignResponse = {
message: string;
user: UserListItem;
};
const PERMISSION_GROUPS: Array<{
title: string;
description: string;
permissions: ClassPermission[];
}> = [
{
title: "成员与组织",
description: "管理班级成员、班委和基础可见范围",
permissions: ["class_view", "member_view", "member_manage", "committee_manage"],
},
{
title: "内容与活动",
description: "管理公告、动态、投票、排期和资源库",
permissions: [
"announcement_manage",
"timeline_manage",
"vote_manage",
"schedule_manage",
"resource_manage",
],
},
{
title: "教学与财务",
description: "管理作业、班费和模块开关",
permissions: ["assignment_manage", "fund_manage", "module_manage"],
},
];
const COMMITTEE_PRESETS: Array<{
role: string;
description: string;
permissions: ClassPermission[];
}> = [
{
role: "班长",
description: "统筹班级日常管理与主要内容发布",
permissions: [
"class_view",
"member_view",
"member_manage",
"committee_manage",
"announcement_manage",
"timeline_manage",
"vote_manage",
"schedule_manage",
"resource_manage",
],
},
{
role: "副班长",
description: "协助班长处理班级组织和内容管理",
permissions: [
"class_view",
"member_view",
"member_manage",
"announcement_manage",
"timeline_manage",
"vote_manage",
"schedule_manage",
"resource_manage",
],
},
{
role: "学习委员",
description: "负责学习通知、作业协同与课程安排",
permissions: [
"class_view",
"member_view",
"announcement_manage",
"assignment_manage",
"schedule_manage",
"resource_manage",
],
},
{
role: "组织委员",
description: "负责活动组织、投票协同和排期推进",
permissions: [
"class_view",
"member_view",
"timeline_manage",
"vote_manage",
"schedule_manage",
"resource_manage",
],
},
{
role: "生活委员",
description: "负责生活事务通知与班费相关管理",
permissions: [
"class_view",
"member_view",
"announcement_manage",
"fund_manage",
"resource_manage",
],
},
{
role: "文体委员",
description: "负责文体活动、动态发布与投票互动",
permissions: [
"class_view",
"member_view",
"timeline_manage",
"vote_manage",
"schedule_manage",
"resource_manage",
],
},
];
export default function MembersPage() {
const { user } = useAuth();
const { activeClassId } = useActiveClass();
// Tab state
const [activeTab, setActiveTab] = useState<"members" | "inactive">("inactive");
// 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);
// Inactive member state
const [inactiveMembers, setInactiveMembers] = useState<InactiveMemberEntry[]>([]);
const [inactiveLoading, setInactiveLoading] = useState(true);
const [inactiveError, setInactiveError] = useState<string | null>(null);
const [inactivePage, setInactivePage] = useState(1);
const [inactiveTotalPages, setInactiveTotalPages] = 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 [teacherCreateOpen, setTeacherCreateOpen] = useState(false);
const [teacherName, setTeacherName] = useState("");
const [teacherEmail, setTeacherEmail] = useState("");
const [teacherPassword, setTeacherPassword] = useState("");
const [teacherSubmitting, setTeacherSubmitting] = useState(false);
const [teacherAssignOpen, setTeacherAssignOpen] = useState(false);
const [teacherAssignEmail, setTeacherAssignEmail] = useState("");
const [teacherAssignSubmitting, setTeacherAssignSubmitting] = useState(false);
const [memberManagerOpen, setMemberManagerOpen] = useState(false);
const [managingMember, setManagingMember] = useState<UserListItem | null>(null);
const [memberRoleValue, setMemberRoleValue] = useState<string>("student");
const [memberCommitteeValue, setMemberCommitteeValue] = useState("");
const [memberPermissionsValue, setMemberPermissionsValue] = useState<ClassPermission[]>([]);
const [memberSubmitting, setMemberSubmitting] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const isSuperAdmin = user?.role === "super_admin";
const canManageCommittee = hasClassPermission(user, "committee_manage", activeClassId);
const canOpenMemberManager = isSuperAdmin || canManageCommittee;
// Load members
const loadMembers = useCallback(async () => {
if (!activeClassId) {
setMembers([]);
setMembersLoading(false);
return;
}
setMembersLoading(true);
setMembersError(null);
try {
const res = await fetchAPI<PageResponse<UserListItem>>(
`/api/classes/${activeClassId}/members`,
{
status: "approved",
page: String(membersPage),
page_size: "20",
}
);
setMembers(res.items ?? []);
setMembersTotalPages(res.total_pages ?? 1);
} catch (err: unknown) {
setMembersError(getErrorMessage(err, "加载失败"));
} finally {
setMembersLoading(false);
}
}, [activeClassId, membersPage]);
// Load inactive members
const loadInactiveMembers = useCallback(async () => {
if (!activeClassId) {
setInactiveMembers([]);
setInviteCode("");
setInactiveLoading(false);
return;
}
setInactiveLoading(true);
setInactiveError(null);
try {
const [inactiveRes, codeRes] = await Promise.all([
fetchAPI<PageResponse<InactiveMemberEntry>>(`/api/classes/${activeClassId}/inactive-members`, {
page: String(inactivePage),
page_size: "50",
}),
fetchAPI<InviteCodeResponse>(`/api/classes/${activeClassId}/invite-code`),
]);
setInactiveMembers(inactiveRes.items ?? []);
setInactiveTotalPages(inactiveRes.total_pages ?? 1);
setInviteCode(codeRes.invite_code ?? "");
} catch (err: unknown) {
setInactiveError(getErrorMessage(err, "加载失败"));
} finally {
setInactiveLoading(false);
}
}, [activeClassId, inactivePage]);
useEffect(() => {
if (!activeClassId) return;
if (activeTab === "members") {
void loadMembers();
return;
}
void loadInactiveMembers();
}, [activeClassId, activeTab, loadInactiveMembers, loadMembers]);
// Member actions
const handleRoleChange = async (userId: number, newRole: string) => {
try {
await putAPI<UserListItem>(`/api/users/${userId}/role`, {
role: newRole,
});
toast.success(
`角色已更新为 ${ROLES[newRole as keyof typeof ROLES] || newRole}`
);
await loadMembers();
} catch (err: unknown) {
toast.error(getErrorMessage(err, "操作失败"));
}
};
const handleCommitteeRoleChange = async (userId: number, newRole: string) => {
try {
await putAPI<UserListItem>(`/api/users/${userId}/committee-role`, {
class_id: activeClassId,
committee_role: newRole === "__none__" ? null : newRole,
});
toast.success(newRole === "__none__" ? "已清除班委标签" : `已设置班委标签: ${newRole}`);
await loadMembers();
} catch (err: unknown) {
toast.error(getErrorMessage(err, "操作失败"));
}
};
// Inactive member actions
const handleCopyCode = () => {
navigator.clipboard.writeText(inviteCode);
toast.success("邀请码已复制");
};
const handleRegenerateCode = async () => {
try {
const res = await postAPI<InviteCodeResponse>(
`/api/classes/${activeClassId}/invite-code/regenerate`
);
setInviteCode(res.invite_code);
toast.success("邀请码已重新生成");
} catch (err: unknown) {
toast.error(getErrorMessage(err, "操作失败"));
}
};
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<MessageResponse>(
`/api/classes/${activeClassId}/inactive-members/import`,
{
entries,
}
);
toast.success(res.message);
setImportOpen(false);
setImportText("");
await loadInactiveMembers();
} catch (err: unknown) {
toast.error(getErrorMessage(err, "导入失败"));
} 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<MessageResponse>(
`/api/classes/${activeClassId}/inactive-members/upload`,
formData
);
toast.success(res.message);
setImportOpen(false);
if (fileInput) fileInput.value = "";
await loadInactiveMembers();
} catch (err: unknown) {
toast.error(getErrorMessage(err, "上传失败"));
} finally {
setImporting(false);
}
};
const handleDeleteInactiveMember = async (userId: number) => {
try {
await deleteAPI<MessageResponse>(`/api/classes/${activeClassId}/inactive-members/${userId}`);
toast.success("已删除");
await loadInactiveMembers();
} catch (err: unknown) {
toast.error(getErrorMessage(err, "删除失败"));
}
};
const handleClearRoster = async () => {
try {
const res = await postAPI<MessageResponse>(`/api/classes/${activeClassId}/inactive-members/clear`);
toast.success(res.message);
setClearTarget(false);
await loadInactiveMembers();
} catch (err: unknown) {
toast.error(getErrorMessage(err, "操作失败"));
}
};
const togglePermission = (permission: ClassPermission) => {
setMemberPermissionsValue((prev) =>
prev.includes(permission)
? prev.filter((item) => item !== permission)
: [...prev, permission]
);
};
const applyPermissionPreset = (permissions: ClassPermission[]) => {
setMemberPermissionsValue(Array.from(new Set(permissions)));
};
const applyCommitteePreset = (presetRole: string) => {
const preset = COMMITTEE_PRESETS.find((item) => item.role === presetRole);
if (!preset) {
return;
}
setMemberCommitteeValue(preset.role);
applyPermissionPreset(preset.permissions);
};
const handlePermissionsSave = async (userId: number, permissions: ClassPermission[]) => {
try {
await putAPI<UserListItem>(`/api/users/${userId}/class-permissions`, {
class_id: activeClassId,
class_permissions: permissions,
});
} catch (err: unknown) {
toast.error(getErrorMessage(err, "操作失败"));
throw err;
}
};
const resetTeacherForm = () => {
setTeacherName("");
setTeacherEmail("");
setTeacherPassword("");
};
const resetTeacherAssignForm = () => {
setTeacherAssignEmail("");
};
const handleCreateTeacher = async () => {
if (!activeClassId) return;
if (!teacherName.trim()) {
toast.error("请输入老师姓名");
return;
}
if (!teacherEmail.trim()) {
toast.error("请输入老师邮箱");
return;
}
if (teacherPassword.length < 8) {
toast.error("初始密码至少 8 位");
return;
}
setTeacherSubmitting(true);
try {
const payload: TeacherCreateRequest = {
class_id: activeClassId,
name: teacherName.trim(),
email: teacherEmail.trim(),
password: teacherPassword,
};
const res = await postAPI<TeacherCreateResponse>("/api/users/teachers", payload);
toast.success(res.message);
setTeacherCreateOpen(false);
resetTeacherForm();
if (activeTab === "members") {
await loadMembers();
}
} catch (err: unknown) {
toast.error(getErrorMessage(err, "创建老师失败"));
} finally {
setTeacherSubmitting(false);
}
};
const handleAssignTeacher = async () => {
if (!activeClassId) return;
if (!teacherAssignEmail.trim()) {
toast.error("请输入老师邮箱");
return;
}
setTeacherAssignSubmitting(true);
try {
const payload: TeacherAssignRequest = {
class_id: activeClassId,
email: teacherAssignEmail.trim(),
};
const res = await postAPI<TeacherAssignResponse>("/api/users/teachers/assign", payload);
toast.success(res.message);
setTeacherAssignOpen(false);
resetTeacherAssignForm();
if (activeTab === "members") {
await loadMembers();
}
} catch (err: unknown) {
toast.error(getErrorMessage(err, "分配老师失败"));
} finally {
setTeacherAssignSubmitting(false);
}
};
const openMemberManager = (member: UserListItem) => {
setManagingMember(member);
setMemberRoleValue(member.role);
setMemberCommitteeValue(member.committee_role ?? "");
setMemberPermissionsValue(member.class_permissions ?? []);
setMemberManagerOpen(true);
};
const resetMemberManager = () => {
setManagingMember(null);
setMemberRoleValue("student");
setMemberCommitteeValue("");
setMemberPermissionsValue([]);
setMemberSubmitting(false);
};
const handleMemberManagerSave = async () => {
if (!managingMember || !activeClassId) return;
const originalRole = managingMember.role;
const originalCommittee = managingMember.committee_role ?? "";
const originalPermissions = managingMember.class_permissions ?? [];
const normalizedCommittee = memberCommitteeValue.trim();
const permissionsChanged =
originalPermissions.length !== memberPermissionsValue.length ||
originalPermissions.some((permission) => !memberPermissionsValue.includes(permission));
setMemberSubmitting(true);
try {
if (isSuperAdmin && memberRoleValue !== originalRole) {
await handleRoleChange(managingMember.id, memberRoleValue);
}
if (memberRoleValue !== "super_admin" && canManageCommittee) {
if (normalizedCommittee !== originalCommittee) {
await handleCommitteeRoleChange(
managingMember.id,
normalizedCommittee ? normalizedCommittee : "__none__"
);
}
if (permissionsChanged) {
await handlePermissionsSave(managingMember.id, memberPermissionsValue);
toast.success("成员设置已更新");
} else if (normalizedCommittee !== originalCommittee || memberRoleValue !== originalRole) {
toast.success("成员设置已更新");
}
} else if (memberRoleValue !== originalRole) {
toast.success("成员设置已更新");
}
setMemberManagerOpen(false);
resetMemberManager();
await loadMembers();
} catch {
// Errors are surfaced by the called actions.
} finally {
setMemberSubmitting(false);
}
};
return (
<div className="space-y-6">
<div>
<div className="text-[11px] uppercase tracking-[0.24em] text-[#976746]">Members</div>
<h1 className="mt-2 text-3xl font-semibold text-[#4e1d1a]"></h1>
<p className="mt-2 text-[#765a4d]"></p>
</div>
{!activeClassId ? (
<div className="py-12 text-center text-[#9d806f]">
{isSuperAdmin ? "请在顶部选择一个班级" : "您尚未分配班级"}
</div>
) : (
<>
{/* Tab switcher */}
<div className="flex gap-2 rounded-2xl border border-[#eadbc8] bg-[#fffaf2] p-2">
<Button
variant={activeTab === "inactive" ? "default" : "ghost"}
size="sm"
onClick={() => setActiveTab("inactive")}
>
</Button>
<Button
variant={activeTab === "members" ? "default" : "ghost"}
size="sm"
onClick={() => setActiveTab("members")}
>
</Button>
</div>
{activeTab === "inactive" && (
<div className="space-y-4">
{/* Invite code section */}
<Card className="bg-[#fffaf2]">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-[#7a5e4f]"></p>
<p className="mt-1 text-2xl font-mono font-bold tracking-widest text-[#4e1d1a]">
{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="mt-2 text-xs text-[#9a7b68]">
</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={inactiveMembers.length === 0}
>
</Button>
</div>
{/* Inactive member list */}
{inactiveLoading ? (
<div className="animate-pulse space-y-2">
{[1, 2, 3].map((i) => (
<Card key={i} className="bg-[#fffaf2]">
<CardContent className="p-4">
<div className="h-12 bg-gray-200 rounded" />
</CardContent>
</Card>
))}
</div>
) : inactiveError ? (
<ErrorState message={inactiveError} onRetry={loadInactiveMembers} />
) : inactiveMembers.length === 0 ? (
<div className="py-12 text-center text-[#9d806f]">
</div>
) : (
<div className="space-y-2">
{inactiveMembers.map((r) => (
<Card key={r.id} className="bg-[#fffdf8]">
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="font-medium text-[#4e1d1a]">{r.name}</p>
<p className="text-sm text-[#73594a]">{r.student_id}</p>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline"></Badge>
<Button
variant="ghost"
size="sm"
className="text-red-500"
onClick={() => handleDeleteInactiveMember(r.id)}
>
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
<Pagination
page={inactivePage}
totalPages={inactiveTotalPages}
onPageChange={setInactivePage}
/>
</div>
)}
{activeTab === "members" && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-[#4e1d1a]"></h2>
{isSuperAdmin && (
<div className="flex items-center gap-2">
<Dialog
open={teacherAssignOpen}
onOpenChange={(open) => {
setTeacherAssignOpen(open);
if (!open) {
resetTeacherAssignForm();
}
}}
>
<DialogTrigger>
<Button size="sm" variant="outline"></Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="space-y-4 pt-2">
<div className="space-y-2">
<Label htmlFor="teacher-assign-email"></Label>
<Input
id="teacher-assign-email"
type="email"
value={teacherAssignEmail}
onChange={(e) => setTeacherAssignEmail(e.target.value)}
placeholder="teacher@example.com"
/>
</div>
<p className="text-xs text-gray-400">
</p>
<Button
className="w-full"
onClick={handleAssignTeacher}
disabled={teacherAssignSubmitting}
>
{teacherAssignSubmitting ? "分配中..." : "加入当前班级"}
</Button>
</div>
</DialogContent>
</Dialog>
<Dialog
open={teacherCreateOpen}
onOpenChange={(open) => {
setTeacherCreateOpen(open);
if (!open) {
resetTeacherForm();
}
}}
>
<DialogTrigger>
<Button size="sm"></Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="space-y-4 pt-2">
<div className="space-y-2">
<Label htmlFor="teacher-name"></Label>
<Input
id="teacher-name"
value={teacherName}
onChange={(e) => setTeacherName(e.target.value)}
placeholder="请输入老师姓名"
/>
</div>
<div className="space-y-2">
<Label htmlFor="teacher-email"></Label>
<Input
id="teacher-email"
type="email"
value={teacherEmail}
onChange={(e) => setTeacherEmail(e.target.value)}
placeholder="teacher@example.com"
/>
</div>
<div className="space-y-2">
<Label htmlFor="teacher-password"></Label>
<Input
id="teacher-password"
type="password"
value={teacherPassword}
onChange={(e) => setTeacherPassword(e.target.value)}
placeholder="至少 8 位"
/>
</div>
<p className="text-xs text-gray-400">
</p>
<Button
className="w-full"
onClick={handleCreateTeacher}
disabled={teacherSubmitting}
>
{teacherSubmitting ? "创建中..." : "创建老师账号"}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
)}
</div>
{membersLoading ? (
<div className="animate-pulse space-y-2">
{[1, 2, 3].map((i) => (
<Card key={i} className="bg-[#fffaf2]">
<CardContent className="p-4">
<div className="h-12 bg-gray-200 rounded" />
</CardContent>
</Card>
))}
</div>
) : membersError ? (
<ErrorState message={membersError} onRetry={loadMembers} />
) : members.length === 0 ? (
<div className="py-8 text-center text-[#9d806f]"></div>
) : (
<div className="space-y-2">
{members.map((m) => (
<Card key={m.id} className="bg-[#fffdf8]">
<CardContent className="flex items-start justify-between gap-4 p-4">
<div className="min-w-0 flex-1">
<div>
<p className="font-medium text-[#4e1d1a]">
{m.name}
{m.committee_role && (
<Badge className="ml-2 text-xs bg-[#f3ddab] text-[#74411f] hover:bg-[#f3ddab] border-[#e5c884]">
{m.committee_role}
</Badge>
)}
</p>
<p className="text-sm text-[#73594a]">
{m.email}
{m.student_id ? ` · ${m.student_id}` : ""}
{m.company ? ` · ${m.company}` : ""}
</p>
</div>
<div className="mt-3 flex flex-wrap gap-2">
{m.class_permissions && m.class_permissions.length > 0 ? (
m.class_permissions.map((permission) => (
<Badge
key={permission}
variant="outline"
className="border-[#dcc6ab] bg-[#fff8ef] text-xs text-[#7a5e4f]"
>
{CLASS_PERMISSIONS[permission]}
</Badge>
))
) : (
<Badge
variant="outline"
className="border-[#e4d5c4] bg-white text-xs text-[#9d806f]"
>
</Badge>
)}
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<Badge variant="outline" className="border-[#dcc6ab] bg-[#fff8ef] text-[#7a5e4f]">
{ROLES[m.role as keyof typeof ROLES] || m.role}
</Badge>
{canOpenMemberManager && (
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => openMemberManager(m)}
>
</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}
/>
<Dialog
open={memberManagerOpen}
onOpenChange={(open) => {
setMemberManagerOpen(open);
if (!open) {
resetMemberManager();
}
}}
>
<DialogContent className="max-h-[88vh] max-w-3xl overflow-hidden border-[#eadbc8] bg-[#fffdf8] p-0">
<DialogHeader>
<div className="border-b border-[#eadbc8] px-6 pb-4 pt-6">
<DialogTitle className="text-xl text-[#4e1d1a]"></DialogTitle>
</div>
</DialogHeader>
{managingMember && (
<div className="flex max-h-[calc(88vh-5.5rem)] flex-col">
<div className="px-6 pt-5">
<div className="rounded-[1.5rem] border border-[#eadbc8] bg-[#fffaf2] p-4">
<div className="flex flex-wrap items-center gap-2">
<p className="text-base font-semibold text-[#4e1d1a]">{managingMember.name}</p>
<Badge variant="outline" className="border-[#dcc6ab] bg-[#fff8ef] text-[#7a5e4f]">
{ROLES[managingMember.role as keyof typeof ROLES] || managingMember.role}
</Badge>
{managingMember.committee_role && (
<Badge className="border-[#e5c884] bg-[#f3ddab] text-[#74411f] hover:bg-[#f3ddab]">
{managingMember.committee_role}
</Badge>
)}
</div>
<p className="mt-2 text-sm text-[#7a5e4f]">
{managingMember.email}
{managingMember.student_id ? ` · ${managingMember.student_id}` : ""}
</p>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-5">
<Tabs defaultValue="identity" className="gap-4">
<TabsList
className="w-full rounded-2xl border border-[#eadbc8] bg-[#fffaf2] p-1"
>
<TabsTrigger
value="identity"
className="rounded-xl data-active:bg-[#fffdf8] data-active:text-[#4e1d1a]"
>
</TabsTrigger>
<TabsTrigger
value="permissions"
className="rounded-xl data-active:bg-[#fffdf8] data-active:text-[#4e1d1a]"
>
</TabsTrigger>
</TabsList>
<TabsContent value="identity" className="space-y-4">
{isSuperAdmin && managingMember.role !== "super_admin" ? (
<section className="space-y-3 rounded-[1.5rem] border border-[#eadbc8] bg-[#fffdf8] p-4">
<div>
<h3 className="text-sm font-semibold text-[#4e1d1a]"></h3>
<p className="mt-1 text-xs text-[#8d7160]">
</p>
</div>
<Select
value={memberRoleValue}
onValueChange={(value) => {
if (value) setMemberRoleValue(value);
}}
>
<SelectTrigger className="w-full">
<SelectValue>
{ROLES[memberRoleValue as keyof typeof ROLES] || memberRoleValue}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="student"></SelectItem>
<SelectItem value="teacher"></SelectItem>
</SelectContent>
</Select>
</section>
) : (
<section className="rounded-[1.5rem] border border-[#eadbc8] bg-[#fffdf8] p-4">
<p className="text-sm font-medium text-[#4e1d1a]"></p>
<p className="mt-2 text-sm text-[#7a5e4f]">
<span className="mx-1 font-medium text-[#4e1d1a]">
{ROLES[memberRoleValue as keyof typeof ROLES] || memberRoleValue}
</span>
</p>
</section>
)}
{managingMember.role !== "super_admin" && canManageCommittee && (
<section className="space-y-4 rounded-[1.5rem] border border-[#eadbc8] bg-[#fffdf8] p-4">
<div>
<h3 className="text-sm font-semibold text-[#4e1d1a]"></h3>
<p className="mt-1 text-xs text-[#8d7160]">
</p>
</div>
<div className="space-y-2">
<Label htmlFor="committee-role"></Label>
<Input
id="committee-role"
value={memberCommitteeValue}
onChange={(e) => setMemberCommitteeValue(e.target.value)}
placeholder="例如:班长、学习委员,留空表示不是班委"
/>
<div className="flex flex-wrap gap-2 pt-1">
{COMMITTEE_PRESETS.map((preset) => (
<Button
key={preset.role}
type="button"
variant={memberCommitteeValue === preset.role ? "default" : "outline"}
size="sm"
className="h-7 text-xs"
onClick={() => applyCommitteePreset(preset.role)}
>
{preset.role}
</Button>
))}
</div>
<div className="rounded-xl bg-[#f7eee1] px-3 py-2 text-xs text-[#7a5e4f]">
{COMMITTEE_PRESETS.find((preset) => preset.role === memberCommitteeValue)?.description ??
"可先选择班委预设,再去权限页微调具体管理能力。"}
</div>
</div>
</section>
)}
</TabsContent>
<TabsContent value="permissions" className="space-y-4">
{managingMember.role !== "super_admin" && canManageCommittee ? (
<section className="space-y-4 rounded-[1.5rem] border border-[#eadbc8] bg-[#fffdf8] p-4">
<div className="space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<Label className="text-[#4e1d1a]"></Label>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() =>
applyPermissionPreset([
"member_view",
"announcement_manage",
"timeline_manage",
"vote_manage",
"schedule_manage",
"resource_manage",
])
}
>
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 text-xs text-[#8d7160]"
onClick={() => applyPermissionPreset([])}
>
</Button>
</div>
</div>
{memberCommitteeValue &&
COMMITTEE_PRESETS.find((preset) => preset.role === memberCommitteeValue) && (
<div className="flex flex-wrap gap-2 rounded-xl bg-[#fff8ef] p-3">
{COMMITTEE_PRESETS.find((preset) => preset.role === memberCommitteeValue)!.permissions.map(
(permission) => (
<Badge
key={permission}
variant="outline"
className="border-[#dcc6ab] bg-white text-xs text-[#7a5e4f]"
>
{CLASS_PERMISSIONS[permission]}
</Badge>
)
)}
</div>
)}
</div>
<div className="space-y-3">
{PERMISSION_GROUPS.map((group) => (
<div key={group.title} className="rounded-xl border border-[#eadbc8] bg-[#fffaf2] p-3">
<div className="mb-2">
<p className="text-sm font-medium text-[#4e1d1a]">{group.title}</p>
<p className="text-xs text-[#8d7160]">{group.description}</p>
</div>
<div className="grid gap-2 sm:grid-cols-2">
{group.permissions.map((permission) => (
<label
key={permission}
className="flex items-center gap-2 rounded-xl border border-transparent px-2 py-2 text-sm text-[#5f4639] hover:border-[#e1cdb6] hover:bg-[#fff8ef]"
>
<input
type="checkbox"
checked={memberPermissionsValue.includes(permission)}
onChange={() => togglePermission(permission)}
/>
<span>{CLASS_PERMISSIONS[permission]}</span>
</label>
))}
</div>
</div>
))}
</div>
</section>
) : (
<section className="rounded-[1.5rem] border border-[#eadbc8] bg-[#fffdf8] p-4">
<p className="text-sm text-[#7a5e4f]"></p>
</section>
)}
</TabsContent>
</Tabs>
</div>
<div className="flex justify-end gap-2 border-t border-[#eadbc8] bg-[#fffdf8] px-6 py-4">
<Button
variant="outline"
onClick={() => {
setMemberManagerOpen(false);
resetMemberManager();
}}
disabled={memberSubmitting}
>
</Button>
<Button onClick={handleMemberManagerSave} disabled={memberSubmitting}>
{memberSubmitting ? "保存中..." : "保存设置"}
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
</div>
);
}