From ad6b0808cb954c718b4a0315069f29800a52873b Mon Sep 17 00:00:00 2001 From: JSR Date: Fri, 20 Mar 2026 17:42:30 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=A6=96=E9=A1=B5=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=A4=9A=E9=80=89=E5=85=A8=E9=80=89=E4=B8=8E=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E7=A7=AF=E5=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Home.tsx | 318 ++++++++++++++++++++++++++++-------- src/i18n/locales/en-US.json | 11 ++ src/i18n/locales/zh-CN.json | 11 ++ 3 files changed, 270 insertions(+), 70 deletions(-) diff --git a/src/components/Home.tsx b/src/components/Home.tsx index 262ace4..1e184bc 100644 --- a/src/components/Home.tsx +++ b/src/components/Home.tsx @@ -89,6 +89,8 @@ export const Home: React.FC = ({ canEdit, isPortraitMode = false }) = const searchAreaRef = useRef(null) const [selectedStudent, setSelectedStudent] = useState(null) + const [batchMode, setBatchMode] = useState(false) + const [selectedStudentIds, setSelectedStudentIds] = useState([]) const [operationVisible, setOperationVisible] = useState(false) const [customScore, setCustomScore] = useState(undefined) const [reasonContent, setReasonContent] = useState("") @@ -280,6 +282,14 @@ export const Home: React.FC = ({ canEdit, isPortraitMode = false }) = } }, []) + useEffect(() => { + setSelectedStudentIds((prev) => { + if (prev.length === 0) return prev + const validIds = new Set(students.map((s) => s.id)) + return prev.filter((id) => validIds.has(id)) + }) + }, [students]) + const t9KeyRows = [ [ { digit: "2", letters: "ABC" }, @@ -430,6 +440,12 @@ export const Home: React.FC = ({ canEdit, isPortraitMode = false }) = }) }, [reasons]) + const selectedStudents = useMemo(() => { + if (selectedStudentIds.length === 0) return [] + const idSet = new Set(selectedStudentIds) + return students.filter((s) => idSet.has(s.id)) + }, [students, selectedStudentIds]) + const getAvatarColor = (name: string) => { const colors = [ "#FF6B6B", @@ -463,55 +479,80 @@ export const Home: React.FC = ({ canEdit, isPortraitMode = false }) = messageApi.error(t("common.readOnly")) return } + if (batchMode) { + setSelectedStudentIds((prev) => + prev.includes(student.id) ? prev.filter((id) => id !== student.id) : [...prev, student.id] + ) + return + } setSelectedStudent(student) setCustomScore(undefined) setReasonContent("") setOperationVisible(true) } - const performSubmit = async (student: student, delta: number, content: string) => { + const performSubmit = async (targetStudents: student[], delta: number, content: string) => { if (!(window as any).api) return if (!canEdit) { messageApi.error(t("common.readOnly")) return } + if (targetStudents.length === 0) { + messageApi.warning(t("home.selectStudentFirst")) + return + } setSubmitLoading(true) - logHome("performSubmit:start", { - student: student.name, - delta, - content, - localScoreBefore: student.score, - }) - const res = await (window as any).api.createEvent({ - student_name: student.name, - reason_content: content, - delta: delta, - }) + logHome("performSubmit:start", { studentCount: targetStudents.length, delta, content }) + let successCount = 0 - logHome("performSubmit:createEvent:response", { - student: student.name, - delta, - success: Boolean(res?.success), - message: (res as any)?.message, - }) + for (const student of targetStudents) { + const res = await (window as any).api.createEvent({ + student_name: student.name, + reason_content: content, + delta: delta, + }) - if (res.success) { - messageApi.success( - delta > 0 - ? t("home.scoreAdded", { name: student.name, points: Math.abs(delta) }) - : t("home.scoreDeducted", { name: student.name, points: Math.abs(delta) }) - ) + logHome("performSubmit:createEvent:response", { + student: student.name, + delta, + success: Boolean(res?.success), + message: (res as any)?.message, + }) + if (res.success) successCount += 1 + } + + if (successCount > 0) { + if (targetStudents.length === 1) { + const student = targetStudents[0] + messageApi.success( + delta > 0 + ? t("home.scoreAdded", { name: student.name, points: Math.abs(delta) }) + : t("home.scoreDeducted", { name: student.name, points: Math.abs(delta) }) + ) + } else if (successCount === targetStudents.length) { + messageApi.success(t("home.batchSuccess", { count: successCount })) + } else { + messageApi.warning( + t("home.batchPartial", { success: successCount, total: targetStudents.length }) + ) + } + + setSelectedStudentIds([]) + setBatchMode(false) + setSelectedStudent(null) setOperationVisible(false) + setCustomScore(undefined) + setReasonContent("") + setQuickActionStudentId(null) fetchData(true) fetchLatestEvent() emitDataUpdated("events") - logHome("performSubmit:afterSuccessRefreshDispatched", { - student: student.name, - delta, - }) + logHome("performSubmit:afterSuccessRefreshDispatched", { studentCount: targetStudents.length, delta }) } else { - messageApi.error(res.message || t("home.submitFailed")) + messageApi.warning( + t("home.batchPartial", { success: successCount, total: targetStudents.length }) + ) } setSubmitLoading(false) } @@ -541,7 +582,11 @@ export const Home: React.FC = ({ canEdit, isPortraitMode = false }) = } const handleSubmit = async () => { - if (!selectedStudent) return + const targets = batchMode ? selectedStudents : selectedStudent ? [selectedStudent] : [] + if (targets.length === 0) { + messageApi.warning(t("home.selectStudentFirst")) + return + } const delta = customScore if (delta === undefined || !Number.isFinite(delta)) { @@ -556,12 +601,16 @@ export const Home: React.FC = ({ canEdit, isPortraitMode = false }) = : delta < 0 ? t("home.deductPoints") : t("home.pointsChange")) - await performSubmit(selectedStudent, delta, content) + await performSubmit(targets, delta, content) } const handleReasonSelect = (reason: reason) => { - if (!selectedStudent) return - performSubmit(selectedStudent, reason.delta, reason.content) + const targets = batchMode ? selectedStudents : selectedStudent ? [selectedStudent] : [] + if (targets.length === 0) { + messageApi.warning(t("home.selectStudentFirst")) + return + } + performSubmit(targets, reason.delta, reason.content) } const cancelLongPress = () => { @@ -596,10 +645,49 @@ export const Home: React.FC = ({ canEdit, isPortraitMode = false }) = const handleQuickAdjust = (student: student, delta: number) => { const content = delta > 0 ? t("home.addPoints") : t("home.deductPoints") - performSubmit(student, delta, content) + performSubmit([student], delta, content) setQuickActionStudentId(null) } + const handleSelectAllStudents = () => { + setSelectedStudentIds(students.map((s) => s.id)) + } + + const handleClearSelectedStudents = () => { + setSelectedStudentIds([]) + } + + const handleEnterBatchMode = () => { + if (!canEdit) { + messageApi.error(t("common.readOnly")) + return + } + setBatchMode(true) + setSelectedStudent(null) + setOperationVisible(false) + setQuickActionStudentId(null) + } + + const handleExitBatchMode = () => { + setBatchMode(false) + setSelectedStudentIds([]) + } + + const handleOpenBatchOperation = () => { + if (!canEdit) { + messageApi.error(t("common.readOnly")) + return + } + if (selectedStudents.length === 0) { + messageApi.warning(t("home.selectStudentFirst")) + return + } + setSelectedStudent(null) + setCustomScore(undefined) + setReasonContent("") + setOperationVisible(true) + } + const renderStudentCard = (student: student, index: number) => { const avatarText = getDisplayText(student.name) const avatarColor = getAvatarColor(student.name) @@ -612,6 +700,7 @@ export const Home: React.FC = ({ canEdit, isPortraitMode = false }) = } const isQuickActionMode = quickActionStudentId === student.id + const isSelected = selectedStudentIds.includes(student.id) return (
= ({ canEdit, isPortraitMode = false }) = openOperation(student) }} onMouseDown={(e) => { + if (batchMode) return if (e.button !== 0) return startLongPress(student) }} onMouseUp={cancelLongPress} onMouseLeave={cancelLongPress} - onTouchStart={() => startLongPress(student)} + onTouchStart={() => { + if (batchMode) return + startLongPress(student) + }} onTouchEnd={cancelLongPress} onTouchCancel={cancelLongPress} onContextMenu={(e) => { + if (batchMode) return e.preventDefault() openQuickAction(student) }} @@ -647,9 +741,12 @@ export const Home: React.FC = ({ canEdit, isPortraitMode = false }) = transition: "all 0.2s cubic-bezier(0.38, 0, 0.24, 1)", border: isQuickActionMode ? "1px solid var(--ant-color-primary, #1677ff)" - : "1px solid var(--ss-border-color)", + : isSelected + ? "1px solid var(--ant-color-primary, #1677ff)" + : "1px solid var(--ss-border-color)", overflow: "visible", - boxShadow: isQuickActionMode ? "0 8px 18px rgba(22, 119, 255, 0.18)" : undefined, + boxShadow: + isQuickActionMode || isSelected ? "0 8px 18px rgba(22, 119, 255, 0.18)" : undefined, }} styles={{ body: { padding: isPortraitMode ? "10px 12px" : "12px" } }} > @@ -787,6 +884,11 @@ export const Home: React.FC = ({ canEdit, isPortraitMode = false }) =
+ {isSelected && ( + + {t("home.selected")} + + )} 0 ? "success" : student.score < 0 ? "error" : "default"} style={{ fontWeight: "bold" }} @@ -806,6 +908,7 @@ export const Home: React.FC = ({ canEdit, isPortraitMode = false }) = const avatarText = getDisplayText(student.name) const avatarColor = getAvatarColor(student.name) const isQuickActionMode = quickActionStudentId === student.id + const isSelected = selectedStudentIds.includes(student.id) return (
= ({ canEdit, isPortraitMode = false }) = openOperation(student) }} onMouseDown={(e) => { + if (batchMode) return if (e.button !== 0) return startLongPress(student) }} onMouseUp={cancelLongPress} onMouseLeave={cancelLongPress} - onTouchStart={() => startLongPress(student)} + onTouchStart={() => { + if (batchMode) return + startLongPress(student) + }} onTouchEnd={cancelLongPress} onTouchCancel={cancelLongPress} onContextMenu={(e) => { + if (batchMode) return e.preventDefault() openQuickAction(student) }} @@ -838,7 +946,8 @@ export const Home: React.FC = ({ canEdit, isPortraitMode = false }) = position: "relative", padding: "8px 10px", borderBottom: isLast ? "none" : "1px solid var(--ss-border-color)", - background: isQuickActionMode ? "rgba(22, 119, 255, 0.06)" : "transparent", + background: + isQuickActionMode || isSelected ? "rgba(22, 119, 255, 0.06)" : "transparent", transition: "background-color 160ms ease", }} > @@ -919,12 +1028,19 @@ export const Home: React.FC = ({ canEdit, isPortraitMode = false }) = ) : ( - 0 ? "success" : student.score < 0 ? "error" : "default"} - style={{ fontWeight: "bold", marginInlineEnd: 0 }} - > - {student.score > 0 ? `+${student.score}` : student.score} - + + {isSelected && ( + + {t("home.selected")} + + )} + 0 ? "success" : student.score < 0 ? "error" : "default"} + style={{ fontWeight: "bold", marginInlineEnd: 0 }} + > + {student.score > 0 ? `+${student.score}` : student.score} + + )}
@@ -935,6 +1051,7 @@ export const Home: React.FC = ({ canEdit, isPortraitMode = false }) = const avatarText = getDisplayText(student.name) const avatarColor = getAvatarColor(student.name) const isQuickActionMode = quickActionStudentId === student.id + const isSelected = selectedStudentIds.includes(student.id) let rankBadge: string | null = null if (sortType === "score" && !searchKeyword) { @@ -957,15 +1074,20 @@ export const Home: React.FC = ({ canEdit, isPortraitMode = false }) = openOperation(student) }} onMouseDown={(e) => { + if (batchMode) return if (e.button !== 0) return startLongPress(student) }} onMouseUp={cancelLongPress} onMouseLeave={cancelLongPress} - onTouchStart={() => startLongPress(student)} + onTouchStart={() => { + if (batchMode) return + startLongPress(student) + }} onTouchEnd={cancelLongPress} onTouchCancel={cancelLongPress} onContextMenu={(e) => { + if (batchMode) return e.preventDefault() openQuickAction(student) }} @@ -991,9 +1113,12 @@ export const Home: React.FC = ({ canEdit, isPortraitMode = false }) = transition: "all 0.2s cubic-bezier(0.38, 0, 0.24, 1)", border: isQuickActionMode ? "1px solid var(--ant-color-primary, #1677ff)" - : "1px solid var(--ss-border-color)", + : isSelected + ? "1px solid var(--ant-color-primary, #1677ff)" + : "1px solid var(--ss-border-color)", overflow: "visible", - boxShadow: isQuickActionMode ? "0 8px 18px rgba(22, 119, 255, 0.18)" : undefined, + boxShadow: + isQuickActionMode || isSelected ? "0 8px 18px rgba(22, 119, 255, 0.18)" : undefined, }} styles={{ body: { height: "100%", padding: "8px" } }} > @@ -1111,6 +1236,11 @@ export const Home: React.FC = ({ canEdit, isPortraitMode = false }) = > {student.score > 0 ? `+${student.score}` : student.score} + {isSelected && ( + + {t("home.selected")} + + )}
)} @@ -1502,7 +1632,10 @@ export const Home: React.FC = ({ canEdit, isPortraitMode = false }) = ) } - const operationPanelContent = selectedStudent && ( + const operationTargets = batchMode ? selectedStudents : selectedStudent ? [selectedStudent] : [] + const isBatchOperation = operationTargets.length > 1 || (batchMode && operationTargets.length > 0) + + const operationPanelContent = operationTargets.length > 0 && (
= ({ canEdit, isPortraitMode = false }) = }} >
- {selectedStudent.avatarUrl ? ( + {!isBatchOperation && selectedStudent?.avatarUrl ? ( {selectedStudent.name} = ({ canEdit, isPortraitMode = false }) = border: "1px solid var(--ss-border-color)", }} /> - ) : ( + ) : !isBatchOperation ? (
= ({ canEdit, isPortraitMode = false }) = fontWeight: "bold", }} > - {getDisplayText(selectedStudent.name)} + {getDisplayText(selectedStudent?.name || "")}
+ ) : ( + {t("home.batchMode")} )} = ({ canEdit, isPortraitMode = false }) = whiteSpace: "nowrap", }} > - {selectedStudent.name} + {!isBatchOperation + ? selectedStudent?.name + : t("home.selectedCount", { count: operationTargets.length })}
-
- - {t("home.currentScore")}: - - 0 ? "success" : selectedStudent.score < 0 ? "error" : "default" - } - style={{ fontWeight: "bold" }} - > - {selectedStudent.score > 0 ? `+${selectedStudent.score}` : selectedStudent.score} - -
+ {!isBatchOperation && selectedStudent && ( +
+ + {t("home.currentScore")}: + + 0 + ? "success" + : selectedStudent.score < 0 + ? "error" + : "default" + } + style={{ fontWeight: "bold" }} + > + {selectedStudent.score > 0 ? `+${selectedStudent.score}` : selectedStudent.score} + +
+ )}
{groupedReasons.length > 0 && ( @@ -1769,7 +1912,9 @@ export const Home: React.FC = ({ canEdit, isPortraitMode = false }) = {t("home.preview")}:
- {selectedStudent.name}{" "} + {isBatchOperation + ? t("home.selectedCount", { count: operationTargets.length }) + : selectedStudent?.name}{" "} = ({ canEdit, isPortraitMode = false }) = > {t("home.undoLastAction")} + {!batchMode ? ( + + ) : ( + + + + + + + )}
@@ -2016,7 +2186,11 @@ export const Home: React.FC = ({ canEdit, isPortraitMode = false }) = {isPortraitMode ? ( {t("home.operationTitle", { name: selectedStudent?.name })} +
+ {isBatchOperation + ? t("home.operationTitleBatch", { count: operationTargets.length }) + : t("home.operationTitle", { name: selectedStudent?.name })} +
} className="ss-operation-drawer" placement="bottom" @@ -2041,7 +2215,11 @@ export const Home: React.FC = ({ canEdit, isPortraitMode = false }) =
) : ( setOperationVisible(false)} onOk={handleSubmit} diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json index 2840d99..0f8ee27 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -396,10 +396,21 @@ "studentCount": "{{count}} students", "operationTitle": "Points Operation: {{name}}", "submitOperation": "Submit Operation", + "operationTitleBatch": "Points Operation: {{count}} Selected", "undoLastAction": "Undo Last", "undoLastHint": "Undo latest: {{name}} {{delta}}", "undoUnavailable": "No points operation available to undo", "undoLastSuccess": "Last points operation has been undone", + "multiSelect": "Multi-select", + "batchMode": "Batch Mode", + "selectAll": "Select All", + "clearSelected": "Clear Selected", + "batchOperate": "Batch Points", + "selected": "Selected", + "selectedCount": "{{count}} selected", + "batchSuccess": "Submitted points for {{count}} students", + "batchPartial": "Successfully submitted for {{success}}/{{total}} students", + "selectStudentFirst": "Please select student(s) first", "addPoints": "Add Points", "deductPoints": "Deduct Points", "pointsChange": "Points Change", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 75f581f..3113af4 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -396,10 +396,21 @@ "studentCount": "{{count}} 人", "operationTitle": "积分操作:{{name}}", "submitOperation": "提交操作", + "operationTitleBatch": "积分操作:已选 {{count}} 人", "undoLastAction": "撤销上一步", "undoLastHint": "撤销上一条:{{name}} {{delta}}", "undoUnavailable": "当前没有可撤销的积分操作", "undoLastSuccess": "已撤销上一步积分操作", + "multiSelect": "多选", + "batchMode": "多选模式", + "selectAll": "全选", + "clearSelected": "清空已选", + "batchOperate": "批量积分", + "selected": "已选", + "selectedCount": "已选 {{count}} 人", + "batchSuccess": "已为 {{count}} 名学生提交积分", + "batchPartial": "成功提交 {{success}}/{{total}} 名学生积分", + "selectStudentFirst": "请先选择学生", "addPoints": "加分", "deductPoints": "扣分", "pointsChange": "积分变更",