feat(阶段3): 补齐用户侧依赖的三个 admin 端点

重判、提交统计、流程图统计这三条挂在旧后端的 admin 路由下,权限也确实是
teacher/super admin,但入口在用户侧页面里(提交列表页的重判按钮、两个统计面板)。
不做完,阶段 3 的出口标准「用户侧全部功能跑在新后端上」就不成立 —— 按 URL
前缀切阶段会漏掉它们。

- POST submissions/:id/rejudge     ← GET admin/submission/rejudge
- GET  submissions/statistics      ← GET admin/submission/statistics
- GET  flowcharts/statistics       ← GET admin/flowchart/statistics

几处对齐旧后端的细节:
- AST_CHECK_FAILED(10) 与 ACCEPTED(0) 同算通过
- 完成度先用原始花名册人数算、再修正 person_count,顺序照搬,兜住「学生已删号
  但提交记录还在」
- 有提交但零通过的学生,两个名单里都不出现(旧后端同样口径)
- 词云用 @node-rs/jieba,STOPWORDS 与 38 个自定义词逐词照搬;jieba@2 没有
  insertWord,改用 loadDict 加载用户词典
- avgScore 分母是有分数的条数,对齐 Django Avg() 跳过 NULL

rejudge 的 jobId 带时间戳。队列保留最近 100 个已完成任务,沿用 submissionId
做 jobId 的话 BullMQ 会认为任务已存在,重判会静默变成空操作。

correctRate 改成数值不带 %,展示格式化交给前端。stripClassPrefix 用
startsWith+slice 而不是 replace,前缀对不上时不会从中间截出乱码;site.ts
原有的 replace 写法一并改掉。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 06:48:05 -06:00
parent cb6d42363b
commit 836d97847e
12 changed files with 787 additions and 233 deletions

View File

