From 92f6f3684bab92be9674f2eaa5109e6170a82343 Mon Sep 17 00:00:00 2001 From: JSR Date: Sat, 18 Apr 2026 12:05:02 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=B8=BB=E9=A1=B5=E5=A4=A7=E5=A4=B4?= =?UTF-8?q?=E5=83=8F=E6=94=AF=E6=8C=81=E5=8F=B3=E9=94=AE=E6=94=B9=E5=90=8D?= =?UTF-8?q?=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")} +
+
+
+