mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-19 19:04:23 +08:00
1336 lines
41 KiB
TypeScript
1336 lines
41 KiB
TypeScript
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
|
import {
|
|
Alert,
|
|
Button,
|
|
Card,
|
|
Empty,
|
|
Input,
|
|
Modal,
|
|
Popconfirm,
|
|
Select,
|
|
Space,
|
|
Table,
|
|
Tabs,
|
|
Tag,
|
|
Typography,
|
|
message,
|
|
} from "antd"
|
|
import { DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined } from "@ant-design/icons"
|
|
import { useTranslation } from "react-i18next"
|
|
|
|
type BoardStudentViewMode = "list" | "card" | "grid"
|
|
type SplitDirection = "horizontal" | "vertical"
|
|
|
|
interface StudentListConfig {
|
|
id: string
|
|
name: string
|
|
sql: string
|
|
viewMode: BoardStudentViewMode
|
|
}
|
|
|
|
interface LayoutLeafNode {
|
|
id: string
|
|
type: "leaf"
|
|
listId: string
|
|
}
|
|
|
|
interface LayoutSplitNode {
|
|
id: string
|
|
type: "split"
|
|
direction: SplitDirection
|
|
ratio: number
|
|
first: LayoutNode
|
|
second: LayoutNode
|
|
}
|
|
|
|
type LayoutNode = LayoutLeafNode | LayoutSplitNode
|
|
|
|
interface BoardConfig {
|
|
id: string
|
|
name: string
|
|
lists: StudentListConfig[]
|
|
layout: LayoutNode
|
|
}
|
|
|
|
interface BoardPreset {
|
|
id: string
|
|
name: string
|
|
description: string
|
|
sql: string
|
|
}
|
|
|
|
interface BoardManagerProps {
|
|
canManage: boolean
|
|
}
|
|
|
|
interface BoardStudentCardData {
|
|
key: string
|
|
name: string
|
|
score?: number
|
|
rewardPoints?: number
|
|
weekChange?: number
|
|
weekDeducted?: number
|
|
answeredCount?: number
|
|
}
|
|
|
|
interface DragState {
|
|
boardId: string
|
|
splitNodeId: string
|
|
direction: SplitDirection
|
|
startRatio: number
|
|
startX: number
|
|
startY: number
|
|
containerSize: number
|
|
}
|
|
|
|
const getFirstLeafId = (node: LayoutNode): string => {
|
|
if (node.type === "leaf") return node.id
|
|
return getFirstLeafId(node.first)
|
|
}
|
|
|
|
const makeId = () =>
|
|
typeof crypto !== "undefined" && "randomUUID" in crypto
|
|
? crypto.randomUUID()
|
|
: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
|
|
|
const clampRatio = (value: number) => Math.max(0.15, Math.min(0.85, value))
|
|
|
|
const getDefaultSql = () => `SELECT
|
|
name AS student_name,
|
|
score,
|
|
reward_points
|
|
FROM students
|
|
ORDER BY score DESC`
|
|
|
|
const createDefaultList = (): StudentListConfig => ({
|
|
id: makeId(),
|
|
name: "学生积分榜",
|
|
sql: getDefaultSql(),
|
|
viewMode: "card",
|
|
})
|
|
|
|
const createLeafForList = (listId: string): LayoutLeafNode => ({
|
|
id: makeId(),
|
|
type: "leaf",
|
|
listId,
|
|
})
|
|
|
|
const createDefaultBoard = (): BoardConfig => {
|
|
const list = createDefaultList()
|
|
return {
|
|
id: makeId(),
|
|
name: "默认看板",
|
|
lists: [list],
|
|
layout: createLeafForList(list.id),
|
|
}
|
|
}
|
|
|
|
const collectLeafListIds = (node: LayoutNode, acc: string[] = []): string[] => {
|
|
if (node.type === "leaf") {
|
|
acc.push(node.listId)
|
|
return acc
|
|
}
|
|
collectLeafListIds(node.first, acc)
|
|
collectLeafListIds(node.second, acc)
|
|
return acc
|
|
}
|
|
|
|
const pruneLayout = (node: LayoutNode, validListIds: Set<string>): LayoutNode | null => {
|
|
if (node.type === "leaf") {
|
|
return validListIds.has(node.listId) ? node : null
|
|
}
|
|
|
|
const first = pruneLayout(node.first, validListIds)
|
|
const second = pruneLayout(node.second, validListIds)
|
|
|
|
if (!first && !second) return null
|
|
if (!first) return second
|
|
if (!second) return first
|
|
|
|
return { ...node, first, second }
|
|
}
|
|
|
|
const appendLeafToLayout = (layout: LayoutNode, listId: string): LayoutNode => {
|
|
return {
|
|
id: makeId(),
|
|
type: "split",
|
|
direction: "horizontal",
|
|
ratio: 0.5,
|
|
first: layout,
|
|
second: createLeafForList(listId),
|
|
}
|
|
}
|
|
|
|
const syncBoardLayout = (board: BoardConfig): BoardConfig => {
|
|
const validListIds = new Set(board.lists.map((item) => item.id))
|
|
const pruned = pruneLayout(board.layout, validListIds)
|
|
|
|
let layout = pruned
|
|
const used = new Set(layout ? collectLeafListIds(layout) : [])
|
|
const missing = board.lists.map((item) => item.id).filter((id) => !used.has(id))
|
|
|
|
if (!layout) {
|
|
const firstMissing = missing.shift() || board.lists[0]?.id
|
|
if (!firstMissing) {
|
|
const fallbackList = createDefaultList()
|
|
return {
|
|
...board,
|
|
lists: [fallbackList],
|
|
layout: createLeafForList(fallbackList.id),
|
|
}
|
|
}
|
|
layout = createLeafForList(firstMissing)
|
|
}
|
|
|
|
for (const listId of missing) {
|
|
layout = appendLeafToLayout(layout, listId)
|
|
}
|
|
|
|
return { ...board, layout }
|
|
}
|
|
|
|
const normalizeBoards = (input: unknown): BoardConfig[] => {
|
|
if (!Array.isArray(input)) return [createDefaultBoard()]
|
|
|
|
const boards = input
|
|
.map((board: any) => {
|
|
const lists = Array.isArray(board?.lists)
|
|
? board.lists
|
|
.map((list: any) => ({
|
|
id: typeof list?.id === "string" && list.id.trim() ? list.id : makeId(),
|
|
name:
|
|
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
|
|
: "card",
|
|
}))
|
|
.filter((list: StudentListConfig) => list.sql.trim())
|
|
: []
|
|
|
|
const normalizedLists = lists.length > 0 ? lists : [createDefaultList()]
|
|
const fallbackLayout = createLeafForList(normalizedLists[0].id)
|
|
const layout =
|
|
board?.layout && typeof board.layout === "object"
|
|
? (board.layout as LayoutNode)
|
|
: fallbackLayout
|
|
|
|
return syncBoardLayout({
|
|
id: typeof board?.id === "string" && board.id.trim() ? board.id : makeId(),
|
|
name:
|
|
typeof board?.name === "string" && board.name.trim() ? board.name.trim() : "未命名看板",
|
|
lists: normalizedLists,
|
|
layout,
|
|
})
|
|
})
|
|
.filter((board: BoardConfig) => board.id)
|
|
|
|
return boards.length > 0 ? boards : [createDefaultBoard()]
|
|
}
|
|
|
|
const resolveSqlTemplate = (sql: string) => {
|
|
const now = new Date()
|
|
const dayMs = 24 * 60 * 60 * 1000
|
|
const at = (offsetDays: number) => new Date(now.getTime() + offsetDays * dayMs)
|
|
|
|
const formatIso = (date: Date) => date.toISOString().replace(/\.\d{3}Z$/, "Z")
|
|
|
|
const todayStart = new Date(now)
|
|
todayStart.setHours(0, 0, 0, 0)
|
|
const mondayOffset = (todayStart.getDay() + 6) % 7
|
|
const thisWeekStart = at(-mondayOffset)
|
|
thisWeekStart.setHours(0, 0, 0, 0)
|
|
const lastWeekStart = at(-mondayOffset - 7)
|
|
lastWeekStart.setHours(0, 0, 0, 0)
|
|
|
|
return sql
|
|
.split("{{now}}")
|
|
.join(formatIso(now))
|
|
.split("{{today_start}}")
|
|
.join(formatIso(todayStart))
|
|
.split("{{this_week_start}}")
|
|
.join(formatIso(thisWeekStart))
|
|
.split("{{last_week_start}}")
|
|
.join(formatIso(lastWeekStart))
|
|
.split("{{since_7d}}")
|
|
.join(formatIso(at(-7)))
|
|
.split("{{since_30d}}")
|
|
.join(formatIso(at(-30)))
|
|
}
|
|
|
|
const trimSqlTailSemicolon = (sql: string) => sql.trimEnd().replace(/;+$/, "")
|
|
|
|
const parseNumber = (value: unknown): number | undefined => {
|
|
if (typeof value === "number" && Number.isFinite(value)) return value
|
|
if (typeof value === "string" && value.trim() !== "") {
|
|
const parsed = Number(value)
|
|
return Number.isFinite(parsed) ? parsed : undefined
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
const pickStudentName = (row: Record<string, unknown>): string | null => {
|
|
const nameCandidate = row.student_name ?? row.name ?? row.studentName
|
|
if (typeof nameCandidate !== "string") return null
|
|
const name = nameCandidate.trim()
|
|
return name ? name : null
|
|
}
|
|
|
|
const toStudentCards = (rows: any[]): BoardStudentCardData[] => {
|
|
const cards: BoardStudentCardData[] = []
|
|
rows.forEach((row, index) => {
|
|
if (!row || typeof row !== "object") return
|
|
const name = pickStudentName(row as Record<string, unknown>)
|
|
if (!name) return
|
|
|
|
const data = row as Record<string, unknown>
|
|
cards.push({
|
|
key: `${name}-${index}`,
|
|
name,
|
|
score: parseNumber(data.score),
|
|
rewardPoints: parseNumber(data.reward_points ?? data.rewardPoints),
|
|
weekChange: parseNumber(data.week_change ?? data.range_change ?? data.change),
|
|
weekDeducted: parseNumber(data.week_deducted ?? data.deducted),
|
|
answeredCount: parseNumber(data.answered_count ?? data.answer_count),
|
|
})
|
|
})
|
|
|
|
if (cards.every((item) => item.score === undefined)) return cards
|
|
|
|
return cards.sort(
|
|
(a, b) => (b.score ?? Number.MIN_SAFE_INTEGER) - (a.score ?? Number.MIN_SAFE_INTEGER)
|
|
)
|
|
}
|
|
|
|
const getAvatarText = (name: string): string => {
|
|
const chars = name.trim()
|
|
if (!chars) return "?"
|
|
const first = chars[0]
|
|
const second = chars.length > 1 ? chars[1] : ""
|
|
return `${first}${second}`.trim()
|
|
}
|
|
|
|
const getAvatarColor = (name: string): string => {
|
|
const palette = [
|
|
"#1677ff",
|
|
"#13c2c2",
|
|
"#52c41a",
|
|
"#faad14",
|
|
"#eb2f96",
|
|
"#722ed1",
|
|
"#2f54eb",
|
|
"#08979c",
|
|
]
|
|
let hash = 0
|
|
for (let i = 0; i < name.length; i += 1) {
|
|
hash = (hash * 31 + name.charCodeAt(i)) >>> 0
|
|
}
|
|
return palette[hash % palette.length]
|
|
}
|
|
|
|
const replaceLayoutNode = (
|
|
node: LayoutNode,
|
|
targetId: string,
|
|
replacement: LayoutNode
|
|
): LayoutNode => {
|
|
if (node.id === targetId) return replacement
|
|
if (node.type === "leaf") return node
|
|
return {
|
|
...node,
|
|
first: replaceLayoutNode(node.first, targetId, replacement),
|
|
second: replaceLayoutNode(node.second, targetId, replacement),
|
|
}
|
|
}
|
|
|
|
const updateSplitRatio = (node: LayoutNode, splitNodeId: string, ratio: number): LayoutNode => {
|
|
if (node.type === "leaf") return node
|
|
if (node.id === splitNodeId) return { ...node, ratio: clampRatio(ratio) }
|
|
return {
|
|
...node,
|
|
first: updateSplitRatio(node.first, splitNodeId, ratio),
|
|
second: updateSplitRatio(node.second, splitNodeId, ratio),
|
|
}
|
|
}
|
|
|
|
const removeListFromBoard = (board: BoardConfig, listId: string): BoardConfig => {
|
|
const nextLists = board.lists.filter((item) => item.id !== listId)
|
|
if (nextLists.length <= 0) return board
|
|
return syncBoardLayout({ ...board, lists: nextLists })
|
|
}
|
|
|
|
export const BoardManager: React.FC<BoardManagerProps> = ({ canManage }) => {
|
|
const { t } = useTranslation()
|
|
const [messageApi, contextHolder] = message.useMessage()
|
|
const [boards, setBoards] = useState<BoardConfig[]>([])
|
|
const [activeBoardId, setActiveBoardId] = useState<string>("")
|
|
const [loading, setLoading] = useState(false)
|
|
const [saving, setSaving] = useState(false)
|
|
const [runningIds, setRunningIds] = useState<Record<string, boolean>>({})
|
|
const [resultMap, setResultMap] = useState<Record<string, any[]>>({})
|
|
const [errorMap, setErrorMap] = useState<Record<string, string>>({})
|
|
const [dragState, setDragState] = useState<DragState | null>(null)
|
|
const [editingListId, setEditingListId] = useState<string | null>(null)
|
|
const [selectedLeafNodeId, setSelectedLeafNodeId] = useState<string | null>(null)
|
|
const [renameVisible, setRenameVisible] = useState(false)
|
|
const [renameValue, setRenameValue] = useState("")
|
|
|
|
const panelRefs = useRef<Record<string, HTMLDivElement | null>>({})
|
|
|
|
const presets: BoardPreset[] = useMemo(
|
|
() => [
|
|
{
|
|
id: "week-low-deduct",
|
|
name: t("board.presets.weekLowDeduct.name"),
|
|
description: t("board.presets.weekLowDeduct.description"),
|
|
sql: `WITH week_stat AS (
|
|
SELECT
|
|
student_name,
|
|
SUM(CASE WHEN delta < 0 THEN -delta ELSE 0 END) AS deducted,
|
|
SUM(delta) AS week_change
|
|
FROM score_events
|
|
WHERE event_time >= '{{since_7d}}'
|
|
GROUP BY student_name
|
|
)
|
|
SELECT
|
|
s.name AS student_name,
|
|
s.score,
|
|
COALESCE(w.week_change, 0) AS week_change,
|
|
COALESCE(w.deducted, 0) AS week_deducted
|
|
FROM students s
|
|
LEFT JOIN week_stat w ON w.student_name = s.name
|
|
WHERE COALESCE(w.deducted, 0) < 3
|
|
ORDER BY s.score DESC`,
|
|
},
|
|
{
|
|
id: "today-active",
|
|
name: t("board.presets.todayActive.name"),
|
|
description: t("board.presets.todayActive.description"),
|
|
sql: `SELECT
|
|
student_name,
|
|
COUNT(*) AS answered_count,
|
|
SUM(delta) AS score_change
|
|
FROM score_events
|
|
WHERE event_time >= '{{today_start}}'
|
|
GROUP BY student_name
|
|
ORDER BY answered_count DESC, score_change DESC`,
|
|
},
|
|
{
|
|
id: "reward-ranking",
|
|
name: t("board.presets.rewardRanking.name"),
|
|
description: t("board.presets.rewardRanking.description"),
|
|
sql: `SELECT
|
|
name AS student_name,
|
|
score,
|
|
reward_points
|
|
FROM students
|
|
ORDER BY reward_points DESC, score DESC`,
|
|
},
|
|
],
|
|
[t]
|
|
)
|
|
|
|
const activeBoard = useMemo(
|
|
() => boards.find((board) => board.id === activeBoardId) || null,
|
|
[boards, activeBoardId]
|
|
)
|
|
|
|
const editingList = useMemo(() => {
|
|
if (!activeBoard || !editingListId) return null
|
|
return activeBoard.lists.find((item) => item.id === editingListId) || null
|
|
}, [activeBoard, editingListId])
|
|
|
|
const saveBoards = useCallback(
|
|
async (nextBoards: BoardConfig[]) => {
|
|
if (!(window as any).api || !canManage) return
|
|
|
|
setSaving(true)
|
|
try {
|
|
const res = await (window as any).api.boardSaveConfigs(nextBoards)
|
|
if (!res?.success) {
|
|
messageApi.error(res?.message || t("board.saveFailed"))
|
|
}
|
|
} catch {
|
|
messageApi.error(t("board.saveFailed"))
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
},
|
|
[canManage, messageApi, t]
|
|
)
|
|
|
|
const mutateBoards = useCallback(
|
|
(updater: (prev: BoardConfig[]) => BoardConfig[]) => {
|
|
setBoards((prev) => {
|
|
const next = updater(prev).map(syncBoardLayout)
|
|
if (canManage) {
|
|
saveBoards(next).catch(() => void 0)
|
|
}
|
|
return next
|
|
})
|
|
},
|
|
[canManage, saveBoards]
|
|
)
|
|
|
|
const fetchBoards = useCallback(async () => {
|
|
if (!(window as any).api) return
|
|
|
|
setLoading(true)
|
|
try {
|
|
const res = await (window as any).api.boardGetConfigs()
|
|
if (res?.success) {
|
|
const normalized = normalizeBoards(res?.data)
|
|
setBoards(normalized)
|
|
setActiveBoardId((prev) => prev || normalized[0]?.id || "")
|
|
} else {
|
|
const fallback = [createDefaultBoard()]
|
|
setBoards(fallback)
|
|
setActiveBoardId(fallback[0].id)
|
|
}
|
|
} catch {
|
|
const fallback = [createDefaultBoard()]
|
|
setBoards(fallback)
|
|
setActiveBoardId(fallback[0].id)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [])
|
|
|
|
const runListQuery = useCallback(
|
|
async (list: StudentListConfig) => {
|
|
if (!(window as any).api) return
|
|
const sql = resolveSqlTemplate(list.sql)
|
|
|
|
setRunningIds((prev) => ({ ...prev, [list.id]: true }))
|
|
setErrorMap((prev) => ({ ...prev, [list.id]: "" }))
|
|
|
|
try {
|
|
const res = await (window as any).api.boardQuerySql({ sql, limit: 500 })
|
|
if (res?.success) {
|
|
setResultMap((prev) => ({ ...prev, [list.id]: Array.isArray(res.data) ? res.data : [] }))
|
|
} else {
|
|
setErrorMap((prev) => ({ ...prev, [list.id]: res?.message || t("board.runFailed") }))
|
|
setResultMap((prev) => ({ ...prev, [list.id]: [] }))
|
|
}
|
|
} catch {
|
|
setErrorMap((prev) => ({ ...prev, [list.id]: t("board.runFailed") }))
|
|
setResultMap((prev) => ({ ...prev, [list.id]: [] }))
|
|
} finally {
|
|
setRunningIds((prev) => ({ ...prev, [list.id]: false }))
|
|
}
|
|
},
|
|
[t]
|
|
)
|
|
|
|
const runAllInBoard = useCallback(
|
|
async (board: BoardConfig | null) => {
|
|
if (!board) return
|
|
for (const list of board.lists) {
|
|
await runListQuery(list)
|
|
}
|
|
},
|
|
[runListQuery]
|
|
)
|
|
|
|
const listConfigSignature = useMemo(() => {
|
|
if (!activeBoard) return ""
|
|
return JSON.stringify(
|
|
activeBoard.lists.map((item) => ({
|
|
id: item.id,
|
|
name: item.name,
|
|
sql: item.sql,
|
|
viewMode: item.viewMode,
|
|
}))
|
|
)
|
|
}, [activeBoard])
|
|
|
|
useEffect(() => {
|
|
fetchBoards()
|
|
}, [fetchBoards])
|
|
|
|
useEffect(() => {
|
|
if (!activeBoardId && boards.length > 0) setActiveBoardId(boards[0].id)
|
|
}, [activeBoardId, boards])
|
|
|
|
useEffect(() => {
|
|
if (!activeBoard) {
|
|
setSelectedLeafNodeId(null)
|
|
return
|
|
}
|
|
const firstLeafId = getFirstLeafId(activeBoard.layout)
|
|
setSelectedLeafNodeId((prev) => prev || firstLeafId)
|
|
}, [activeBoard?.id, activeBoard?.layout])
|
|
|
|
useEffect(() => {
|
|
if (!activeBoard) return
|
|
const timer = window.setTimeout(() => {
|
|
runAllInBoard(activeBoard).catch(() => void 0)
|
|
}, 260)
|
|
return () => window.clearTimeout(timer)
|
|
}, [activeBoard?.id, listConfigSignature, runAllInBoard])
|
|
|
|
useEffect(() => {
|
|
const onDataUpdated = (e: Event) => {
|
|
const detail = (e as CustomEvent<{ category?: string }>).detail
|
|
if (!detail?.category || detail.category === "all")
|
|
runAllInBoard(activeBoard).catch(() => void 0)
|
|
if (
|
|
detail.category === "students" ||
|
|
detail.category === "events" ||
|
|
detail.category === "reasons"
|
|
) {
|
|
runAllInBoard(activeBoard).catch(() => void 0)
|
|
}
|
|
}
|
|
|
|
window.addEventListener("ss:data-updated", onDataUpdated)
|
|
return () => window.removeEventListener("ss:data-updated", onDataUpdated)
|
|
}, [activeBoard, runAllInBoard])
|
|
|
|
useEffect(() => {
|
|
if (!dragState) return
|
|
|
|
const onMove = (event: MouseEvent) => {
|
|
const delta =
|
|
dragState.direction === "horizontal"
|
|
? event.clientX - dragState.startX
|
|
: event.clientY - dragState.startY
|
|
const ratio = clampRatio(dragState.startRatio + delta / dragState.containerSize)
|
|
|
|
mutateBoards((prev) =>
|
|
prev.map((board) =>
|
|
board.id === dragState.boardId
|
|
? {
|
|
...board,
|
|
layout: updateSplitRatio(board.layout, dragState.splitNodeId, ratio),
|
|
}
|
|
: board
|
|
)
|
|
)
|
|
}
|
|
|
|
const onUp = () => setDragState(null)
|
|
|
|
window.addEventListener("mousemove", onMove)
|
|
window.addEventListener("mouseup", onUp)
|
|
return () => {
|
|
window.removeEventListener("mousemove", onMove)
|
|
window.removeEventListener("mouseup", onUp)
|
|
}
|
|
}, [dragState, mutateBoards])
|
|
|
|
const updateBoardName = (boardId: string, name: string) => {
|
|
mutateBoards((prev) =>
|
|
prev.map((board) =>
|
|
board.id === boardId ? { ...board, name: name || t("board.untitledBoard") } : board
|
|
)
|
|
)
|
|
}
|
|
|
|
const openRenameModal = () => {
|
|
if (!activeBoard) return
|
|
setRenameValue(activeBoard.name)
|
|
setRenameVisible(true)
|
|
}
|
|
|
|
const submitRenameBoard = () => {
|
|
if (!activeBoard) return
|
|
updateBoardName(activeBoard.id, renameValue.trim() || t("board.untitledBoard"))
|
|
setRenameVisible(false)
|
|
}
|
|
|
|
const addBoard = () => {
|
|
if (!canManage) return
|
|
const newBoard = createDefaultBoard()
|
|
newBoard.name = t("board.newBoard")
|
|
mutateBoards((prev) => [...prev, newBoard])
|
|
setActiveBoardId(newBoard.id)
|
|
}
|
|
|
|
const removeBoard = (boardId: string) => {
|
|
if (!canManage) return
|
|
mutateBoards((prev) => {
|
|
if (prev.length <= 1) {
|
|
messageApi.warning(t("board.keepAtLeastOneBoard"))
|
|
return prev
|
|
}
|
|
const next = prev.filter((board) => board.id !== boardId)
|
|
if (activeBoardId === boardId && next.length > 0) setActiveBoardId(next[0].id)
|
|
return next
|
|
})
|
|
}
|
|
|
|
const addListBySplit = (boardId: string, leafNodeId: string, direction: SplitDirection) => {
|
|
if (!canManage) return
|
|
|
|
mutateBoards((prev) =>
|
|
prev.map((board) => {
|
|
if (board.id !== boardId) return board
|
|
|
|
const newList: StudentListConfig = {
|
|
id: makeId(),
|
|
name: t("board.newList"),
|
|
sql: getDefaultSql(),
|
|
viewMode: "card",
|
|
}
|
|
|
|
const findLeaf = (node: LayoutNode): LayoutLeafNode | null => {
|
|
if (node.type === "leaf") return node.id === leafNodeId ? node : null
|
|
return findLeaf(node.first) || findLeaf(node.second)
|
|
}
|
|
|
|
const targetLeaf = findLeaf(board.layout)
|
|
if (!targetLeaf) return board
|
|
|
|
const replacement: LayoutSplitNode = {
|
|
id: makeId(),
|
|
type: "split",
|
|
direction,
|
|
ratio: 0.5,
|
|
first: targetLeaf,
|
|
second: createLeafForList(newList.id),
|
|
}
|
|
|
|
return syncBoardLayout({
|
|
...board,
|
|
lists: [...board.lists, newList],
|
|
layout: replaceLayoutNode(board.layout, leafNodeId, replacement),
|
|
})
|
|
})
|
|
)
|
|
}
|
|
|
|
const splitSelectedLeaf = (direction: SplitDirection) => {
|
|
if (!activeBoard || !selectedLeafNodeId) return
|
|
addListBySplit(activeBoard.id, selectedLeafNodeId, direction)
|
|
}
|
|
|
|
const removeList = (boardId: string, listId: string) => {
|
|
if (!canManage) return
|
|
|
|
mutateBoards((prev) =>
|
|
prev.map((board) => {
|
|
if (board.id !== boardId) return board
|
|
if (board.lists.length <= 1) {
|
|
messageApi.warning(t("board.keepAtLeastOneList"))
|
|
return board
|
|
}
|
|
return removeListFromBoard(board, listId)
|
|
})
|
|
)
|
|
}
|
|
|
|
const updateList = (boardId: string, listId: string, patch: Partial<StudentListConfig>) => {
|
|
mutateBoards((prev) =>
|
|
prev.map((board) =>
|
|
board.id === boardId
|
|
? {
|
|
...board,
|
|
lists: board.lists.map((list) => (list.id === listId ? { ...list, ...patch } : list)),
|
|
}
|
|
: board
|
|
)
|
|
)
|
|
}
|
|
|
|
const applyPreset = (boardId: string, listId: string, presetId: string) => {
|
|
const preset = presets.find((item) => item.id === presetId)
|
|
if (!preset) return
|
|
|
|
updateList(boardId, listId, {
|
|
name: preset.name,
|
|
sql: preset.sql,
|
|
})
|
|
}
|
|
|
|
const renderStudentView = (list: StudentListConfig) => {
|
|
const rows = resultMap[list.id] || []
|
|
const studentCards = toStudentCards(rows)
|
|
const useCardView = studentCards.length > 0
|
|
|
|
const columns =
|
|
rows.length > 0
|
|
? Object.keys(rows[0]).map((key) => ({
|
|
title: key,
|
|
dataIndex: key,
|
|
key,
|
|
ellipsis: true,
|
|
render: (value: any) =>
|
|
value === null || value === undefined || value === "" ? "-" : String(value),
|
|
}))
|
|
: []
|
|
|
|
if (!useCardView) {
|
|
return (
|
|
<Table
|
|
rowKey={(_, index) => `${list.id}-${index}`}
|
|
dataSource={rows}
|
|
columns={columns}
|
|
loading={Boolean(runningIds[list.id])}
|
|
locale={{
|
|
emptyText: (
|
|
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t("common.noData")} />
|
|
),
|
|
}}
|
|
scroll={{ x: true }}
|
|
pagination={{ pageSize: 20, showSizeChanger: false }}
|
|
size="small"
|
|
/>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div
|
|
style={{
|
|
display: "grid",
|
|
gridTemplateColumns:
|
|
list.viewMode === "grid"
|
|
? "repeat(auto-fill, minmax(102px, 1fr))"
|
|
: list.viewMode === "list"
|
|
? "1fr"
|
|
: "repeat(auto-fill, minmax(220px, 1fr))",
|
|
gap: 12,
|
|
}}
|
|
>
|
|
{studentCards.map((item, index) => {
|
|
const avatarColor = getAvatarColor(item.name)
|
|
const avatarText = getAvatarText(item.name)
|
|
const rankBadge = index === 0 ? "🥇" : index === 1 ? "🥈" : index === 2 ? "🥉" : null
|
|
const primaryMetric =
|
|
item.score !== undefined
|
|
? { label: t("board.metrics.totalScore"), value: item.score }
|
|
: item.rewardPoints !== undefined
|
|
? { label: t("board.metrics.rewardPoints"), value: item.rewardPoints }
|
|
: item.weekChange !== undefined
|
|
? { label: t("board.metrics.weekChange"), value: item.weekChange }
|
|
: item.weekDeducted !== undefined
|
|
? { label: t("board.metrics.weekDeducted"), value: item.weekDeducted }
|
|
: item.answeredCount !== undefined
|
|
? { label: t("board.metrics.todayAnswered"), value: item.answeredCount }
|
|
: null
|
|
|
|
return (
|
|
<div
|
|
key={item.key}
|
|
style={{ ...(list.viewMode === "grid" ? { aspectRatio: "1 / 1" } : null) }}
|
|
>
|
|
<Card
|
|
style={{
|
|
width: "100%",
|
|
height: "100%",
|
|
backgroundColor: "var(--ss-card-bg)",
|
|
border: "1px solid var(--ss-border-color)",
|
|
boxShadow: "0 6px 16px rgba(0, 0, 0, 0.06)",
|
|
position: "relative",
|
|
}}
|
|
styles={{
|
|
body: {
|
|
padding:
|
|
list.viewMode === "grid"
|
|
? "8px"
|
|
: list.viewMode === "list"
|
|
? "10px 12px"
|
|
: "12px 14px",
|
|
height: list.viewMode === "grid" ? "100%" : undefined,
|
|
},
|
|
}}
|
|
>
|
|
{rankBadge && (
|
|
<div
|
|
style={{
|
|
position: "absolute",
|
|
top: 10,
|
|
right: 10,
|
|
fontSize: "32px",
|
|
lineHeight: 1,
|
|
zIndex: 1,
|
|
}}
|
|
>
|
|
{rankBadge}
|
|
</div>
|
|
)}
|
|
|
|
{list.viewMode === "grid" ? (
|
|
<div
|
|
style={{
|
|
height: "100%",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
gap: 8,
|
|
textAlign: "center",
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
width: 42,
|
|
height: 42,
|
|
borderRadius: 12,
|
|
backgroundColor: avatarColor,
|
|
color: "#fff",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
fontWeight: 700,
|
|
fontSize: avatarText.length > 1 ? "14px" : "18px",
|
|
boxShadow: `0 4px 10px ${avatarColor}40`,
|
|
}}
|
|
>
|
|
{avatarText}
|
|
</div>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
gap: 6,
|
|
width: "100%",
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
fontWeight: 600,
|
|
fontSize: 13,
|
|
color: "var(--ss-text-main)",
|
|
whiteSpace: "nowrap",
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
}}
|
|
>
|
|
{item.name}
|
|
</div>
|
|
{primaryMetric && (
|
|
<Tag
|
|
color={
|
|
typeof primaryMetric.value === "number" && primaryMetric.value >= 0
|
|
? "success"
|
|
: "error"
|
|
}
|
|
style={{ fontWeight: "bold", marginInlineEnd: 0 }}
|
|
>
|
|
{primaryMetric.label}:
|
|
{primaryMetric.value > 0
|
|
? `+${primaryMetric.value}`
|
|
: primaryMetric.value}
|
|
</Tag>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
|
<div
|
|
style={{
|
|
width: 42,
|
|
height: 42,
|
|
borderRadius: 12,
|
|
backgroundColor: avatarColor,
|
|
color: "#fff",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
fontWeight: 700,
|
|
boxShadow: `0 4px 10px ${avatarColor}40`,
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
{avatarText}
|
|
</div>
|
|
<div style={{ minWidth: 0, flex: 1 }}>
|
|
<div
|
|
style={{
|
|
fontSize: 15,
|
|
fontWeight: 600,
|
|
color: "var(--ss-text-main)",
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
whiteSpace: "nowrap",
|
|
}}
|
|
>
|
|
{item.name}
|
|
</div>
|
|
<div style={{ marginTop: 4, display: "flex", flexWrap: "wrap", gap: 6 }}>
|
|
{item.score !== undefined && (
|
|
<Tag color={item.score >= 0 ? "success" : "error"} style={{ margin: 0 }}>
|
|
{t("board.metrics.totalScore")}:{" "}
|
|
{item.score > 0 ? `+${item.score}` : item.score}
|
|
</Tag>
|
|
)}
|
|
{item.rewardPoints !== undefined && (
|
|
<Tag color="processing" style={{ margin: 0 }}>
|
|
{t("board.metrics.rewardPoints")}: {item.rewardPoints}
|
|
</Tag>
|
|
)}
|
|
{item.weekChange !== undefined && (
|
|
<Tag
|
|
color={item.weekChange >= 0 ? "success" : "error"}
|
|
style={{ margin: 0 }}
|
|
>
|
|
{t("board.metrics.weekChange")}:{" "}
|
|
{item.weekChange > 0 ? `+${item.weekChange}` : item.weekChange}
|
|
</Tag>
|
|
)}
|
|
{item.weekDeducted !== undefined && (
|
|
<Tag color="gold" style={{ margin: 0 }}>
|
|
{t("board.metrics.weekDeducted")}: {item.weekDeducted}
|
|
</Tag>
|
|
)}
|
|
{item.answeredCount !== undefined && (
|
|
<Tag color="cyan" style={{ margin: 0 }}>
|
|
{t("board.metrics.todayAnswered")}: {item.answeredCount}
|
|
</Tag>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Card>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const renderLeafPanel = (board: BoardConfig, leaf: LayoutLeafNode): React.JSX.Element => {
|
|
const list = board.lists.find((item) => item.id === leaf.listId)
|
|
if (!list) return <Empty description={t("common.noData")} />
|
|
const isSelected = selectedLeafNodeId === leaf.id
|
|
|
|
return (
|
|
<Card
|
|
onClick={() => setSelectedLeafNodeId(leaf.id)}
|
|
style={{
|
|
height: "100%",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
overflow: "hidden",
|
|
backgroundColor: "var(--ss-card-bg)",
|
|
border: isSelected
|
|
? "1px solid var(--ant-color-primary, #1677ff)"
|
|
: "1px solid var(--ss-border-color)",
|
|
boxShadow: isSelected ? "0 8px 18px rgba(22, 119, 255, 0.14)" : undefined,
|
|
cursor: "pointer",
|
|
}}
|
|
styles={{
|
|
body: { flex: 1, minHeight: 0, display: "flex", flexDirection: "column", padding: 12 },
|
|
}}
|
|
title={<span style={{ fontWeight: 600 }}>{list.name}</span>}
|
|
extra={
|
|
<Space size={6}>
|
|
<Button
|
|
size="small"
|
|
type="text"
|
|
shape="circle"
|
|
onClick={() => setEditingListId(list.id)}
|
|
icon={<EditOutlined />}
|
|
aria-label={t("board.editList")}
|
|
title={t("board.editList")}
|
|
/>
|
|
<Popconfirm
|
|
title={t("board.removeListConfirm")}
|
|
onConfirm={() => removeList(board.id, list.id)}
|
|
disabled={!canManage || board.lists.length <= 1}
|
|
>
|
|
<Button
|
|
size="small"
|
|
type="text"
|
|
shape="circle"
|
|
danger
|
|
icon={<DeleteOutlined />}
|
|
disabled={!canManage || board.lists.length <= 1}
|
|
aria-label={t("board.removeList")}
|
|
title={t("board.removeList")}
|
|
/>
|
|
</Popconfirm>
|
|
</Space>
|
|
}
|
|
>
|
|
{errorMap[list.id] && (
|
|
<Alert style={{ marginBottom: 12 }} type="error" message={errorMap[list.id]} showIcon />
|
|
)}
|
|
<div style={{ flex: 1, overflowY: "auto", overflowX: "hidden" }}>
|
|
{renderStudentView(list)}
|
|
</div>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
const renderLayoutNode = (board: BoardConfig, node: LayoutNode): React.JSX.Element => {
|
|
if (node.type === "leaf") {
|
|
return (
|
|
<div style={{ width: "100%", height: "100%", minHeight: 120 }}>
|
|
{renderLeafPanel(board, node)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const isHorizontal = node.direction === "horizontal"
|
|
const handleSize = 8
|
|
|
|
return (
|
|
<div
|
|
ref={(el) => {
|
|
panelRefs.current[node.id] = el
|
|
}}
|
|
style={{
|
|
width: "100%",
|
|
height: "100%",
|
|
display: "flex",
|
|
flexDirection: isHorizontal ? "row" : "column",
|
|
minHeight: 200,
|
|
}}
|
|
>
|
|
<div style={{ flex: `${node.ratio} 1 0`, minWidth: 0, minHeight: 0 }}>
|
|
{renderLayoutNode(board, node.first)}
|
|
</div>
|
|
<div
|
|
onMouseDown={(event) => {
|
|
if (!canManage) return
|
|
const container = panelRefs.current[node.id]
|
|
if (!container) return
|
|
const rect = container.getBoundingClientRect()
|
|
const containerSize = isHorizontal ? rect.width : rect.height
|
|
if (containerSize <= 0) return
|
|
|
|
setDragState({
|
|
boardId: board.id,
|
|
splitNodeId: node.id,
|
|
direction: node.direction,
|
|
startRatio: node.ratio,
|
|
startX: event.clientX,
|
|
startY: event.clientY,
|
|
containerSize,
|
|
})
|
|
}}
|
|
style={{
|
|
flex: `0 0 ${handleSize}px`,
|
|
cursor: canManage ? (isHorizontal ? "col-resize" : "row-resize") : "default",
|
|
background: "var(--ss-border-color)",
|
|
borderRadius: 4,
|
|
opacity: canManage ? 0.85 : 0.5,
|
|
margin: isHorizontal ? "0 4px" : "4px 0",
|
|
}}
|
|
title={t("board.dragHandle")}
|
|
/>
|
|
<div style={{ flex: `${1 - node.ratio} 1 0`, minWidth: 0, minHeight: 0 }}>
|
|
{renderLayoutNode(board, node.second)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const renderBoardWorkspace = () => {
|
|
if (!activeBoard) return <Empty description={t("common.noData")} />
|
|
|
|
return (
|
|
<div style={{ flex: 1, minHeight: 0, overflow: "hidden" }}>
|
|
{renderLayoutNode(activeBoard, activeBoard.layout)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div
|
|
style={{
|
|
padding: 24,
|
|
height: "100%",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
overflow: "hidden",
|
|
boxSizing: "border-box",
|
|
}}
|
|
>
|
|
{contextHolder}
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: 16,
|
|
width: "100%",
|
|
minHeight: 0,
|
|
flex: 1,
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
width: "100%",
|
|
justifyContent: "space-between",
|
|
alignItems: "center",
|
|
}}
|
|
>
|
|
<Space align="center" size={8}>
|
|
<Typography.Title level={2} style={{ margin: 0, color: "var(--ss-text-main)" }}>
|
|
{t("board.title")}
|
|
</Typography.Title>
|
|
<Tag color={canManage ? "success" : "default"}>
|
|
{canManage ? t("board.editable") : t("board.readonly")}
|
|
</Tag>
|
|
</Space>
|
|
<Space>
|
|
<Button
|
|
icon={<PlusOutlined />}
|
|
onClick={() => splitSelectedLeaf("horizontal")}
|
|
disabled={!canManage || !activeBoard || !selectedLeafNodeId}
|
|
>
|
|
{t("board.splitHorizontal")}
|
|
</Button>
|
|
<Button
|
|
icon={<PlusOutlined />}
|
|
onClick={() => splitSelectedLeaf("vertical")}
|
|
disabled={!canManage || !activeBoard || !selectedLeafNodeId}
|
|
>
|
|
{t("board.splitVertical")}
|
|
</Button>
|
|
<Button
|
|
icon={<EditOutlined />}
|
|
onClick={openRenameModal}
|
|
disabled={!canManage || !activeBoard}
|
|
>
|
|
{t("board.renameBoard")}
|
|
</Button>
|
|
<Popconfirm
|
|
title={t("board.removeBoardConfirm")}
|
|
onConfirm={() => activeBoard && removeBoard(activeBoard.id)}
|
|
disabled={!canManage || !activeBoard}
|
|
>
|
|
<Button danger icon={<DeleteOutlined />} disabled={!canManage || !activeBoard}>
|
|
{t("board.removeBoard")}
|
|
</Button>
|
|
</Popconfirm>
|
|
<Button icon={<ReloadOutlined />} loading={loading} onClick={fetchBoards}>
|
|
{t("common.refresh")}
|
|
</Button>
|
|
<Button type="primary" icon={<PlusOutlined />} onClick={addBoard} disabled={!canManage}>
|
|
{t("board.addBoard")}
|
|
</Button>
|
|
</Space>
|
|
</div>
|
|
|
|
<Tabs
|
|
type="card"
|
|
activeKey={activeBoardId}
|
|
onChange={setActiveBoardId}
|
|
items={boards.map((board) => ({ key: board.id, label: board.name }))}
|
|
style={{ marginBottom: 0 }}
|
|
/>
|
|
|
|
{activeBoard ? (
|
|
<div
|
|
style={{
|
|
width: "100%",
|
|
minHeight: 0,
|
|
flex: 1,
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: 8,
|
|
}}
|
|
>
|
|
{saving && (
|
|
<Typography.Text type="secondary" style={{ flexShrink: 0 }}>
|
|
{t("board.saving")}
|
|
</Typography.Text>
|
|
)}
|
|
{renderBoardWorkspace()}
|
|
</div>
|
|
) : (
|
|
<Card style={{ flex: 1 }}>
|
|
<Empty description={t("common.noData")} />
|
|
</Card>
|
|
)}
|
|
</div>
|
|
|
|
<Modal
|
|
title={t("board.renameBoard")}
|
|
open={renameVisible}
|
|
onCancel={() => setRenameVisible(false)}
|
|
onOk={submitRenameBoard}
|
|
okText={t("common.confirm")}
|
|
cancelText={t("common.cancel")}
|
|
>
|
|
<Input
|
|
value={renameValue}
|
|
onChange={(e) => setRenameValue(e.target.value)}
|
|
placeholder={t("board.boardNamePlaceholder")}
|
|
/>
|
|
</Modal>
|
|
|
|
<Modal
|
|
title={t("board.sqlEditorTitle")}
|
|
open={Boolean(editingList)}
|
|
onCancel={() => setEditingListId(null)}
|
|
onOk={() => {
|
|
if (editingList && activeBoard && canManage) {
|
|
const nextSql = trimSqlTailSemicolon(editingList.sql)
|
|
if (nextSql !== editingList.sql) {
|
|
updateList(activeBoard.id, editingList.id, { sql: nextSql })
|
|
}
|
|
}
|
|
setEditingListId(null)
|
|
}}
|
|
okText={t("common.confirm")}
|
|
cancelText={t("common.cancel")}
|
|
width={860}
|
|
>
|
|
{editingList && activeBoard && (
|
|
<Space direction="vertical" style={{ width: "100%" }} size={12}>
|
|
<Input
|
|
value={editingList.name}
|
|
onChange={(e) => updateList(activeBoard.id, editingList.id, { name: e.target.value })}
|
|
placeholder={t("board.listNamePlaceholder")}
|
|
disabled={!canManage}
|
|
/>
|
|
<Space>
|
|
<Select
|
|
style={{ width: 260 }}
|
|
placeholder={t("board.applyPreset")}
|
|
options={presets.map((preset) => ({
|
|
value: preset.id,
|
|
label: `${preset.name} · ${preset.description}`,
|
|
}))}
|
|
onChange={(presetId) => applyPreset(activeBoard.id, editingList.id, presetId)}
|
|
disabled={!canManage}
|
|
/>
|
|
<Select
|
|
style={{ width: 160 }}
|
|
value={editingList.viewMode}
|
|
onChange={(viewMode: BoardStudentViewMode) =>
|
|
updateList(activeBoard.id, editingList.id, { viewMode })
|
|
}
|
|
options={[
|
|
{ value: "list", label: t("board.viewModes.list") },
|
|
{ value: "card", label: t("board.viewModes.card") },
|
|
{ value: "grid", label: t("board.viewModes.grid") },
|
|
]}
|
|
disabled={!canManage}
|
|
/>
|
|
</Space>
|
|
<Input.TextArea
|
|
value={editingList.sql}
|
|
autoSize={{ minRows: 10, maxRows: 18 }}
|
|
onChange={(e) => updateList(activeBoard.id, editingList.id, { sql: e.target.value })}
|
|
disabled={!canManage}
|
|
spellCheck={false}
|
|
placeholder={t("board.sqlPlaceholder")}
|
|
style={{
|
|
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
|
|
}}
|
|
/>
|
|
<Typography.Link href="https://doubao.com/bot/uEh3mtxq" target="_blank" rel="noreferrer">
|
|
豆包智能体一句话生成查询
|
|
</Typography.Link>
|
|
<Typography.Link
|
|
href="https://www.coze.cn/store/agent/7620447018255384610?bot_id=true"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
>
|
|
扣子智能体一句话生成查询
|
|
</Typography.Link>
|
|
</Space>
|
|
)}
|
|
</Modal>
|
|
</div>
|
|
)
|
|
}
|