import React, { useEffect, useMemo, useRef, useState, useCallback } from "react" import { Table, Button, Space, message, Modal, Form, Input, Tag, Pagination, Dropdown } from "antd" import type { ColumnsType } from "antd/es/table" import { UploadOutlined, MoreOutlined } from "@ant-design/icons" import { useTranslation } from "react-i18next" import { TagEditorDialog } from "./TagEditorDialog" import { getAvatarFromExtraJson, setAvatarInExtraJson } from "../utils/studentAvatar" import { useResponsive, useIsMobile, useIsTablet } from "../hooks/useResponsive" const createXlsxWorker = () => { return new Worker(new URL("../workers/xlsxWorker.ts", import.meta.url), { type: "module", }) } interface student { id: number name: string group_name?: string | null score: number tags?: string[] tagIds?: number[] extra_json?: string | null avatarUrl?: string | null } export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { const { t } = useTranslation() const [data, setData] = useState([]) const [loading, setLoading] = useState(false) const [currentPage, setCurrentPage] = useState(1) const [pageSize, setPageSize] = useState(50) const [visible, setVisible] = useState(false) const [importVisible, setImportVisible] = useState(false) const [xlsxVisible, setXlsxVisible] = useState(false) const [tagEditVisible, setTagEditVisible] = useState(false) const [editingStudent, setEditingStudent] = useState(null) const [groupEditVisible, setGroupEditVisible] = useState(false) const [groupEditStudent, setGroupEditStudent] = useState(null) const [groupSaving, setGroupSaving] = useState(false) const [avatarVisible, setAvatarVisible] = useState(false) const [avatarSaving, setAvatarSaving] = useState(false) const [avatarStudent, setAvatarStudent] = useState(null) const [avatarValue, setAvatarValue] = useState(null) const avatarInputRef = useRef(null) const [xlsxLoading, setXlsxLoading] = useState(false) const [textImportLoading, setTextImportLoading] = useState(false) const [xlsxFileName, setXlsxFileName] = useState("") const [xlsxAoa, setXlsxAoa] = useState([]) const [xlsxSelectedCol, setXlsxSelectedCol] = useState(null) const [textImportValue, setTextImportValue] = useState("") const xlsxInputRef = useRef(null) const xlsxWorkerRef = useRef(null) const [form] = Form.useForm() const [groupForm] = Form.useForm() const [messageApi, contextHolder] = message.useMessage() const isMobile = useIsMobile() const isTablet = useIsTablet() const breakpoint = useResponsive() useEffect(() => { xlsxWorkerRef.current = createXlsxWorker() return () => { xlsxWorkerRef.current?.terminate() } }, []) const emitDataUpdated = (category: "students" | "all") => { window.dispatchEvent(new CustomEvent("ss:data-updated", { detail: { category } })) } const fetchStudents = useCallback(async () => { if (!(window as any).api) return setLoading(true) try { const res = await (window as any).api.queryStudents({}) if (res.success && res.data) { try { const students = await Promise.all( (res.data as any[]).map(async (s) => { let tagIds: number[] = [] let tags: string[] = [] try { const tagsRes = await (window as any).api.tagsGetByStudent(s.id) if (tagsRes.success && tagsRes.data) { tagIds = tagsRes.data.map((t: any) => t.id) tags = tagsRes.data.map((t: any) => t.name) } } catch (e) { console.warn("Failed to fetch tags for student:", s.id, e) } return { id: s.id, name: s.name, group_name: s.group_name ?? null, score: s.score, extra_json: s.extra_json ?? null, avatarUrl: getAvatarFromExtraJson(s.extra_json), tags, tagIds, } }) ) console.debug("Fetched students:", students) setData(students) } catch (e) { console.warn("Failed to parse students response, falling back:", e) setData(res.data) } } } catch (e) { console.error("Failed to fetch students:", e) } finally { setLoading(false) } }, []) useEffect(() => { fetchStudents() const onDataUpdated = (e: any) => { const category = e?.detail?.category if (category === "students" || category === "all") fetchStudents() } window.addEventListener("ss:data-updated", onDataUpdated as any) return () => window.removeEventListener("ss:data-updated", onDataUpdated as any) }, [fetchStudents]) const handleAdd = async () => { if (!(window as any).api) return if (!canEdit) { messageApi.error(t("common.readOnly")) return } try { const values = await form.validateFields() if (!values.name) { messageApi.warning(t("students.nameRequired")) return } const name = values.name.trim() const groupName = typeof values.group_name === "string" && values.group_name.trim() ? values.group_name.trim() : undefined if (data.some((s) => s.name === name)) { messageApi.warning(t("students.nameExists")) return } const res = await (window as any).api.createStudent({ ...values, name, group_name: groupName }) if (res.success) { messageApi.success(t("students.addSuccess")) setVisible(false) form.resetFields() fetchStudents() emitDataUpdated("students") } else { messageApi.error(res.message || t("students.addFailed")) } } catch (err) { try { const api = (window as any).api api?.writeLog?.({ level: "error", message: "renderer:validate error", meta: err instanceof Error ? { message: err.message, stack: err.stack } : { err: String(err) }, }) } catch { return } } } const handleDelete = async (id: number) => { if (!(window as any).api) return if (!canEdit) { messageApi.error(t("common.readOnly")) return } const res = await (window as any).api.deleteStudent(id) if (res.success) { messageApi.success(t("students.deleteSuccess")) fetchStudents() emitDataUpdated("students") } else { messageApi.error(res.message || t("students.deleteFailed")) } } const handleOpenTagEditor = (student: student) => { if (!canEdit) { messageApi.error(t("common.readOnly")) return } setEditingStudent(student) setTagEditVisible(true) } const handleOpenAvatarEditor = (student: student) => { if (!canEdit) { messageApi.error(t("common.readOnly")) return } setAvatarStudent(student) setAvatarValue(student.avatarUrl || null) setAvatarVisible(true) } const handleOpenGroupEditor = (student: student) => { if (!canEdit) { messageApi.error(t("common.readOnly")) return } setGroupEditStudent(student) groupForm.setFieldsValue({ group_name: student.group_name || "" }) setGroupEditVisible(true) } const handleSaveGroup = async () => { if (!(window as any).api || !groupEditStudent) return try { const values = await groupForm.validateFields() const groupName = typeof values.group_name === "string" && values.group_name.trim() ? values.group_name.trim() : "" setGroupSaving(true) const res = await (window as any).api.updateStudent(groupEditStudent.id, { group_name: groupName, }) if (res?.success) { messageApi.success(t("students.groupSaveSuccess")) setGroupEditVisible(false) setGroupEditStudent(null) groupForm.resetFields() fetchStudents() emitDataUpdated("students") } else { messageApi.error(res?.message || t("students.groupSaveFailed")) } } catch { return } finally { setGroupSaving(false) } } const readFileAsDataUrl = (file: File): Promise => { return new Promise((resolve, reject) => { const reader = new FileReader() reader.onload = () => { const result = reader.result if (typeof result === "string") resolve(result) else reject(new Error("invalid-result")) } reader.onerror = () => reject(reader.error || new Error("read-failed")) reader.readAsDataURL(file) }) } const handleAvatarFileChange = async (file?: File) => { if (!file) return if (!file.type.startsWith("image/")) { messageApi.error(t("students.avatarInvalidFile")) return } const maxSize = 2 * 1024 * 1024 if (file.size > maxSize) { messageApi.error(t("students.avatarTooLarge")) return } try { const dataUrl = await readFileAsDataUrl(file) setAvatarValue(dataUrl) } catch { messageApi.error(t("students.avatarReadFailed")) } } const handleSaveAvatar = async () => { if (!(window as any).api || !avatarStudent) return setAvatarSaving(true) try { const payload = setAvatarInExtraJson(avatarStudent.extra_json, avatarValue) const res = await (window as any).api.updateStudent(avatarStudent.id, { extra_json: payload, }) if (res?.success) { messageApi.success(t("students.avatarSaveSuccess")) setAvatarVisible(false) setAvatarStudent(null) setAvatarValue(null) fetchStudents() emitDataUpdated("students") } else { messageApi.error(res?.message || t("students.avatarSaveFailed")) } } catch { messageApi.error(t("students.avatarSaveFailed")) } finally { setAvatarSaving(false) } } const handleSaveTags = async (tagIds: number[]) => { if (!editingStudent || !(window as any).api) return try { const res = await (window as any).api.tagsUpdateStudentTags(editingStudent.id, tagIds) if (res && res.success) { messageApi.success("标签保存成功") setTagEditVisible(false) setEditingStudent(null) fetchStudents() emitDataUpdated("students") } else { const errorMsg = res?.message || t("students.tagSaveFailed") messageApi.error(errorMsg) } } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error) messageApi.error(`${t("students.tagSaveFailed")}: ${errorMsg}`) } } const excelColName = (idx: number) => { let n = idx + 1 let s = "" while (n > 0) { const mod = (n - 1) % 26 s = String.fromCharCode(65 + mod) + s n = Math.floor((n - 1) / 26) } return s } const parseXlsxFile = async (file: File) => { if (!xlsxWorkerRef.current) { messageApi.error(t("students.workerNotReady")) return } setXlsxLoading(true) try { const buf = await file.arrayBuffer() xlsxWorkerRef.current.postMessage({ type: "parseXlsx", data: { buffer: buf }, }) const handleMessage = (event: MessageEvent) => { if (event.data.type === "success") { setXlsxFileName(file.name) setXlsxAoa(event.data.data) setXlsxSelectedCol(null) setXlsxVisible(true) setImportVisible(false) setXlsxLoading(false) } else if (event.data.type === "error") { messageApi.error(event.data.error || t("students.parseXlsxFailed")) setXlsxLoading(false) } xlsxWorkerRef.current?.removeEventListener("message", handleMessage) } xlsxWorkerRef.current.addEventListener("message", handleMessage) } catch (e: any) { messageApi.error(e?.message || t("students.parseXlsxFailed")) setXlsxLoading(false) } } const xlsxMaxCols = useMemo(() => { let max = 0 for (const row of xlsxAoa) { if (Array.isArray(row)) max = Math.max(max, row.length) } return max }, [xlsxAoa]) const xlsxPreviewRows = useMemo(() => { const limit = 50 const rows = xlsxAoa.slice(0, limit) return rows.map((row, idx) => { const record: any = { __row: idx + 1 } for (let c = 0; c < xlsxMaxCols; c++) { record[`c${c}`] = row?.[c] ?? "" } return record }) }, [xlsxAoa, xlsxMaxCols]) const xlsxPreviewColumns = useMemo(() => { const cols: any[] = [ { title: "#", dataIndex: "__row", key: "__row", width: 60, align: "center" as const, fixed: "left" as const, }, ] for (let c = 0; c < xlsxMaxCols; c++) { const selected = xlsxSelectedCol === c cols.push({ title: ( setXlsxSelectedCol(c)} > {excelColName(c)} ), dataIndex: `c${c}`, key: `c${c}`, width: 120, }) } return cols }, [xlsxMaxCols, xlsxSelectedCol]) const extractUniqueNames = (rawNames: string[]) => { const out: string[] = [] const seen = new Set() const banned = new Set([t("students.name").toLowerCase(), "name", t("students.name")]) for (const raw of rawNames) { const name = String(raw ?? "").trim() if (!name) continue if (banned.has(name.toLowerCase()) || banned.has(name)) continue if (seen.has(name)) continue seen.add(name) out.push(name) } return out } const extractNamesFromAoa = (aoa: any[][], colIdx: number) => { const names = aoa.map((row) => String(row?.[colIdx] ?? "")) return extractUniqueNames(names) } const extractNamesFromText = (text: string) => { const lines = text.split(/\r?\n/) return extractUniqueNames(lines) } const importNames = async (names: string[]) => { if (!(window as any).api) return false const res = await (window as any).api.importStudentsFromXlsx({ names }) if (!res?.success) { messageApi.error(res?.message || t("students.importFailed")) return false } const inserted = Number(res?.data?.inserted ?? 0) const skipped = Number(res?.data?.skipped ?? 0) messageApi.success(t("students.importComplete", { inserted, skipped })) fetchStudents() emitDataUpdated("students") return true } const handleConfirmXlsxImport = async () => { if (!canEdit) { messageApi.error(t("common.readOnly")) return } if (xlsxSelectedCol == null) { messageApi.warning(t("students.selectNameColFirst")) return } const names = extractNamesFromAoa(xlsxAoa, xlsxSelectedCol) if (!names.length) { messageApi.error(t("students.noNamesFound")) return } setXlsxLoading(true) try { const success = await importNames(names) if (!success) return setXlsxVisible(false) setXlsxAoa([]) setXlsxFileName("") setXlsxSelectedCol(null) } finally { setXlsxLoading(false) } } const handleImportTextNames = async () => { if (!canEdit) { messageApi.error(t("common.readOnly")) return } const names = extractNamesFromText(textImportValue) if (!names.length) { messageApi.warning(t("students.noNamesFound")) return } setTextImportLoading(true) try { const success = await importNames(names) if (!success) return setTextImportValue("") setImportVisible(false) } finally { setTextImportLoading(false) } } const columns: ColumnsType = useMemo(() => { const baseColumns: ColumnsType = [ { title: t("students.avatar"), key: "avatar", width: isMobile ? 60 : 88, align: "center", render: (_, row) => row.avatarUrl ? ( {row.name} ) : (
-
), }, { title: t("students.name"), dataIndex: "name", key: "name", width: isMobile ? 80 : 100, ellipsis: true, }, { title: t("students.group"), dataIndex: "group_name", key: "group_name", width: isMobile ? 90 : 120, ellipsis: true, render: (groupName?: string | null) => groupName?.trim() || t("students.noGroup"), }, { title: t("students.currentScore"), dataIndex: "score", key: "score", width: isMobile ? 100 : 160, align: "center", render: (score: number) => ( 0 ? "var(--ant-color-success, #52c41a)" : score < 0 ? "var(--ant-color-error, #ff4d4f)" : "inherit", fontSize: isMobile ? 12 : 14, }} > {score > 0 ? `+${score}` : score} ), }, { title: t("students.tags"), dataIndex: "tags", key: "tags", width: isMobile ? 120 : 200, render: (tags: string[] = []) => ( {tags.length === 0 ? ( {t("students.noTags")} ) : ( tags.slice(0, isMobile ? 2 : 3).map((tag) => ( {tag} )) )} {tags.length > (isMobile ? 2 : 3) && ( +{tags.length - (isMobile ? 2 : 3)} )} ), }, { title: t("common.operation"), key: "operation", width: isMobile ? 96 : 110, fixed: isMobile ? "right" : undefined, render: (_, row) => ( { if (key === "editTags") handleOpenTagEditor(row) else if (key === "editGroup") handleOpenGroupEditor(row) else if (key === "editAvatar") handleOpenAvatarEditor(row) else if (key === "delete") handleDelete(row.id) }, }} > ), }, ] if (isMobile) { return baseColumns.filter((col) => col.key !== "tags") } return baseColumns }, [t, canEdit, isMobile, isTablet, breakpoint]) const paginatedData = data.slice((currentPage - 1) * pageSize, currentPage * pageSize) return (
{contextHolder}

{t("students.title")}

{ setCurrentPage(page) setPageSize(size) }} showSizeChanger showTotal={(total) => t("common.total", { count: total })} />
setVisible(false)} okText={t("students.addConfirm")} cancelText={t("common.cancel")} destroyOnHidden >
{ setGroupEditVisible(false) setGroupEditStudent(null) groupForm.resetFields() }} onOk={handleSaveGroup} okText={t("common.save")} cancelText={t("common.cancel")} okButtonProps={{ loading: groupSaving, disabled: !groupEditStudent }} destroyOnHidden >
setImportVisible(false)} footer={null} destroyOnHidden >
{t("students.importTextHint")}
setTextImportValue(e.target.value)} rows={8} placeholder={t("students.importTextPlaceholder")} disabled={!canEdit || textImportLoading} /> { const file = e.target.files?.[0] if (file) parseXlsxFile(file) if (xlsxInputRef.current) xlsxInputRef.current.value = "" }} />
setXlsxVisible(false)} onOk={handleConfirmXlsxImport} okText={t("students.importConfirm")} okButtonProps={{ loading: xlsxLoading, disabled: xlsxSelectedCol == null }} width="80%" destroyOnHidden >
{t("students.file")} {xlsxFileName || "-"}
{t("students.selectNameCol")} {xlsxSelectedCol == null ? "-" : excelColName(xlsxSelectedCol)}
{t("students.previewRows")}
{ setTagEditVisible(false) setEditingStudent(null) }} onConfirm={handleSaveTags} initialTagIds={editingStudent?.tagIds || []} title={t("students.editTagTitle", { name: editingStudent?.name || "" })} /> { setAvatarVisible(false) setAvatarStudent(null) setAvatarValue(null) }} onOk={handleSaveAvatar} okButtonProps={{ loading: avatarSaving, disabled: !avatarStudent }} okText={t("common.save")} cancelText={t("common.cancel")} destroyOnHidden >
{avatarValue ? ( {avatarStudent?.name ) : (
{t("students.noAvatar")}
)}
{ const file = e.target.files?.[0] handleAvatarFileChange(file) if (avatarInputRef.current) avatarInputRef.current.value = "" }} />
{t("students.avatarTip")}
) }