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 ? ( + {item.name} + ) : ( +
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 ? ( + {student.name} + ) : ( +
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") }, ]} />