@@ -2,9 +2,10 @@ import {
createSubmissionResponseSchema,
problemDetailSchema,
submissionDetailSchema,
type FlowchartStatistics,
type SubmissionStatistics,
} from "@oj2/contract"
import api2 from "utils/api2"
import http from "utils/http"
import type { ApiResponse } from "utils/http"
import { filterResult } from "oj/transforms"
import type {
@@ -30,7 +31,9 @@ function toLegacy<T>(value: unknown): T {
) as T
}
async function legacyResponse<T>(request: Promise<ApiResponse<unknown>>): Promise<ApiResponse<T>> {
async function legacyResponse<T>(
request: Promise<ApiResponse<unknown>>,
): Promise<ApiResponse<T>> {
const response = await request
return { error: response.error, data: toLegacy<T>(response.data) }
}
@@ -117,11 +120,13 @@ export async function getProblemList(
}
export function getAuthors(all = false) {
return legacyResponse(api2.get("problem-authors", {
params: {
all: all ? "1" : "0",
},
}))
return legacyResponse(
api2.get("problem-authors", {
params: {
all: all ? "1" : "0",
},
}),
)
}
export function getRandomProblemID() {
@@ -132,9 +137,7 @@ export async function getProblem(problemID: string, contestID: string) {
const endpoint = contestID
? `contests/${encodeURIComponent(contestID)}/problems/${encodeURIComponent(problemID)}`
: `problems/${encodeURIComponent(problemID)}`
const response = await api2.get<unknown>(
endpoint,
)
const response = await api2.get<unknown>(endpoint)
return { error: null, data: detailProblem(response.data) }
}
@@ -200,17 +203,23 @@ export function getSubmissions(params: Partial<SubmissionListPayload>) {
const endpoint = params.contest_id
? `contests/${encodeURIComponent(params.contest_id)}/submissions`
: "submissions"
return legacyResponse(api2.get(endpoint, { params: {
...params,
problemId: params.problem_id,
contest_id: undefined,
problem_id: undefined,
page: undefined,
} }))
return legacyResponse(
api2.get(endpoint, {
params: {
...params,
problemId: params.problem_id,
contest_id: undefined,
problem_id: undefined,
page: undefined,
},
}),
)
}
export function getRankOfProblem(problem_id: string) {
return legacyResponse(api2.get(`problems/${encodeURIComponent(problem_id)}/rank`))
return legacyResponse(
api2.get(`problems/${encodeURIComponent(problem_id)}/rank`),
)
}
export function getTodaySubmissionCount(language?: string) {
@@ -218,9 +227,7 @@ export function getTodaySubmissionCount(language?: string) {
}
export function adminRejudge(id: string) {
return http.get("admin/submission/rejudge", {
params: { id },
})
return api2.post(`submissions/${encodeURIComponent(id)}/rejudge`)
}
export function getSubmissionStatistics(
@@ -228,12 +235,8 @@ export function getSubmissionStatistics(
problemID?: string,
username?: string,
) {
return http.get("admin/submission/statistics", {
params: {
...duration,
problem_id: problemID,
username,
},
return api2.get<SubmissionStatistics>("submissions/statistics", {
params: { ...duration, problemId: problemID, username },
})
}
@@ -243,9 +246,11 @@ export function getRank(
n: number,
username?: string,
) {
return legacyResponse(api2.get("rankings/users", {
params: { offset, limit, username, top: n },
}))
return legacyResponse(
api2.get("rankings/users", {
params: { offset, limit, username, top: n },
}),
)
}
export function getActivityRank(start: string) {
@@ -255,9 +260,11 @@ export function getActivityRank(start: string) {
}
export function getClassRank(grade?: number | null) {
return legacyResponse(api2.get("rankings/classes", {
params: { grade },
}))
return legacyResponse(
api2.get("rankings/classes", {
params: { grade },
}),
)
}
export function getUserClassRank(
@@ -265,7 +272,9 @@ export function getUserClassRank(
offset?: number,
limit?: number,
) {
return legacyResponse(api2.get("me/class-rank", { params: { scope, offset, limit } }))
return legacyResponse(
api2.get("me/class-rank", { params: { scope, offset, limit } }),
)
}
export function getClassPK(
@@ -304,11 +313,15 @@ export function getContestAccess(id: string) {
}
export function checkContestPassword(contestID: string, password: string) {
return api2.post(`contests/${encodeURIComponent(contestID)}/access`, { password })
return api2.post(`contests/${encodeURIComponent(contestID)}/access`, {
password,
})
}
export async function getContestProblems(contestID: string) {
const res = await api2.get<any[]>(`contests/${encodeURIComponent(contestID)}/problems`)
const res = await api2.get<any[]>(
`contests/${encodeURIComponent(contestID)}/problems`,
)
return res.data.map(listProblem).map(filterResult)
}
@@ -316,17 +329,20 @@ export function getContestRank(
contestID: string,
query: { limit: number; offset: number },
) {
return legacyResponse<any>(api2.get(`contests/${encodeURIComponent(contestID)}/rank`, { params: query }))
.then((response) => ({
...response,
data: {
...response.data,
results: response.data.results.map((item: any) => ({
...item,
contest: item.contest_id,
})),
},
}))
return legacyResponse<any>(
api2.get(`contests/${encodeURIComponent(contestID)}/rank`, {
params: query,
}),
).then((response) => ({
...response,
data: {
...response.data,
results: response.data.results.map((item: any) => ({
...item,
contest: item.contest_id,
})),
},
}))
}
export function uploadAvatar(file: File) {
@@ -338,14 +354,18 @@ export function uploadAvatar(file: File) {
}
export function updateProfile(data: { real_name: string; mood: string }) {
return legacyResponse(api2.put("me/profile", {
realName: data.real_name,
mood: data.mood,
}))
return legacyResponse(
api2.put("me/profile", {
realName: data.real_name,
mood: data.mood,
}),
)
}
export function getAnnouncementList(offset = 0, limit = 10) {
return legacyResponse(api2.get("announcements", { params: { limit, offset } }))
return legacyResponse(
api2.get("announcements", { params: { limit, offset } }),
)
}
export function getAnnouncement(id: number) {
@@ -394,17 +414,19 @@ export function getTutorials(type: "python" | "c") {
}
export function getAIDetailData(start: string, end: string, username?: string) {
return legacyResponse<any>(api2.get("ai/detail", { params: { start, end, username } }))
.then((response) => ({
...response,
data: {
...response.data,
flowcharts: response.data.flowcharts?.map((item: any) => ({
return legacyResponse<any>(
api2.get("ai/detail", { params: { start, end, username } }),
).then((response) => ({
...response,
data: {
...response.data,
flowcharts:
response.data.flowcharts?.map((item: any) => ({
...item,
problem__id: item.problem_id,
})) ?? [],
},
}))
},
}))
}
export function getAIDurationData(
@@ -412,7 +434,9 @@ export function getAIDurationData(
duration: string,
username?: string,
) {
return legacyResponse(api2.get("ai/duration", { params: { end, duration, username } }))
return legacyResponse(
api2.get("ai/duration", { params: { end, duration, username } }),
)
}
export function getAIHeatmapData(username?: string) {
@@ -430,8 +454,12 @@ export function getAIPinnedReport() {
// ==================== 相似题目推荐 ====================
export function getSimilarProblems(problemId: string) {
return api2.get<any[]>(`problems/${encodeURIComponent(problemId)}/similar`)
.then((response) => ({ ...response, data: response.data.map(listProblem).map(filterResult) }))
return api2
.get<any[]>(`problems/${encodeURIComponent(problemId)}/similar`)
.then((response) => ({
...response,
data: response.data.map(listProblem).map(filterResult),
}))
}
export interface YearlyACData {
@@ -442,7 +470,9 @@ export interface YearlyACData {
}
export function getProblemYearlyAC(problemId: string) {
return legacyResponse<YearlyACData[]>(api2.get(`problems/${encodeURIComponent(problemId)}/yearly-ac`))
return legacyResponse<YearlyACData[]>(
api2.get(`problems/${encodeURIComponent(problemId)}/yearly-ac`),
)
}
// ==================== 流程图相关API ====================
@@ -452,11 +482,13 @@ export function submitFlowchart(data: {
mermaid_code: string
flowchart_data: any // 这个是压缩之后的,元数据太长了
}) {
return legacyResponse(api2.post("flowcharts", {
problemId: data.problem_id,
mermaidCode: data.mermaid_code,
flowchartData: data.flowchart_data,
}))
return legacyResponse(
api2.post("flowcharts", {
problemId: data.problem_id,
mermaidCode: data.mermaid_code,
flowchartData: data.flowchart_data,
}),
)
}
function legacyFlowchart(value: unknown) {
@@ -482,11 +514,15 @@ export function getFlowchartSubmissions(params: {
today?: string
grade?: string
}) {
return legacyResponse<any>(api2.get("flowcharts", { params: {
...params,
problemId: params.problem_id,
problem_id: undefined,
} }))
return legacyResponse<any>(
api2.get("flowcharts", {
params: {
...params,
problemId: params.problem_id,
problem_id: undefined,
},
}),
)
}
export function getFlowchartStatistics(
@@ -494,30 +530,36 @@ export function getFlowchartStatistics(
problemID?: string,
username?: string,
) {
return http.get("admin/flowchart/statistics", {
params: {
...duration,
problem_id: problemID,
username,
},
return api2.get<FlowchartStatistics>("flowcharts/statistics", {
params: { ...duration, problemId: problemID, username },
})
}
export function retryFlowchartSubmission(submissionId: string) {
return legacyResponse(api2.post(`flowcharts/${encodeURIComponent(submissionId)}/retry`))
return legacyResponse(
api2.post(`flowcharts/${encodeURIComponent(submissionId)}/retry`),
)
}
export function getCurrentProblemFlowchartSubmission(problemId: number) {
return api2.get(`problems/${problemId}/flowchart/current`)
}
export async function getFlowchartSubmissionDetail(problemId: number, page = 0) {
const response = await api2.get<any>(`problems/${problemId}/flowchart/history`, { params: { page } })
export async function getFlowchartSubmissionDetail(
problemId: number,
page = 0,
) {
const response = await api2.get<any>(
`problems/${problemId}/flowchart/history`,
{ params: { page } },
)
return {
...response,
data: {
...response.data,
submission: response.data.submission ? legacyFlowchart(response.data.submission) : null,
submission: response.data.submission
? legacyFlowchart(response.data.submission)
: null,
},
}
}
@@ -531,22 +573,26 @@ export function getProblemSetList(
difficulty = "",
status = "",
) {
return legacyResponse<any>(api2.get("problem-sets", {
params: {
offset,
limit,
keyword,
difficulty,
status,
},
})).then(mapProblemSetResponse)
return legacyResponse<any>(
api2.get("problem-sets", {
params: {
offset,
limit,
keyword,
difficulty,
status,
},
}),
).then(mapProblemSetResponse)
}
export function getProblemSetDetail(id: number) {
return legacyResponse<any>(api2.get(`problem-sets/${id}`)).then((response) => ({
...response,
data: legacyProblemSet(response.data),
}))
return legacyResponse<any>(api2.get(`problem-sets/${id}`)).then(
(response) => ({
...response,
data: legacyProblemSet(response.data),
}),
)
}
function legacyBadge(value: any) {
@@ -571,7 +617,9 @@ function mapProblemSetResponse(response: ApiResponse<any>) {
}
export async function getProblemSetProblems(problemSetId: number) {
const response = await legacyResponse<any[]>(api2.get(`problem-sets/${problemSetId}/problems`))
const response = await legacyResponse<any[]>(
api2.get(`problem-sets/${problemSetId}/problems`),
)
return {
...response,
data: response.data.map((item) => ({
@@ -594,19 +642,20 @@ export function updateProblemSetProgress(
problemId: number,
submissionId: string,
) {
return legacyResponse(api2.put("problem-set-progress", {
problemSetId,
problemId,
submissionId,
})
return legacyResponse(
api2.put("problem-set-progress", {
problemSetId,
problemId,
submissionId,
}),
)
}
// 获取用户徽章列表
export async function getUserBadges(username?: string) {
const response = await legacyResponse<any[]>(api2.get(
`users/${encodeURIComponent(username ?? "me")}/badges`,
))
const response = await legacyResponse<any[]>(
api2.get(`users/${encodeURIComponent(username ?? "me")}/badges`),
)
return {
...response,
data: response.data.map((item) => ({
@@ -619,7 +668,9 @@ export async function getUserBadges(username?: string) {
// 获取题单徽章列表
export async function getProblemSetBadges(problemSetId: number) {
const response = await legacyResponse<any[]>(api2.get(`problem-sets/${problemSetId}/badges`))
const response = await legacyResponse<any[]>(
api2.get(`problem-sets/${problemSetId}/badges`),
)
return { ...response, data: response.data.map(legacyBadge) }
}
@@ -633,14 +684,16 @@ export function getProblemSetUserProgress(
completion_status?: "" | "completed" | "in_progress" | "not_started"
},
) {
return legacyResponse(api2.get(`problem-sets/${problemSetId}/user-progress`, {
params: {
limit: params?.limit,
offset: params?.offset,
className: params?.class_name,
completionStatus: params?.completion_status,
},
}))
return legacyResponse(
api2.get(`problem-sets/${problemSetId}/user-progress`, {
params: {
limit: params?.limit,
offset: params?.offset,
className: params?.class_name,
completionStatus: params?.completion_status,
},
}),
)
}
export async function getExercises(tutorialId: number): Promise<Exercise[]> {

View File

@@ -21,37 +21,37 @@
</n-flex>
<n-empty
v-if="data.total_count === 0"
v-if="data.totalCount === 0"
description="暂无数据"
style="margin: 40px 0"
/>
<template v-if="data.total_count > 0">
<template v-if="data.totalCount > 0">
<n-divider style="margin: 16px 0" />
<n-flex justify="space-around">
<div class="stat-item">
<n-text>总提交</n-text>
<n-gradient-text type="info" font-size="28">
{{ data.total_count }}
{{ data.totalCount }}
</n-gradient-text>
</div>
<div class="stat-item">
<n-text>平均分</n-text>
<n-gradient-text type="primary" font-size="28">
{{ data.avg_score }}
{{ data.avgScore }}
</n-gradient-text>
</div>
<template v-if="data.person_count > 0">
<template v-if="data.personCount > 0">
<div class="stat-item">
<n-text>完成人数</n-text>
<n-gradient-text type="error" font-size="28">
{{ data.completed_count }}
{{ data.completedCount }}
</n-gradient-text>
</div>
<div class="stat-item">
<n-text>班级人数</n-text>
<n-gradient-text type="warning" font-size="28">
{{ data.person_count }}
{{ data.personCount }}
</n-gradient-text>
</div>
<div class="stat-item">
@@ -76,7 +76,7 @@
</n-card>
</n-gi>
<!-- 3. Completion doughnut -->
<n-gi v-if="data.person_count > 0">
<n-gi v-if="data.personCount > 0">
<n-card title="班级完成度">
<div class="chart-container">
<Doughnut
@@ -95,7 +95,7 @@
</n-card>
</n-gi>
<!-- 4. Criteria bar chart (only when class exists, pairs with radar) -->
<n-gi v-if="data.person_count > 0 && hasRadarData">
<n-gi v-if="data.personCount > 0 && hasRadarData">
<n-card title="各维度平均得分">
<div class="chart-container">
<Bar :data="criteriaBarChartData" :options="barOptions" />
@@ -103,7 +103,7 @@
</n-card>
</n-gi>
<!-- 4. Word cloud -->
<n-gi :span="2" v-if="data.word_frequencies.length > 0">
<n-gi :span="2" v-if="data.wordFrequencies.length > 0">
<n-card title="常见问题高频词">
<div class="wordcloud-container">
<canvas ref="wordcloudCanvas"></canvas>
@@ -114,7 +114,7 @@
</n-tab-pane>
<n-tab-pane
v-if="data.data_unaccepted.length > 0"
v-if="data.dataUnaccepted.length > 0"
name="unaccepted"
:tab="`未完成(${visibleUnaccepted.length}`"
>
@@ -148,9 +148,9 @@
style="font-size: 20px"
@close="hideStudent(item.username)"
>
{{ item.real_name }}
{{ item.realName }}
</n-tag>
<span v-else style="font-size: 24px">{{ item.real_name }}</span>
<span v-else style="font-size: 24px">{{ item.realName }}</span>
</template>
</n-flex>
</n-tab-pane>
@@ -160,6 +160,7 @@
<script setup lang="ts">
import { formatISO, sub, type Duration } from "date-fns"
import type { FlowchartStatistics } from "@oj2/contract"
import { getFlowchartStatistics } from "oj/api"
import { DURATION_OPTIONS } from "utils/constants"
import { Doughnut, Radar, Bar } from "vue-chartjs"
@@ -216,26 +217,15 @@ const query = reactive({
duration: durationOptions[0].value,
})
interface StatisticsData {
total_count: number
avg_score: number
grade_distribution: Record<string, number>
criteria_averages: Record<string, { avg: number; max: number }>
person_count: number
completed_count: number
word_frequencies: { word: string; count: number }[]
data_unaccepted: { username: string; real_name: string }[]
}
const data = reactive<StatisticsData>({
total_count: 0,
avg_score: 0,
grade_distribution: {},
criteria_averages: {},
person_count: 0,
completed_count: 0,
word_frequencies: [],
data_unaccepted: [],
const data = reactive<FlowchartStatistics>({
totalCount: 0,
avgScore: 0,
gradeDistribution: {},
criteriaAverages: {},
personCount: 0,
completedCount: 0,
wordFrequencies: [],
dataUnaccepted: [],
})
const wordcloudCanvas = useTemplateRef<HTMLCanvasElement>("wordcloudCanvas")
@@ -274,7 +264,7 @@ function showAll() {
const visibleUnaccepted = computed(() => {
const now = Date.now()
return data.data_unaccepted.filter((item) => {
return data.dataUnaccepted.filter((item) => {
const exp = hiddenStudents.value[item.username]
return !exp || exp <= now
})
@@ -282,14 +272,14 @@ const visibleUnaccepted = computed(() => {
const hiddenCount = computed(() => {
const now = Date.now()
return data.data_unaccepted.filter((item) => {
return data.dataUnaccepted.filter((item) => {
const exp = hiddenStudents.value[item.username]
return !!exp && exp > now
}).length
})
const adjustedPersonCount = computed(() =>
Math.max(0, data.person_count - hiddenCount.value),
Math.max(0, data.personCount - hiddenCount.value),
)
onMounted(() => {
@@ -305,7 +295,7 @@ const completionRate = computed(() => {
if (adjustedPersonCount.value <= 0) return "0%"
const rate = Math.min(
100,
(data.completed_count / adjustedPersonCount.value) * 100,
(data.completedCount / adjustedPersonCount.value) * 100,
)
return `${Math.round(rate * 100) / 100}%`
})
@@ -319,10 +309,8 @@ const GRADE_COLORS: Record<string, { bg: string; border: string }> = {
const gradeChartData = computed(() => {
const grades = ["S", "A", "B", "C"]
const counts = grades.map((g) => data.grade_distribution[g] || 0)
const labels = grades.map(
(g) => `${g}级 (${data.grade_distribution[g] || 0})`,
)
const counts = grades.map((g) => data.gradeDistribution[g] || 0)
const labels = grades.map((g) => `${g}级 (${data.gradeDistribution[g] || 0})`)
return {
labels,
datasets: [
@@ -339,13 +327,13 @@ const gradeChartData = computed(() => {
const completionChartData = computed(() => {
const uncompleted = Math.max(
0,
adjustedPersonCount.value - data.completed_count,
adjustedPersonCount.value - data.completedCount,
)
return {
labels: ["已完成", "未完成"],
datasets: [
{
data: [data.completed_count, uncompleted],
data: [data.completedCount, uncompleted],
backgroundColor: ["rgba(106, 176, 76, 0.6)", "rgba(255, 159, 64, 0.6)"],
borderColor: ["rgba(106, 176, 76, 1)", "rgba(255, 159, 64, 1)"],
borderWidth: 2,
@@ -379,13 +367,13 @@ const doughnutOptions = {
const CRITERIA_ORDER = ["逻辑正确性", "完整性", "规范性", "清晰度"]
const hasRadarData = computed(() =>
CRITERIA_ORDER.some((k) => k in data.criteria_averages),
CRITERIA_ORDER.some((k) => k in data.criteriaAverages),
)
const radarChartData = computed(() => {
const labels = CRITERIA_ORDER
const values = CRITERIA_ORDER.map((k) => {
const item = data.criteria_averages[k]
const item = data.criteriaAverages[k]
if (!item) return 0
return Math.round((item.avg / item.max) * 100)
})
@@ -420,7 +408,7 @@ const radarOptions = {
callbacks: {
label(context: any) {
const key = CRITERIA_ORDER[context.dataIndex]
const item = data.criteria_averages[key]
const item = data.criteriaAverages[key]
if (!item) return ""
return `${key}: ${item.avg}/${item.max} (${context.parsed.r}%)`
},
@@ -430,13 +418,13 @@ const radarOptions = {
}
const criteriaBarChartData = computed(() => {
const labels = CRITERIA_ORDER.filter((k) => k in data.criteria_averages)
const labels = CRITERIA_ORDER.filter((k) => k in data.criteriaAverages)
return {
labels,
datasets: [
{
label: "平均得分",
data: labels.map((k) => data.criteria_averages[k]?.avg ?? 0),
data: labels.map((k) => data.criteriaAverages[k]?.avg ?? 0),
backgroundColor: labels.map(
(_, i) => GRADE_COLORS[["S", "A", "B", "C"][i]].bg,
),
@@ -461,7 +449,7 @@ const barOptions = {
callbacks: {
label(context: any) {
const key = context.label
const item = data.criteria_averages[key]
const item = data.criteriaAverages[key]
if (!item) return ""
return `${item.avg} / ${item.max}`
},
@@ -484,14 +472,14 @@ const WORD_COLORS = [
]
function renderWordCloud() {
if (!wordcloudCanvas.value || data.word_frequencies.length === 0) return
if (!wordcloudCanvas.value || data.wordFrequencies.length === 0) return
if (wordcloudChart) {
wordcloudChart.destroy()
wordcloudChart = null
}
const words = data.word_frequencies
const words = data.wordFrequencies
const maxCount = Math.max(...words.map((w) => w.count))
wordcloudChart = new ChartJS(wordcloudCanvas.value, {

View File

@@ -46,9 +46,9 @@
</div>
<div class="stat-item">
<n-text>正确率</n-text>
<n-gradient-text type="warning" font-size="28">{{
count.rate
}}</n-gradient-text>
<n-gradient-text type="warning" font-size="28"
>{{ count.rate }}%</n-gradient-text
>
</div>
<template v-if="person.count > 0">
<div class="stat-item">
@@ -140,9 +140,9 @@
style="font-size: 20px"
@close="hideStudent(item.username)"
>
{{ item.real_name }}
{{ item.realName }}
</n-tag>
<span v-else style="font-size: 24px">{{ item.real_name }}</span>
<span v-else style="font-size: 24px">{{ item.realName }}</span>
</template>
</n-flex>
</n-tab-pane>
@@ -158,7 +158,7 @@ import { Doughnut } from "vue-chartjs"
import { Chart as ChartJS, ArcElement, Title, Tooltip, Legend } from "chart.js"
import { NButton, NFlex, NText, type DataTableRowKey } from "naive-ui"
import { JUDGE_STATUS } from "utils/constants"
import type { SUBMISSION_RESULT } from "utils/types"
import type { SubmissionStatisticsUser, UnacceptedStudent } from "@oj2/contract"
// 注册 Chart.js 组件
ChartJS.register(ArcElement, Title, Tooltip, Legend)
@@ -182,12 +182,12 @@ function openSubmission(id: string) {
window.open(`/submission/${id}`, "_blank", "noopener")
}
const columns: DataTableColumn<UserStatistic>[] = [
const columns: DataTableColumn<SubmissionStatisticsUser>[] = [
{
type: "expand",
renderExpand: (row) => {
return h(NFlex, { size: "small", wrap: true }, () =>
row.submission_items.map((item) =>
row.submissionItems.map((item) =>
h(
NButton,
{
@@ -207,9 +207,14 @@ const columns: DataTableColumn<UserStatistic>[] = [
},
},
{ title: "用户", key: "username" },
{ title: "提交数", key: "submission_count" },
{ title: "已解决", key: "accepted_count" },
{ title: "正确率", key: "correct_rate" },
{ title: "提交数", key: "submissionCount" },
{ title: "已解决", key: "acceptedCount" },
// 新后端返回的是数值,百分号在这里补 —— 旧后端直接返回 "85.5%" 字符串
{
title: "正确率",
key: "correctRate",
render: (row) => `${row.correctRate}%`,
},
]
const query = reactive({
@@ -230,24 +235,8 @@ const person = reactive({
const route = useRoute()
const router = useRouter()
interface UserStatistic {
username: string
submission_count: number
accepted_count: number
correct_rate: string
submission_items: Array<{
id: string
result: SUBMISSION_RESULT
}>
}
interface UnacceptedItem {
username: string
real_name: string
}
const list = ref<UserStatistic[]>([])
const listUnaccepted = ref<UnacceptedItem[]>([])
const list = ref<SubmissionStatisticsUser[]>([])
const listUnaccepted = ref<UnacceptedStudent[]>([])
const expandedRowKeys = ref<DataTableRowKey[]>([])
const HIDE_DURATION = 2 * 60 * 60 * 1000
@@ -432,16 +421,16 @@ async function handleStatistics() {
query.problem,
query.username,
)
count.total = res.data.submission_count
count.accepted = res.data.accepted_count
count.rate = res.data.correct_rate
count.total = res.data.submissionCount
count.accepted = res.data.acceptedCount
count.rate = res.data.correctRate
list.value = res.data.data
listUnaccepted.value = res.data.data_unaccepted
person.count = res.data.person_count
person.rate = res.data.person_rate
listUnaccepted.value = res.data.dataUnaccepted
person.count = res.data.personCount
person.rate = res.data.personRate
}
function rowKey(row: UserStatistic): DataTableRowKey {
function rowKey(row: SubmissionStatisticsUser): DataTableRowKey {
return row.username
}
@@ -449,7 +438,7 @@ function updateExpandedRowKeys(keys: DataTableRowKey[]) {
expandedRowKeys.value = keys.slice(-1)
}
function rowProps(row: UserStatistic) {
function rowProps(row: SubmissionStatisticsUser) {
return {
style: "cursor: pointer;",
onClick: () => {