mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-19 21:14:21 +08:00
Merge branch 'main' of https://github.com/SECTL/SecScore
This commit is contained in:
@@ -18,7 +18,7 @@ import {
|
|||||||
import { DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined } from "@ant-design/icons"
|
import { DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined } from "@ant-design/icons"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
type BoardStudentViewMode = "list" | "card" | "grid"
|
type BoardStudentViewMode = "list" | "card" | "grid" | "largeAvatar"
|
||||||
type BoardScoreDisplayMode = "total" | "split"
|
type BoardScoreDisplayMode = "total" | "split"
|
||||||
type SplitDirection = "horizontal" | "vertical"
|
type SplitDirection = "horizontal" | "vertical"
|
||||||
|
|
||||||
@@ -68,6 +68,7 @@ interface BoardManagerProps {
|
|||||||
interface BoardStudentCardData {
|
interface BoardStudentCardData {
|
||||||
key: string
|
key: string
|
||||||
name: string
|
name: string
|
||||||
|
avatarUrl?: string
|
||||||
score?: number
|
score?: number
|
||||||
addScore?: number
|
addScore?: number
|
||||||
deductScore?: number
|
deductScore?: number
|
||||||
@@ -221,7 +222,10 @@ const normalizeBoards = (input: unknown): BoardConfig[] => {
|
|||||||
typeof list?.name === "string" && list.name.trim() ? list.name.trim() : "学生列表",
|
typeof list?.name === "string" && list.name.trim() ? list.name.trim() : "学生列表",
|
||||||
sql: typeof list?.sql === "string" && list.sql.trim() ? list.sql : getDefaultSql(),
|
sql: typeof list?.sql === "string" && list.sql.trim() ? list.sql : getDefaultSql(),
|
||||||
viewMode:
|
viewMode:
|
||||||
list?.viewMode === "list" || list?.viewMode === "card" || list?.viewMode === "grid"
|
list?.viewMode === "list" ||
|
||||||
|
list?.viewMode === "card" ||
|
||||||
|
list?.viewMode === "grid" ||
|
||||||
|
list?.viewMode === "largeAvatar"
|
||||||
? list.viewMode
|
? list.viewMode
|
||||||
: "card",
|
: "card",
|
||||||
scoreDisplayMode:
|
scoreDisplayMode:
|
||||||
@@ -347,6 +351,11 @@ const toStudentCards = (rows: any[]): BoardStudentCardData[] => {
|
|||||||
cards.push({
|
cards.push({
|
||||||
key: `${name}-${index}`,
|
key: `${name}-${index}`,
|
||||||
name,
|
name,
|
||||||
|
avatarUrl:
|
||||||
|
typeof (data.avatar_url ?? data.avatarUrl ?? data.avatar) === "string" &&
|
||||||
|
String(data.avatar_url ?? data.avatarUrl ?? data.avatar).trim()
|
||||||
|
? String(data.avatar_url ?? data.avatarUrl ?? data.avatar).trim()
|
||||||
|
: undefined,
|
||||||
score: parseNumber(data.score),
|
score: parseNumber(data.score),
|
||||||
addScore,
|
addScore,
|
||||||
deductScore,
|
deductScore,
|
||||||
@@ -851,6 +860,8 @@ ORDER BY reward_points DESC, score DESC`,
|
|||||||
gridTemplateColumns:
|
gridTemplateColumns:
|
||||||
list.viewMode === "grid"
|
list.viewMode === "grid"
|
||||||
? "repeat(auto-fill, minmax(102px, 1fr))"
|
? "repeat(auto-fill, minmax(102px, 1fr))"
|
||||||
|
: list.viewMode === "largeAvatar"
|
||||||
|
? "repeat(auto-fill, minmax(180px, 1fr))"
|
||||||
: list.viewMode === "list"
|
: list.viewMode === "list"
|
||||||
? "1fr"
|
? "1fr"
|
||||||
: "repeat(auto-fill, minmax(220px, 1fr))",
|
: "repeat(auto-fill, minmax(220px, 1fr))",
|
||||||
@@ -897,11 +908,22 @@ ORDER BY reward_points DESC, score DESC`,
|
|||||||
: item.answeredCount !== undefined
|
: item.answeredCount !== undefined
|
||||||
? { label: t("board.metrics.todayAnswered"), value: item.answeredCount }
|
? { label: t("board.metrics.todayAnswered"), value: item.answeredCount }
|
||||||
: null
|
: null
|
||||||
|
const metricValueText = primaryMetric
|
||||||
|
? primaryMetric.value > 0
|
||||||
|
? `+${primaryMetric.value}`
|
||||||
|
: String(primaryMetric.value)
|
||||||
|
: null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={item.key}
|
key={item.key}
|
||||||
style={{ ...(list.viewMode === "grid" ? { aspectRatio: "1 / 1" } : null) }}
|
style={{
|
||||||
|
...(list.viewMode === "grid"
|
||||||
|
? { aspectRatio: "1 / 1" }
|
||||||
|
: list.viewMode === "largeAvatar"
|
||||||
|
? { aspectRatio: "1.2 / 1" }
|
||||||
|
: null),
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Card
|
<Card
|
||||||
style={{
|
style={{
|
||||||
@@ -917,10 +939,15 @@ ORDER BY reward_points DESC, score DESC`,
|
|||||||
padding:
|
padding:
|
||||||
list.viewMode === "grid"
|
list.viewMode === "grid"
|
||||||
? "8px"
|
? "8px"
|
||||||
|
: list.viewMode === "largeAvatar"
|
||||||
|
? 0
|
||||||
: list.viewMode === "list"
|
: list.viewMode === "list"
|
||||||
? "10px 12px"
|
? "10px 12px"
|
||||||
: "12px 14px",
|
: "12px 14px",
|
||||||
height: list.viewMode === "grid" ? "100%" : undefined,
|
height:
|
||||||
|
list.viewMode === "grid" || list.viewMode === "largeAvatar"
|
||||||
|
? "100%"
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -1006,6 +1033,96 @@ ORDER BY reward_points DESC, score DESC`,
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : list.viewMode === "largeAvatar" ? (
|
||||||
|
<div style={{ position: "relative", width: "100%", height: "100%" }}>
|
||||||
|
{item.avatarUrl ? (
|
||||||
|
<img
|
||||||
|
src={item.avatarUrl}
|
||||||
|
alt={item.name}
|
||||||
|
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
background: `linear-gradient(140deg, ${avatarColor} 0%, ${avatarColor}aa 100%)`,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
color: "rgba(255,255,255,0.95)",
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: avatarText.length > 1 ? "46px" : "56px",
|
||||||
|
letterSpacing: "0.02em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{avatarText}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
background:
|
||||||
|
"linear-gradient(180deg, rgba(255,255,255,0) 56%, rgba(255,255,255,0.72) 84%, rgba(255,255,255,0.95) 100%)",
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 8,
|
||||||
|
right: 8,
|
||||||
|
bottom: 8,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
maxWidth: "65%",
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: 22,
|
||||||
|
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: 8,
|
||||||
|
padding: "3px 9px",
|
||||||
|
backdropFilter: "blur(4px)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</div>
|
||||||
|
{metricValueText !== null && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: 28,
|
||||||
|
lineHeight: 1,
|
||||||
|
color:
|
||||||
|
primaryMetric && primaryMetric.value >= 0
|
||||||
|
? "#52c41a"
|
||||||
|
: "#ff4d4f",
|
||||||
|
background: "rgba(255,255,255,0.62)",
|
||||||
|
border: "1px solid rgba(255,255,255,0.82)",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "2px 9px",
|
||||||
|
backdropFilter: "blur(4px)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{metricValueText}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||||
<div
|
<div
|
||||||
@@ -1407,6 +1524,7 @@ ORDER BY reward_points DESC, score DESC`,
|
|||||||
{ value: "list", label: t("board.viewModes.list") },
|
{ value: "list", label: t("board.viewModes.list") },
|
||||||
{ value: "card", label: t("board.viewModes.card") },
|
{ value: "card", label: t("board.viewModes.card") },
|
||||||
{ value: "grid", label: t("board.viewModes.grid") },
|
{ value: "grid", label: t("board.viewModes.grid") },
|
||||||
|
{ value: "largeAvatar", label: t("board.viewModes.largeAvatar") },
|
||||||
]}
|
]}
|
||||||
disabled={!canManage}
|
disabled={!canManage}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+536
-3
@@ -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 {
|
||||||
@@ -56,7 +57,7 @@ interface rewardSetting {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SortType = "alphabet" | "surname" | "group" | "score"
|
type SortType = "alphabet" | "surname" | "group" | "score"
|
||||||
type LayoutType = "grouped" | "squareGrid"
|
type LayoutType = "grouped" | "squareGrid" | "largeAvatar"
|
||||||
type SearchKeyboardLayout = "t9" | "qwerty26"
|
type SearchKeyboardLayout = "t9" | "qwerty26"
|
||||||
|
|
||||||
const T9_KEY_MAP: Record<string, string> = {
|
const T9_KEY_MAP: Record<string, string> = {
|
||||||
@@ -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)
|
||||||
@@ -1749,6 +1893,271 @@ export const Home: React.FC<HomeProps> = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const renderStudentLargeAvatarCard = (student: student, index: number) => {
|
||||||
|
const avatarText = getDisplayText(student.name)
|
||||||
|
const avatarColor = getAvatarColor(student.name)
|
||||||
|
const isQuickActionMode = quickActionStudentId === student.id
|
||||||
|
const isSelected = selectedStudentIds.includes(student.id)
|
||||||
|
const displayPoints = getDisplayPoints(student)
|
||||||
|
const scoreColor = displayPoints > 0 ? "#52c41a" : displayPoints < 0 ? "#ff4d4f" : "#595959"
|
||||||
|
|
||||||
|
let rankBadge: string | null = null
|
||||||
|
if (sortType === "score" && !searchKeyword) {
|
||||||
|
if (index === 0) rankBadge = "🥇"
|
||||||
|
else if (index === 1) rankBadge = "🥈"
|
||||||
|
else if (index === 2) rankBadge = "🥉"
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={student.id}
|
||||||
|
data-student-quick-card="true"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (suppressClickRef.current) {
|
||||||
|
suppressClickRef.current = false
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
openOperation(student, e.currentTarget as HTMLElement)
|
||||||
|
}}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
if (batchMode) return
|
||||||
|
if (e.button !== 0) return
|
||||||
|
startLongPress(student)
|
||||||
|
}}
|
||||||
|
onMouseUp={cancelLongPress}
|
||||||
|
onMouseLeave={cancelLongPress}
|
||||||
|
onTouchStart={() => {
|
||||||
|
if (batchMode) return
|
||||||
|
startLongPress(student)
|
||||||
|
}}
|
||||||
|
onTouchEnd={cancelLongPress}
|
||||||
|
onTouchCancel={cancelLongPress}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
if (batchMode) return
|
||||||
|
e.preventDefault()
|
||||||
|
openQuickAction(student)
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
cursor: "pointer",
|
||||||
|
position: "relative",
|
||||||
|
aspectRatio: "1.2 / 1",
|
||||||
|
}}
|
||||||
|
ref={(el) => {
|
||||||
|
groupedStudents.forEach((group) => {
|
||||||
|
if (group.key === "all") return
|
||||||
|
if (firstStudentIdByGroup.get(group.key) === student.id) {
|
||||||
|
groupRefs.current[group.key] = el
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
backgroundColor: "var(--ss-card-bg)",
|
||||||
|
transition: "all 0.2s cubic-bezier(0.38, 0, 0.24, 1)",
|
||||||
|
border: isQuickActionMode
|
||||||
|
? "1px solid var(--ant-color-primary, #1677ff)"
|
||||||
|
: isSelected
|
||||||
|
? "1px solid var(--ant-color-primary, #1677ff)"
|
||||||
|
: "1px solid var(--ss-border-color)",
|
||||||
|
overflow: "hidden",
|
||||||
|
borderRadius: "18px",
|
||||||
|
boxShadow:
|
||||||
|
isQuickActionMode || isSelected ? "0 8px 18px rgba(22, 119, 255, 0.18)" : undefined,
|
||||||
|
}}
|
||||||
|
styles={{ body: { height: "100%", padding: 0 } }}
|
||||||
|
>
|
||||||
|
<div style={{ position: "relative", width: "100%", height: "100%" }}>
|
||||||
|
{student.avatarUrl ? (
|
||||||
|
<img
|
||||||
|
src={student.avatarUrl}
|
||||||
|
alt={student.name}
|
||||||
|
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
background: `linear-gradient(140deg, ${avatarColor} 0%, ${avatarColor}aa 100%)`,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
color: "rgba(255,255,255,0.95)",
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: avatarText.length > 1 ? "46px" : "56px",
|
||||||
|
letterSpacing: "0.02em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{avatarText}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
background:
|
||||||
|
"linear-gradient(180deg, rgba(255,255,255,0) 56%, rgba(255,255,255,0.72) 84%, rgba(255,255,255,0.95) 100%)",
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{rankBadge && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 8,
|
||||||
|
right: 10,
|
||||||
|
fontSize: "24px",
|
||||||
|
lineHeight: 1,
|
||||||
|
zIndex: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{rankBadge}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isSelected && (
|
||||||
|
<Tag
|
||||||
|
color="processing"
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 8,
|
||||||
|
left: 8,
|
||||||
|
marginInlineEnd: 0,
|
||||||
|
zIndex: 2,
|
||||||
|
backdropFilter: "blur(4px)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("home.selected")}
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isQuickActionMode ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: "10px",
|
||||||
|
zIndex: 3,
|
||||||
|
background: "rgba(0, 0, 0, 0.18)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleQuickAdjust(student, 1)
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
minWidth: "52px",
|
||||||
|
height: "32px",
|
||||||
|
borderRadius: "16px",
|
||||||
|
fontWeight: 700,
|
||||||
|
paddingInline: "10px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+1
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
size="small"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleQuickAdjust(student, -1)
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
minWidth: "52px",
|
||||||
|
height: "32px",
|
||||||
|
borderRadius: "16px",
|
||||||
|
fontWeight: 700,
|
||||||
|
paddingInline: "10px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
-1
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 8,
|
||||||
|
right: 8,
|
||||||
|
bottom: 8,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: "8px",
|
||||||
|
zIndex: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Dropdown
|
||||||
|
trigger={["contextMenu"]}
|
||||||
|
menu={{
|
||||||
|
items: [
|
||||||
|
{ key: "rename", label: "改名", disabled: !canEdit },
|
||||||
|
{ key: "avatar", label: t("students.editAvatar"), disabled: !canEdit },
|
||||||
|
],
|
||||||
|
onClick: ({ key, domEvent }) => {
|
||||||
|
domEvent.stopPropagation()
|
||||||
|
if (key === "rename") openRenameStudent(student)
|
||||||
|
if (key === "avatar") openAvatarEditor(student)
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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
|
||||||
|
style={{
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: "28px",
|
||||||
|
lineHeight: 1,
|
||||||
|
color: scoreColor,
|
||||||
|
background: "rgba(255,255,255,0.62)",
|
||||||
|
border: "1px solid rgba(255,255,255,0.82)",
|
||||||
|
borderRadius: "8px",
|
||||||
|
padding: "2px 9px",
|
||||||
|
backdropFilter: "blur(4px)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{displayPoints > 0 ? `+${displayPoints}` : displayPoints}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const renderGroupedCards = () => {
|
const renderGroupedCards = () => {
|
||||||
if (layoutType === "squareGrid") {
|
if (layoutType === "squareGrid") {
|
||||||
return (
|
return (
|
||||||
@@ -1764,6 +2173,20 @@ export const Home: React.FC<HomeProps> = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (layoutType === "largeAvatar") {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(auto-fill, minmax(180px, 1fr))",
|
||||||
|
gap: isPortraitMode ? "10px" : "14px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sortedStudents.map((student, idx) => renderStudentLargeAvatarCard(student, idx))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return groupedStudents.map((group) => (
|
return groupedStudents.map((group) => (
|
||||||
<div
|
<div
|
||||||
key={group.key}
|
key={group.key}
|
||||||
@@ -2667,6 +3090,7 @@ export const Home: React.FC<HomeProps> = ({
|
|||||||
options={[
|
options={[
|
||||||
{ value: "grouped", label: t("home.layoutBy.grouped") },
|
{ value: "grouped", label: t("home.layoutBy.grouped") },
|
||||||
{ value: "squareGrid", label: t("home.layoutBy.squareGrid") },
|
{ value: "squareGrid", label: t("home.layoutBy.squareGrid") },
|
||||||
|
{ value: "largeAvatar", label: t("home.layoutBy.largeAvatar") },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
@@ -2795,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"
|
||||||
@@ -2872,6 +3404,7 @@ export const Home: React.FC<HomeProps> = ({
|
|||||||
options={[
|
options={[
|
||||||
{ value: "grouped", label: t("home.layoutBy.grouped") },
|
{ value: "grouped", label: t("home.layoutBy.grouped") },
|
||||||
{ value: "squareGrid", label: t("home.layoutBy.squareGrid") },
|
{ value: "squareGrid", label: t("home.layoutBy.squareGrid") },
|
||||||
|
{ value: "largeAvatar", label: t("home.layoutBy.largeAvatar") },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
Select,
|
Select,
|
||||||
} from "antd"
|
} from "antd"
|
||||||
import type { ColumnsType } from "antd/es/table"
|
import type { ColumnsType } from "antd/es/table"
|
||||||
import { UploadOutlined, MoreOutlined, PlusOutlined } from "@ant-design/icons"
|
import { UploadOutlined, MoreOutlined, PlusOutlined, CopyOutlined } from "@ant-design/icons"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { TagEditorDialog } from "./TagEditorDialog"
|
import { TagEditorDialog } from "./TagEditorDialog"
|
||||||
import { getAvatarFromExtraJson, setAvatarInExtraJson } from "../utils/studentAvatar"
|
import { getAvatarFromExtraJson, setAvatarInExtraJson } from "../utils/studentAvatar"
|
||||||
@@ -720,6 +720,28 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 handleAvatarFileChange(file)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
messageApi.warning(t("students.avatarClipboardNoImage"))
|
||||||
|
} catch {
|
||||||
|
messageApi.error(t("students.avatarClipboardReadFailed"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleSaveAvatar = async () => {
|
const handleSaveAvatar = async () => {
|
||||||
if (!(window as any).api || !avatarStudent) return
|
if (!(window as any).api || !avatarStudent) return
|
||||||
|
|
||||||
@@ -2197,6 +2219,13 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
>
|
>
|
||||||
{t("students.avatarUpload")}
|
{t("students.avatarUpload")}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
icon={<CopyOutlined />}
|
||||||
|
onClick={handleAvatarPasteFromClipboard}
|
||||||
|
disabled={!canEdit}
|
||||||
|
>
|
||||||
|
{t("students.avatarClipboardImport")}
|
||||||
|
</Button>
|
||||||
<Button onClick={() => setAvatarValue(null)} disabled={!canEdit}>
|
<Button onClick={() => setAvatarValue(null)} disabled={!canEdit}>
|
||||||
{t("students.avatarClear")}
|
{t("students.avatarClear")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -430,7 +430,8 @@
|
|||||||
},
|
},
|
||||||
"layoutBy": {
|
"layoutBy": {
|
||||||
"grouped": "قائمة مجمعة",
|
"grouped": "قائمة مجمعة",
|
||||||
"squareGrid": "شبكة مربعة"
|
"squareGrid": "شبكة مربعة",
|
||||||
|
"largeAvatar": "صورة رمزية كبيرة"
|
||||||
},
|
},
|
||||||
"noStudents": "لا توجد بيانات طلاب، يرجى الإضافة في إدارة الطلاب",
|
"noStudents": "لا توجد بيانات طلاب، يرجى الإضافة في إدارة الطلاب",
|
||||||
"noMatch": "لم يتم العثور على طلاب مطابقين",
|
"noMatch": "لم يتم العثور على طلاب مطابقين",
|
||||||
|
|||||||
@@ -430,7 +430,8 @@
|
|||||||
},
|
},
|
||||||
"layoutBy": {
|
"layoutBy": {
|
||||||
"grouped": "Gruppierte Liste",
|
"grouped": "Gruppierte Liste",
|
||||||
"squareGrid": "Quadratisches Raster"
|
"squareGrid": "Quadratisches Raster",
|
||||||
|
"largeAvatar": "Großer Avatar"
|
||||||
},
|
},
|
||||||
"noStudents": "Keine Schülerdaten vorhanden, bitte in der Schülerverwaltung hinzufügen",
|
"noStudents": "Keine Schülerdaten vorhanden, bitte in der Schülerverwaltung hinzufügen",
|
||||||
"noMatch": "Keine übereinstimmenden Schüler gefunden",
|
"noMatch": "Keine übereinstimmenden Schüler gefunden",
|
||||||
|
|||||||
@@ -423,7 +423,8 @@
|
|||||||
},
|
},
|
||||||
"layoutBy": {
|
"layoutBy": {
|
||||||
"grouped": "Grouped List",
|
"grouped": "Grouped List",
|
||||||
"squareGrid": "Square Grid"
|
"squareGrid": "Square Grid",
|
||||||
|
"largeAvatar": "Large Avatar"
|
||||||
},
|
},
|
||||||
"noStudents": "No student data, please add in Student Management",
|
"noStudents": "No student data, please add in Student Management",
|
||||||
"noMatch": "No matching students found",
|
"noMatch": "No matching students found",
|
||||||
@@ -567,7 +568,11 @@
|
|||||||
"avatarSaveFailed": "Failed to save avatar",
|
"avatarSaveFailed": "Failed to save avatar",
|
||||||
"avatarInvalidFile": "Please choose an image file",
|
"avatarInvalidFile": "Please choose an image file",
|
||||||
"avatarTooLarge": "Image must be smaller than 2MB",
|
"avatarTooLarge": "Image must be smaller than 2MB",
|
||||||
"avatarReadFailed": "Failed to read image"
|
"avatarReadFailed": "Failed to read image",
|
||||||
|
"avatarClipboardImport": "Import from Clipboard",
|
||||||
|
"avatarClipboardUnsupported": "Clipboard image read is not supported",
|
||||||
|
"avatarClipboardNoImage": "No image found in clipboard",
|
||||||
|
"avatarClipboardReadFailed": "Failed to read clipboard image"
|
||||||
},
|
},
|
||||||
"score": {
|
"score": {
|
||||||
"title": "Points Management",
|
"title": "Points Management",
|
||||||
@@ -686,7 +691,8 @@
|
|||||||
"viewModes": {
|
"viewModes": {
|
||||||
"list": "List View",
|
"list": "List View",
|
||||||
"card": "Card View",
|
"card": "Card View",
|
||||||
"grid": "Grid View"
|
"grid": "Grid View",
|
||||||
|
"largeAvatar": "Large Avatar"
|
||||||
},
|
},
|
||||||
"scoreDisplayModes": {
|
"scoreDisplayModes": {
|
||||||
"total": "Show Total Score",
|
"total": "Show Total Score",
|
||||||
|
|||||||
@@ -430,7 +430,8 @@
|
|||||||
},
|
},
|
||||||
"layoutBy": {
|
"layoutBy": {
|
||||||
"grouped": "Lista agrupada",
|
"grouped": "Lista agrupada",
|
||||||
"squareGrid": "Cuadrícula cuadrada"
|
"squareGrid": "Cuadrícula cuadrada",
|
||||||
|
"largeAvatar": "Avatar grande"
|
||||||
},
|
},
|
||||||
"noStudents": "Sin datos de estudiantes, por favor añada en gestión de estudiantes",
|
"noStudents": "Sin datos de estudiantes, por favor añada en gestión de estudiantes",
|
||||||
"noMatch": "Sin estudiantes coincidentes encontrados",
|
"noMatch": "Sin estudiantes coincidentes encontrados",
|
||||||
|
|||||||
@@ -430,7 +430,8 @@
|
|||||||
},
|
},
|
||||||
"layoutBy": {
|
"layoutBy": {
|
||||||
"grouped": "Liste groupée",
|
"grouped": "Liste groupée",
|
||||||
"squareGrid": "Grille carrée"
|
"squareGrid": "Grille carrée",
|
||||||
|
"largeAvatar": "Grand avatar"
|
||||||
},
|
},
|
||||||
"noStudents": "Aucune donnée d'élève, veuillez ajouter dans la gestion des élèves",
|
"noStudents": "Aucune donnée d'élève, veuillez ajouter dans la gestion des élèves",
|
||||||
"noMatch": "Aucun élève correspondant trouvé",
|
"noMatch": "Aucun élève correspondant trouvé",
|
||||||
|
|||||||
@@ -430,7 +430,8 @@
|
|||||||
},
|
},
|
||||||
"layoutBy": {
|
"layoutBy": {
|
||||||
"grouped": "グループリスト",
|
"grouped": "グループリスト",
|
||||||
"squareGrid": "正方形グリッド"
|
"squareGrid": "正方形グリッド",
|
||||||
|
"largeAvatar": "大型アバター"
|
||||||
},
|
},
|
||||||
"noStudents": "生徒データがありません。生徒管理で追加してください",
|
"noStudents": "生徒データがありません。生徒管理で追加してください",
|
||||||
"noMatch": "一致する生徒が見つかりません",
|
"noMatch": "一致する生徒が見つかりません",
|
||||||
@@ -574,7 +575,11 @@
|
|||||||
"avatarSaveFailed": "アバターの保存に失敗しました",
|
"avatarSaveFailed": "アバターの保存に失敗しました",
|
||||||
"avatarInvalidFile": "画像ファイルを選択してください",
|
"avatarInvalidFile": "画像ファイルを選択してください",
|
||||||
"avatarTooLarge": "画像は2MB以下にしてください",
|
"avatarTooLarge": "画像は2MB以下にしてください",
|
||||||
"avatarReadFailed": "画像の読み取りに失敗しました"
|
"avatarReadFailed": "画像の読み取りに失敗しました",
|
||||||
|
"avatarClipboardImport": "クリップボードから取り込み",
|
||||||
|
"avatarClipboardUnsupported": "この環境ではクリップボード画像を読み取れません",
|
||||||
|
"avatarClipboardNoImage": "クリップボードに画像がありません",
|
||||||
|
"avatarClipboardReadFailed": "クリップボード画像の読み取りに失敗しました"
|
||||||
},
|
},
|
||||||
"score": {
|
"score": {
|
||||||
"title": "ポイント管理",
|
"title": "ポイント管理",
|
||||||
@@ -693,7 +698,8 @@
|
|||||||
"viewModes": {
|
"viewModes": {
|
||||||
"list": "リスト表示",
|
"list": "リスト表示",
|
||||||
"card": "カード表示",
|
"card": "カード表示",
|
||||||
"grid": "グリッド表示"
|
"grid": "グリッド表示",
|
||||||
|
"largeAvatar": "大型アバター表示"
|
||||||
},
|
},
|
||||||
"scoreDisplayModes": {
|
"scoreDisplayModes": {
|
||||||
"total": "合計スコアを表示",
|
"total": "合計スコアを表示",
|
||||||
|
|||||||
@@ -430,7 +430,8 @@
|
|||||||
},
|
},
|
||||||
"layoutBy": {
|
"layoutBy": {
|
||||||
"grouped": "그룹화된 목록",
|
"grouped": "그룹화된 목록",
|
||||||
"squareGrid": "정사각형 그리드"
|
"squareGrid": "정사각형 그리드",
|
||||||
|
"largeAvatar": "대형 아바타"
|
||||||
},
|
},
|
||||||
"noStudents": "학생 데이터가 없습니다. 학생 관리에서 추가해 주세요",
|
"noStudents": "학생 데이터가 없습니다. 학생 관리에서 추가해 주세요",
|
||||||
"noMatch": "일치하는 학생이 없습니다",
|
"noMatch": "일치하는 학생이 없습니다",
|
||||||
|
|||||||
@@ -430,7 +430,8 @@
|
|||||||
},
|
},
|
||||||
"layoutBy": {
|
"layoutBy": {
|
||||||
"grouped": "Lista agrupada",
|
"grouped": "Lista agrupada",
|
||||||
"squareGrid": "Grade quadrada"
|
"squareGrid": "Grade quadrada",
|
||||||
|
"largeAvatar": "Avatar grande"
|
||||||
},
|
},
|
||||||
"noStudents": "Sem dados de alunos, por favor adicione em gerenciamento de alunos",
|
"noStudents": "Sem dados de alunos, por favor adicione em gerenciamento de alunos",
|
||||||
"noMatch": "Nenhum aluno correspondente encontrado",
|
"noMatch": "Nenhum aluno correspondente encontrado",
|
||||||
|
|||||||
@@ -430,7 +430,8 @@
|
|||||||
},
|
},
|
||||||
"layoutBy": {
|
"layoutBy": {
|
||||||
"grouped": "Группированный список",
|
"grouped": "Группированный список",
|
||||||
"squareGrid": "Квадратная сетка"
|
"squareGrid": "Квадратная сетка",
|
||||||
|
"largeAvatar": "Большой аватар"
|
||||||
},
|
},
|
||||||
"noStudents": "Нет данных о студентах, пожалуйста, добавьте в управлении студентами",
|
"noStudents": "Нет данных о студентах, пожалуйста, добавьте в управлении студентами",
|
||||||
"noMatch": "Нет совпадающих студентов",
|
"noMatch": "Нет совпадающих студентов",
|
||||||
|
|||||||
@@ -423,7 +423,8 @@
|
|||||||
},
|
},
|
||||||
"layoutBy": {
|
"layoutBy": {
|
||||||
"grouped": "分组列表",
|
"grouped": "分组列表",
|
||||||
"squareGrid": "正方形网格"
|
"squareGrid": "正方形网格",
|
||||||
|
"largeAvatar": "大头像"
|
||||||
},
|
},
|
||||||
"noStudents": "暂无学生数据,请前往学生管理添加",
|
"noStudents": "暂无学生数据,请前往学生管理添加",
|
||||||
"noMatch": "未找到匹配的学生",
|
"noMatch": "未找到匹配的学生",
|
||||||
@@ -567,7 +568,11 @@
|
|||||||
"avatarSaveFailed": "头像保存失败",
|
"avatarSaveFailed": "头像保存失败",
|
||||||
"avatarInvalidFile": "请选择图片文件",
|
"avatarInvalidFile": "请选择图片文件",
|
||||||
"avatarTooLarge": "图片不能超过 2MB",
|
"avatarTooLarge": "图片不能超过 2MB",
|
||||||
"avatarReadFailed": "读取图片失败"
|
"avatarReadFailed": "读取图片失败",
|
||||||
|
"avatarClipboardImport": "从剪贴板导入",
|
||||||
|
"avatarClipboardUnsupported": "当前环境不支持读取剪贴板图片",
|
||||||
|
"avatarClipboardNoImage": "剪贴板中没有图片",
|
||||||
|
"avatarClipboardReadFailed": "读取剪贴板图片失败"
|
||||||
},
|
},
|
||||||
"score": {
|
"score": {
|
||||||
"title": "积分管理",
|
"title": "积分管理",
|
||||||
@@ -686,7 +691,8 @@
|
|||||||
"viewModes": {
|
"viewModes": {
|
||||||
"list": "列表视图",
|
"list": "列表视图",
|
||||||
"card": "卡片视图",
|
"card": "卡片视图",
|
||||||
"grid": "网格视图"
|
"grid": "网格视图",
|
||||||
|
"largeAvatar": "大头像视图"
|
||||||
},
|
},
|
||||||
"scoreDisplayModes": {
|
"scoreDisplayModes": {
|
||||||
"total": "显示总分",
|
"total": "显示总分",
|
||||||
|
|||||||
Reference in New Issue
Block a user