mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-19 19:04:23 +08:00
支持学生头像设置与展示(上传/清除)
This commit is contained in:
+69
-34
@@ -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" }}>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "积分管理",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user