From 1b42a1a94010eda4597526f4add849624046e255 Mon Sep 17 00:00:00 2001 From: JSR Date: Thu, 26 Mar 2026 16:43:38 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E5=88=86=E7=BB=84?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E9=A1=B5=E6=8B=96=E6=8B=BD=E5=88=86=E7=BB=84?= =?UTF-8?q?=E6=A0=87=E9=A2=98=E8=B0=83=E6=95=B4=E9=A1=BA=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/StudentManager.tsx | 163 ++++++++++++++++++++++++++++-- 1 file changed, 156 insertions(+), 7 deletions(-) diff --git a/src/components/StudentManager.tsx b/src/components/StudentManager.tsx index f9cca94..00b32b7 100644 --- a/src/components/StudentManager.tsx +++ b/src/components/StudentManager.tsx @@ -108,12 +108,21 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { const [pointerDraggingStudentId, setPointerDraggingStudentId] = useState(null) const [pointerTargetGroup, setPointerTargetGroup] = useState(null) const [pointerDragStudentName, setPointerDragStudentName] = useState("") + const [pointerDraggingGroupKey, setPointerDraggingGroupKey] = useState(null) + const [pointerTargetGroupOrderKey, setPointerTargetGroupOrderKey] = useState(null) + const [pointerDragGroupName, setPointerDragGroupName] = useState("") const pointerDragGhostRef = useRef(null) const pointerDragPositionRef = useRef<{ x: number; y: number } | null>(null) const pointerDragRafRef = useRef(null) const draggingStudentIdRef = useRef(null) const pointerDragSourceGroupRef = useRef(null) const pointerDragTargetGroupRef = useRef(null) + const pointerDragGroupGhostRef = useRef(null) + const pointerDragGroupPositionRef = useRef<{ x: number; y: number } | null>(null) + const pointerDragGroupRafRef = useRef(null) + const pointerDragGroupSourceRef = useRef(null) + const pointerDragGroupTargetRef = useRef(null) + const pointerDragGroupInsertAfterRef = useRef(false) const [avatarVisible, setAvatarVisible] = useState(false) const [avatarSaving, setAvatarSaving] = useState(false) const [avatarStudent, setAvatarStudent] = useState(null) @@ -159,6 +168,10 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { cancelAnimationFrame(pointerDragRafRef.current) pointerDragRafRef.current = null } + if (pointerDragGroupRafRef.current != null) { + cancelAnimationFrame(pointerDragGroupRafRef.current) + pointerDragGroupRafRef.current = null + } } }, []) @@ -349,6 +362,14 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { } } + 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")) @@ -361,7 +382,7 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { if (normalized) groups.add(normalized) }) const sortedGroups = Array.from(groups).sort((a, b) => a.localeCompare(b, "zh-CN")) - const order = [...sortedGroups, UNGROUPED_KEY] + const order = normalizeGroupBoardOrder([...sortedGroups, UNGROUPED_KEY]) const board: Record = {} order.forEach((key) => { board[key] = [] @@ -381,7 +402,7 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { groups: order.map((group) => (group === UNGROUPED_KEY ? "ungrouped" : group)), studentCount: data.length, }) - setGroupBoardOrder(order) + setGroupBoardOrder(normalizeGroupBoardOrder(order)) setGroupBoard(board) setGroupBoardNewGroupName("") setGroupBoardVisible(true) @@ -405,7 +426,7 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { setGroupBoard((prev) => ({ ...prev, [name]: [] })) setGroupBoardOrder((prev) => { const withoutUngrouped = prev.filter((key) => key !== UNGROUPED_KEY) - return [...withoutUngrouped, name, UNGROUPED_KEY] + return normalizeGroupBoardOrder([...withoutUngrouped, name, UNGROUPED_KEY]) }) setGroupBoardNewGroupName("") } @@ -581,6 +602,88 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { 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, + 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 => { return new Promise((resolve, reject) => { const reader = new FileReader() @@ -1428,6 +1531,7 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { setPointerDraggingStudentId(null) setPointerDragStudentName("") setPointerTargetGroup(null) + resetGroupOrderDragState() }} onOk={handleSaveGroupBoard} okText={t("common.save")} @@ -1459,21 +1563,28 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { gap: 12, overflowX: "auto", paddingBottom: 4, - userSelect: pointerDraggingStudentId != null ? "none" : "auto", - WebkitUserSelect: pointerDraggingStudentId != null ? "none" : "auto", + 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 (
= ({ canEdit }) => { display: "flex", flexDirection: "column", gap: 8, - transition: "background-color 120ms ease", + transition: "background-color 120ms ease, border-color 120ms ease", + opacity: isDraggingGroup ? 0.55 : 1, }} >
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}) @@ -1570,6 +1692,33 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { {pointerDragStudentName}
)} + {pointerDraggingGroupKey != null && ( +
+ {pointerDragGroupName} +
+ )}