mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-19 19:04:23 +08:00
feat: 支持分组编辑页拖拽分组标题调整顺序
This commit is contained in:
@@ -108,12 +108,21 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
const [pointerDraggingStudentId, setPointerDraggingStudentId] = useState<number | null>(null)
|
const [pointerDraggingStudentId, setPointerDraggingStudentId] = useState<number | null>(null)
|
||||||
const [pointerTargetGroup, setPointerTargetGroup] = useState<string | null>(null)
|
const [pointerTargetGroup, setPointerTargetGroup] = useState<string | null>(null)
|
||||||
const [pointerDragStudentName, setPointerDragStudentName] = useState("")
|
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 pointerDragGhostRef = useRef<HTMLDivElement | null>(null)
|
||||||
const pointerDragPositionRef = useRef<{ x: number; y: number } | null>(null)
|
const pointerDragPositionRef = useRef<{ x: number; y: number } | null>(null)
|
||||||
const pointerDragRafRef = useRef<number | null>(null)
|
const pointerDragRafRef = useRef<number | null>(null)
|
||||||
const draggingStudentIdRef = useRef<number | null>(null)
|
const draggingStudentIdRef = useRef<number | null>(null)
|
||||||
const pointerDragSourceGroupRef = useRef<string | null>(null)
|
const pointerDragSourceGroupRef = useRef<string | null>(null)
|
||||||
const pointerDragTargetGroupRef = 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 [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)
|
||||||
@@ -159,6 +168,10 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
cancelAnimationFrame(pointerDragRafRef.current)
|
cancelAnimationFrame(pointerDragRafRef.current)
|
||||||
pointerDragRafRef.current = null
|
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 = () => {
|
const openGroupBoardEditor = () => {
|
||||||
if (!canEdit) {
|
if (!canEdit) {
|
||||||
messageApi.error(t("common.readOnly"))
|
messageApi.error(t("common.readOnly"))
|
||||||
@@ -361,7 +382,7 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
if (normalized) groups.add(normalized)
|
if (normalized) groups.add(normalized)
|
||||||
})
|
})
|
||||||
const sortedGroups = Array.from(groups).sort((a, b) => a.localeCompare(b, "zh-CN"))
|
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<string, student[]> = {}
|
const board: Record<string, student[]> = {}
|
||||||
order.forEach((key) => {
|
order.forEach((key) => {
|
||||||
board[key] = []
|
board[key] = []
|
||||||
@@ -381,7 +402,7 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
groups: order.map((group) => (group === UNGROUPED_KEY ? "ungrouped" : group)),
|
groups: order.map((group) => (group === UNGROUPED_KEY ? "ungrouped" : group)),
|
||||||
studentCount: data.length,
|
studentCount: data.length,
|
||||||
})
|
})
|
||||||
setGroupBoardOrder(order)
|
setGroupBoardOrder(normalizeGroupBoardOrder(order))
|
||||||
setGroupBoard(board)
|
setGroupBoard(board)
|
||||||
setGroupBoardNewGroupName("")
|
setGroupBoardNewGroupName("")
|
||||||
setGroupBoardVisible(true)
|
setGroupBoardVisible(true)
|
||||||
@@ -405,7 +426,7 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
setGroupBoard((prev) => ({ ...prev, [name]: [] }))
|
setGroupBoard((prev) => ({ ...prev, [name]: [] }))
|
||||||
setGroupBoardOrder((prev) => {
|
setGroupBoardOrder((prev) => {
|
||||||
const withoutUngrouped = prev.filter((key) => key !== UNGROUPED_KEY)
|
const withoutUngrouped = prev.filter((key) => key !== UNGROUPED_KEY)
|
||||||
return [...withoutUngrouped, name, UNGROUPED_KEY]
|
return normalizeGroupBoardOrder([...withoutUngrouped, name, UNGROUPED_KEY])
|
||||||
})
|
})
|
||||||
setGroupBoardNewGroupName("")
|
setGroupBoardNewGroupName("")
|
||||||
}
|
}
|
||||||
@@ -581,6 +602,88 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
if (ghost) ghost.style.transform = "translate3d(-9999px, -9999px, 0)"
|
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> => {
|
const readFileAsDataUrl = (file: File): Promise<string> => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const reader = new FileReader()
|
const reader = new FileReader()
|
||||||
@@ -1428,6 +1531,7 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
setPointerDraggingStudentId(null)
|
setPointerDraggingStudentId(null)
|
||||||
setPointerDragStudentName("")
|
setPointerDragStudentName("")
|
||||||
setPointerTargetGroup(null)
|
setPointerTargetGroup(null)
|
||||||
|
resetGroupOrderDragState()
|
||||||
}}
|
}}
|
||||||
onOk={handleSaveGroupBoard}
|
onOk={handleSaveGroupBoard}
|
||||||
okText={t("common.save")}
|
okText={t("common.save")}
|
||||||
@@ -1459,21 +1563,28 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
gap: 12,
|
gap: 12,
|
||||||
overflowX: "auto",
|
overflowX: "auto",
|
||||||
paddingBottom: 4,
|
paddingBottom: 4,
|
||||||
userSelect: pointerDraggingStudentId != null ? "none" : "auto",
|
userSelect:
|
||||||
WebkitUserSelect: pointerDraggingStudentId != null ? "none" : "auto",
|
pointerDraggingStudentId != null || pointerDraggingGroupKey != null ? "none" : "auto",
|
||||||
|
WebkitUserSelect:
|
||||||
|
pointerDraggingStudentId != null || pointerDraggingGroupKey != null ? "none" : "auto",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{groupBoardOrder.map((groupKey) => {
|
{groupBoardOrder.map((groupKey) => {
|
||||||
const studentsInGroup = groupBoard[groupKey] || []
|
const studentsInGroup = groupBoard[groupKey] || []
|
||||||
const groupLabel = groupKey === UNGROUPED_KEY ? t("students.noGroup") : groupKey
|
const groupLabel = groupKey === UNGROUPED_KEY ? t("students.noGroup") : groupKey
|
||||||
|
const isDraggingGroup = pointerDraggingGroupKey === groupKey
|
||||||
|
const isGroupOrderTarget = pointerTargetGroupOrderKey === groupKey
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={groupKey}
|
key={groupKey}
|
||||||
|
data-group-column={groupKey}
|
||||||
data-group-drop={groupKey}
|
data-group-drop={groupKey}
|
||||||
style={{
|
style={{
|
||||||
minWidth: isMobile ? 180 : 220,
|
minWidth: isMobile ? 180 : 220,
|
||||||
maxWidth: isMobile ? 220 : 260,
|
maxWidth: isMobile ? 220 : 260,
|
||||||
border: "1px solid var(--ss-border-color)",
|
border: isGroupOrderTarget
|
||||||
|
? "1px solid var(--ant-color-primary, #1677ff)"
|
||||||
|
: "1px solid var(--ss-border-color)",
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
pointerTargetGroup === groupKey ? "var(--ss-bg-color)" : "var(--ss-card-bg)",
|
pointerTargetGroup === groupKey ? "var(--ss-bg-color)" : "var(--ss-card-bg)",
|
||||||
@@ -1481,15 +1592,26 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
gap: 8,
|
gap: 8,
|
||||||
transition: "background-color 120ms ease",
|
transition: "background-color 120ms ease, border-color 120ms ease",
|
||||||
|
opacity: isDraggingGroup ? 0.55 : 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
onPointerDown={(e) => beginGroupOrderDrag(e, groupKey, groupLabel)}
|
||||||
|
onPointerMove={(e) => trackGroupOrderTarget(e.clientX, e.clientY)}
|
||||||
|
onPointerUp={finishGroupOrderDrag}
|
||||||
|
onPointerCancel={finishGroupOrderDrag}
|
||||||
|
onLostPointerCapture={finishGroupOrderDrag}
|
||||||
style={{
|
style={{
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
color: "var(--ss-text-main)",
|
color: "var(--ss-text-main)",
|
||||||
borderBottom: "1px dashed var(--ss-border-color)",
|
borderBottom: "1px dashed var(--ss-border-color)",
|
||||||
paddingBottom: 8,
|
paddingBottom: 8,
|
||||||
|
cursor:
|
||||||
|
groupKey === UNGROUPED_KEY || pointerDraggingStudentId != null ? "default" : "grab",
|
||||||
|
userSelect: "none",
|
||||||
|
WebkitUserSelect: "none",
|
||||||
|
touchAction: "none",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{groupLabel} ({studentsInGroup.length})
|
{groupLabel} ({studentsInGroup.length})
|
||||||
@@ -1570,6 +1692,33 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
{pointerDragStudentName}
|
{pointerDragStudentName}
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
|
|||||||
Reference in New Issue
Block a user