Files
SecScore/src/components/Leaderboard.tsx
T
Yukino_fox 98afd446c9 feat(auth): 实现 OAuth PKCE 安全流程并优化错误处理
添加 PKCE code_verifier 和 code_challenge 支持以增强 OAuth 安全性
重构 OAuth 回调处理逻辑,添加全局锁防止重复处理
优化错误处理和信息打印,增加调试日志
移除不必要的 setTimeout 包装导出功能
格式化代码以提高可读性
2026-04-12 17:01:32 +08:00

258 lines
7.8 KiB
TypeScript

import React, { useState, useEffect, useCallback } from "react"
import { Table, Tag, Button, Select, Space, Card, message, Modal } from "antd"
import type { ColumnsType } from "antd/es/table"
import { useTranslation } from "react-i18next"
import { EyeOutlined, DownloadOutlined } from "@ant-design/icons"
import * as XLSX from "xlsx"
interface studentRank {
id: number
name: string
score: number
range_change: number
}
export const Leaderboard: React.FC = () => {
const { t } = useTranslation()
const [data, setData] = useState<studentRank[]>([])
const [loading, setLoading] = useState(false)
const [timeRange, setTimeRange] = useState("today")
const [startTime, setStartTime] = useState<string | null>(null)
const [historyVisible, setHistoryVisible] = useState(false)
const [historyHeader, setHistoryHeader] = useState("")
const [historyText, setHistoryText] = useState("")
const [messageApi, contextHolder] = message.useMessage()
const fetchRankings = useCallback(async () => {
if (!(window as any).api) return
setLoading(true)
try {
const res = await (window as any).api.queryLeaderboard({ range: timeRange })
if (res.success && res.data) {
setStartTime(res.data.startTime)
setData(res.data.rows)
}
} catch (e) {
console.error("Failed to fetch rankings:", e)
} finally {
setLoading(false)
}
}, [timeRange])
useEffect(() => {
fetchRankings()
}, [fetchRankings])
useEffect(() => {
const onDataUpdated = (e: any) => {
const category = e?.detail?.category
if (category === "events" || category === "students" || category === "all") fetchRankings()
}
window.addEventListener("ss:data-updated", onDataUpdated as any)
return () => window.removeEventListener("ss:data-updated", onDataUpdated as any)
}, [fetchRankings])
const handleViewHistory = async (studentName: string) => {
if (!(window as any).api) return
const res = await (window as any).api.queryEventsByStudent({
student_name: studentName,
limit: 200,
startTime,
})
if (!res.success) {
messageApi.error(res.message || t("leaderboard.queryFailed"))
return
}
const lines = (res.data || []).map((e: any) => {
const time = new Date(e.event_time).toLocaleString()
const delta = e.delta > 0 ? `+${e.delta}` : String(e.delta)
return `${time} ${delta} ${e.reason_content}`
})
setHistoryHeader(t("leaderboard.historyTitle", { name: studentName }))
setHistoryText(lines.join("\n") || t("common.noData"))
setHistoryVisible(true)
}
const handleExport = () => {
const title =
timeRange === "today"
? t("leaderboard.today")
: timeRange === "week"
? t("leaderboard.week")
: t("leaderboard.month")
const sanitizeCell = (v: unknown) => {
if (typeof v !== "string") return v
if (/^[=+\-@]/.test(v)) return `'${v}`
return v
}
const sheetData = [
[
t("leaderboard.rank"),
t("leaderboard.name"),
t("leaderboard.totalScore"),
`${title}${t("leaderboard.change")}`,
],
...data.map((item, index) => [
index + 1,
sanitizeCell(item.name),
item.score,
item.range_change,
]),
]
const ws = XLSX.utils.aoa_to_sheet(sheetData)
ws["!cols"] = [{ wch: 6 }, { wch: 14 }, { wch: 10 }, { wch: 10 }]
const wb = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(wb, ws, t("leaderboard.title"))
const xlsxBytes = XLSX.write(wb, { bookType: "xlsx", type: "array" })
const blob = new Blob([xlsxBytes], {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
})
const link = document.createElement("a")
const url = URL.createObjectURL(blob)
link.setAttribute("href", url)
link.setAttribute(
"download",
`${t("leaderboard.title")}_${timeRange}_${new Date().toISOString().slice(0, 10)}.xlsx`
)
link.style.visibility = "hidden"
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
messageApi.success(t("leaderboard.exportSuccess"))
}
const columns: ColumnsType<studentRank> = [
{
title: t("leaderboard.rank"),
key: "rank",
width: 70,
align: "center",
render: (_, __, index) => {
const rank = index + 1
let color = "inherit"
if (rank === 1) color = "#FFD700"
if (rank === 2) color = "#C0C0C0"
if (rank === 3) color = "#CD7F32"
return (
<span style={{ fontWeight: "bold", color, fontSize: rank <= 3 ? "18px" : "14px" }}>
{rank}
</span>
)
},
},
{ title: t("leaderboard.name"), dataIndex: "name", key: "name", width: 120, align: "center" },
{
title: t("leaderboard.totalScore"),
dataIndex: "score",
key: "score",
width: 100,
align: "center",
render: (score: number) => <span style={{ fontWeight: "bold" }}>{score}</span>,
},
{
title:
timeRange === "today"
? t("leaderboard.todayChange")
: timeRange === "week"
? t("leaderboard.weekChange")
: t("leaderboard.monthChange"),
dataIndex: "range_change",
key: "range_change",
width: 100,
align: "center",
render: (change: number) => (
<Tag color={change > 0 ? "success" : change < 0 ? "error" : "default"}>
{change > 0 ? `+${change}` : change}
</Tag>
),
},
{
title: t("leaderboard.operationRecord"),
key: "operation",
width: 100,
align: "center",
render: (_, row) => (
<Button type="link" icon={<EyeOutlined />} onClick={() => handleViewHistory(row.name)}>
{t("leaderboard.viewHistory")}
</Button>
),
},
]
return (
<div style={{ padding: "24px" }}>
{contextHolder}
<div
style={{
marginBottom: "24px",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<h2 style={{ margin: 0, color: "var(--ss-text-main)" }}>{t("leaderboard.title")}</h2>
<Space>
<Select
value={timeRange}
onChange={(v) => setTimeRange(v)}
style={{ width: "120px" }}
options={[
{ value: "today", label: t("leaderboard.today") },
{ value: "week", label: t("leaderboard.week") },
{ value: "month", label: t("leaderboard.month") },
]}
/>
<Button icon={<DownloadOutlined />} onClick={handleExport}>
{t("leaderboard.exportXlsx")}
</Button>
</Space>
</div>
<Card style={{ backgroundColor: "var(--ss-card-bg)" }}>
<Table
dataSource={data}
columns={columns}
rowKey="id"
loading={loading}
bordered
pagination={{ pageSize: 30, total: data.length, defaultCurrent: 1 }}
style={{ color: "var(--ss-text-main)" }}
/>
</Card>
<Modal
title={historyHeader}
open={historyVisible}
onCancel={() => setHistoryVisible(false)}
footer={<Button onClick={() => setHistoryVisible(false)}>{t("common.close")}</Button>}
width="80%"
>
<div
style={{
maxHeight: "420px",
overflowY: "auto",
fontSize: "12px",
fontFamily:
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", "Microsoft YaHei UI", "Microsoft YaHei", "PingFang SC", monospace',
whiteSpace: "pre-wrap",
backgroundColor: "#1e1e1e",
color: "#d4d4d4",
padding: "10px",
}}
>
{historyText}
</div>
</Modal>
</div>
)
}