mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-19 19:04:23 +08:00
7a59572f11
- 新增 OAuth 登录组件和回调页面 - 集成 Tauri 深度链接插件处理 OAuth 回调 - 添加账户设置页面显示登录信息 - 更新 CI 配置添加 OAuth 环境变量 - 扩展 API 接口支持 OAuth 相关操作 - 添加国际化支持
2223 lines
77 KiB
TypeScript
2223 lines
77 KiB
TypeScript
import React, { useEffect, useMemo, useRef, useState, useCallback } from "react"
|
|
import {
|
|
Table,
|
|
Button,
|
|
Space,
|
|
message,
|
|
Modal,
|
|
Form,
|
|
Input,
|
|
Tag,
|
|
Pagination,
|
|
Dropdown,
|
|
Checkbox,
|
|
Select,
|
|
} from "antd"
|
|
import type { ColumnsType } from "antd/es/table"
|
|
import { UploadOutlined, MoreOutlined, PlusOutlined } 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
|
|
}
|
|
|
|
interface BanYouClassroom {
|
|
classId: string
|
|
classNickName: string
|
|
invitationCode?: string | null
|
|
masterName?: string | null
|
|
studentsNum?: number | null
|
|
praiseCount?: number | null
|
|
classAvatarPath?: string | null
|
|
classAvatarDataUrl?: string | null
|
|
isOwn?: boolean | null
|
|
}
|
|
|
|
interface BanYouMedal {
|
|
key?: string
|
|
uid?: string
|
|
name: string
|
|
type?: number
|
|
medalType?: number
|
|
value?: number
|
|
}
|
|
|
|
interface BanYouStudentItem {
|
|
studentId: string
|
|
studentName: string
|
|
}
|
|
|
|
interface BanYouTeamItem {
|
|
teamId: string
|
|
teamName: string
|
|
students: BanYouStudentItem[]
|
|
}
|
|
|
|
interface BanYouTeamPlanOption {
|
|
teamPlanId: number
|
|
name?: string
|
|
}
|
|
|
|
interface BanYouClassroomDetail {
|
|
medals: BanYouMedal[]
|
|
students: BanYouStudentItem[]
|
|
teams: BanYouTeamItem[]
|
|
ungroupedStudents: BanYouStudentItem[]
|
|
teamPlanIdUsed?: number
|
|
teamPlans?: BanYouTeamPlanOption[]
|
|
teamPlanSource?: string
|
|
}
|
|
|
|
export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|
const UNGROUPED_KEY = "__ungrouped__"
|
|
const { t } = useTranslation()
|
|
const [data, setData] = useState<student[]>([])
|
|
const [loading, setLoading] = useState(false)
|
|
const [currentPage, setCurrentPage] = useState(1)
|
|
const [pageSize, setPageSize] = useState<number>(50)
|
|
const [visible, setVisible] = useState(false)
|
|
const [textImportVisible, setTextImportVisible] = useState(false)
|
|
const [xlsxVisible, setXlsxVisible] = useState(false)
|
|
const [banYouVisible, setBanYouVisible] = useState(false)
|
|
const [tagEditVisible, setTagEditVisible] = useState(false)
|
|
const [editingStudent, setEditingStudent] = useState<student | null>(null)
|
|
const [groupEditVisible, setGroupEditVisible] = useState(false)
|
|
const [groupEditStudent, setGroupEditStudent] = useState<student | null>(null)
|
|
const [groupSaving, setGroupSaving] = useState(false)
|
|
const [groupBoardVisible, setGroupBoardVisible] = useState(false)
|
|
const [groupBoardSaving, setGroupBoardSaving] = useState(false)
|
|
const [groupBoard, setGroupBoard] = useState<Record<string, student[]>>({})
|
|
const [groupBoardOrder, setGroupBoardOrder] = useState<string[]>([])
|
|
const [groupBoardNewGroupName, setGroupBoardNewGroupName] = useState("")
|
|
const [pointerDraggingStudentId, setPointerDraggingStudentId] = useState<number | null>(null)
|
|
const [pointerTargetGroup, setPointerTargetGroup] = useState<string | null>(null)
|
|
const [pointerDragStudentName, setPointerDragStudentName] = useState("")
|
|
const [pointerDraggingGroupKey, setPointerDraggingGroupKey] = useState<string | null>(null)
|
|
const [pointerTargetGroupOrderKey, setPointerTargetGroupOrderKey] = useState<string | null>(null)
|
|
const [pointerDragGroupName, setPointerDragGroupName] = useState("")
|
|
const pointerDragGhostRef = useRef<HTMLDivElement | null>(null)
|
|
const pointerDragPositionRef = useRef<{ x: number; y: number } | null>(null)
|
|
const pointerDragRafRef = useRef<number | null>(null)
|
|
const draggingStudentIdRef = useRef<number | null>(null)
|
|
const pointerDragSourceGroupRef = useRef<string | null>(null)
|
|
const pointerDragTargetGroupRef = useRef<string | null>(null)
|
|
const pointerDragGroupGhostRef = useRef<HTMLDivElement | null>(null)
|
|
const pointerDragGroupPositionRef = useRef<{ x: number; y: number } | null>(null)
|
|
const pointerDragGroupRafRef = useRef<number | null>(null)
|
|
const pointerDragGroupSourceRef = useRef<string | null>(null)
|
|
const pointerDragGroupTargetRef = useRef<string | null>(null)
|
|
const pointerDragGroupInsertAfterRef = useRef(false)
|
|
const [avatarVisible, setAvatarVisible] = useState(false)
|
|
const [avatarSaving, setAvatarSaving] = useState(false)
|
|
const [avatarStudent, setAvatarStudent] = useState<student | null>(null)
|
|
const [avatarValue, setAvatarValue] = useState<string | null>(null)
|
|
const avatarInputRef = useRef<HTMLInputElement | null>(null)
|
|
const [xlsxLoading, setXlsxLoading] = useState(false)
|
|
const [textImportLoading, setTextImportLoading] = useState(false)
|
|
const [xlsxFileName, setXlsxFileName] = useState("")
|
|
const [xlsxAoa, setXlsxAoa] = useState<any[][]>([])
|
|
const [xlsxSelectedCol, setXlsxSelectedCol] = useState<number | null>(null)
|
|
const [textImportValue, setTextImportValue] = useState("")
|
|
const [banYouCookie, setBanYouCookie] = useState("")
|
|
const [banYouLoading, setBanYouLoading] = useState(false)
|
|
const [banYouDetailLoading, setBanYouDetailLoading] = useState(false)
|
|
const [banYouImportLoading, setBanYouImportLoading] = useState(false)
|
|
const [banYouClassrooms, setBanYouClassrooms] = useState<BanYouClassroom[]>([])
|
|
const [banYouDetailVisible, setBanYouDetailVisible] = useState(false)
|
|
const [banYouSelectedClass, setBanYouSelectedClass] = useState<BanYouClassroom | null>(null)
|
|
const [banYouDetail, setBanYouDetail] = useState<BanYouClassroomDetail | null>(null)
|
|
const [banYouCheckedMedals, setBanYouCheckedMedals] = useState<string[]>([])
|
|
const [banYouCheckedStudents, setBanYouCheckedStudents] = useState<string[]>([])
|
|
const [banYouCheckedTeams, setBanYouCheckedTeams] = useState<string[]>([])
|
|
const [banYouTeamPlanOptions, setBanYouTeamPlanOptions] = useState<BanYouTeamPlanOption[]>([])
|
|
const [banYouSelectedTeamPlanId, setBanYouSelectedTeamPlanId] = useState<number | undefined>(
|
|
undefined
|
|
)
|
|
const xlsxInputRef = useRef<HTMLInputElement | null>(null)
|
|
const xlsxWorkerRef = useRef<Worker | null>(null)
|
|
const [form] = Form.useForm()
|
|
const [groupForm] = Form.useForm()
|
|
const [messageApi, contextHolder] = message.useMessage()
|
|
const addFormGroupName = Form.useWatch("group_name", form)
|
|
const editFormGroupName = Form.useWatch("group_name", groupForm)
|
|
const isMobile = useIsMobile()
|
|
const isTablet = useIsTablet()
|
|
const breakpoint = useResponsive()
|
|
|
|
useEffect(() => {
|
|
xlsxWorkerRef.current = createXlsxWorker()
|
|
return () => {
|
|
xlsxWorkerRef.current?.terminate()
|
|
if (pointerDragRafRef.current != null) {
|
|
cancelAnimationFrame(pointerDragRafRef.current)
|
|
pointerDragRafRef.current = null
|
|
}
|
|
if (pointerDragGroupRafRef.current != null) {
|
|
cancelAnimationFrame(pointerDragGroupRafRef.current)
|
|
pointerDragGroupRafRef.current = null
|
|
}
|
|
}
|
|
}, [])
|
|
|
|
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 normalizeGroupBoardOrder = useCallback(
|
|
(order: string[]) => {
|
|
const withoutUngrouped = order.filter((key) => key !== UNGROUPED_KEY)
|
|
return [...withoutUngrouped, UNGROUPED_KEY]
|
|
},
|
|
[UNGROUPED_KEY]
|
|
)
|
|
|
|
const openGroupBoardEditor = () => {
|
|
if (!canEdit) {
|
|
messageApi.error(t("common.readOnly"))
|
|
return
|
|
}
|
|
|
|
const groups = new Set<string>()
|
|
data.forEach((student) => {
|
|
const normalized = student.group_name?.trim()
|
|
if (normalized) groups.add(normalized)
|
|
})
|
|
const sortedGroups = Array.from(groups).sort((a, b) => a.localeCompare(b, "zh-CN"))
|
|
const order = normalizeGroupBoardOrder([...sortedGroups, UNGROUPED_KEY])
|
|
const board: Record<string, student[]> = {}
|
|
order.forEach((key) => {
|
|
board[key] = []
|
|
})
|
|
data.forEach((student) => {
|
|
const key = student.group_name?.trim() || UNGROUPED_KEY
|
|
if (!board[key]) {
|
|
board[key] = []
|
|
order.push(key)
|
|
}
|
|
board[key].push(student)
|
|
})
|
|
order.forEach((key) => {
|
|
board[key].sort((a, b) => a.name.localeCompare(b.name, "zh-CN"))
|
|
})
|
|
console.debug("[GroupBoard] open editor", {
|
|
groups: order.map((group) => (group === UNGROUPED_KEY ? "ungrouped" : group)),
|
|
studentCount: data.length,
|
|
})
|
|
setGroupBoardOrder(normalizeGroupBoardOrder(order))
|
|
setGroupBoard(board)
|
|
setGroupBoardNewGroupName("")
|
|
setGroupBoardVisible(true)
|
|
}
|
|
|
|
const handleCreateGroupInBoard = () => {
|
|
const name = groupBoardNewGroupName.trim()
|
|
if (!name) {
|
|
messageApi.warning(t("students.groupBoardGroupNameRequired"))
|
|
return
|
|
}
|
|
if (name === UNGROUPED_KEY) {
|
|
messageApi.warning(t("students.groupBoardGroupNameInvalid"))
|
|
return
|
|
}
|
|
if (groupBoardOrder.includes(name)) {
|
|
messageApi.warning(t("students.groupBoardGroupExists"))
|
|
return
|
|
}
|
|
|
|
setGroupBoard((prev) => ({ ...prev, [name]: [] }))
|
|
setGroupBoardOrder((prev) => {
|
|
const withoutUngrouped = prev.filter((key) => key !== UNGROUPED_KEY)
|
|
return normalizeGroupBoardOrder([...withoutUngrouped, name, UNGROUPED_KEY])
|
|
})
|
|
setGroupBoardNewGroupName("")
|
|
}
|
|
|
|
const moveStudentToGroup = (studentId: number, targetGroup: string) => {
|
|
setGroupBoard((prev) => {
|
|
let movingStudent: student | null = null
|
|
let sourceGroup = ""
|
|
const next: Record<string, student[]> = {}
|
|
Object.entries(prev).forEach(([group, students]) => {
|
|
next[group] = students.filter((student) => {
|
|
if (student.id === studentId) {
|
|
movingStudent = student
|
|
sourceGroup = group
|
|
return false
|
|
}
|
|
return true
|
|
})
|
|
})
|
|
|
|
if (!movingStudent) return prev
|
|
const movedStudent = movingStudent as student
|
|
if (sourceGroup === targetGroup) return prev
|
|
if (!next[targetGroup]) next[targetGroup] = []
|
|
next[targetGroup] = [...next[targetGroup], movedStudent]
|
|
console.debug("[GroupBoard] move student", {
|
|
studentId,
|
|
studentName: movedStudent.name,
|
|
from: sourceGroup === UNGROUPED_KEY ? "ungrouped" : sourceGroup,
|
|
to: targetGroup === UNGROUPED_KEY ? "ungrouped" : targetGroup,
|
|
})
|
|
return next
|
|
})
|
|
}
|
|
|
|
const handleSaveGroupBoard = async () => {
|
|
if (!(window as any).api) return
|
|
|
|
const groupByStudentId = new Map<number, string>()
|
|
groupBoardOrder.forEach((groupKey) => {
|
|
const students = groupBoard[groupKey] || []
|
|
students.forEach((student) => {
|
|
groupByStudentId.set(student.id, groupKey === UNGROUPED_KEY ? "" : groupKey)
|
|
})
|
|
})
|
|
|
|
const changedStudents = data.filter((student) => {
|
|
const originalGroup = student.group_name?.trim() || ""
|
|
const nextGroup = groupByStudentId.get(student.id) ?? ""
|
|
return originalGroup !== nextGroup
|
|
})
|
|
|
|
if (changedStudents.length === 0) {
|
|
console.debug("[GroupBoard] save skipped, no changes")
|
|
messageApi.info(t("students.groupBoardNoChanges"))
|
|
setGroupBoardVisible(false)
|
|
return
|
|
}
|
|
|
|
setGroupBoardSaving(true)
|
|
try {
|
|
const results = await Promise.allSettled(
|
|
changedStudents.map((student) => {
|
|
const nextGroup = groupByStudentId.get(student.id) ?? ""
|
|
return (window as any).api.updateStudent(student.id, { group_name: nextGroup })
|
|
})
|
|
)
|
|
const failedCount = results.filter(
|
|
(result) => result.status === "rejected" || !result.value?.success
|
|
).length
|
|
console.debug("[GroupBoard] save result", {
|
|
changedCount: changedStudents.length,
|
|
failedCount,
|
|
})
|
|
|
|
if (failedCount > 0) {
|
|
messageApi.error(t("students.groupBoardSaveFailed", { failed: failedCount }))
|
|
} else {
|
|
messageApi.success(t("students.groupBoardSaveSuccess", { count: changedStudents.length }))
|
|
setGroupBoardVisible(false)
|
|
}
|
|
fetchStudents()
|
|
emitDataUpdated("students")
|
|
} finally {
|
|
setGroupBoardSaving(false)
|
|
}
|
|
}
|
|
|
|
const beginPointerDrag = (
|
|
e: React.PointerEvent<HTMLDivElement>,
|
|
studentId: number,
|
|
studentName: string,
|
|
sourceGroup: string
|
|
) => {
|
|
if (e.button !== 0) return
|
|
e.preventDefault()
|
|
draggingStudentIdRef.current = studentId
|
|
pointerDragSourceGroupRef.current = sourceGroup
|
|
pointerDragTargetGroupRef.current = null
|
|
setPointerDraggingStudentId(studentId)
|
|
setPointerDragStudentName(studentName)
|
|
pointerDragPositionRef.current = { x: e.clientX, y: e.clientY }
|
|
requestAnimationFrame(() => {
|
|
if (!pointerDragPositionRef.current) return
|
|
const ghost = pointerDragGhostRef.current
|
|
if (!ghost) return
|
|
ghost.style.transform = `translate3d(${pointerDragPositionRef.current.x + 14}px, ${
|
|
pointerDragPositionRef.current.y + 14
|
|
}px, 0)`
|
|
})
|
|
setPointerTargetGroup(null)
|
|
e.currentTarget.setPointerCapture(e.pointerId)
|
|
console.debug("[GroupBoard] pointer drag start", {
|
|
studentId,
|
|
studentName,
|
|
sourceGroup: sourceGroup === UNGROUPED_KEY ? "ungrouped" : sourceGroup,
|
|
})
|
|
}
|
|
|
|
const schedulePointerDragPositionUpdate = (clientX: number, clientY: number) => {
|
|
pointerDragPositionRef.current = { x: clientX, y: clientY }
|
|
if (pointerDragRafRef.current != null) return
|
|
pointerDragRafRef.current = requestAnimationFrame(() => {
|
|
pointerDragRafRef.current = null
|
|
if (!pointerDragPositionRef.current) return
|
|
const ghost = pointerDragGhostRef.current
|
|
if (!ghost) return
|
|
ghost.style.transform = `translate3d(${pointerDragPositionRef.current.x + 14}px, ${
|
|
pointerDragPositionRef.current.y + 14
|
|
}px, 0)`
|
|
})
|
|
}
|
|
|
|
const trackPointerTarget = (clientX: number, clientY: number) => {
|
|
if (draggingStudentIdRef.current == null) return
|
|
schedulePointerDragPositionUpdate(clientX, clientY)
|
|
const element = document.elementFromPoint(clientX, clientY) as HTMLElement | null
|
|
const dropZone = element?.closest("[data-group-drop]") as HTMLElement | null
|
|
const targetGroup = dropZone?.dataset.groupDrop ?? null
|
|
if (pointerDragTargetGroupRef.current !== targetGroup) {
|
|
pointerDragTargetGroupRef.current = targetGroup
|
|
setPointerTargetGroup(targetGroup)
|
|
console.debug("[GroupBoard] pointer target", {
|
|
targetGroup: targetGroup === UNGROUPED_KEY ? "ungrouped" : targetGroup,
|
|
})
|
|
}
|
|
}
|
|
|
|
const finishPointerDrag = () => {
|
|
const studentId = draggingStudentIdRef.current
|
|
const sourceGroup = pointerDragSourceGroupRef.current
|
|
const targetGroup = pointerDragTargetGroupRef.current
|
|
console.debug("[GroupBoard] pointer drag end", {
|
|
studentId,
|
|
sourceGroup: sourceGroup === UNGROUPED_KEY ? "ungrouped" : sourceGroup,
|
|
targetGroup: targetGroup === UNGROUPED_KEY ? "ungrouped" : targetGroup,
|
|
})
|
|
if (studentId != null && targetGroup && targetGroup !== sourceGroup) {
|
|
moveStudentToGroup(studentId, targetGroup)
|
|
}
|
|
draggingStudentIdRef.current = null
|
|
pointerDragSourceGroupRef.current = null
|
|
pointerDragTargetGroupRef.current = null
|
|
pointerDragPositionRef.current = null
|
|
if (pointerDragRafRef.current != null) {
|
|
cancelAnimationFrame(pointerDragRafRef.current)
|
|
pointerDragRafRef.current = null
|
|
}
|
|
setPointerDraggingStudentId(null)
|
|
setPointerDragStudentName("")
|
|
setPointerTargetGroup(null)
|
|
const ghost = pointerDragGhostRef.current
|
|
if (ghost) ghost.style.transform = "translate3d(-9999px, -9999px, 0)"
|
|
}
|
|
|
|
const resetGroupOrderDragState = () => {
|
|
pointerDragGroupSourceRef.current = null
|
|
pointerDragGroupTargetRef.current = null
|
|
pointerDragGroupPositionRef.current = null
|
|
pointerDragGroupInsertAfterRef.current = false
|
|
if (pointerDragGroupRafRef.current != null) {
|
|
cancelAnimationFrame(pointerDragGroupRafRef.current)
|
|
pointerDragGroupRafRef.current = null
|
|
}
|
|
setPointerDraggingGroupKey(null)
|
|
setPointerTargetGroupOrderKey(null)
|
|
setPointerDragGroupName("")
|
|
const ghost = pointerDragGroupGhostRef.current
|
|
if (ghost) ghost.style.transform = "translate3d(-9999px, -9999px, 0)"
|
|
}
|
|
|
|
const scheduleGroupPointerPositionUpdate = (clientX: number, clientY: number) => {
|
|
pointerDragGroupPositionRef.current = { x: clientX, y: clientY }
|
|
if (pointerDragGroupRafRef.current != null) return
|
|
pointerDragGroupRafRef.current = requestAnimationFrame(() => {
|
|
pointerDragGroupRafRef.current = null
|
|
if (!pointerDragGroupPositionRef.current) return
|
|
const ghost = pointerDragGroupGhostRef.current
|
|
if (!ghost) return
|
|
ghost.style.transform = `translate3d(${pointerDragGroupPositionRef.current.x + 14}px, ${
|
|
pointerDragGroupPositionRef.current.y + 14
|
|
}px, 0)`
|
|
})
|
|
}
|
|
|
|
const beginGroupOrderDrag = (
|
|
e: React.PointerEvent<HTMLDivElement>,
|
|
groupKey: string,
|
|
groupLabel: string
|
|
) => {
|
|
if (e.button !== 0 || pointerDraggingStudentId != null || groupKey === UNGROUPED_KEY) return
|
|
e.preventDefault()
|
|
pointerDragGroupSourceRef.current = groupKey
|
|
pointerDragGroupTargetRef.current = null
|
|
pointerDragGroupInsertAfterRef.current = false
|
|
setPointerDraggingGroupKey(groupKey)
|
|
setPointerTargetGroupOrderKey(null)
|
|
setPointerDragGroupName(groupLabel)
|
|
scheduleGroupPointerPositionUpdate(e.clientX, e.clientY)
|
|
e.currentTarget.setPointerCapture(e.pointerId)
|
|
}
|
|
|
|
const trackGroupOrderTarget = (clientX: number, clientY: number) => {
|
|
if (!pointerDragGroupSourceRef.current) return
|
|
scheduleGroupPointerPositionUpdate(clientX, clientY)
|
|
const element = document.elementFromPoint(clientX, clientY) as HTMLElement | null
|
|
const targetElement = element?.closest("[data-group-column]") as HTMLElement | null
|
|
const targetGroup = targetElement?.dataset.groupColumn ?? null
|
|
if (targetElement) {
|
|
const rect = targetElement.getBoundingClientRect()
|
|
pointerDragGroupInsertAfterRef.current = clientX >= rect.left + rect.width / 2
|
|
} else {
|
|
pointerDragGroupInsertAfterRef.current = false
|
|
}
|
|
if (pointerDragGroupTargetRef.current !== targetGroup) {
|
|
pointerDragGroupTargetRef.current = targetGroup
|
|
setPointerTargetGroupOrderKey(targetGroup)
|
|
}
|
|
}
|
|
|
|
const finishGroupOrderDrag = () => {
|
|
const source = pointerDragGroupSourceRef.current
|
|
const target = pointerDragGroupTargetRef.current
|
|
const insertAfter = pointerDragGroupInsertAfterRef.current
|
|
if (source && target && source !== target) {
|
|
setGroupBoardOrder((prev) => {
|
|
const next = prev.filter((key) => key !== source)
|
|
const targetIndex = next.indexOf(target)
|
|
if (targetIndex < 0) return prev
|
|
const insertIndex = Math.max(0, Math.min(next.length, targetIndex + (insertAfter ? 1 : 0)))
|
|
next.splice(insertIndex, 0, source)
|
|
return normalizeGroupBoardOrder(next)
|
|
})
|
|
}
|
|
resetGroupOrderDragState()
|
|
}
|
|
|
|
const readFileAsDataUrl = (file: File): Promise<string> => {
|
|
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)
|
|
setTextImportVisible(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: (
|
|
<span
|
|
style={{
|
|
cursor: "pointer",
|
|
fontWeight: selected ? 700 : 500,
|
|
color: selected ? "var(--ant-color-primary, #1890ff)" : undefined,
|
|
}}
|
|
onClick={() => setXlsxSelectedCol(c)}
|
|
>
|
|
{excelColName(c)}
|
|
</span>
|
|
),
|
|
dataIndex: `c${c}`,
|
|
key: `c${c}`,
|
|
width: 120,
|
|
})
|
|
}
|
|
return cols
|
|
}, [xlsxMaxCols, xlsxSelectedCol])
|
|
|
|
const extractUniqueNames = (rawNames: string[]) => {
|
|
const out: string[] = []
|
|
const seen = new Set<string>()
|
|
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[], options?: { toast?: boolean }) => {
|
|
const toast = options?.toast ?? true
|
|
if (!(window as any).api) return { success: false, inserted: 0, skipped: 0 }
|
|
const res = await (window as any).api.importStudentsFromXlsx({ names })
|
|
if (!res?.success) {
|
|
if (toast) messageApi.error(res?.message || t("students.importFailed"))
|
|
return { success: false, inserted: 0, skipped: 0 }
|
|
}
|
|
const inserted = Number(res?.data?.inserted ?? 0)
|
|
const skipped = Number(res?.data?.skipped ?? 0)
|
|
if (toast) messageApi.success(t("students.importComplete", { inserted, skipped }))
|
|
fetchStudents()
|
|
emitDataUpdated("students")
|
|
return { success: true, inserted, skipped }
|
|
}
|
|
|
|
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 result = await importNames(names)
|
|
if (!result.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 result = await importNames(names)
|
|
if (!result.success) return
|
|
setTextImportValue("")
|
|
setTextImportVisible(false)
|
|
} finally {
|
|
setTextImportLoading(false)
|
|
}
|
|
}
|
|
|
|
const handleOpenTextImport = () => {
|
|
setTextImportVisible(true)
|
|
}
|
|
|
|
const handleOpenXlsxImport = () => {
|
|
xlsxInputRef.current?.click()
|
|
}
|
|
|
|
const handleOpenBanYouImport = () => {
|
|
setBanYouVisible(true)
|
|
}
|
|
|
|
const handleFetchBanYouClassrooms = async () => {
|
|
if (!canEdit) {
|
|
messageApi.error(t("common.readOnly"))
|
|
return
|
|
}
|
|
const cookie = banYouCookie.trim()
|
|
if (!cookie) {
|
|
messageApi.warning(t("students.banyouCookieRequired"))
|
|
return
|
|
}
|
|
|
|
setBanYouLoading(true)
|
|
try {
|
|
console.debug("[BanYou] fetch classrooms start", {
|
|
cookieLength: cookie.length,
|
|
containsUid: cookie.includes("uid="),
|
|
containsAccessToken: cookie.includes("accessToken="),
|
|
})
|
|
const res = await (window as any).api.fetchBanYouClassrooms({ cookie })
|
|
if (!res?.success || !res?.data) {
|
|
console.error("[BanYou] fetch classrooms failed", res)
|
|
messageApi.error(res?.message || t("students.banyouFetchFailed"))
|
|
return
|
|
}
|
|
const classrooms = Array.isArray(res.data.classrooms) ? res.data.classrooms : []
|
|
console.debug("[BanYou] fetch classrooms success", {
|
|
classrooms: classrooms.length,
|
|
administrativeGroups: Array.isArray(res.data.administrativeGroups)
|
|
? res.data.administrativeGroups.length
|
|
: 0,
|
|
})
|
|
setBanYouClassrooms(classrooms)
|
|
messageApi.success(t("students.banyouFetchSuccess", { count: classrooms.length }))
|
|
} catch (e: any) {
|
|
console.error("[BanYou] fetch classrooms exception", e)
|
|
messageApi.error(e?.message || t("students.banyouFetchFailed"))
|
|
} finally {
|
|
setBanYouLoading(false)
|
|
}
|
|
}
|
|
|
|
const medalKey = (item: BanYouMedal, idx: number) => item.key || item.uid || `${item.name}-${idx}`
|
|
|
|
const fetchBanYouClassDetail = async (classroom: BanYouClassroom, teamPlanId?: number) => {
|
|
const cookie = banYouCookie.trim()
|
|
if (!cookie) {
|
|
messageApi.warning(t("students.banyouCookieRequired"))
|
|
return
|
|
}
|
|
setBanYouSelectedClass(classroom)
|
|
setBanYouDetailVisible(true)
|
|
setBanYouDetailLoading(true)
|
|
try {
|
|
const parsedTeamPlanId = typeof teamPlanId === "number" ? teamPlanId : undefined
|
|
const params: any = {
|
|
cookie,
|
|
classId: classroom.classId,
|
|
}
|
|
if (Number.isFinite(parsedTeamPlanId)) params.teamPlanId = parsedTeamPlanId
|
|
const res = await (window as any).api.fetchBanYouClassroomDetail(params)
|
|
if (!res?.success || !res?.data) {
|
|
messageApi.error(res?.message || t("students.banyouFetchDetailFailed"))
|
|
setBanYouDetail(null)
|
|
return
|
|
}
|
|
const detail = res.data as BanYouClassroomDetail
|
|
setBanYouDetail(detail)
|
|
const options = Array.isArray(detail.teamPlans) ? detail.teamPlans : []
|
|
setBanYouTeamPlanOptions(options)
|
|
const used = Number(detail.teamPlanIdUsed)
|
|
if (Number.isFinite(used) && used > 0) {
|
|
setBanYouSelectedTeamPlanId(used)
|
|
} else if (options.length > 0) {
|
|
setBanYouSelectedTeamPlanId(Number(options[0].teamPlanId))
|
|
} else {
|
|
setBanYouSelectedTeamPlanId(undefined)
|
|
}
|
|
setBanYouCheckedStudents((detail.students || []).map((s) => s.studentId))
|
|
setBanYouCheckedMedals((detail.medals || []).map((m, idx) => medalKey(m, idx)))
|
|
setBanYouCheckedTeams((detail.teams || []).map((team) => team.teamId))
|
|
} catch (e: any) {
|
|
messageApi.error(e?.message || t("students.banyouFetchDetailFailed"))
|
|
setBanYouDetail(null)
|
|
} finally {
|
|
setBanYouDetailLoading(false)
|
|
}
|
|
}
|
|
|
|
const handleOpenBanYouClassDetail = async (classroom: BanYouClassroom) => {
|
|
await fetchBanYouClassDetail(classroom)
|
|
}
|
|
|
|
const handleReloadBanYouGroupByPlan = async () => {
|
|
if (!banYouSelectedClass) return
|
|
if (!banYouSelectedTeamPlanId) {
|
|
messageApi.warning(t("students.banyouSelectTeamPlanFirst"))
|
|
return
|
|
}
|
|
await fetchBanYouClassDetail(banYouSelectedClass, banYouSelectedTeamPlanId)
|
|
}
|
|
|
|
const handleImportBanYouSelected = async () => {
|
|
if (!banYouDetail) return
|
|
if (!(window as any).api) return
|
|
|
|
const selectedStudents = (banYouDetail.students || []).filter((s) =>
|
|
banYouCheckedStudents.includes(s.studentId)
|
|
)
|
|
const selectedMedals = (banYouDetail.medals || []).filter((m, idx) =>
|
|
banYouCheckedMedals.includes(medalKey(m, idx))
|
|
)
|
|
const selectedTeams = (banYouDetail.teams || []).filter((g) =>
|
|
banYouCheckedTeams.includes(g.teamId)
|
|
)
|
|
|
|
if (!selectedStudents.length && !selectedMedals.length && !selectedTeams.length) {
|
|
messageApi.warning(t("students.banyouNothingSelected"))
|
|
return
|
|
}
|
|
|
|
setBanYouImportLoading(true)
|
|
try {
|
|
let studentInserted = 0
|
|
let studentSkipped = 0
|
|
if (selectedStudents.length) {
|
|
const names = selectedStudents.map((s) => s.studentName)
|
|
const res = await importNames(names, { toast: false })
|
|
if (!res.success) {
|
|
messageApi.error(t("students.importFailed"))
|
|
return
|
|
}
|
|
studentInserted = res.inserted
|
|
studentSkipped = res.skipped
|
|
}
|
|
|
|
let reasonInserted = 0
|
|
let reasonSkipped = 0
|
|
if (selectedMedals.length) {
|
|
const reasonCategory = "班优导入"
|
|
const currentReasons = await (window as any).api.queryReasons()
|
|
const existing = new Set<string>()
|
|
if (currentReasons?.success && Array.isArray(currentReasons?.data)) {
|
|
for (const r of currentReasons.data) {
|
|
existing.add(`${r.category}::${r.content}::${Number(r.delta)}`)
|
|
}
|
|
}
|
|
for (const medal of selectedMedals) {
|
|
const content = String(medal.name || "").trim()
|
|
if (!content) {
|
|
reasonSkipped += 1
|
|
continue
|
|
}
|
|
const medalType = Number(medal.medalType ?? medal.type ?? 1)
|
|
const value = Math.abs(Number(medal.value ?? 1)) || 1
|
|
const delta = medalType < 0 ? -value : value
|
|
const key = `${reasonCategory}::${content}::${delta}`
|
|
if (existing.has(key)) {
|
|
reasonSkipped += 1
|
|
continue
|
|
}
|
|
const createRes = await (window as any).api.createReason({
|
|
content,
|
|
category: reasonCategory,
|
|
delta,
|
|
})
|
|
if (createRes?.success) {
|
|
reasonInserted += 1
|
|
existing.add(key)
|
|
} else {
|
|
reasonSkipped += 1
|
|
}
|
|
}
|
|
}
|
|
|
|
let groupUpdated = 0
|
|
let groupSkipped = 0
|
|
if (selectedTeams.length) {
|
|
const mapping = new Map<string, string>()
|
|
for (const team of selectedTeams) {
|
|
for (const student of team.students || []) {
|
|
const name = String(student.studentName || "").trim()
|
|
if (name && !mapping.has(name)) mapping.set(name, team.teamName || "")
|
|
}
|
|
}
|
|
if (mapping.size > 0) {
|
|
const localStudentsRes = await (window as any).api.queryStudents({})
|
|
const localMap = new Map<string, number>()
|
|
if (localStudentsRes?.success && Array.isArray(localStudentsRes?.data)) {
|
|
for (const s of localStudentsRes.data) {
|
|
localMap.set(String(s.name), Number(s.id))
|
|
}
|
|
}
|
|
for (const [name, groupName] of mapping.entries()) {
|
|
const id = localMap.get(name)
|
|
if (!id) {
|
|
groupSkipped += 1
|
|
continue
|
|
}
|
|
const updateRes = await (window as any).api.updateStudent(id, { group_name: groupName })
|
|
if (updateRes?.success) groupUpdated += 1
|
|
else groupSkipped += 1
|
|
}
|
|
}
|
|
}
|
|
|
|
emitDataUpdated("all")
|
|
fetchStudents()
|
|
messageApi.success(
|
|
t("students.banyouImportSummary", {
|
|
studentsInserted: studentInserted,
|
|
studentsSkipped: studentSkipped,
|
|
reasonsInserted: reasonInserted,
|
|
reasonsSkipped: reasonSkipped,
|
|
groupsUpdated: groupUpdated,
|
|
groupsSkipped: groupSkipped,
|
|
})
|
|
)
|
|
} finally {
|
|
setBanYouImportLoading(false)
|
|
}
|
|
}
|
|
|
|
const banYouCreatedClasses = useMemo(
|
|
() => banYouClassrooms.filter((item) => item.isOwn !== false),
|
|
[banYouClassrooms]
|
|
)
|
|
const banYouJoinedClasses = useMemo(
|
|
() => banYouClassrooms.filter((item) => item.isOwn === false),
|
|
[banYouClassrooms]
|
|
)
|
|
|
|
const columns: ColumnsType<student> = useMemo(() => {
|
|
const baseColumns: ColumnsType<student> = [
|
|
{
|
|
title: t("students.avatar"),
|
|
key: "avatar",
|
|
width: isMobile ? 60 : 88,
|
|
align: "center",
|
|
render: (_, row) =>
|
|
row.avatarUrl ? (
|
|
<img
|
|
src={row.avatarUrl}
|
|
alt={row.name}
|
|
style={{
|
|
width: isMobile ? 24 : 32,
|
|
height: isMobile ? 24 : 32,
|
|
borderRadius: "50%",
|
|
objectFit: "cover",
|
|
border: "1px solid var(--ss-border-color)",
|
|
}}
|
|
/>
|
|
) : (
|
|
<div
|
|
style={{
|
|
width: isMobile ? 24 : 32,
|
|
height: isMobile ? 24 : 32,
|
|
borderRadius: "50%",
|
|
margin: "0 auto",
|
|
backgroundColor: "var(--ss-bg-color)",
|
|
border: "1px dashed var(--ss-border-color)",
|
|
color: "var(--ss-text-secondary)",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
fontSize: isMobile ? 10 : 12,
|
|
}}
|
|
>
|
|
-
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
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) => (
|
|
<span
|
|
style={{
|
|
fontWeight: "bold",
|
|
color:
|
|
score > 0
|
|
? "var(--ant-color-success, #52c41a)"
|
|
: score < 0
|
|
? "var(--ant-color-error, #ff4d4f)"
|
|
: "inherit",
|
|
fontSize: isMobile ? 12 : 14,
|
|
}}
|
|
>
|
|
{score > 0 ? `+${score}` : score}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
title: t("students.tags"),
|
|
dataIndex: "tags",
|
|
key: "tags",
|
|
width: isMobile ? 120 : 200,
|
|
render: (tags: string[] = []) => (
|
|
<Space size={isMobile ? 2 : 4}>
|
|
{tags.length === 0 ? (
|
|
<span style={{ color: "var(--ss-text-secondary)", fontSize: isMobile ? 10 : 12 }}>
|
|
{t("students.noTags")}
|
|
</span>
|
|
) : (
|
|
tags.slice(0, isMobile ? 2 : 3).map((tag) => (
|
|
<Tag key={tag} color="blue" style={{ fontSize: isMobile ? 10 : 12, margin: 0 }}>
|
|
{tag}
|
|
</Tag>
|
|
))
|
|
)}
|
|
{tags.length > (isMobile ? 2 : 3) && (
|
|
<Tag style={{ fontSize: isMobile ? 10 : 12, margin: 0 }}>
|
|
+{tags.length - (isMobile ? 2 : 3)}
|
|
</Tag>
|
|
)}
|
|
</Space>
|
|
),
|
|
},
|
|
{
|
|
title: t("common.operation"),
|
|
key: "operation",
|
|
width: isMobile ? 96 : 110,
|
|
fixed: isMobile ? "right" : undefined,
|
|
render: (_, row) => (
|
|
<Dropdown
|
|
trigger={["click"]}
|
|
menu={{
|
|
items: [
|
|
{ key: "editTags", label: t("students.editTags") },
|
|
{ key: "editGroup", label: t("students.editGroup") },
|
|
{ key: "editAvatar", label: t("students.editAvatar") },
|
|
{ key: "delete", danger: true, label: t("common.delete") },
|
|
],
|
|
onClick: ({ key }) => {
|
|
if (key === "editTags") handleOpenTagEditor(row)
|
|
else if (key === "editGroup") handleOpenGroupEditor(row)
|
|
else if (key === "editAvatar") handleOpenAvatarEditor(row)
|
|
else if (key === "delete") handleDelete(row.id)
|
|
},
|
|
}}
|
|
>
|
|
<Button
|
|
size={isMobile ? "small" : "middle"}
|
|
disabled={!canEdit}
|
|
icon={<MoreOutlined />}
|
|
>
|
|
{t("common.operation")}
|
|
</Button>
|
|
</Dropdown>
|
|
),
|
|
},
|
|
]
|
|
|
|
if (isMobile) {
|
|
return baseColumns.filter((col) => col.key !== "tags")
|
|
}
|
|
|
|
return baseColumns
|
|
}, [t, canEdit, isMobile, isTablet, breakpoint])
|
|
|
|
const existingGroupNames = useMemo(() => {
|
|
const groups = new Set<string>()
|
|
data.forEach((student) => {
|
|
const normalized = student.group_name?.trim()
|
|
if (normalized) groups.add(normalized)
|
|
})
|
|
return Array.from(groups).sort((a, b) => a.localeCompare(b, "zh-CN"))
|
|
}, [data])
|
|
|
|
const normalizedAddFormGroupName =
|
|
typeof addFormGroupName === "string" && addFormGroupName.trim() ? addFormGroupName.trim() : ""
|
|
const normalizedEditFormGroupName =
|
|
typeof editFormGroupName === "string" && editFormGroupName.trim()
|
|
? editFormGroupName.trim()
|
|
: ""
|
|
|
|
const importMenu = {
|
|
items: [
|
|
{ key: "text", label: t("students.importByText") },
|
|
{ key: "xlsx", label: t("students.importByXlsx") },
|
|
{ key: "banyou", label: t("students.importByBanyou") },
|
|
],
|
|
onClick: ({ key }: { key: string }) => {
|
|
if (!canEdit) {
|
|
messageApi.error(t("common.readOnly"))
|
|
return
|
|
}
|
|
if (key === "text") handleOpenTextImport()
|
|
else if (key === "xlsx") handleOpenXlsxImport()
|
|
else if (key === "banyou") handleOpenBanYouImport()
|
|
},
|
|
}
|
|
|
|
const paginatedData = data.slice((currentPage - 1) * pageSize, currentPage * pageSize)
|
|
|
|
return (
|
|
<div style={{ padding: "24px" }}>
|
|
{contextHolder}
|
|
<div style={{ marginBottom: "16px", display: "flex", justifyContent: "space-between" }}>
|
|
<h2 style={{ margin: 0, color: "var(--ss-text-main)" }}>{t("students.title")}</h2>
|
|
<Space>
|
|
<Button disabled={!canEdit} onClick={openGroupBoardEditor}>
|
|
{t("students.groupBoardEdit")}
|
|
</Button>
|
|
<Dropdown trigger={["click"]} menu={importMenu} disabled={!canEdit}>
|
|
<Button disabled={!canEdit}>{t("students.importList")}</Button>
|
|
</Dropdown>
|
|
<Button type="primary" disabled={!canEdit} onClick={() => setVisible(true)}>
|
|
{t("students.addStudent")}
|
|
</Button>
|
|
</Space>
|
|
</div>
|
|
|
|
<Table
|
|
dataSource={paginatedData}
|
|
columns={columns}
|
|
rowKey="id"
|
|
loading={loading}
|
|
pagination={false}
|
|
style={{ backgroundColor: "var(--ss-card-bg)", color: "var(--ss-text-main)" }}
|
|
/>
|
|
<div style={{ marginTop: 16, textAlign: "right" }}>
|
|
<Pagination
|
|
current={currentPage}
|
|
pageSize={pageSize}
|
|
total={data.length}
|
|
onChange={(page, size) => {
|
|
setCurrentPage(page)
|
|
setPageSize(size)
|
|
}}
|
|
showSizeChanger
|
|
showTotal={(total) => t("common.total", { count: total })}
|
|
/>
|
|
</div>
|
|
|
|
<Modal
|
|
title={t("students.addTitle")}
|
|
open={visible}
|
|
onOk={handleAdd}
|
|
onCancel={() => setVisible(false)}
|
|
okText={t("students.addConfirm")}
|
|
cancelText={t("common.cancel")}
|
|
destroyOnHidden
|
|
>
|
|
<Form form={form} layout="vertical">
|
|
<Form.Item
|
|
label={t("students.name")}
|
|
name="name"
|
|
rules={[{ required: true, message: t("students.nameRequired") }]}
|
|
>
|
|
<Input placeholder={t("students.namePlaceholder")} />
|
|
</Form.Item>
|
|
<Form.Item label={t("students.group")} name="group_name">
|
|
<Input placeholder={t("students.groupPlaceholder")} />
|
|
</Form.Item>
|
|
<div style={{ marginTop: 8 }}>
|
|
<div style={{ color: "var(--ss-text-secondary)", marginBottom: 8, fontSize: 12 }}>
|
|
{t("students.existingGroups")}
|
|
</div>
|
|
{existingGroupNames.length > 0 ? (
|
|
<Space wrap size={[8, 8]}>
|
|
{existingGroupNames.map((group) => {
|
|
const active = normalizedAddFormGroupName === group
|
|
return (
|
|
<Tag
|
|
key={`add-group-${group}`}
|
|
color={active ? "processing" : undefined}
|
|
style={{ cursor: "pointer", userSelect: "none", marginInlineEnd: 0 }}
|
|
onClick={() => form.setFieldValue("group_name", group)}
|
|
>
|
|
{group}
|
|
</Tag>
|
|
)
|
|
})}
|
|
</Space>
|
|
) : (
|
|
<div style={{ color: "var(--ss-text-secondary)", fontSize: 12 }}>
|
|
{t("students.noExistingGroups")}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Form>
|
|
</Modal>
|
|
|
|
<Modal
|
|
title={t("students.editGroupTitle", { name: groupEditStudent?.name || "" })}
|
|
open={groupEditVisible}
|
|
onCancel={() => {
|
|
setGroupEditVisible(false)
|
|
setGroupEditStudent(null)
|
|
groupForm.resetFields()
|
|
}}
|
|
onOk={handleSaveGroup}
|
|
okText={t("common.save")}
|
|
cancelText={t("common.cancel")}
|
|
okButtonProps={{ loading: groupSaving, disabled: !groupEditStudent }}
|
|
destroyOnHidden
|
|
>
|
|
<Form form={groupForm} layout="vertical">
|
|
<Form.Item label={t("students.group")} name="group_name">
|
|
<Input placeholder={t("students.groupPlaceholder")} />
|
|
</Form.Item>
|
|
<div style={{ marginTop: 8 }}>
|
|
<div style={{ color: "var(--ss-text-secondary)", marginBottom: 8, fontSize: 12 }}>
|
|
{t("students.existingGroups")}
|
|
</div>
|
|
{existingGroupNames.length > 0 ? (
|
|
<Space wrap size={[8, 8]}>
|
|
{existingGroupNames.map((group) => {
|
|
const active = normalizedEditFormGroupName === group
|
|
return (
|
|
<Tag
|
|
key={`edit-group-${group}`}
|
|
color={active ? "processing" : undefined}
|
|
style={{ cursor: "pointer", userSelect: "none", marginInlineEnd: 0 }}
|
|
onClick={() => groupForm.setFieldValue("group_name", group)}
|
|
>
|
|
{group}
|
|
</Tag>
|
|
)
|
|
})}
|
|
</Space>
|
|
) : (
|
|
<div style={{ color: "var(--ss-text-secondary)", fontSize: 12 }}>
|
|
{t("students.noExistingGroups")}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Form>
|
|
</Modal>
|
|
|
|
<Modal
|
|
title={t("students.groupBoardTitle")}
|
|
open={groupBoardVisible}
|
|
centered
|
|
onCancel={() => {
|
|
setGroupBoardVisible(false)
|
|
setGroupBoardNewGroupName("")
|
|
draggingStudentIdRef.current = null
|
|
pointerDragSourceGroupRef.current = null
|
|
pointerDragTargetGroupRef.current = null
|
|
pointerDragPositionRef.current = null
|
|
if (pointerDragRafRef.current != null) {
|
|
cancelAnimationFrame(pointerDragRafRef.current)
|
|
pointerDragRafRef.current = null
|
|
}
|
|
const ghost = pointerDragGhostRef.current
|
|
if (ghost) ghost.style.transform = "translate3d(-9999px, -9999px, 0)"
|
|
setPointerDraggingStudentId(null)
|
|
setPointerDragStudentName("")
|
|
setPointerTargetGroup(null)
|
|
resetGroupOrderDragState()
|
|
}}
|
|
onOk={handleSaveGroupBoard}
|
|
okText={t("common.save")}
|
|
cancelText={t("common.cancel")}
|
|
okButtonProps={{ loading: groupBoardSaving }}
|
|
width="90%"
|
|
wrapClassName="ss-group-board-modal"
|
|
styles={{ body: { maxHeight: "calc(100vh - 220px)", overflow: "hidden" } }}
|
|
destroyOnHidden
|
|
>
|
|
<div style={{ color: "var(--ss-text-secondary)", marginBottom: 12, fontSize: 12 }}>
|
|
{t("students.groupBoardHint")}
|
|
</div>
|
|
<Space style={{ marginBottom: 12, width: "100%" }}>
|
|
<Input
|
|
value={groupBoardNewGroupName}
|
|
onChange={(e) => setGroupBoardNewGroupName(e.target.value)}
|
|
onPressEnter={handleCreateGroupInBoard}
|
|
placeholder={t("students.groupBoardNewGroupPlaceholder")}
|
|
maxLength={32}
|
|
/>
|
|
<Button icon={<PlusOutlined />} onClick={handleCreateGroupInBoard}>
|
|
{t("students.groupBoardAddGroup")}
|
|
</Button>
|
|
</Space>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
gap: 12,
|
|
overflowX: "auto",
|
|
paddingBottom: 4,
|
|
userSelect:
|
|
pointerDraggingStudentId != null || pointerDraggingGroupKey != null ? "none" : "auto",
|
|
WebkitUserSelect:
|
|
pointerDraggingStudentId != null || pointerDraggingGroupKey != null ? "none" : "auto",
|
|
}}
|
|
>
|
|
{groupBoardOrder.map((groupKey) => {
|
|
const studentsInGroup = groupBoard[groupKey] || []
|
|
const groupLabel = groupKey === UNGROUPED_KEY ? t("students.noGroup") : groupKey
|
|
const isDraggingGroup = pointerDraggingGroupKey === groupKey
|
|
const isGroupOrderTarget = pointerTargetGroupOrderKey === groupKey
|
|
return (
|
|
<div
|
|
key={groupKey}
|
|
data-group-column={groupKey}
|
|
data-group-drop={groupKey}
|
|
style={{
|
|
minWidth: isMobile ? 180 : 220,
|
|
maxWidth: isMobile ? 220 : 260,
|
|
border: isGroupOrderTarget
|
|
? "1px solid var(--ant-color-primary, #1677ff)"
|
|
: "1px solid var(--ss-border-color)",
|
|
borderRadius: 10,
|
|
backgroundColor:
|
|
pointerTargetGroup === groupKey ? "var(--ss-bg-color)" : "var(--ss-card-bg)",
|
|
padding: 10,
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: 8,
|
|
transition: "background-color 120ms ease, border-color 120ms ease",
|
|
opacity: isDraggingGroup ? 0.55 : 1,
|
|
}}
|
|
>
|
|
<div
|
|
onPointerDown={(e) => beginGroupOrderDrag(e, groupKey, groupLabel)}
|
|
onPointerMove={(e) => trackGroupOrderTarget(e.clientX, e.clientY)}
|
|
onPointerUp={finishGroupOrderDrag}
|
|
onPointerCancel={finishGroupOrderDrag}
|
|
onLostPointerCapture={finishGroupOrderDrag}
|
|
style={{
|
|
fontWeight: 600,
|
|
color: "var(--ss-text-main)",
|
|
borderBottom: "1px dashed var(--ss-border-color)",
|
|
paddingBottom: 8,
|
|
cursor:
|
|
groupKey === UNGROUPED_KEY || pointerDraggingStudentId != null
|
|
? "default"
|
|
: "grab",
|
|
userSelect: "none",
|
|
WebkitUserSelect: "none",
|
|
touchAction: "none",
|
|
}}
|
|
>
|
|
{groupLabel} ({studentsInGroup.length})
|
|
</div>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: 8,
|
|
minHeight: 160,
|
|
maxHeight: "44vh",
|
|
overflowY: "auto",
|
|
overflowX: "hidden",
|
|
paddingRight: 2,
|
|
}}
|
|
>
|
|
{studentsInGroup.length > 0 ? (
|
|
studentsInGroup.map((student) => (
|
|
<div
|
|
key={`${groupKey}-${student.id}`}
|
|
onPointerDown={(e) =>
|
|
beginPointerDrag(e, student.id, student.name, groupKey)
|
|
}
|
|
onPointerMove={(e) => trackPointerTarget(e.clientX, e.clientY)}
|
|
onPointerUp={finishPointerDrag}
|
|
onPointerCancel={finishPointerDrag}
|
|
onLostPointerCapture={finishPointerDrag}
|
|
style={{
|
|
border: "1px solid var(--ss-border-color)",
|
|
borderRadius: 8,
|
|
backgroundColor:
|
|
pointerDraggingStudentId === student.id
|
|
? "var(--ss-bg-color)"
|
|
: "var(--ss-card-bg)",
|
|
opacity: pointerDraggingStudentId === student.id ? 0.45 : 1,
|
|
padding: "8px 10px",
|
|
cursor: pointerDraggingStudentId === student.id ? "grabbing" : "grab",
|
|
userSelect: "none",
|
|
WebkitUserSelect: "none",
|
|
touchAction: "none",
|
|
}}
|
|
>
|
|
{student.name}
|
|
</div>
|
|
))
|
|
) : (
|
|
<div style={{ color: "var(--ss-text-secondary)", fontSize: 12 }}>
|
|
{t("students.groupBoardEmpty")}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
{pointerDraggingStudentId != null && (
|
|
<div
|
|
ref={pointerDragGhostRef}
|
|
style={{
|
|
position: "fixed",
|
|
left: 0,
|
|
top: 0,
|
|
pointerEvents: "none",
|
|
zIndex: 2100,
|
|
border: "1px solid var(--ss-border-color)",
|
|
borderRadius: 8,
|
|
backgroundColor: "var(--ss-card-bg)",
|
|
color: "var(--ss-text-main)",
|
|
padding: "8px 10px",
|
|
boxShadow: "0 8px 24px rgba(0, 0, 0, 0.16)",
|
|
fontSize: 13,
|
|
maxWidth: 220,
|
|
whiteSpace: "nowrap",
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
transform: "translate3d(-9999px, -9999px, 0)",
|
|
willChange: "transform",
|
|
}}
|
|
>
|
|
{pointerDragStudentName}
|
|
</div>
|
|
)}
|
|
{pointerDraggingGroupKey != null && (
|
|
<div
|
|
ref={pointerDragGroupGhostRef}
|
|
style={{
|
|
position: "fixed",
|
|
left: 0,
|
|
top: 0,
|
|
pointerEvents: "none",
|
|
zIndex: 2101,
|
|
border: "1px solid var(--ant-color-primary, #1677ff)",
|
|
borderRadius: 8,
|
|
backgroundColor: "var(--ss-card-bg)",
|
|
color: "var(--ss-text-main)",
|
|
padding: "8px 10px",
|
|
boxShadow: "0 8px 24px rgba(0, 0, 0, 0.16)",
|
|
fontSize: 13,
|
|
maxWidth: 240,
|
|
whiteSpace: "nowrap",
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
transform: "translate3d(-9999px, -9999px, 0)",
|
|
willChange: "transform",
|
|
}}
|
|
>
|
|
{pointerDragGroupName}
|
|
</div>
|
|
)}
|
|
</Modal>
|
|
|
|
<Modal
|
|
title={t("students.importByText")}
|
|
open={textImportVisible}
|
|
onCancel={() => setTextImportVisible(false)}
|
|
footer={null}
|
|
destroyOnHidden
|
|
>
|
|
<Space orientation="vertical" style={{ width: "100%" }}>
|
|
<div style={{ color: "var(--ss-text-secondary)", fontSize: 12 }}>
|
|
{t("students.importTextHint")}
|
|
</div>
|
|
<Input.TextArea
|
|
value={textImportValue}
|
|
onChange={(e) => setTextImportValue(e.target.value)}
|
|
rows={8}
|
|
placeholder={t("students.importTextPlaceholder")}
|
|
disabled={!canEdit || textImportLoading}
|
|
/>
|
|
<Button
|
|
type="primary"
|
|
loading={textImportLoading}
|
|
disabled={!canEdit}
|
|
onClick={handleImportTextNames}
|
|
>
|
|
{t("students.importConfirm")}
|
|
</Button>
|
|
</Space>
|
|
</Modal>
|
|
|
|
<Modal
|
|
title={t("students.importByBanyou")}
|
|
open={banYouVisible}
|
|
onCancel={() => setBanYouVisible(false)}
|
|
footer={null}
|
|
width={900}
|
|
destroyOnHidden
|
|
>
|
|
<Space orientation="vertical" style={{ width: "100%" }} size={12}>
|
|
<div style={{ color: "var(--ss-text-secondary)", fontSize: 12 }}>
|
|
{t("students.banyouCookieHint")}
|
|
</div>
|
|
<Input.TextArea
|
|
value={banYouCookie}
|
|
onChange={(e) => setBanYouCookie(e.target.value)}
|
|
rows={4}
|
|
placeholder={t("students.banyouCookiePlaceholder")}
|
|
disabled={banYouLoading}
|
|
/>
|
|
<Button type="primary" loading={banYouLoading} onClick={handleFetchBanYouClassrooms}>
|
|
{t("students.banyouFetch")}
|
|
</Button>
|
|
|
|
<div style={{ marginTop: 8 }}>
|
|
<h3 style={{ margin: "0 0 12px", color: "var(--ss-text-main)" }}>
|
|
{t("students.banyouCreatedClasses")}
|
|
</h3>
|
|
{banYouCreatedClasses.length > 0 ? (
|
|
<div
|
|
style={{
|
|
display: "grid",
|
|
gridTemplateColumns: isMobile ? "1fr" : "repeat(auto-fill, minmax(320px, 1fr))",
|
|
gap: 12,
|
|
}}
|
|
>
|
|
{banYouCreatedClasses.map((item) => (
|
|
<div
|
|
key={`created-${item.classId}`}
|
|
onClick={() => handleOpenBanYouClassDetail(item)}
|
|
style={{
|
|
border: "1px solid var(--ss-border-color)",
|
|
borderRadius: 12,
|
|
backgroundColor: "var(--ss-card-bg)",
|
|
overflow: "hidden",
|
|
cursor: "pointer",
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
padding: "12px 14px",
|
|
borderBottom: "1px solid var(--ss-border-color)",
|
|
fontWeight: 700,
|
|
color: "var(--ss-text-main)",
|
|
fontSize: 20,
|
|
}}
|
|
>
|
|
{item.classNickName} {item.invitationCode ? `(${item.invitationCode})` : ""}
|
|
</div>
|
|
<div style={{ padding: 14, display: "flex", gap: 12 }}>
|
|
{item.classAvatarDataUrl ? (
|
|
<img
|
|
src={item.classAvatarDataUrl}
|
|
alt={item.classNickName}
|
|
style={{ width: 72, height: 72, borderRadius: "50%", objectFit: "cover" }}
|
|
/>
|
|
) : (
|
|
<div
|
|
style={{
|
|
width: 72,
|
|
height: 72,
|
|
borderRadius: "50%",
|
|
backgroundColor: "var(--ss-bg-color)",
|
|
border: "1px dashed var(--ss-border-color)",
|
|
}}
|
|
/>
|
|
)}
|
|
<div style={{ flex: 1, color: "var(--ss-text-main)" }}>
|
|
<div style={{ fontSize: 16, fontWeight: 600, marginBottom: 6 }}>
|
|
{t("students.banyouClassTeacher")}
|
|
{item.masterName || "-"}
|
|
</div>
|
|
<div style={{ color: "var(--ss-text-secondary)", fontSize: 14 }}>
|
|
{t("students.banyouStudentCount")}
|
|
{Number(item.studentsNum ?? 0)}
|
|
</div>
|
|
</div>
|
|
<div
|
|
style={{
|
|
minWidth: 48,
|
|
display: "flex",
|
|
alignItems: "flex-end",
|
|
justifyContent: "center",
|
|
color: "var(--ss-text-main)",
|
|
fontSize: 42,
|
|
fontWeight: 700,
|
|
lineHeight: 1,
|
|
}}
|
|
>
|
|
{Number(item.praiseCount ?? 0)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div style={{ color: "var(--ss-text-secondary)", fontSize: 13 }}>
|
|
{t("students.banyouNoClasses")}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div style={{ marginTop: 8 }}>
|
|
<h3 style={{ margin: "0 0 12px", color: "var(--ss-text-main)" }}>
|
|
{t("students.banyouJoinedClasses")}
|
|
</h3>
|
|
{banYouJoinedClasses.length > 0 ? (
|
|
<div
|
|
style={{
|
|
display: "grid",
|
|
gridTemplateColumns: isMobile ? "1fr" : "repeat(auto-fill, minmax(320px, 1fr))",
|
|
gap: 12,
|
|
}}
|
|
>
|
|
{banYouJoinedClasses.map((item) => (
|
|
<div
|
|
key={`joined-${item.classId}`}
|
|
onClick={() => handleOpenBanYouClassDetail(item)}
|
|
style={{
|
|
border: "1px solid var(--ss-border-color)",
|
|
borderRadius: 12,
|
|
backgroundColor: "var(--ss-card-bg)",
|
|
padding: 14,
|
|
color: "var(--ss-text-main)",
|
|
cursor: "pointer",
|
|
}}
|
|
>
|
|
<div style={{ fontWeight: 700, fontSize: 18, marginBottom: 8 }}>
|
|
{item.classNickName} {item.invitationCode ? `(${item.invitationCode})` : ""}
|
|
</div>
|
|
<div style={{ color: "var(--ss-text-secondary)", fontSize: 14 }}>
|
|
{t("students.banyouClassTeacher")}
|
|
{item.masterName || "-"}
|
|
</div>
|
|
<div style={{ color: "var(--ss-text-secondary)", fontSize: 14 }}>
|
|
{t("students.banyouStudentCount")}
|
|
{Number(item.studentsNum ?? 0)}
|
|
</div>
|
|
<div style={{ color: "var(--ss-text-secondary)", fontSize: 14 }}>
|
|
{t("students.banyouPraiseCount")}
|
|
{Number(item.praiseCount ?? 0)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div style={{ color: "var(--ss-text-secondary)", fontSize: 13 }}>
|
|
{t("students.banyouNoClasses")}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Space>
|
|
</Modal>
|
|
|
|
<Modal
|
|
title={
|
|
banYouSelectedClass
|
|
? `${t("students.banyouClassDetail")} - ${banYouSelectedClass.classNickName}`
|
|
: t("students.banyouClassDetail")
|
|
}
|
|
open={banYouDetailVisible}
|
|
onCancel={() => {
|
|
setBanYouDetailVisible(false)
|
|
setBanYouDetail(null)
|
|
setBanYouSelectedClass(null)
|
|
setBanYouTeamPlanOptions([])
|
|
setBanYouSelectedTeamPlanId(undefined)
|
|
}}
|
|
width={980}
|
|
destroyOnHidden
|
|
onOk={handleImportBanYouSelected}
|
|
okText={t("students.banyouImportSelected")}
|
|
okButtonProps={{ loading: banYouImportLoading, disabled: !banYouDetail }}
|
|
cancelText={t("common.cancel")}
|
|
>
|
|
{banYouDetailLoading ? (
|
|
<div
|
|
style={{ padding: "24px 0", textAlign: "center", color: "var(--ss-text-secondary)" }}
|
|
>
|
|
{t("common.loading")}
|
|
</div>
|
|
) : !banYouDetail ? (
|
|
<div style={{ color: "var(--ss-text-secondary)" }}>{t("students.banyouDetailEmpty")}</div>
|
|
) : (
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 16, maxHeight: "70vh" }}>
|
|
{banYouTeamPlanOptions.length > 0 && (
|
|
<div>
|
|
<div style={{ fontWeight: 700, marginBottom: 8 }}>
|
|
{t("students.banyouTeamPlanSelector")}
|
|
</div>
|
|
<Space>
|
|
<Select
|
|
style={{ minWidth: 320 }}
|
|
value={banYouSelectedTeamPlanId}
|
|
onChange={(v) => setBanYouSelectedTeamPlanId(Number(v))}
|
|
options={banYouTeamPlanOptions.map((item) => ({
|
|
value: item.teamPlanId,
|
|
label: item.name?.trim()
|
|
? `${item.name} (${item.teamPlanId})`
|
|
: String(item.teamPlanId),
|
|
}))}
|
|
/>
|
|
<Button loading={banYouDetailLoading} onClick={handleReloadBanYouGroupByPlan}>
|
|
{t("students.banyouLoadTeamPlan")}
|
|
</Button>
|
|
</Space>
|
|
{banYouDetail.teamPlanSource ? (
|
|
<div style={{ marginTop: 6, color: "var(--ss-text-secondary)", fontSize: 12 }}>
|
|
{t("students.banyouTeamPlanSource")}
|
|
{banYouDetail.teamPlanSource}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
<div style={{ fontWeight: 700, marginBottom: 8 }}>
|
|
{t("students.banyouReasonList")}
|
|
</div>
|
|
<div
|
|
style={{
|
|
border: "1px solid var(--ss-border-color)",
|
|
borderRadius: 8,
|
|
padding: 10,
|
|
maxHeight: 180,
|
|
overflowY: "auto",
|
|
}}
|
|
>
|
|
<Checkbox.Group
|
|
value={banYouCheckedMedals}
|
|
onChange={(vals) => setBanYouCheckedMedals(vals as string[])}
|
|
style={{ width: "100%" }}
|
|
>
|
|
<Space direction="vertical" style={{ width: "100%" }}>
|
|
{banYouDetail.medals.map((item, idx) => {
|
|
const key = medalKey(item, idx)
|
|
const type = Number(item.medalType ?? item.type ?? 1)
|
|
const value = Math.abs(Number(item.value ?? 1)) || 1
|
|
const delta = type < 0 ? -value : value
|
|
return (
|
|
<Checkbox key={key} value={key}>
|
|
{item.name} ({delta > 0 ? `+${delta}` : delta})
|
|
</Checkbox>
|
|
)
|
|
})}
|
|
</Space>
|
|
</Checkbox.Group>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<div style={{ fontWeight: 700, marginBottom: 8 }}>
|
|
{t("students.banyouStudentList")}
|
|
</div>
|
|
<div
|
|
style={{
|
|
border: "1px solid var(--ss-border-color)",
|
|
borderRadius: 8,
|
|
padding: 10,
|
|
maxHeight: 220,
|
|
overflowY: "auto",
|
|
}}
|
|
>
|
|
<Checkbox.Group
|
|
value={banYouCheckedStudents}
|
|
onChange={(vals) => setBanYouCheckedStudents(vals as string[])}
|
|
style={{ width: "100%" }}
|
|
>
|
|
<Space direction="vertical" style={{ width: "100%" }}>
|
|
{banYouDetail.students.map((item) => (
|
|
<Checkbox key={item.studentId} value={item.studentId}>
|
|
{item.studentName}
|
|
</Checkbox>
|
|
))}
|
|
</Space>
|
|
</Checkbox.Group>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<div style={{ fontWeight: 700, marginBottom: 8 }}>{t("students.banyouTeamList")}</div>
|
|
<div
|
|
style={{
|
|
border: "1px solid var(--ss-border-color)",
|
|
borderRadius: 8,
|
|
padding: 10,
|
|
maxHeight: 220,
|
|
overflowY: "auto",
|
|
}}
|
|
>
|
|
<Checkbox.Group
|
|
value={banYouCheckedTeams}
|
|
onChange={(vals) => setBanYouCheckedTeams(vals as string[])}
|
|
style={{ width: "100%" }}
|
|
>
|
|
<Space direction="vertical" style={{ width: "100%" }}>
|
|
{banYouDetail.teams.length > 0 ? (
|
|
banYouDetail.teams.map((team) => (
|
|
<Checkbox key={team.teamId} value={team.teamId}>
|
|
{team.teamName} ({(team.students || []).length})
|
|
</Checkbox>
|
|
))
|
|
) : (
|
|
<span style={{ color: "var(--ss-text-secondary)", fontSize: 12 }}>
|
|
{t("students.banyouNoTeams")}
|
|
</span>
|
|
)}
|
|
</Space>
|
|
</Checkbox.Group>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Modal>
|
|
|
|
<input
|
|
ref={xlsxInputRef}
|
|
type="file"
|
|
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|
style={{ display: "none" }}
|
|
onChange={(e) => {
|
|
const file = e.target.files?.[0]
|
|
if (file) parseXlsxFile(file)
|
|
if (xlsxInputRef.current) xlsxInputRef.current.value = ""
|
|
}}
|
|
/>
|
|
|
|
<Modal
|
|
title={t("students.xlsxPreview")}
|
|
open={xlsxVisible}
|
|
onCancel={() => setXlsxVisible(false)}
|
|
onOk={handleConfirmXlsxImport}
|
|
okText={t("students.importConfirm")}
|
|
okButtonProps={{ loading: xlsxLoading, disabled: xlsxSelectedCol == null }}
|
|
width="80%"
|
|
destroyOnHidden
|
|
>
|
|
<div style={{ marginBottom: "12px", color: "var(--ss-text-secondary)", fontSize: "12px" }}>
|
|
<div>
|
|
{t("students.file")}
|
|
{xlsxFileName || "-"}
|
|
</div>
|
|
<div>
|
|
{t("students.selectNameCol")}
|
|
{xlsxSelectedCol == null ? "-" : excelColName(xlsxSelectedCol)}
|
|
</div>
|
|
<div>{t("students.previewRows")}</div>
|
|
</div>
|
|
<Table
|
|
dataSource={xlsxPreviewRows}
|
|
columns={xlsxPreviewColumns}
|
|
rowKey="__row"
|
|
bordered
|
|
scroll={{ y: 420 }}
|
|
style={{ backgroundColor: "var(--ss-card-bg)", color: "var(--ss-text-main)" }}
|
|
pagination={false}
|
|
/>
|
|
</Modal>
|
|
|
|
<TagEditorDialog
|
|
visible={tagEditVisible}
|
|
onClose={() => {
|
|
setTagEditVisible(false)
|
|
setEditingStudent(null)
|
|
}}
|
|
onConfirm={handleSaveTags}
|
|
initialTagIds={editingStudent?.tagIds || []}
|
|
title={t("students.editTagTitle", { name: editingStudent?.name || "" })}
|
|
/>
|
|
|
|
<Modal
|
|
title={t("students.editAvatarTitle", { name: avatarStudent?.name || "" })}
|
|
open={avatarVisible}
|
|
onCancel={() => {
|
|
setAvatarVisible(false)
|
|
setAvatarStudent(null)
|
|
setAvatarValue(null)
|
|
}}
|
|
onOk={handleSaveAvatar}
|
|
okButtonProps={{ loading: avatarSaving, disabled: !avatarStudent }}
|
|
okText={t("common.save")}
|
|
cancelText={t("common.cancel")}
|
|
destroyOnHidden
|
|
>
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
|
<div style={{ display: "flex", justifyContent: "center", padding: "8px 0" }}>
|
|
{avatarValue ? (
|
|
<img
|
|
src={avatarValue}
|
|
alt={avatarStudent?.name || "avatar"}
|
|
style={{
|
|
width: 96,
|
|
height: 96,
|
|
borderRadius: "50%",
|
|
objectFit: "cover",
|
|
border: "1px solid var(--ss-border-color)",
|
|
}}
|
|
/>
|
|
) : (
|
|
<div
|
|
style={{
|
|
width: 96,
|
|
height: 96,
|
|
borderRadius: "50%",
|
|
border: "1px dashed var(--ss-border-color)",
|
|
backgroundColor: "var(--ss-bg-color)",
|
|
color: "var(--ss-text-secondary)",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
}}
|
|
>
|
|
{t("students.noAvatar")}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div style={{ display: "flex", gap: 8 }}>
|
|
<Button
|
|
icon={<UploadOutlined />}
|
|
onClick={() => avatarInputRef.current?.click()}
|
|
disabled={!canEdit}
|
|
>
|
|
{t("students.avatarUpload")}
|
|
</Button>
|
|
<Button onClick={() => setAvatarValue(null)} disabled={!canEdit}>
|
|
{t("students.avatarClear")}
|
|
</Button>
|
|
</div>
|
|
<input
|
|
ref={avatarInputRef}
|
|
type="file"
|
|
accept="image/*"
|
|
style={{ display: "none" }}
|
|
onChange={(e) => {
|
|
const file = e.target.files?.[0]
|
|
handleAvatarFileChange(file)
|
|
if (avatarInputRef.current) avatarInputRef.current.value = ""
|
|
}}
|
|
/>
|
|
<div style={{ color: "var(--ss-text-secondary)", fontSize: 12 }}>
|
|
{t("students.avatarTip")}
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
</div>
|
|
)
|
|
}
|