新增看板模块:支持多看板多SQL学生列表与预设模板

This commit is contained in:
JSR
2026-03-20 18:41:45 +08:00
parent ca8068dfff
commit 61f3b24a1c
11 changed files with 1003 additions and 14 deletions
+589
View File
@@ -0,0 +1,589 @@
import React, { useCallback, useEffect, useMemo, useState } from "react"
import {
Alert,
Button,
Card,
Empty,
Input,
Popconfirm,
Select,
Space,
Table,
Tabs,
Tag,
Typography,
message,
} from "antd"
import { DeleteOutlined, PlayCircleOutlined, PlusOutlined, ReloadOutlined } from "@ant-design/icons"
import { useTranslation } from "react-i18next"
interface StudentListConfig {
id: string
name: string
sql: string
}
interface BoardConfig {
id: string
name: string
lists: StudentListConfig[]
}
interface BoardPreset {
id: string
name: string
description: string
sql: string
}
interface BoardManagerProps {
canManage: boolean
}
const makeId = () =>
typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const getDefaultSql = () => `SELECT
name AS student_name,
score,
reward_points
FROM students
ORDER BY score DESC`
const createDefaultList = (): StudentListConfig => ({
id: makeId(),
name: "学生积分榜",
sql: getDefaultSql(),
})
const createDefaultBoard = (): BoardConfig => ({
id: makeId(),
name: "默认看板",
lists: [createDefaultList()],
})
const normalizeBoards = (input: unknown): BoardConfig[] => {
if (!Array.isArray(input)) return [createDefaultBoard()]
const boards: BoardConfig[] = 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(),
}))
.filter((list: StudentListConfig) => list.sql.trim())
: []
return {
id: typeof board?.id === "string" && board.id.trim() ? board.id : makeId(),
name: typeof board?.name === "string" && board.name.trim() ? board.name.trim() : "未命名看板",
lists: lists.length > 0 ? lists : [createDefaultList()],
}
})
.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)
return sql
.split("{{now}}")
.join(formatIso(now))
.split("{{today_start}}")
.join(formatIso(todayStart))
.split("{{since_7d}}")
.join(formatIso(at(-7)))
.split("{{since_30d}}")
.join(formatIso(at(-30)))
}
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 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 saveBoards = useCallback(
async (nextBoards: BoardConfig[]) => {
if (!(window as any).api || !canManage) return
setSaving(true)
try {
const res = await (window as any).api.setSetting("dashboards_config", nextBoards)
if (!res?.success) {
messageApi.error(res?.message || t("board.saveFailed"))
}
} catch {
messageApi.error(t("board.saveFailed"))
} finally {
setSaving(false)
}
},
[canManage, messageApi, t]
)
const fetchBoards = useCallback(async () => {
if (!(window as any).api) return
setLoading(true)
try {
const res = await (window as any).api.getSetting("dashboards_config")
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]
)
useEffect(() => {
fetchBoards()
}, [fetchBoards])
useEffect(() => {
if (!activeBoardId && boards.length > 0) {
setActiveBoardId(boards[0].id)
}
}, [activeBoardId, boards])
useEffect(() => {
if (!activeBoard) return
runAllInBoard(activeBoard).catch(() => void 0)
}, [activeBoardId])
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])
const mutateBoards = (updater: (prev: BoardConfig[]) => BoardConfig[]) => {
setBoards((prev) => {
const next = updater(prev)
if (canManage) {
saveBoards(next).catch(() => void 0)
}
return next
})
}
const updateBoardName = (boardId: string, name: string) => {
mutateBoards((prev) =>
prev.map((board) => (board.id === boardId ? { ...board, name: name || t("board.untitledBoard") } : board))
)
}
const addBoard = () => {
if (!canManage) return
const newBoard: BoardConfig = {
id: makeId(),
name: t("board.newBoard"),
lists: [createDefaultList()],
}
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 addList = (boardId: string) => {
if (!canManage) return
mutateBoards((prev) =>
prev.map((board) =>
board.id === boardId
? {
...board,
lists: [
...board.lists,
{
id: makeId(),
name: t("board.newList"),
sql: getDefaultSql(),
},
],
}
: board
)
)
}
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 {
...board,
lists: board.lists.filter((list) => list.id !== 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,
})
}
return (
<div style={{ padding: 24 }}>
{contextHolder}
<Space direction="vertical" size={16} style={{ width: "100%" }}>
<Space style={{ width: "100%", justifyContent: "space-between" }}>
<Space align="center">
<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={<ReloadOutlined />} loading={loading} onClick={fetchBoards}>
{t("common.refresh")}
</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={addBoard} disabled={!canManage}>
{t("board.addBoard")}
</Button>
</Space>
</Space>
<Alert
type="info"
showIcon
message={t("board.templateHint")}
description={t("board.templateDescription")}
/>
<Tabs
type="card"
activeKey={activeBoardId}
onChange={setActiveBoardId}
items={boards.map((board) => ({
key: board.id,
label: board.name,
}))}
/>
{activeBoard ? (
<Space direction="vertical" size={16} style={{ width: "100%" }}>
<Card
title={t("board.boardConfig")}
extra={
<Space>
<Button onClick={() => runAllInBoard(activeBoard)} icon={<PlayCircleOutlined />}>
{t("board.runAll")}
</Button>
<Button onClick={() => addList(activeBoard.id)} icon={<PlusOutlined />} disabled={!canManage}>
{t("board.addList")}
</Button>
<Popconfirm
title={t("board.removeBoardConfirm")}
onConfirm={() => removeBoard(activeBoard.id)}
disabled={!canManage}
>
<Button danger icon={<DeleteOutlined />} disabled={!canManage}>
{t("board.removeBoard")}
</Button>
</Popconfirm>
</Space>
}
style={{ backgroundColor: "var(--ss-card-bg)" }}
>
<Input
value={activeBoard.name}
onChange={(e) => updateBoardName(activeBoard.id, e.target.value.trim())}
placeholder={t("board.boardNamePlaceholder")}
disabled={!canManage}
/>
{saving && (
<Typography.Text type="secondary" style={{ display: "block", marginTop: 8 }}>
{t("board.saving")}
</Typography.Text>
)}
</Card>
{activeBoard.lists.map((list) => {
const rows = resultMap[list.id] || []
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),
}))
: []
return (
<Card
key={list.id}
title={
<Input
value={list.name}
onChange={(e) => updateList(activeBoard.id, list.id, { name: e.target.value })}
placeholder={t("board.listNamePlaceholder")}
disabled={!canManage}
/>
}
extra={
<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, list.id, presetId)}
disabled={!canManage}
/>
<Button
type="primary"
icon={<PlayCircleOutlined />}
loading={Boolean(runningIds[list.id])}
onClick={() => runListQuery(list)}
>
{t("board.run")}
</Button>
<Popconfirm
title={t("board.removeListConfirm")}
onConfirm={() => removeList(activeBoard.id, list.id)}
disabled={!canManage}
>
<Button
danger
icon={<DeleteOutlined />}
disabled={!canManage || activeBoard.lists.length <= 1}
>
{t("board.removeList")}
</Button>
</Popconfirm>
</Space>
}
style={{ backgroundColor: "var(--ss-card-bg)" }}
>
<Input.TextArea
value={list.sql}
autoSize={{ minRows: 6, maxRows: 12 }}
onChange={(e) => updateList(activeBoard.id, list.id, { sql: e.target.value })}
disabled={!canManage}
spellCheck={false}
placeholder={t("board.sqlPlaceholder")}
style={{ fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace" }}
/>
{errorMap[list.id] && (
<Alert
style={{ marginTop: 12 }}
type="error"
message={errorMap[list.id]}
showIcon
/>
)}
<div style={{ marginTop: 12 }}>
<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"
/>
</div>
</Card>
)
})}
</Space>
) : (
<Card>
<Empty description={t("common.noData")} />
</Card>
)}
</Space>
</div>
)
}