支持学生头像设置与展示(上传/清除)

This commit is contained in:
JSR
2026-03-18 18:25:25 +08:00
parent f75e00f45e
commit 0d2a299b06
5 changed files with 338 additions and 37 deletions
+69 -34
View File
@@ -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 }) => {
</div>
)}
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
<div
style={{
width: "44px",
height: "44px",
borderRadius: "12px",
backgroundColor: avatarColor,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "white",
fontWeight: "bold",
fontSize: avatarText.length > 1 ? "14px" : "18px",
flexShrink: 0,
boxShadow: `0 4px 10px ${avatarColor}40`,
}}
>
{avatarText}
</div>
{student.avatarUrl ? (
<img
src={student.avatarUrl}
alt={student.name}
style={{
width: "44px",
height: "44px",
borderRadius: "12px",
objectFit: "cover",
flexShrink: 0,
boxShadow: `0 4px 10px ${avatarColor}40`,
border: "1px solid var(--ss-border-color)",
backgroundColor: "var(--ss-bg-color)",
}}
/>
) : (
<div
style={{
width: "44px",
height: "44px",
borderRadius: "12px",
backgroundColor: avatarColor,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "white",
fontWeight: "bold",
fontSize: avatarText.length > 1 ? "14px" : "18px",
flexShrink: 0,
boxShadow: `0 4px 10px ${avatarColor}40`,
}}
>
{avatarText}
</div>
)}
<div style={{ flex: 1, overflow: "hidden" }}>
<div
style={{
@@ -621,22 +642,36 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
<div
style={{
width: "32px",
height: "32px",
borderRadius: "50%",
backgroundColor: getAvatarColor(selectedStudent.name),
color: "white",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "14px",
fontWeight: "bold",
}}
>
{getDisplayText(selectedStudent.name)}
</div>
{selectedStudent.avatarUrl ? (
<img
src={selectedStudent.avatarUrl}
alt={selectedStudent.name}
style={{
width: "32px",
height: "32px",
borderRadius: "50%",
objectFit: "cover",
border: "1px solid var(--ss-border-color)",
}}
/>
) : (
<div
style={{
width: "32px",
height: "32px",
borderRadius: "50%",
backgroundColor: getAvatarColor(selectedStudent.name),
color: "white",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "14px",
fontWeight: "bold",
}}
>
{getDisplayText(selectedStudent.name)}
</div>
)}
<span style={{ fontWeight: 600 }}>{selectedStudent.name}</span>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
+196 -1
View File
@@ -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<student | null>(null)
const [avatarVisible, setAvatarVisible] = useState(false)
const [avatarSaving, setAvatarSaving] = useState(false)
const [avatarStudent, setAvatarStudent] = useState<student | null>(null)
const [avatarValue, setAvatarValue] = useState<string | null>(null)
const avatarInputRef = useRef<HTMLInputElement | null>(null)
const [xlsxLoading, setXlsxLoading] = useState(false)
const [xlsxFileName, setXlsxFileName] = useState("")
const [xlsxAoa, setXlsxAoa] = useState<any[][]>([])
@@ -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<string> => {
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<student> = [
{
title: t("students.avatar"),
key: "avatar",
width: 88,
align: "center",
render: (_, row) =>
row.avatarUrl ? (
<img
src={row.avatarUrl}
alt={row.name}
style={{
width: 32,
height: 32,
borderRadius: "50%",
objectFit: "cover",
border: "1px solid var(--ss-border-color)",
}}
/>
) : (
<div
style={{
width: 32,
height: 32,
borderRadius: "50%",
margin: "0 auto",
backgroundColor: "var(--ss-bg-color)",
border: "1px dashed var(--ss-border-color)",
color: "var(--ss-text-secondary)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 12,
}}
>
-
</div>
),
},
{ 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) => (
<Space>
<Button type="link" disabled={!canEdit} onClick={() => handleOpenTagEditor(row)}>
{t("students.editTags")}
</Button>
<Button type="link" disabled={!canEdit} onClick={() => handleOpenAvatarEditor(row)}>
{t("students.editAvatar")}
</Button>
<Button type="link" danger disabled={!canEdit} onClick={() => handleDelete(row.id)}>
{t("common.delete")}
</Button>
@@ -545,6 +665,81 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
initialTagIds={editingStudent?.tagIds || []}
title={t("students.editTagTitle", { name: editingStudent?.name || "" })}
/>
<Modal
title={t("students.editAvatarTitle", { name: avatarStudent?.name || "" })}
open={avatarVisible}
onCancel={() => {
setAvatarVisible(false)
setAvatarStudent(null)
setAvatarValue(null)
}}
onOk={handleSaveAvatar}
okButtonProps={{ loading: avatarSaving, disabled: !avatarStudent }}
okText={t("common.save")}
cancelText={t("common.cancel")}
destroyOnHidden
>
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<div style={{ display: "flex", justifyContent: "center", padding: "8px 0" }}>
{avatarValue ? (
<img
src={avatarValue}
alt={avatarStudent?.name || "avatar"}
style={{
width: 96,
height: 96,
borderRadius: "50%",
objectFit: "cover",
border: "1px solid var(--ss-border-color)",
}}
/>
) : (
<div
style={{
width: 96,
height: 96,
borderRadius: "50%",
border: "1px dashed var(--ss-border-color)",
backgroundColor: "var(--ss-bg-color)",
color: "var(--ss-text-secondary)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{t("students.noAvatar")}
</div>
)}
</div>
<div style={{ display: "flex", gap: 8 }}>
<Button
icon={<UploadOutlined />}
onClick={() => avatarInputRef.current?.click()}
disabled={!canEdit}
>
{t("students.avatarUpload")}
</Button>
<Button onClick={() => setAvatarValue(null)} disabled={!canEdit}>
{t("students.avatarClear")}
</Button>
</div>
<input
ref={avatarInputRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={(e) => {
const file = e.target.files?.[0]
handleAvatarFileChange(file)
if (avatarInputRef.current) avatarInputRef.current.value = ""
}}
/>
<div style={{ color: "var(--ss-text-secondary)", fontSize: 12 }}>
{t("students.avatarTip")}
</div>
</div>
</Modal>
</div>
)
}
+13 -1
View File
@@ -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",
+13 -1
View File
@@ -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": "积分管理",
+47
View File
@@ -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<string, unknown>).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<string, unknown> = {}
if (extraJson) {
try {
const parsed = JSON.parse(extraJson)
if (parsed && typeof parsed === "object") {
base = parsed as Record<string, unknown>
}
} catch {
base = {}
}
}
if (avatar && avatar.trim()) {
base.avatar = avatar.trim()
} else {
delete base.avatar
}
return JSON.stringify(base)
}