diff --git a/src/components/BoardManager.tsx b/src/components/BoardManager.tsx
index 806599a..2b8b021 100644
--- a/src/components/BoardManager.tsx
+++ b/src/components/BoardManager.tsx
@@ -18,7 +18,7 @@ import {
import { DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined } from "@ant-design/icons"
import { useTranslation } from "react-i18next"
-type BoardStudentViewMode = "list" | "card" | "grid"
+type BoardStudentViewMode = "list" | "card" | "grid" | "largeAvatar"
type BoardScoreDisplayMode = "total" | "split"
type SplitDirection = "horizontal" | "vertical"
@@ -68,6 +68,7 @@ interface BoardManagerProps {
interface BoardStudentCardData {
key: string
name: string
+ avatarUrl?: string
score?: number
addScore?: number
deductScore?: number
@@ -221,7 +222,10 @@ const normalizeBoards = (input: unknown): BoardConfig[] => {
typeof list?.name === "string" && list.name.trim() ? list.name.trim() : "学生列表",
sql: typeof list?.sql === "string" && list.sql.trim() ? list.sql : getDefaultSql(),
viewMode:
- list?.viewMode === "list" || list?.viewMode === "card" || list?.viewMode === "grid"
+ list?.viewMode === "list" ||
+ list?.viewMode === "card" ||
+ list?.viewMode === "grid" ||
+ list?.viewMode === "largeAvatar"
? list.viewMode
: "card",
scoreDisplayMode:
@@ -347,6 +351,11 @@ const toStudentCards = (rows: any[]): BoardStudentCardData[] => {
cards.push({
key: `${name}-${index}`,
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),
addScore,
deductScore,
@@ -851,6 +860,8 @@ ORDER BY reward_points DESC, score DESC`,
gridTemplateColumns:
list.viewMode === "grid"
? "repeat(auto-fill, minmax(102px, 1fr))"
+ : list.viewMode === "largeAvatar"
+ ? "repeat(auto-fill, minmax(180px, 1fr))"
: list.viewMode === "list"
? "1fr"
: "repeat(auto-fill, minmax(220px, 1fr))",
@@ -897,11 +908,22 @@ ORDER BY reward_points DESC, score DESC`,
: item.answeredCount !== undefined
? { label: t("board.metrics.todayAnswered"), value: item.answeredCount }
: null
+ const metricValueText = primaryMetric
+ ? primaryMetric.value > 0
+ ? `+${primaryMetric.value}`
+ : String(primaryMetric.value)
+ : null
return (
@@ -1006,6 +1033,96 @@ ORDER BY reward_points DESC, score DESC`,
)}
+ ) : list.viewMode === "largeAvatar" ? (
+
+ {item.avatarUrl ? (
+

+ ) : (
+
1 ? "46px" : "56px",
+ letterSpacing: "0.02em",
+ }}
+ >
+ {avatarText}
+
+ )}
+
+
+
+
+
+ {item.name}
+
+ {metricValueText !== null && (
+
= 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}
+
+ )}
+
+
) : (
diff --git a/src/components/Home.tsx b/src/components/Home.tsx
index a48014e..73ad675 100644
--- a/src/components/Home.tsx
+++ b/src/components/Home.tsx
@@ -56,7 +56,7 @@ interface rewardSetting {
}
type SortType = "alphabet" | "surname" | "group" | "score"
-type LayoutType = "grouped" | "squareGrid"
+type LayoutType = "grouped" | "squareGrid" | "largeAvatar"
type SearchKeyboardLayout = "t9" | "qwerty26"
const T9_KEY_MAP: Record
= {
@@ -1749,6 +1749,255 @@ export const Home: React.FC = ({
)
}
+ 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 (
+ {
+ 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
+ }
+ })
+ }}
+ >
+
+
+ {student.avatarUrl ? (
+

+ ) : (
+
1 ? "46px" : "56px",
+ letterSpacing: "0.02em",
+ }}
+ >
+ {avatarText}
+
+ )}
+
+
+
+ {rankBadge && (
+
+ {rankBadge}
+
+ )}
+
+ {isSelected && (
+
+ {t("home.selected")}
+
+ )}
+
+ {isQuickActionMode ? (
+
+
+
+
+ ) : (
+
+
+ {student.name}
+
+
+ {displayPoints > 0 ? `+${displayPoints}` : displayPoints}
+
+
+ )}
+
+
+
+ )
+ }
+
const renderGroupedCards = () => {
if (layoutType === "squareGrid") {
return (
@@ -1764,6 +2013,20 @@ export const Home: React.FC = ({
)
}
+ if (layoutType === "largeAvatar") {
+ return (
+
+ {sortedStudents.map((student, idx) => renderStudentLargeAvatarCard(student, idx))}
+
+ )
+ }
+
return groupedStudents.map((group) => (
= ({
options={[
{ value: "grouped", label: t("home.layoutBy.grouped") },
{ value: "squareGrid", label: t("home.layoutBy.squareGrid") },
+ { value: "largeAvatar", label: t("home.layoutBy.largeAvatar") },
]}
/>