From e82866bbfc9e0ccf271c15e0d67a5b20b5080bfd Mon Sep 17 00:00:00 2001 From: JSR Date: Sat, 18 Apr 2026 11:16:44 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=A4=A7?= =?UTF-8?q?=E5=A4=B4=E5=83=8F=E8=A7=86=E5=9B=BE=E5=B9=B6=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E5=A4=9A=E8=AF=AD=E8=A8=80=E6=96=87=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/BoardManager.tsx | 126 ++++++++++++++- src/components/Home.tsx | 267 +++++++++++++++++++++++++++++++- src/i18n/locales/ar-SA.json | 3 +- src/i18n/locales/de-DE.json | 3 +- src/i18n/locales/en-US.json | 6 +- src/i18n/locales/es-ES.json | 3 +- src/i18n/locales/fr-FR.json | 3 +- src/i18n/locales/ja-JP.json | 6 +- src/i18n/locales/ko-KR.json | 3 +- src/i18n/locales/pt-BR.json | 3 +- src/i18n/locales/ru-RU.json | 3 +- src/i18n/locales/zh-CN.json | 6 +- 12 files changed, 414 insertions(+), 18 deletions(-) diff --git a/src/components/BoardManager.tsx b/src/components/BoardManager.tsx index 806599a..2b8b021 100644 --- a/src/components/BoardManager.tsx +++ b/src/components/BoardManager.tsx @@ -18,7 +18,7 @@ import { import { DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined } from "@ant-design/icons" import { useTranslation } from "react-i18next" -type BoardStudentViewMode = "list" | "card" | "grid" +type BoardStudentViewMode = "list" | "card" | "grid" | "largeAvatar" type BoardScoreDisplayMode = "total" | "split" type SplitDirection = "horizontal" | "vertical" @@ -68,6 +68,7 @@ interface BoardManagerProps { interface BoardStudentCardData { key: string name: string + avatarUrl?: string score?: number addScore?: number deductScore?: number @@ -221,7 +222,10 @@ const normalizeBoards = (input: unknown): BoardConfig[] => { typeof list?.name === "string" && list.name.trim() ? list.name.trim() : "学生列表", sql: typeof list?.sql === "string" && list.sql.trim() ? list.sql : getDefaultSql(), viewMode: - list?.viewMode === "list" || list?.viewMode === "card" || list?.viewMode === "grid" + list?.viewMode === "list" || + list?.viewMode === "card" || + list?.viewMode === "grid" || + list?.viewMode === "largeAvatar" ? list.viewMode : "card", scoreDisplayMode: @@ -347,6 +351,11 @@ const toStudentCards = (rows: any[]): BoardStudentCardData[] => { cards.push({ key: `${name}-${index}`, name, + avatarUrl: + typeof (data.avatar_url ?? data.avatarUrl ?? data.avatar) === "string" && + String(data.avatar_url ?? data.avatarUrl ?? data.avatar).trim() + ? String(data.avatar_url ?? data.avatarUrl ?? data.avatar).trim() + : undefined, score: parseNumber(data.score), addScore, deductScore, @@ -851,6 +860,8 @@ ORDER BY reward_points DESC, score DESC`, gridTemplateColumns: list.viewMode === "grid" ? "repeat(auto-fill, minmax(102px, 1fr))" + : list.viewMode === "largeAvatar" + ? "repeat(auto-fill, minmax(180px, 1fr))" : list.viewMode === "list" ? "1fr" : "repeat(auto-fill, minmax(220px, 1fr))", @@ -897,11 +908,22 @@ ORDER BY reward_points DESC, score DESC`, : item.answeredCount !== undefined ? { label: t("board.metrics.todayAnswered"), value: item.answeredCount } : null + const metricValueText = primaryMetric + ? primaryMetric.value > 0 + ? `+${primaryMetric.value}` + : String(primaryMetric.value) + : null return (
@@ -1006,6 +1033,96 @@ ORDER BY reward_points DESC, score DESC`, )}
+ ) : list.viewMode === "largeAvatar" ? ( +
+ {item.avatarUrl ? ( + {item.name} + ) : ( +
1 ? "46px" : "56px", + letterSpacing: "0.02em", + }} + > + {avatarText} +
+ )} + +
+ +
+
+ {item.name} +
+ {metricValueText !== null && ( +
= 0 + ? "#52c41a" + : "#ff4d4f", + background: "rgba(255,255,255,0.62)", + border: "1px solid rgba(255,255,255,0.82)", + borderRadius: 8, + padding: "2px 9px", + backdropFilter: "blur(4px)", + }} + > + {metricValueText} +
+ )} +
+
) : (
diff --git a/src/components/Home.tsx b/src/components/Home.tsx index a48014e..73ad675 100644 --- a/src/components/Home.tsx +++ b/src/components/Home.tsx @@ -56,7 +56,7 @@ interface rewardSetting { } type SortType = "alphabet" | "surname" | "group" | "score" -type LayoutType = "grouped" | "squareGrid" +type LayoutType = "grouped" | "squareGrid" | "largeAvatar" type SearchKeyboardLayout = "t9" | "qwerty26" const T9_KEY_MAP: Record = { @@ -1749,6 +1749,255 @@ export const Home: React.FC = ({ ) } + const renderStudentLargeAvatarCard = (student: student, index: number) => { + const avatarText = getDisplayText(student.name) + const avatarColor = getAvatarColor(student.name) + const isQuickActionMode = quickActionStudentId === student.id + const isSelected = selectedStudentIds.includes(student.id) + const displayPoints = getDisplayPoints(student) + const scoreColor = displayPoints > 0 ? "#52c41a" : displayPoints < 0 ? "#ff4d4f" : "#595959" + + let rankBadge: string | null = null + if (sortType === "score" && !searchKeyword) { + if (index === 0) rankBadge = "🥇" + else if (index === 1) rankBadge = "🥈" + else if (index === 2) rankBadge = "🥉" + } + + return ( +
{ + if (suppressClickRef.current) { + suppressClickRef.current = false + e.preventDefault() + e.stopPropagation() + return + } + openOperation(student, e.currentTarget as HTMLElement) + }} + onMouseDown={(e) => { + if (batchMode) return + if (e.button !== 0) return + startLongPress(student) + }} + onMouseUp={cancelLongPress} + onMouseLeave={cancelLongPress} + onTouchStart={() => { + if (batchMode) return + startLongPress(student) + }} + onTouchEnd={cancelLongPress} + onTouchCancel={cancelLongPress} + onContextMenu={(e) => { + if (batchMode) return + e.preventDefault() + openQuickAction(student) + }} + style={{ + cursor: "pointer", + position: "relative", + aspectRatio: "1.2 / 1", + }} + ref={(el) => { + groupedStudents.forEach((group) => { + if (group.key === "all") return + if (firstStudentIdByGroup.get(group.key) === student.id) { + groupRefs.current[group.key] = el + } + }) + }} + > + +
+ {student.avatarUrl ? ( + {student.name} + ) : ( +
1 ? "46px" : "56px", + letterSpacing: "0.02em", + }} + > + {avatarText} +
+ )} + +
+ + {rankBadge && ( +
+ {rankBadge} +
+ )} + + {isSelected && ( + + {t("home.selected")} + + )} + + {isQuickActionMode ? ( +
+ + +
+ ) : ( +
+
+ {student.name} +
+
+ {displayPoints > 0 ? `+${displayPoints}` : displayPoints} +
+
+ )} +
+ +
+ ) + } + const renderGroupedCards = () => { if (layoutType === "squareGrid") { return ( @@ -1764,6 +2013,20 @@ export const Home: React.FC = ({ ) } + if (layoutType === "largeAvatar") { + return ( +
+ {sortedStudents.map((student, idx) => renderStudentLargeAvatarCard(student, idx))} +
+ ) + } + return groupedStudents.map((group) => (
= ({ options={[ { value: "grouped", label: t("home.layoutBy.grouped") }, { value: "squareGrid", label: t("home.layoutBy.squareGrid") }, + { value: "largeAvatar", label: t("home.layoutBy.largeAvatar") }, ]} /> + diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json index 7e4b6ee..3a361a2 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -568,7 +568,11 @@ "avatarSaveFailed": "Failed to save avatar", "avatarInvalidFile": "Please choose an image file", "avatarTooLarge": "Image must be smaller than 2MB", - "avatarReadFailed": "Failed to read image" + "avatarReadFailed": "Failed to read image", + "avatarClipboardImport": "Import from Clipboard", + "avatarClipboardUnsupported": "Clipboard image read is not supported", + "avatarClipboardNoImage": "No image found in clipboard", + "avatarClipboardReadFailed": "Failed to read clipboard image" }, "score": { "title": "Points Management", diff --git a/src/i18n/locales/ja-JP.json b/src/i18n/locales/ja-JP.json index 803974d..92b7750 100644 --- a/src/i18n/locales/ja-JP.json +++ b/src/i18n/locales/ja-JP.json @@ -575,7 +575,11 @@ "avatarSaveFailed": "アバターの保存に失敗しました", "avatarInvalidFile": "画像ファイルを選択してください", "avatarTooLarge": "画像は2MB以下にしてください", - "avatarReadFailed": "画像の読み取りに失敗しました" + "avatarReadFailed": "画像の読み取りに失敗しました", + "avatarClipboardImport": "クリップボードから取り込み", + "avatarClipboardUnsupported": "この環境ではクリップボード画像を読み取れません", + "avatarClipboardNoImage": "クリップボードに画像がありません", + "avatarClipboardReadFailed": "クリップボード画像の読み取りに失敗しました" }, "score": { "title": "ポイント管理", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 85b9938..52dc263 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -568,7 +568,11 @@ "avatarSaveFailed": "头像保存失败", "avatarInvalidFile": "请选择图片文件", "avatarTooLarge": "图片不能超过 2MB", - "avatarReadFailed": "读取图片失败" + "avatarReadFailed": "读取图片失败", + "avatarClipboardImport": "从剪贴板导入", + "avatarClipboardUnsupported": "当前环境不支持读取剪贴板图片", + "avatarClipboardNoImage": "剪贴板中没有图片", + "avatarClipboardReadFailed": "读取剪贴板图片失败" }, "score": { "title": "积分管理", From 92f6f3684bab92be9674f2eaa5109e6170a82343 Mon Sep 17 00:00:00 2001 From: JSR Date: Sat, 18 Apr 2026 12:05:02 +0800 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20=E4=B8=BB=E9=A1=B5=E5=A4=A7?= =?UTF-8?q?=E5=A4=B4=E5=83=8F=E6=94=AF=E6=8C=81=E5=8F=B3=E9=94=AE=E6=94=B9?= =?UTF-8?q?=E5=90=8D=E4=B8=8E=E8=AE=BE=E7=BD=AE=E5=A4=B4=E5=83=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Home.tsx | 306 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 287 insertions(+), 19 deletions(-) diff --git a/src/components/Home.tsx b/src/components/Home.tsx index 73ad675..4146a97 100644 --- a/src/components/Home.tsx +++ b/src/components/Home.tsx @@ -11,11 +11,12 @@ import { message, InputNumber, Divider, + Dropdown, } from "antd" -import { SearchOutlined, DeleteOutlined, UndoOutlined } from "@ant-design/icons" +import { SearchOutlined, DeleteOutlined, UndoOutlined, UploadOutlined, CopyOutlined } from "@ant-design/icons" import { useTranslation } from "react-i18next" import { match, pinyin } from "pinyin-pro" -import { getAvatarFromExtraJson } from "../utils/studentAvatar" +import { getAvatarFromExtraJson, setAvatarInExtraJson } from "../utils/studentAvatar" import { useResponsive } from "../hooks/useResponsive" interface student { @@ -139,6 +140,15 @@ export const Home: React.FC = ({ const [rewardStudent, setRewardStudent] = useState(null) const [rewardModalVisible, setRewardModalVisible] = useState(false) const [redeemLoading, setRedeemLoading] = useState(false) + const [renameVisible, setRenameVisible] = useState(false) + const [renameSaving, setRenameSaving] = useState(false) + const [renameStudent, setRenameStudent] = useState(null) + const [renameValue, setRenameValue] = useState("") + const [avatarEditorVisible, setAvatarEditorVisible] = useState(false) + const [avatarEditorSaving, setAvatarEditorSaving] = useState(false) + const [avatarEditorStudent, setAvatarEditorStudent] = useState(null) + const [avatarEditorValue, setAvatarEditorValue] = useState(null) + const avatarInputRef = useRef(null) const longPressTimerRef = useRef(null) const suppressClickRef = useRef(false) const fetchRequestIdRef = useRef(0) @@ -1143,6 +1153,140 @@ export const Home: React.FC = ({ setOperationVisible(true) } + const readFileAsDataUrl = (file: File): Promise => { + 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 handleAvatarEditorFileChange = 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) + setAvatarEditorValue(dataUrl) + } catch { + messageApi.error(t("students.avatarReadFailed")) + } + } + + const handleAvatarPasteFromClipboard = async () => { + if (typeof navigator === "undefined" || !navigator.clipboard?.read) { + messageApi.error(t("students.avatarClipboardUnsupported")) + return + } + try { + const clipboardItems = await navigator.clipboard.read() + for (const item of clipboardItems) { + const imageType = item.types.find((type) => type.startsWith("image/")) + if (!imageType) continue + const blob = await item.getType(imageType) + const ext = imageType.split("/")[1] || "png" + const file = new File([blob], `avatar-clipboard.${ext}`, { type: imageType }) + await handleAvatarEditorFileChange(file) + return + } + messageApi.warning(t("students.avatarClipboardNoImage")) + } catch { + messageApi.error(t("students.avatarClipboardReadFailed")) + } + } + + const openRenameStudent = (target: student) => { + if (!canEdit) { + messageApi.error(t("common.readOnly")) + return + } + setRenameStudent(target) + setRenameValue(target.name) + setRenameVisible(true) + } + + const handleRenameStudent = async () => { + if (!renameStudent || !(window as any).api) return + const nextName = renameValue.trim() + if (!nextName) { + messageApi.warning(`${t("common.pleaseEnter")} ${t("common.name")}`) + return + } + if (nextName === renameStudent.name) { + setRenameVisible(false) + setRenameStudent(null) + setRenameValue("") + return + } + setRenameSaving(true) + try { + const res = await (window as any).api.updateStudent(renameStudent.id, { name: nextName }) + if (res?.success) { + messageApi.success(t("common.success")) + setRenameVisible(false) + setRenameStudent(null) + setRenameValue("") + await fetchData(true) + emitDataUpdated("students") + } else { + messageApi.error(res?.message || t("home.submitFailed")) + } + } catch { + messageApi.error(t("home.submitFailed")) + } finally { + setRenameSaving(false) + } + } + + const openAvatarEditor = (target: student) => { + if (!canEdit) { + messageApi.error(t("common.readOnly")) + return + } + setAvatarEditorStudent(target) + setAvatarEditorValue(target.avatarUrl || null) + setAvatarEditorVisible(true) + } + + const handleSaveAvatarEditor = async () => { + if (!avatarEditorStudent || !(window as any).api) return + setAvatarEditorSaving(true) + try { + const latest = + students.find((item) => item.id === avatarEditorStudent.id) || avatarEditorStudent + const payload = setAvatarInExtraJson(latest.extra_json, avatarEditorValue) + const res = await (window as any).api.updateStudent(avatarEditorStudent.id, { + extra_json: payload, + }) + if (res?.success) { + messageApi.success(t("students.avatarSaveSuccess")) + setAvatarEditorVisible(false) + setAvatarEditorStudent(null) + setAvatarEditorValue(null) + await fetchData(true) + emitDataUpdated("students") + } else { + messageApi.error(res?.message || t("students.avatarSaveFailed")) + } + } catch { + messageApi.error(t("students.avatarSaveFailed")) + } finally { + setAvatarEditorSaving(false) + } + } + const renderStudentCard = (student: student, index: number) => { const avatarText = getDisplayText(student.name) const avatarColor = getAvatarColor(student.name) @@ -1956,25 +2100,41 @@ export const Home: React.FC = ({ zIndex: 2, }} > -
{ + domEvent.stopPropagation() + if (key === "rename") openRenameStudent(student) + if (key === "avatar") openAvatarEditor(student) + }, }} > - {student.name} -
+
e.stopPropagation()} + style={{ + maxWidth: "65%", + fontWeight: 700, + fontSize: "22px", + lineHeight: 1, + color: "#111", + whiteSpace: "nowrap", + overflow: "hidden", + textOverflow: "ellipsis", + background: "rgba(255,255,255,0.62)", + border: "1px solid rgba(255,255,255,0.82)", + borderRadius: "8px", + padding: "3px 9px", + backdropFilter: "blur(4px)", + }} + > + {student.name} +
+
= ({ )} + { + setRenameVisible(false) + setRenameStudent(null) + setRenameValue("") + }} + onOk={handleRenameStudent} + okButtonProps={{ loading: renameSaving, disabled: !renameValue.trim() }} + okText={t("common.save")} + cancelText={t("common.cancel")} + destroyOnHidden + > + setRenameValue(e.target.value)} + placeholder={`${t("common.pleaseEnter")} ${t("common.name")}`} + maxLength={32} + autoFocus + onPressEnter={() => { + if (!renameSaving && renameValue.trim()) handleRenameStudent() + }} + /> + + + { + setAvatarEditorVisible(false) + setAvatarEditorStudent(null) + setAvatarEditorValue(null) + }} + onOk={handleSaveAvatarEditor} + okButtonProps={{ loading: avatarEditorSaving, disabled: !avatarEditorStudent }} + okText={t("common.save")} + cancelText={t("common.cancel")} + destroyOnHidden + > +
+
+ {avatarEditorValue ? ( + {avatarEditorStudent?.name + ) : ( +
+ {t("students.noAvatar")} +
+ )} +
+
+ + + +
+ { + const file = e.target.files?.[0] + handleAvatarEditorFileChange(file) + if (avatarInputRef.current) avatarInputRef.current.value = "" + }} + /> +
+ {t("students.avatarTip")} +
+
+
+