feat: 新增学生分组拖拽快捷编辑

This commit is contained in:
JSR
2026-03-25 21:06:26 +08:00
parent c344b79cb2
commit a651fd672a
3 changed files with 339 additions and 0 deletions
+325
View File
@@ -25,6 +25,7 @@ interface student {
} }
export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
const UNGROUPED_KEY = "__ungrouped__"
const { t } = useTranslation() const { t } = useTranslation()
const [data, setData] = useState<student[]>([]) const [data, setData] = useState<student[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@@ -38,6 +39,19 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
const [groupEditVisible, setGroupEditVisible] = useState(false) const [groupEditVisible, setGroupEditVisible] = useState(false)
const [groupEditStudent, setGroupEditStudent] = useState<student | null>(null) const [groupEditStudent, setGroupEditStudent] = useState<student | null>(null)
const [groupSaving, setGroupSaving] = useState(false) 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 [pointerDraggingStudentId, setPointerDraggingStudentId] = useState<number | null>(null)
const [pointerTargetGroup, setPointerTargetGroup] = useState<string | null>(null)
const [pointerDragStudentName, setPointerDragStudentName] = useState("")
const [pointerDragPosition, setPointerDragPosition] = useState<{ x: number; y: number } | null>(
null
)
const draggingStudentIdRef = useRef<number | null>(null)
const pointerDragSourceGroupRef = useRef<string | null>(null)
const pointerDragTargetGroupRef = useRef<string | null>(null)
const [avatarVisible, setAvatarVisible] = useState(false) const [avatarVisible, setAvatarVisible] = useState(false)
const [avatarSaving, setAvatarSaving] = useState(false) const [avatarSaving, setAvatarSaving] = useState(false)
const [avatarStudent, setAvatarStudent] = useState<student | null>(null) const [avatarStudent, setAvatarStudent] = useState<student | null>(null)
@@ -254,6 +268,186 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
} }
} }
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 = [...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(order)
setGroupBoard(board)
setGroupBoardVisible(true)
}
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)
setPointerDragPosition({ x: e.clientX, y: e.clientY })
setPointerTargetGroup(null)
e.currentTarget.setPointerCapture(e.pointerId)
console.debug("[GroupBoard] pointer drag start", {
studentId,
studentName,
sourceGroup: sourceGroup === UNGROUPED_KEY ? "ungrouped" : sourceGroup,
})
}
const trackPointerTarget = (clientX: number, clientY: number) => {
if (draggingStudentIdRef.current == null) return
setPointerDragPosition({ x: clientX, y: 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
setPointerDraggingStudentId(null)
setPointerDragStudentName("")
setPointerDragPosition(null)
setPointerTargetGroup(null)
}
const readFileAsDataUrl = (file: File): Promise<string> => { const readFileAsDataUrl = (file: File): Promise<string> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const reader = new FileReader() const reader = new FileReader()
@@ -692,6 +886,9 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
<div style={{ marginBottom: "16px", display: "flex", justifyContent: "space-between" }}> <div style={{ marginBottom: "16px", display: "flex", justifyContent: "space-between" }}>
<h2 style={{ margin: 0, color: "var(--ss-text-main)" }}>{t("students.title")}</h2> <h2 style={{ margin: 0, color: "var(--ss-text-main)" }}>{t("students.title")}</h2>
<Space> <Space>
<Button disabled={!canEdit} onClick={openGroupBoardEditor}>
{t("students.groupBoardEdit")}
</Button>
<Button disabled={!canEdit} onClick={() => setImportVisible(true)}> <Button disabled={!canEdit} onClick={() => setImportVisible(true)}>
{t("students.importList")} {t("students.importList")}
</Button> </Button>
@@ -819,6 +1016,134 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
</Form> </Form>
</Modal> </Modal>
<Modal
title={t("students.groupBoardTitle")}
open={groupBoardVisible}
onCancel={() => {
setGroupBoardVisible(false)
draggingStudentIdRef.current = null
pointerDragSourceGroupRef.current = null
pointerDragTargetGroupRef.current = null
setPointerDraggingStudentId(null)
setPointerDragStudentName("")
setPointerDragPosition(null)
setPointerTargetGroup(null)
}}
onOk={handleSaveGroupBoard}
okText={t("common.save")}
cancelText={t("common.cancel")}
okButtonProps={{ loading: groupBoardSaving }}
width="90%"
destroyOnHidden
>
<div style={{ color: "var(--ss-text-secondary)", marginBottom: 12, fontSize: 12 }}>
{t("students.groupBoardHint")}
</div>
<div
style={{
display: "flex",
gap: 12,
overflowX: "auto",
paddingBottom: 4,
userSelect: pointerDraggingStudentId != null ? "none" : "auto",
WebkitUserSelect: pointerDraggingStudentId != null ? "none" : "auto",
}}
>
{groupBoardOrder.map((groupKey) => {
const studentsInGroup = groupBoard[groupKey] || []
const groupLabel = groupKey === UNGROUPED_KEY ? t("students.noGroup") : groupKey
return (
<div
key={groupKey}
data-group-drop={groupKey}
style={{
minWidth: isMobile ? 180 : 220,
maxWidth: isMobile ? 220 : 260,
border: "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",
}}
>
<div
style={{
fontWeight: 600,
color: "var(--ss-text-main)",
borderBottom: "1px dashed var(--ss-border-color)",
paddingBottom: 8,
}}
>
{groupLabel} ({studentsInGroup.length})
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 8, minHeight: 160 }}>
{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 && pointerDragPosition && (
<div
style={{
position: "fixed",
left: pointerDragPosition.x + 14,
top: pointerDragPosition.y + 14,
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",
}}
>
{pointerDragStudentName}
</div>
)}
</Modal>
<Modal <Modal
title={t("students.importTitle")} title={t("students.importTitle")}
open={importVisible} open={importVisible}
+7
View File
@@ -490,6 +490,13 @@
"groupSaveFailed": "Failed to save group", "groupSaveFailed": "Failed to save group",
"existingGroups": "Existing groups (click to fill)", "existingGroups": "Existing groups (click to fill)",
"noExistingGroups": "No existing groups yet", "noExistingGroups": "No existing groups yet",
"groupBoardEdit": "Edit Groups",
"groupBoardTitle": "Quick Group Editor",
"groupBoardHint": "Drag students across group columns and save to apply in batch.",
"groupBoardEmpty": "Drag students here",
"groupBoardNoChanges": "No group changes",
"groupBoardSaveSuccess": "Updated groups for {{count}} students",
"groupBoardSaveFailed": "Save failed, {{failed}} students were not updated",
"noGroup": "Ungrouped", "noGroup": "Ungrouped",
"editAvatarTitle": "Set Avatar - {{name}}", "editAvatarTitle": "Set Avatar - {{name}}",
"avatarUpload": "Upload Image", "avatarUpload": "Upload Image",
+7
View File
@@ -490,6 +490,13 @@
"groupSaveFailed": "小组保存失败", "groupSaveFailed": "小组保存失败",
"existingGroups": "已有小组(点击一键填入)", "existingGroups": "已有小组(点击一键填入)",
"noExistingGroups": "暂无可用的小组", "noExistingGroups": "暂无可用的小组",
"groupBoardEdit": "编辑分组",
"groupBoardTitle": "快速编辑分组",
"groupBoardHint": "拖动学生到不同小组卡片中,点击保存后批量生效。",
"groupBoardEmpty": "可将学生拖拽到这里",
"groupBoardNoChanges": "分组未发生变化",
"groupBoardSaveSuccess": "已更新 {{count}} 名学生的小组",
"groupBoardSaveFailed": "保存失败,{{failed}} 名学生未更新",
"noGroup": "未分组", "noGroup": "未分组",
"editAvatarTitle": "设置头像 - {{name}}", "editAvatarTitle": "设置头像 - {{name}}",
"avatarUpload": "上传图片", "avatarUpload": "上传图片",