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:
@@ -14,6 +14,7 @@
|
||||
"db:pull": "drizzle-kit pull"
|
||||
},
|
||||
"dependencies": {
|
||||
"@node-rs/jieba": "^2.0.1",
|
||||
"@oj2/contract": "workspace:*",
|
||||
"bullmq": "^6.0.9",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
|
||||
@@ -7,9 +7,10 @@ import {
|
||||
flowchartDetailSchema,
|
||||
flowchartListItemSchema,
|
||||
flowchartListSchema,
|
||||
flowchartStatisticsSchema,
|
||||
flowchartSubmissionSchema,
|
||||
} from "@oj2/contract"
|
||||
import { and, asc, count, desc, eq, ilike, sql } from "drizzle-orm"
|
||||
import { and, asc, count, desc, eq, ilike, isNull, sql } from "drizzle-orm"
|
||||
import { Hono } from "hono"
|
||||
|
||||
import { requireAuth, type AppEnv } from "../auth/middleware"
|
||||
@@ -17,7 +18,16 @@ import { config } from "../config"
|
||||
import { db, schema } from "../db"
|
||||
import { failure, success } from "../http"
|
||||
import { flowchartQueue } from "../queue"
|
||||
import { isAdminRole, objectValue, queryInteger, todayStart } from "./helpers"
|
||||
import { buildWordFrequencies } from "../services/word-frequency"
|
||||
import {
|
||||
isAdminRole,
|
||||
isTeacherOrAbove,
|
||||
objectValue,
|
||||
queryInteger,
|
||||
rounded,
|
||||
stripClassPrefix,
|
||||
todayStart,
|
||||
} from "./helpers"
|
||||
|
||||
export const flowchartRoutes = new Hono<AppEnv>()
|
||||
|
||||
@@ -127,6 +137,137 @@ flowchartRoutes.get("/flowcharts", requireAuth, async (c) => {
|
||||
}))
|
||||
})
|
||||
|
||||
const FLOWCHART_COMPLETED = 2
|
||||
|
||||
flowchartRoutes.get("/flowcharts/statistics", requireAuth, async (c) => {
|
||||
if (!isTeacherOrAbove(c.get("user"))) {
|
||||
return failure(c, 403, "permission-denied", "Teacher permission required")
|
||||
}
|
||||
const end = c.req.query("end")?.trim()
|
||||
if (!end) return failure(c, 400, "invalid-request", "end is required")
|
||||
const start = c.req.query("start")?.trim()
|
||||
|
||||
const filters = [
|
||||
eq(schema.flowchartSubmission.status, FLOWCHART_COMPLETED),
|
||||
sql`${schema.flowchartSubmission.createTime} <= ${end}`,
|
||||
]
|
||||
if (start) filters.push(sql`${schema.flowchartSubmission.createTime} >= ${start}`)
|
||||
|
||||
const displayId = c.req.query("problemId")?.trim()
|
||||
if (displayId) {
|
||||
const [problem] = await db
|
||||
.select({ id: schema.problem.id })
|
||||
.from(schema.problem)
|
||||
.where(and(
|
||||
sql`lower(${schema.problem.displayId}) = lower(${displayId})`,
|
||||
isNull(schema.problem.contestId),
|
||||
eq(schema.problem.visible, true),
|
||||
))
|
||||
.limit(1)
|
||||
if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist")
|
||||
filters.push(eq(schema.flowchartSubmission.problemId, problem.id))
|
||||
}
|
||||
|
||||
const username = c.req.query("username")?.trim()
|
||||
if (username) filters.push(ilike(schema.user.username, `%${username}%`))
|
||||
|
||||
// 只有指定了用户名才谈得上「班级人数」,不指定时分母无意义
|
||||
const roster = username
|
||||
? await db
|
||||
.select({ username: schema.user.username, className: schema.user.className })
|
||||
.from(schema.user)
|
||||
.where(and(
|
||||
ilike(schema.user.username, `%${username}%`),
|
||||
eq(schema.user.isDisabled, false),
|
||||
eq(schema.user.adminType, "Regular User"),
|
||||
))
|
||||
: []
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
username: schema.user.username,
|
||||
score: schema.flowchartSubmission.aiScore,
|
||||
grade: schema.flowchartSubmission.aiGrade,
|
||||
criteria: schema.flowchartSubmission.aiCriteriaDetails,
|
||||
feedback: schema.flowchartSubmission.aiFeedback,
|
||||
suggestions: schema.flowchartSubmission.aiSuggestions,
|
||||
})
|
||||
.from(schema.flowchartSubmission)
|
||||
.innerJoin(schema.user, eq(schema.flowchartSubmission.userId, schema.user.id))
|
||||
.where(and(...filters))
|
||||
|
||||
const empty = {
|
||||
totalCount: 0,
|
||||
avgScore: 0,
|
||||
gradeDistribution: {},
|
||||
criteriaAverages: {},
|
||||
personCount: roster.length,
|
||||
completedCount: 0,
|
||||
wordFrequencies: [],
|
||||
dataUnaccepted: [],
|
||||
}
|
||||
if (rows.length === 0) return success(c, flowchartStatisticsSchema.parse(empty))
|
||||
|
||||
const gradeDistribution: Record<string, number> = {}
|
||||
const criteriaTotals = new Map<string, { sum: number; count: number; max: number }>()
|
||||
const texts: string[] = []
|
||||
const submitted = new Set<string>()
|
||||
let scoreSum = 0
|
||||
let scoreCount = 0
|
||||
|
||||
for (const row of rows) {
|
||||
submitted.add(row.username)
|
||||
// 旧后端用 values_list("ai_grade") 分组,null 也会成为一个桶;这里保持同样的口径
|
||||
const grade = row.grade ?? ""
|
||||
gradeDistribution[grade] = (gradeDistribution[grade] ?? 0) + 1
|
||||
if (row.score !== null) {
|
||||
scoreSum += row.score
|
||||
scoreCount += 1
|
||||
}
|
||||
for (const [key, value] of Object.entries(objectValue(row.criteria))) {
|
||||
const detail = objectValue(value)
|
||||
if (typeof detail.score !== "number") continue
|
||||
const bucket = criteriaTotals.get(key)
|
||||
if (bucket) {
|
||||
bucket.sum += detail.score
|
||||
bucket.count += 1
|
||||
} else {
|
||||
// max 取第一次见到的那条,与旧后端 `if key not in criteria_max` 一致
|
||||
criteriaTotals.set(key, {
|
||||
sum: detail.score,
|
||||
count: 1,
|
||||
max: typeof detail.max === "number" ? detail.max : 100,
|
||||
})
|
||||
}
|
||||
if (typeof detail.comment === "string" && detail.comment) texts.push(detail.comment)
|
||||
}
|
||||
if (row.feedback) texts.push(row.feedback)
|
||||
if (row.suggestions) texts.push(row.suggestions)
|
||||
}
|
||||
|
||||
const criteriaAverages: Record<string, { avg: number; max: number }> = {}
|
||||
for (const [key, bucket] of criteriaTotals) {
|
||||
criteriaAverages[key] = { avg: rounded(bucket.sum / bucket.count, 1), max: bucket.max }
|
||||
}
|
||||
|
||||
return success(c, flowchartStatisticsSchema.parse({
|
||||
totalCount: rows.length,
|
||||
// 分母是有分数的条数,不是总条数 —— 对齐 Django 的 Avg(),它跳过 NULL
|
||||
avgScore: scoreCount ? rounded(scoreSum / scoreCount, 1) : 0,
|
||||
gradeDistribution,
|
||||
criteriaAverages,
|
||||
personCount: roster.length,
|
||||
completedCount: submitted.size,
|
||||
wordFrequencies: buildWordFrequencies(texts),
|
||||
dataUnaccepted: roster
|
||||
.filter((row) => !submitted.has(row.username))
|
||||
.map((row) => ({
|
||||
username: row.username,
|
||||
realName: stripClassPrefix(row.username, row.className),
|
||||
})),
|
||||
}))
|
||||
})
|
||||
|
||||
flowchartRoutes.get("/flowcharts/:id", requireAuth, async (c) => {
|
||||
const [row] = await db.select({ flowchart: schema.flowchartSubmission, username: schema.user.username, problem: schema.problem })
|
||||
.from(schema.flowchartSubmission).innerJoin(schema.user, eq(schema.flowchartSubmission.userId, schema.user.id))
|
||||
|
||||
@@ -24,6 +24,22 @@ export function sampleUser(
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 去掉用户名里的 `ks<班级号>` 前缀,得到学生本人那一段:`ks251张三` + `251` → `张三`。
|
||||
* 对齐旧后端 `utils/shortcuts.py:52` 的 `strip_class_prefix`。
|
||||
*
|
||||
* 用 startsWith + slice 而不是 replace:replace 会删掉字符串中间的匹配,
|
||||
* 前缀对不上时从中间截出乱码。前缀不匹配就原样返回。
|
||||
*/
|
||||
export function stripClassPrefix(
|
||||
username: string,
|
||||
className: string | null | undefined,
|
||||
) {
|
||||
if (!className) return username
|
||||
const prefix = `ks${className}`
|
||||
return username.startsWith(prefix) ? username.slice(prefix.length) : username
|
||||
}
|
||||
|
||||
export function objectValue(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Hono } from "hono"
|
||||
import { db, schema } from "../db"
|
||||
import { failure, success } from "../http"
|
||||
import { getWebsiteOptions } from "../services/options"
|
||||
import { stripClassPrefix } from "./helpers"
|
||||
|
||||
export const siteRoutes = new Hono()
|
||||
|
||||
@@ -43,5 +44,6 @@ siteRoutes.get("/classes/:className/usernames", async (c) => {
|
||||
.from(schema.user)
|
||||
.where(eq(schema.user.className, className))
|
||||
.orderBy(desc(schema.user.createTime), asc(schema.user.id))
|
||||
return success(c, rows.map(({ username }) => username.replace(`ks${className}`, "")))
|
||||
// 用 stripClassPrefix 而不是 replace:replace 会把中间的匹配也删掉,前缀对不上时截出乱码
|
||||
return success(c, rows.map(({ username }) => stripClassPrefix(username, className)))
|
||||
})
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
submissionDetailSchema,
|
||||
submissionListItemSchema,
|
||||
submissionListSchema,
|
||||
submissionStatisticsSchema,
|
||||
} from "@oj2/contract"
|
||||
import { and, count, desc, eq, ilike, inArray, isNull, sql } from "drizzle-orm"
|
||||
import { Hono } from "hono"
|
||||
@@ -29,7 +30,15 @@ import {
|
||||
import { CodeFormatError, formatCode } from "../services/format-code"
|
||||
import { getBooleanOption } from "../services/options"
|
||||
import { consumeToken } from "../services/throttling"
|
||||
import { isAdminRole, queryInteger, todayStart } from "./helpers"
|
||||
import {
|
||||
isAdminRole,
|
||||
isSuperAdmin,
|
||||
isTeacherOrAbove,
|
||||
queryInteger,
|
||||
rounded,
|
||||
stripClassPrefix,
|
||||
todayStart,
|
||||
} from "./helpers"
|
||||
|
||||
export const submissionRoutes = new Hono<AppEnv>()
|
||||
|
||||
@@ -157,6 +166,196 @@ submissionRoutes.get("/submissions/today-count", async (c) => {
|
||||
return success(c, row?.value ?? 0)
|
||||
})
|
||||
|
||||
const ACCEPTED_RESULTS = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]
|
||||
|
||||
/**
|
||||
* 统计接口共用的时间窗解析。旧后端 `end` 必填、`start` 可选(不给就是「全部时段」)。
|
||||
*/
|
||||
function statisticsRange(c: { req: { query(name: string): string | undefined } }) {
|
||||
const end = c.req.query("end")?.trim()
|
||||
if (!end) return null
|
||||
const start = c.req.query("start")?.trim()
|
||||
return { start: start || null, end }
|
||||
}
|
||||
|
||||
/**
|
||||
* 按题号(展示用的 _id)定位公开题目。找不到时统计接口要报错而不是退化成「全部题目」,
|
||||
* 否则教师打错一个字就会看到全站数据还以为是本题的。
|
||||
*/
|
||||
async function findPublicProblemByDisplayId(displayId: string) {
|
||||
const [row] = await db
|
||||
.select({ id: schema.problem.id })
|
||||
.from(schema.problem)
|
||||
.where(
|
||||
and(
|
||||
sql`lower(${schema.problem.displayId}) = lower(${displayId})`,
|
||||
isNull(schema.problem.contestId),
|
||||
eq(schema.problem.visible, true),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
return row ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户名模糊匹配到的在册学生,用来算「班级人数」和「谁没做」。
|
||||
* 只算未禁用的普通用户 —— 教师和管理员不该出现在完成度分母里。
|
||||
*/
|
||||
async function matchedStudents(username: string) {
|
||||
return db
|
||||
.select({ username: schema.user.username, className: schema.user.className })
|
||||
.from(schema.user)
|
||||
.where(
|
||||
and(
|
||||
ilike(schema.user.username, `%${username}%`),
|
||||
eq(schema.user.isDisabled, false),
|
||||
eq(schema.user.adminType, "Regular User"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
submissionRoutes.get("/submissions/statistics", requireAuth, async (c) => {
|
||||
if (!isTeacherOrAbove(c.get("user"))) {
|
||||
return failure(c, 403, "permission-denied", "Teacher permission required")
|
||||
}
|
||||
const range = statisticsRange(c)
|
||||
if (!range) return failure(c, 400, "invalid-request", "end is required")
|
||||
|
||||
const filters = [
|
||||
isNull(schema.submission.contestId),
|
||||
sql`${schema.submission.createTime} <= ${range.end}`,
|
||||
]
|
||||
if (range.start) filters.push(sql`${schema.submission.createTime} >= ${range.start}`)
|
||||
|
||||
const displayId = c.req.query("problemId")?.trim()
|
||||
if (displayId) {
|
||||
const problem = await findPublicProblemByDisplayId(displayId)
|
||||
if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist")
|
||||
filters.push(eq(schema.submission.problemId, problem.id))
|
||||
}
|
||||
|
||||
const username = c.req.query("username")?.trim()
|
||||
if (username) filters.push(ilike(schema.submission.username, `%${username}%`))
|
||||
const where = and(...filters)
|
||||
|
||||
const acceptedFilter = sql`count(*) filter (where ${inArray(schema.submission.result, ACCEPTED_RESULTS)})`
|
||||
|
||||
const [[totals], perUser, rosterRows, items] = await Promise.all([
|
||||
db
|
||||
.select({ total: count(), accepted: acceptedFilter.mapWith(Number) })
|
||||
.from(schema.submission)
|
||||
.where(where),
|
||||
db
|
||||
.select({
|
||||
username: schema.submission.username,
|
||||
submissionCount: count(),
|
||||
acceptedCount: acceptedFilter.mapWith(Number),
|
||||
})
|
||||
.from(schema.submission)
|
||||
.where(where)
|
||||
.groupBy(schema.submission.username)
|
||||
.orderBy(desc(count())),
|
||||
// 只有指定了用户名才有「班级人数」这个概念;不指定时分母无意义,旧后端也返回 0
|
||||
username ? matchedStudents(username) : Promise.resolve([]),
|
||||
db
|
||||
.select({
|
||||
username: schema.submission.username,
|
||||
id: schema.submission.id,
|
||||
result: schema.submission.result,
|
||||
})
|
||||
.from(schema.submission)
|
||||
.where(where)
|
||||
.orderBy(desc(schema.submission.createTime)),
|
||||
])
|
||||
|
||||
const submissionCount = totals?.total ?? 0
|
||||
const acceptedCount = totals?.accepted ?? 0
|
||||
|
||||
const itemsByUser = new Map<string, { id: string; result: number }[]>()
|
||||
for (const item of items) {
|
||||
const bucket = itemsByUser.get(item.username)
|
||||
if (bucket) bucket.push({ id: item.id, result: item.result })
|
||||
else itemsByUser.set(item.username, [{ id: item.id, result: item.result }])
|
||||
}
|
||||
|
||||
const submittedUsernames = new Set(perUser.map((row) => row.username))
|
||||
const classNames = new Map<string, string | null>()
|
||||
if (submittedUsernames.size) {
|
||||
const rows = await db
|
||||
.select({ username: schema.user.username, className: schema.user.className })
|
||||
.from(schema.user)
|
||||
.where(inArray(schema.user.username, [...submittedUsernames]))
|
||||
for (const row of rows) classNames.set(row.username, row.className)
|
||||
}
|
||||
|
||||
// 只列出有正确提交的人。做了但一次没对的学生落在「未完成」那一栏
|
||||
const data = perUser
|
||||
.filter((row) => row.acceptedCount > 0)
|
||||
.map((row) => ({
|
||||
username: row.username,
|
||||
className: classNames.get(row.username) ?? null,
|
||||
submissionCount: row.submissionCount,
|
||||
acceptedCount: row.acceptedCount,
|
||||
correctRate: rounded((row.acceptedCount / row.submissionCount) * 100),
|
||||
submissionItems: itemsByUser.get(row.username) ?? [],
|
||||
}))
|
||||
|
||||
const dataUnaccepted = rosterRows
|
||||
.filter((row) => !submittedUsernames.has(row.username))
|
||||
.map((row) => ({
|
||||
username: row.username,
|
||||
realName: stripClassPrefix(row.username, row.className),
|
||||
}))
|
||||
|
||||
// 顺序照搬旧后端:先用原始 person_count 算完成度,再修正 person_count。
|
||||
// 修正是为了兜住「学生已删号但提交记录还在」——那时完成人数会大于花名册人数。
|
||||
let personCount = rosterRows.length
|
||||
let personRate = 0
|
||||
if (personCount) {
|
||||
personRate = Math.min(100, rounded((data.length / personCount) * 100))
|
||||
if (personCount < data.length) personCount = data.length
|
||||
}
|
||||
|
||||
return success(
|
||||
c,
|
||||
submissionStatisticsSchema.parse({
|
||||
submissionCount,
|
||||
acceptedCount,
|
||||
correctRate: submissionCount ? rounded((acceptedCount / submissionCount) * 100) : 0,
|
||||
personCount,
|
||||
personRate,
|
||||
data,
|
||||
dataUnaccepted,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
submissionRoutes.post("/submissions/:id/rejudge", requireAuth, async (c) => {
|
||||
if (!isSuperAdmin(c.get("user"))) {
|
||||
return failure(c, 403, "permission-denied", "Super admin permission required")
|
||||
}
|
||||
const [row] = await db
|
||||
.select({ id: schema.submission.id, problemId: schema.submission.problemId })
|
||||
.from(schema.submission)
|
||||
.where(and(eq(schema.submission.id, c.req.param("id")), isNull(schema.submission.contestId)))
|
||||
.limit(1)
|
||||
if (!row) return failure(c, 404, "submission-not-found", "Submission does not exist")
|
||||
|
||||
await db
|
||||
.update(schema.submission)
|
||||
.set({ statisticInfo: {}, result: JudgeStatus.PENDING })
|
||||
.where(eq(schema.submission.id, row.id))
|
||||
|
||||
// jobId 必须带时间戳。队列保留最近 100 个已完成任务,沿用 submissionId 做 jobId 的话
|
||||
// BullMQ 会认为这个任务已经存在,重判静默变成空操作。与 flowcharts/:id/retry 同一处理。
|
||||
await judgeQueue.add(
|
||||
"judge",
|
||||
{ submissionId: row.id, problemId: row.problemId },
|
||||
{ jobId: `${row.id}:rejudge:${Date.now()}` },
|
||||
)
|
||||
return success(c, null)
|
||||
})
|
||||
|
||||
submissionRoutes.post("/code/format", requireAuth, async (c) => {
|
||||
const parsed = formatCodeRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid format payload")
|
||||
|
||||
67
apps/api/src/services/word-frequency.ts
Normal file
67
apps/api/src/services/word-frequency.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Jieba } from "@node-rs/jieba"
|
||||
import { dict } from "@node-rs/jieba/dict"
|
||||
|
||||
/**
|
||||
* 流程图评语词云的分词。对齐旧后端 `flowchart/views/admin.py` 的
|
||||
* STOPWORDS / CUSTOM_WORDS / _build_word_frequencies 三段。
|
||||
*
|
||||
* 停用词表逐词照搬,改一个词就会让词云和旧后端对不上 —— 教师是拿它横向比不同班级的,
|
||||
* 词表变了历史截图就没法比。
|
||||
*/
|
||||
const STOPWORDS = new Set(
|
||||
(
|
||||
"的 了 是 在 和 有 就 不 也 都 要 会 这 那 到 说 上 为 与 及 等 " +
|
||||
"把 被 从 而 所 但 如 又 或 很 更 还 让 对 已 向 只 能 以 中 可以 " +
|
||||
"可能 需要 没有 使用 进行 注意 建议 应该 考虑 整体 基本 部分 " +
|
||||
"一个 一些 一下 一定 一种 这个 所有 其他 比较 存在 明确 " +
|
||||
"正确 良好 清晰 合理 较好 不错 符合 标准 "
|
||||
)
|
||||
.split(" ")
|
||||
.filter(Boolean),
|
||||
)
|
||||
|
||||
const CUSTOM_WORDS = [
|
||||
"循环结构", "条件判断", "判断条件", "结束条件", "循环条件",
|
||||
"异常处理", "边界条件", "输入输出", "输入验证", "开始结束",
|
||||
"结束节点", "开始节点", "判断节点", "流程走向", "逻辑错误",
|
||||
"逻辑缺陷", "逻辑不清", "缺少分支", "缺少步骤", "缺少判断",
|
||||
"缺少循环", "死循环", "无限循环", "循环出口", "循环体",
|
||||
"条件分支", "分支结构", "分支不全", "分支缺失", "符号使用",
|
||||
"符号不规范", "连线混乱", "变量初始化", "赋值操作", "累加操作",
|
||||
"终止条件", "退出条件", "返回值",
|
||||
]
|
||||
|
||||
/**
|
||||
* 词典加载有一次性开销(约 100ms),放在模块级会拖慢 API 冷启动,
|
||||
* 而词云只有教师偶尔点一次。改成首次调用时才建。
|
||||
*/
|
||||
let instance: Jieba | null = null
|
||||
|
||||
function jieba() {
|
||||
if (instance) return instance
|
||||
const built = Jieba.withDict(dict)
|
||||
// 对应旧后端的 jieba.add_word(w, freq=9999)。
|
||||
// @node-rs/jieba@2 没有导出 insertWord/addWord,改用用户词典缓冲区,格式为「词 词频」。
|
||||
built.loadDict(
|
||||
Buffer.from(CUSTOM_WORDS.map((word) => `${word} 9999`).join("\n") + "\n"),
|
||||
)
|
||||
instance = built
|
||||
return built
|
||||
}
|
||||
|
||||
export function buildWordFrequencies(texts: string[], topN = 80) {
|
||||
const counter = new Map<string, number>()
|
||||
const cutter = jieba()
|
||||
for (const raw of texts) {
|
||||
const text = raw.replaceAll("【重点】", "")
|
||||
for (const token of cutter.cut(text)) {
|
||||
const word = token.trim()
|
||||
if (word.length < 2 || STOPWORDS.has(word)) continue
|
||||
counter.set(word, (counter.get(word) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
return [...counter]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, topN)
|
||||
.map(([word, count]) => ({ word, count }))
|
||||
}
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
Reference in New Issue
Block a user