From 0d2a299b067adb29fed9bcb3990f7df5fae83fc9 Mon Sep 17 00:00:00 2001 From: JSR Date: Wed, 18 Mar 2026 18:25:25 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=AD=A6=E7=94=9F=E5=A4=B4?= =?UTF-8?q?=E5=83=8F=E8=AE=BE=E7=BD=AE=E4=B8=8E=E5=B1=95=E7=A4=BA=EF=BC=88?= =?UTF-8?q?=E4=B8=8A=E4=BC=A0/=E6=B8=85=E9=99=A4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Home.tsx | 103 ++++++++++------ src/components/StudentManager.tsx | 197 +++++++++++++++++++++++++++++- src/i18n/locales/en-US.json | 14 ++- src/i18n/locales/zh-CN.json | 14 ++- src/utils/studentAvatar.ts | 47 +++++++ 5 files changed, 338 insertions(+), 37 deletions(-) create mode 100644 src/utils/studentAvatar.ts diff --git a/src/components/Home.tsx b/src/components/Home.tsx index 7e58435..f1e764b 100644 --- a/src/components/Home.tsx +++ b/src/components/Home.tsx @@ -3,11 +3,14 @@ import { Card, Space, Button, Tag, Input, Select, Modal, message, InputNumber, D import { SearchOutlined, DeleteOutlined } from "@ant-design/icons" import { useTranslation } from "react-i18next" import { match, pinyin } from "pinyin-pro" +import { getAvatarFromExtraJson } from "../utils/studentAvatar" interface student { id: number name: string score: number + extra_json?: string | null + avatarUrl?: string | null pinyinName?: string pinyinFirst?: string } @@ -67,6 +70,7 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { if (stuRes.success) { const enrichedStudents = (stuRes.data as student[]).map((s) => ({ ...s, + avatarUrl: getAvatarFromExtraJson(s.extra_json), pinyinName: pinyin(s.name, { toneType: "none" }).toLowerCase(), pinyinFirst: getFirstLetter(s.name), })) @@ -313,24 +317,41 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { )}
-
1 ? "14px" : "18px", - flexShrink: 0, - boxShadow: `0 4px 10px ${avatarColor}40`, - }} - > - {avatarText} -
+ {student.avatarUrl ? ( + {student.name} + ) : ( +
1 ? "14px" : "18px", + flexShrink: 0, + boxShadow: `0 4px 10px ${avatarColor}40`, + }} + > + {avatarText} +
+ )}
= ({ canEdit }) => { }} >
-
- {getDisplayText(selectedStudent.name)} -
+ {selectedStudent.avatarUrl ? ( + {selectedStudent.name} + ) : ( +
+ {getDisplayText(selectedStudent.name)} +
+ )} {selectedStudent.name}
diff --git a/src/components/StudentManager.tsx b/src/components/StudentManager.tsx index 2fd3c24..c83e4f3 100644 --- a/src/components/StudentManager.tsx +++ b/src/components/StudentManager.tsx @@ -1,8 +1,10 @@ import React, { useEffect, useMemo, useRef, useState, useCallback } from "react" import { Table, Button, Space, message, Modal, Form, Input, Tag, Pagination } from "antd" import type { ColumnsType } from "antd/es/table" +import { UploadOutlined } from "@ant-design/icons" import { useTranslation } from "react-i18next" import { TagEditorDialog } from "./TagEditorDialog" +import { getAvatarFromExtraJson, setAvatarInExtraJson } from "../utils/studentAvatar" const createXlsxWorker = () => { return new Worker(new URL("../workers/xlsxWorker.ts", import.meta.url), { @@ -16,6 +18,8 @@ interface student { score: number tags?: string[] tagIds?: number[] + extra_json?: string | null + avatarUrl?: string | null } export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { @@ -29,6 +33,11 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { const [xlsxVisible, setXlsxVisible] = useState(false) const [tagEditVisible, setTagEditVisible] = useState(false) const [editingStudent, setEditingStudent] = useState(null) + const [avatarVisible, setAvatarVisible] = useState(false) + const [avatarSaving, setAvatarSaving] = useState(false) + const [avatarStudent, setAvatarStudent] = useState(null) + const [avatarValue, setAvatarValue] = useState(null) + const avatarInputRef = useRef(null) const [xlsxLoading, setXlsxLoading] = useState(false) const [xlsxFileName, setXlsxFileName] = useState("") const [xlsxAoa, setXlsxAoa] = useState([]) @@ -75,6 +84,8 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { id: s.id, name: s.name, score: s.score, + extra_json: s.extra_json ?? null, + avatarUrl: getAvatarFromExtraJson(s.extra_json), tags, tagIds, } @@ -175,6 +186,74 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { setTagEditVisible(true) } + const handleOpenAvatarEditor = (student: student) => { + if (!canEdit) { + messageApi.error(t("common.readOnly")) + return + } + setAvatarStudent(student) + setAvatarValue(student.avatarUrl || null) + setAvatarVisible(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 handleAvatarFileChange = 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) + setAvatarValue(dataUrl) + } catch { + messageApi.error(t("students.avatarReadFailed")) + } + } + + const handleSaveAvatar = async () => { + if (!(window as any).api || !avatarStudent) return + + setAvatarSaving(true) + try { + const payload = setAvatarInExtraJson(avatarStudent.extra_json, avatarValue) + const res = await (window as any).api.updateStudent(avatarStudent.id, { + extra_json: payload, + }) + if (res?.success) { + messageApi.success(t("students.avatarSaveSuccess")) + setAvatarVisible(false) + setAvatarStudent(null) + setAvatarValue(null) + fetchStudents() + emitDataUpdated("students") + } else { + messageApi.error(res?.message || t("students.avatarSaveFailed")) + } + } catch { + messageApi.error(t("students.avatarSaveFailed")) + } finally { + setAvatarSaving(false) + } + } + const handleSaveTags = async (tagIds: number[]) => { if (!editingStudent || !(window as any).api) return @@ -353,6 +432,44 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { } const columns: ColumnsType = [ + { + title: t("students.avatar"), + key: "avatar", + width: 88, + align: "center", + render: (_, row) => + row.avatarUrl ? ( + {row.name} + ) : ( +
+ - +
+ ), + }, { title: t("students.name"), dataIndex: "name", key: "name", width: 100 }, { title: t("students.currentScore"), @@ -399,12 +516,15 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { { title: t("common.operation"), key: "operation", - width: 150, + width: 220, render: (_, row) => ( + @@ -545,6 +665,81 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { initialTagIds={editingStudent?.tagIds || []} title={t("students.editTagTitle", { name: editingStudent?.name || "" })} /> + + { + setAvatarVisible(false) + setAvatarStudent(null) + setAvatarValue(null) + }} + onOk={handleSaveAvatar} + okButtonProps={{ loading: avatarSaving, disabled: !avatarStudent }} + okText={t("common.save")} + cancelText={t("common.cancel")} + destroyOnHidden + > +
+
+ {avatarValue ? ( + {avatarStudent?.name + ) : ( +
+ {t("students.noAvatar")} +
+ )} +
+
+ + +
+ { + const file = e.target.files?.[0] + handleAvatarFileChange(file) + if (avatarInputRef.current) avatarInputRef.current.value = "" + }} + /> +
+ {t("students.avatarTip")} +
+
+
) } diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json index 5bca8ab..ada8acf 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -350,10 +350,12 @@ "importList": "Import List", "addStudent": "Add Student", "name": "Name", + "avatar": "Avatar", "currentScore": "Current Points", "tags": "Tags", "noTags": "No tags", "editTags": "Edit Tags", + "editAvatar": "Set Avatar", "deleteConfirm": "Confirm delete this student?", "addTitle": "Add Student", "addConfirm": "Add", @@ -379,7 +381,17 @@ "selectNameColFirst": "Please select \"Name Column\" first", "noNamesFound": "No importable names found in selected column", "importFailed": "Import failed", - "editTagTitle": "Edit Tags - {{name}}" + "editTagTitle": "Edit Tags - {{name}}", + "editAvatarTitle": "Set Avatar - {{name}}", + "avatarUpload": "Upload Image", + "avatarClear": "Clear Avatar", + "noAvatar": "No Avatar", + "avatarTip": "Supports common image formats, up to 2MB", + "avatarSaveSuccess": "Avatar saved", + "avatarSaveFailed": "Failed to save avatar", + "avatarInvalidFile": "Please choose an image file", + "avatarTooLarge": "Image must be smaller than 2MB", + "avatarReadFailed": "Failed to read image" }, "score": { "title": "Points Management", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 9f0d515..78b5b60 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -350,10 +350,12 @@ "importList": "导入名单", "addStudent": "添加学生", "name": "姓名", + "avatar": "头像", "currentScore": "当前积分", "tags": "标签", "noTags": "无标签", "editTags": "编辑标签", + "editAvatar": "设置头像", "deleteConfirm": "确认删除该学生?", "addTitle": "添加学生", "addConfirm": "添加", @@ -379,7 +381,17 @@ "selectNameColFirst": "请先点击选择\"姓名列\"", "noNamesFound": "所选列未解析到可导入的姓名", "importFailed": "导入失败", - "editTagTitle": "编辑标签 - {{name}}" + "editTagTitle": "编辑标签 - {{name}}", + "editAvatarTitle": "设置头像 - {{name}}", + "avatarUpload": "上传图片", + "avatarClear": "清除头像", + "noAvatar": "无头像", + "avatarTip": "支持常见图片格式,文件大小不超过 2MB", + "avatarSaveSuccess": "头像保存成功", + "avatarSaveFailed": "头像保存失败", + "avatarInvalidFile": "请选择图片文件", + "avatarTooLarge": "图片不能超过 2MB", + "avatarReadFailed": "读取图片失败" }, "score": { "title": "积分管理", diff --git a/src/utils/studentAvatar.ts b/src/utils/studentAvatar.ts new file mode 100644 index 0000000..de37a0e --- /dev/null +++ b/src/utils/studentAvatar.ts @@ -0,0 +1,47 @@ +export const getAvatarFromExtraJson = (extraJson?: string | null): string | null => { + if (!extraJson) return null + + try { + const parsed = JSON.parse(extraJson) + if (!parsed || typeof parsed !== "object") return null + + const avatar = (parsed as Record).avatar + if (typeof avatar !== "string") return null + + const trimmed = avatar.trim() + if (!trimmed) return null + + if (trimmed.startsWith("data:image/")) return trimmed + if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) return trimmed + + return null + } catch { + return null + } +} + +export const setAvatarInExtraJson = ( + extraJson: string | null | undefined, + avatar: string | null +): string => { + let base: Record = {} + + if (extraJson) { + try { + const parsed = JSON.parse(extraJson) + if (parsed && typeof parsed === "object") { + base = parsed as Record + } + } catch { + base = {} + } + } + + if (avatar && avatar.trim()) { + base.avatar = avatar.trim() + } else { + delete base.avatar + } + + return JSON.stringify(base) +}