1220 lines
47 KiB
TypeScript
1220 lines
47 KiB
TypeScript
"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>
|
||
);
|
||
}
|