mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-19 12:34:22 +08:00
feat: 主页大头像支持右键改名与设置头像
This commit is contained in:
+287
-19
@@ -11,11 +11,12 @@ import {
|
|||||||
message,
|
message,
|
||||||
InputNumber,
|
InputNumber,
|
||||||
Divider,
|
Divider,
|
||||||
|
Dropdown,
|
||||||
} from "antd"
|
} 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 { useTranslation } from "react-i18next"
|
||||||
import { match, pinyin } from "pinyin-pro"
|
import { match, pinyin } from "pinyin-pro"
|
||||||
import { getAvatarFromExtraJson } from "../utils/studentAvatar"
|
import { getAvatarFromExtraJson, setAvatarInExtraJson } from "../utils/studentAvatar"
|
||||||
import { useResponsive } from "../hooks/useResponsive"
|
import { useResponsive } from "../hooks/useResponsive"
|
||||||
|
|
||||||
interface student {
|
interface student {
|
||||||
@@ -139,6 +140,15 @@ export const Home: React.FC<HomeProps> = ({
|
|||||||
const [rewardStudent, setRewardStudent] = useState<student | null>(null)
|
const [rewardStudent, setRewardStudent] = useState<student | null>(null)
|
||||||
const [rewardModalVisible, setRewardModalVisible] = useState(false)
|
const [rewardModalVisible, setRewardModalVisible] = useState(false)
|
||||||
const [redeemLoading, setRedeemLoading] = useState(false)
|
const [redeemLoading, setRedeemLoading] = useState(false)
|
||||||
|
const [renameVisible, setRenameVisible] = useState(false)
|
||||||
|
const [renameSaving, setRenameSaving] = useState(false)
|
||||||
|
const [renameStudent, setRenameStudent] = useState<student | null>(null)
|
||||||
|
const [renameValue, setRenameValue] = useState("")
|
||||||
|
const [avatarEditorVisible, setAvatarEditorVisible] = useState(false)
|
||||||
|
const [avatarEditorSaving, setAvatarEditorSaving] = useState(false)
|
||||||
|
const [avatarEditorStudent, setAvatarEditorStudent] = useState<student | null>(null)
|
||||||
|
const [avatarEditorValue, setAvatarEditorValue] = useState<string | null>(null)
|
||||||
|
const avatarInputRef = useRef<HTMLInputElement>(null)
|
||||||
const longPressTimerRef = useRef<number | null>(null)
|
const longPressTimerRef = useRef<number | null>(null)
|
||||||
const suppressClickRef = useRef(false)
|
const suppressClickRef = useRef(false)
|
||||||
const fetchRequestIdRef = useRef(0)
|
const fetchRequestIdRef = useRef(0)
|
||||||
@@ -1143,6 +1153,140 @@ export const Home: React.FC<HomeProps> = ({
|
|||||||
setOperationVisible(true)
|
setOperationVisible(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 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 renderStudentCard = (student: student, index: number) => {
|
||||||
const avatarText = getDisplayText(student.name)
|
const avatarText = getDisplayText(student.name)
|
||||||
const avatarColor = getAvatarColor(student.name)
|
const avatarColor = getAvatarColor(student.name)
|
||||||
@@ -1956,25 +2100,41 @@ export const Home: React.FC<HomeProps> = ({
|
|||||||
zIndex: 2,
|
zIndex: 2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<Dropdown
|
||||||
style={{
|
trigger={["contextMenu"]}
|
||||||
maxWidth: "65%",
|
menu={{
|
||||||
fontWeight: 700,
|
items: [
|
||||||
fontSize: "22px",
|
{ key: "rename", label: "改名", disabled: !canEdit },
|
||||||
lineHeight: 1,
|
{ key: "avatar", label: t("students.editAvatar"), disabled: !canEdit },
|
||||||
color: "#111",
|
],
|
||||||
whiteSpace: "nowrap",
|
onClick: ({ key, domEvent }) => {
|
||||||
overflow: "hidden",
|
domEvent.stopPropagation()
|
||||||
textOverflow: "ellipsis",
|
if (key === "rename") openRenameStudent(student)
|
||||||
background: "rgba(255,255,255,0.62)",
|
if (key === "avatar") openAvatarEditor(student)
|
||||||
border: "1px solid rgba(255,255,255,0.82)",
|
},
|
||||||
borderRadius: "8px",
|
|
||||||
padding: "3px 9px",
|
|
||||||
backdropFilter: "blur(4px)",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{student.name}
|
<div
|
||||||
</div>
|
onContextMenu={(e) => 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}
|
||||||
|
</div>
|
||||||
|
</Dropdown>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontWeight: 800,
|
fontWeight: 800,
|
||||||
@@ -3059,6 +3219,114 @@ export const Home: React.FC<HomeProps> = ({
|
|||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={`${t("common.edit")} ${t("common.name")} - ${renameStudent?.name || ""}`}
|
||||||
|
open={renameVisible}
|
||||||
|
onCancel={() => {
|
||||||
|
setRenameVisible(false)
|
||||||
|
setRenameStudent(null)
|
||||||
|
setRenameValue("")
|
||||||
|
}}
|
||||||
|
onOk={handleRenameStudent}
|
||||||
|
okButtonProps={{ loading: renameSaving, disabled: !renameValue.trim() }}
|
||||||
|
okText={t("common.save")}
|
||||||
|
cancelText={t("common.cancel")}
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={renameValue}
|
||||||
|
onChange={(e) => setRenameValue(e.target.value)}
|
||||||
|
placeholder={`${t("common.pleaseEnter")} ${t("common.name")}`}
|
||||||
|
maxLength={32}
|
||||||
|
autoFocus
|
||||||
|
onPressEnter={() => {
|
||||||
|
if (!renameSaving && renameValue.trim()) handleRenameStudent()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={t("students.editAvatarTitle", { name: avatarEditorStudent?.name || "" })}
|
||||||
|
open={avatarEditorVisible}
|
||||||
|
onCancel={() => {
|
||||||
|
setAvatarEditorVisible(false)
|
||||||
|
setAvatarEditorStudent(null)
|
||||||
|
setAvatarEditorValue(null)
|
||||||
|
}}
|
||||||
|
onOk={handleSaveAvatarEditor}
|
||||||
|
okButtonProps={{ loading: avatarEditorSaving, disabled: !avatarEditorStudent }}
|
||||||
|
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" }}>
|
||||||
|
{avatarEditorValue ? (
|
||||||
|
<img
|
||||||
|
src={avatarEditorValue}
|
||||||
|
alt={avatarEditorStudent?.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
|
||||||
|
icon={<CopyOutlined />}
|
||||||
|
onClick={handleAvatarPasteFromClipboard}
|
||||||
|
disabled={!canEdit}
|
||||||
|
>
|
||||||
|
{t("students.avatarClipboardImport")}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setAvatarEditorValue(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]
|
||||||
|
handleAvatarEditorFileChange(file)
|
||||||
|
if (avatarInputRef.current) avatarInputRef.current.value = ""
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{ color: "var(--ss-text-secondary)", fontSize: 12 }}>
|
||||||
|
{t("students.avatarTip")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref={immersiveToolbarRef}
|
ref={immersiveToolbarRef}
|
||||||
data-immersive-toolbar="true"
|
data-immersive-toolbar="true"
|
||||||
|
|||||||
Reference in New Issue
Block a user