mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-21 18:19:03 +08:00
新增看板模块:支持多看板多SQL学生列表与预设模板
This commit is contained in:
@@ -69,6 +69,7 @@ function MainContent(): React.JSX.Element {
|
||||
if (p === "/" || p.startsWith("/home")) return "home"
|
||||
if (p.startsWith("/students")) return "students"
|
||||
if (p.startsWith("/score")) return "score"
|
||||
if (p.startsWith("/boards")) return "boards"
|
||||
if (p.startsWith("/leaderboard")) return "leaderboard"
|
||||
if (p.startsWith("/settlements")) return "settlements"
|
||||
if (p.startsWith("/reasons")) return "reasons"
|
||||
@@ -269,6 +270,7 @@ function MainContent(): React.JSX.Element {
|
||||
if (key === "home") navigate("/")
|
||||
if (key === "students") navigate("/students")
|
||||
if (key === "score") navigate("/score")
|
||||
if (key === "boards") navigate("/boards")
|
||||
if (key === "leaderboard") navigate("/leaderboard")
|
||||
if (key === "settlements") navigate("/settlements")
|
||||
if (key === "reasons") navigate("/reasons")
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -14,6 +14,7 @@ const loadLeaderboard = () => import("./Leaderboard")
|
||||
const loadSettlementHistory = () => import("./SettlementHistory")
|
||||
const loadAutoScoreManager = () => import("./AutoScoreManager")
|
||||
const loadRewardSettings = () => import("./RewardSettings")
|
||||
const loadBoardManager = () => import("./BoardManager")
|
||||
|
||||
const Home = lazy(() => loadHome().then((m) => ({ default: m.Home })))
|
||||
const StudentManager = lazy(() => loadStudentManager().then((m) => ({ default: m.StudentManager })))
|
||||
@@ -30,6 +31,7 @@ const AutoScoreManager = lazy(() =>
|
||||
const RewardSettings = lazy(() =>
|
||||
loadRewardSettings().then((m) => ({ default: m.RewardSettings }))
|
||||
)
|
||||
const BoardManager = lazy(() => loadBoardManager().then((m) => ({ default: m.BoardManager })))
|
||||
|
||||
const warmupRouteChunks = () =>
|
||||
Promise.allSettled([
|
||||
@@ -42,6 +44,7 @@ const warmupRouteChunks = () =>
|
||||
loadSettlementHistory(),
|
||||
loadAutoScoreManager(),
|
||||
loadRewardSettings(),
|
||||
loadBoardManager(),
|
||||
])
|
||||
|
||||
const { Content } = Layout
|
||||
@@ -236,6 +239,7 @@ export function ContentArea({
|
||||
path="/score"
|
||||
element={<ScoreManager canEdit={permission === "admin" || permission === "points"} />}
|
||||
/>
|
||||
<Route path="/boards" element={<BoardManager canManage={permission === "admin"} />} />
|
||||
<Route path="/leaderboard" element={<Leaderboard />} />
|
||||
<Route path="/settlements" element={<SettlementHistory />} />
|
||||
<Route path="/reasons" element={<ReasonManager canEdit={permission === "admin"} />} />
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
CloudOutlined,
|
||||
UploadOutlined,
|
||||
AppstoreAddOutlined,
|
||||
ApartmentOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { useState, useEffect } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
@@ -173,6 +174,11 @@ export function Sidebar({
|
||||
label: t("sidebar.rewardSettings"),
|
||||
disabled: permission !== "admin",
|
||||
},
|
||||
{
|
||||
key: "boards",
|
||||
icon: <ApartmentOutlined />,
|
||||
label: t("sidebar.boards"),
|
||||
},
|
||||
{
|
||||
key: "leaderboard",
|
||||
icon: <UnorderedListOutlined />,
|
||||
|
||||
@@ -306,6 +306,7 @@
|
||||
"home": "Home",
|
||||
"students": "Students",
|
||||
"score": "Score",
|
||||
"boards": "Boards",
|
||||
"leaderboard": "Leaderboard",
|
||||
"settlements": "Settlements",
|
||||
"reasons": "Reasons",
|
||||
@@ -558,6 +559,48 @@
|
||||
"deleteFailed": "Delete reward failed",
|
||||
"deleteConfirm": "Delete reward \"{{name}}\"?"
|
||||
},
|
||||
"board": {
|
||||
"title": "Boards",
|
||||
"editable": "Editable",
|
||||
"readonly": "Read-only",
|
||||
"addBoard": "Add Board",
|
||||
"removeBoard": "Delete Board",
|
||||
"removeBoardConfirm": "Delete current board?",
|
||||
"boardConfig": "Board Config",
|
||||
"boardNamePlaceholder": "Enter board name",
|
||||
"newBoard": "New Board",
|
||||
"untitledBoard": "Untitled Board",
|
||||
"addList": "Add Student List",
|
||||
"removeList": "Delete List",
|
||||
"removeListConfirm": "Delete current student list?",
|
||||
"listNamePlaceholder": "Enter list name",
|
||||
"newList": "New List",
|
||||
"applyPreset": "Apply Preset",
|
||||
"run": "Run SQL",
|
||||
"runAll": "Run All",
|
||||
"sqlPlaceholder": "Enter SQL (single SELECT / WITH query only)",
|
||||
"templateHint": "Template variables: {{today_start}}, {{since_7d}}, {{since_30d}}, {{now}}",
|
||||
"templateDescription": "Use template variables directly in SQL. They will be replaced with ISO timestamps at runtime for both SQLite and PostgreSQL.",
|
||||
"saving": "Saving...",
|
||||
"saveFailed": "Failed to save boards",
|
||||
"runFailed": "Failed to run query",
|
||||
"keepAtLeastOneBoard": "Keep at least one board",
|
||||
"keepAtLeastOneList": "Keep at least one student list",
|
||||
"presets": {
|
||||
"weekLowDeduct": {
|
||||
"name": "Low Deduction (7d)",
|
||||
"description": "Rank students with weekly deduction < 3"
|
||||
},
|
||||
"todayActive": {
|
||||
"name": "Today's Activity",
|
||||
"description": "Sort by today's answer count and score change"
|
||||
},
|
||||
"rewardRanking": {
|
||||
"name": "Reward Ranking",
|
||||
"description": "Sort by reward points and total score"
|
||||
}
|
||||
}
|
||||
},
|
||||
"leaderboard": {
|
||||
"title": "Points Leaderboard",
|
||||
"rank": "Rank",
|
||||
|
||||
@@ -306,6 +306,7 @@
|
||||
"home": "首页",
|
||||
"students": "学生管理",
|
||||
"score": "积分操作",
|
||||
"boards": "看板",
|
||||
"leaderboard": "排行榜",
|
||||
"settlements": "结算历史",
|
||||
"reasons": "理由管理",
|
||||
@@ -558,6 +559,48 @@
|
||||
"deleteFailed": "奖励删除失败",
|
||||
"deleteConfirm": "确认删除奖励「{{name}}」?"
|
||||
},
|
||||
"board": {
|
||||
"title": "看板",
|
||||
"editable": "可编辑",
|
||||
"readonly": "只读",
|
||||
"addBoard": "新增看板",
|
||||
"removeBoard": "删除看板",
|
||||
"removeBoardConfirm": "确认删除当前看板?",
|
||||
"boardConfig": "看板配置",
|
||||
"boardNamePlaceholder": "输入看板名称",
|
||||
"newBoard": "新看板",
|
||||
"untitledBoard": "未命名看板",
|
||||
"addList": "新增学生列表",
|
||||
"removeList": "删除列表",
|
||||
"removeListConfirm": "确认删除当前学生列表?",
|
||||
"listNamePlaceholder": "输入列表名称",
|
||||
"newList": "新列表",
|
||||
"applyPreset": "套用预设",
|
||||
"run": "运行 SQL",
|
||||
"runAll": "运行全部",
|
||||
"sqlPlaceholder": "输入 SQL(仅支持单条 SELECT / WITH 查询)",
|
||||
"templateHint": "支持时间模板变量:{{today_start}}、{{since_7d}}、{{since_30d}}、{{now}}",
|
||||
"templateDescription": "可直接在 SQL 中使用模板变量,运行时会自动替换为 ISO 时间,兼容 SQLite 与 PostgreSQL。",
|
||||
"saving": "保存中...",
|
||||
"saveFailed": "保存看板失败",
|
||||
"runFailed": "查询执行失败",
|
||||
"keepAtLeastOneBoard": "至少保留一个看板",
|
||||
"keepAtLeastOneList": "至少保留一个学生列表",
|
||||
"presets": {
|
||||
"weekLowDeduct": {
|
||||
"name": "上周低扣分排行",
|
||||
"description": "上一周扣分 < 3 的学生积分榜"
|
||||
},
|
||||
"todayActive": {
|
||||
"name": "今日活跃榜",
|
||||
"description": "按今日回答次数与积分变化排序"
|
||||
},
|
||||
"rewardRanking": {
|
||||
"name": "奖励积分榜",
|
||||
"description": "按奖励积分和总积分排序"
|
||||
}
|
||||
}
|
||||
},
|
||||
"leaderboard": {
|
||||
"title": "积分排行榜",
|
||||
"rank": "排名",
|
||||
|
||||
@@ -27,6 +27,7 @@ export type settingsKey =
|
||||
| "auto_score_enabled"
|
||||
| "auto_score_rules"
|
||||
| "current_theme_id"
|
||||
| "dashboards_config"
|
||||
| "pg_connection_string"
|
||||
| "pg_connection_status"
|
||||
|
||||
@@ -40,6 +41,7 @@ export interface settingsSpec {
|
||||
auto_score_enabled: boolean
|
||||
auto_score_rules: any[]
|
||||
current_theme_id: string
|
||||
dashboards_config: any[]
|
||||
pg_connection_string: string
|
||||
pg_connection_status: {
|
||||
connected: boolean
|
||||
@@ -141,6 +143,11 @@ const api = {
|
||||
range: "today" | "week" | "month"
|
||||
}): Promise<{ success: boolean; data: { startTime: string; rows: any[] } }> =>
|
||||
invoke("leaderboard_query", { params }),
|
||||
boardQuerySql: (params: {
|
||||
sql: string
|
||||
limit?: number
|
||||
}): Promise<{ success: boolean; data: any[]; message?: string }> =>
|
||||
invoke("board_query_sql", { params }),
|
||||
|
||||
// Settlement
|
||||
querySettlements: (): Promise<{ success: boolean; data: any[] }> => invoke("db_settlement_query"),
|
||||
|
||||
Reference in New Issue
Block a user