From 836d97847e4f503af0310b5d2af6f61d46e0ddfc Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Fri, 7 Aug 2026 06:48:05 -0600 Subject: [PATCH] =?UTF-8?q?feat(=E9=98=B6=E6=AE=B53):=20=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E4=BE=A7=E4=BE=9D=E8=B5=96=E7=9A=84=E4=B8=89?= =?UTF-8?q?=E4=B8=AA=20admin=20=E7=AB=AF=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 重判、提交统计、流程图统计这三条挂在旧后端的 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 --- apps/api/package.json | 1 + apps/api/src/routes/flowchart.ts | 145 ++++++++- apps/api/src/routes/helpers.ts | 16 + apps/api/src/routes/site.ts | 4 +- apps/api/src/routes/submission.ts | 201 ++++++++++++- apps/api/src/services/word-frequency.ts | 67 +++++ apps/web/src/oj/api.ts | 283 +++++++++++------- .../components/FlowchartStatisticsPanel.vue | 90 +++--- .../src/shared/components/StatisticsPanel.vue | 63 ++-- bun.lock | 93 ++++-- packages/contract/src/flowchart.ts | 20 ++ packages/contract/src/submission.ts | 37 +++ 12 files changed, 787 insertions(+), 233 deletions(-) create mode 100644 apps/api/src/services/word-frequency.ts diff --git a/apps/api/package.json b/apps/api/package.json index 4649357..6e915ca 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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", diff --git a/apps/api/src/routes/flowchart.ts b/apps/api/src/routes/flowchart.ts index c589f70..7274c55 100644 --- a/apps/api/src/routes/flowchart.ts +++ b/apps/api/src/routes/flowchart.ts @@ -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() @@ -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 = {} + const criteriaTotals = new Map() + const texts: string[] = [] + const submitted = new Set() + 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 = {} + 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)) diff --git a/apps/api/src/routes/helpers.ts b/apps/api/src/routes/helpers.ts index cfb9631..5dd1575 100644 --- a/apps/api/src/routes/helpers.ts +++ b/apps/api/src/routes/helpers.ts @@ -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 { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) diff --git a/apps/api/src/routes/site.ts b/apps/api/src/routes/site.ts index e266341..bdafa54 100644 --- a/apps/api/src/routes/site.ts +++ b/apps/api/src/routes/site.ts @@ -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))) }) diff --git a/apps/api/src/routes/submission.ts b/apps/api/src/routes/submission.ts index 3da5c05..288c2ad 100644 --- a/apps/api/src/routes/submission.ts +++ b/apps/api/src/routes/submission.ts @@ -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() @@ -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() + 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() + 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") diff --git a/apps/api/src/services/word-frequency.ts b/apps/api/src/services/word-frequency.ts new file mode 100644 index 0000000..016f7c0 --- /dev/null +++ b/apps/api/src/services/word-frequency.ts @@ -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() + 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 })) +} diff --git a/apps/web/src/oj/api.ts b/apps/web/src/oj/api.ts index d9a64cf..6b64ebf 100644 --- a/apps/web/src/oj/api.ts +++ b/apps/web/src/oj/api.ts @@ -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(value: unknown): T { ) as T } -async function legacyResponse(request: Promise>): Promise> { +async function legacyResponse( + request: Promise>, +): Promise> { const response = await request return { error: response.error, data: toLegacy(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( - endpoint, - ) + const response = await api2.get(endpoint) return { error: null, data: detailProblem(response.data) } } @@ -200,17 +203,23 @@ export function getSubmissions(params: Partial) { 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("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(`contests/${encodeURIComponent(contestID)}/problems`) + const res = await api2.get( + `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(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( + 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(api2.get("ai/detail", { params: { start, end, username } })) - .then((response) => ({ - ...response, - data: { - ...response.data, - flowcharts: response.data.flowcharts?.map((item: any) => ({ + return legacyResponse( + 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(`problems/${encodeURIComponent(problemId)}/similar`) - .then((response) => ({ ...response, data: response.data.map(listProblem).map(filterResult) })) + return api2 + .get(`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(api2.get(`problems/${encodeURIComponent(problemId)}/yearly-ac`)) + return legacyResponse( + 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(api2.get("flowcharts", { params: { - ...params, - problemId: params.problem_id, - problem_id: undefined, - } })) + return legacyResponse( + 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("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(`problems/${problemId}/flowchart/history`, { params: { page } }) +export async function getFlowchartSubmissionDetail( + problemId: number, + page = 0, +) { + const response = await api2.get( + `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(api2.get("problem-sets", { - params: { - offset, - limit, - keyword, - difficulty, - status, - }, - })).then(mapProblemSetResponse) + return legacyResponse( + api2.get("problem-sets", { + params: { + offset, + limit, + keyword, + difficulty, + status, + }, + }), + ).then(mapProblemSetResponse) } export function getProblemSetDetail(id: number) { - return legacyResponse(api2.get(`problem-sets/${id}`)).then((response) => ({ - ...response, - data: legacyProblemSet(response.data), - })) + return legacyResponse(api2.get(`problem-sets/${id}`)).then( + (response) => ({ + ...response, + data: legacyProblemSet(response.data), + }), + ) } function legacyBadge(value: any) { @@ -571,7 +617,9 @@ function mapProblemSetResponse(response: ApiResponse) { } export async function getProblemSetProblems(problemSetId: number) { - const response = await legacyResponse(api2.get(`problem-sets/${problemSetId}/problems`)) + const response = await legacyResponse( + 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(api2.get( - `users/${encodeURIComponent(username ?? "me")}/badges`, - )) + const response = await legacyResponse( + 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(api2.get(`problem-sets/${problemSetId}/badges`)) + const response = await legacyResponse( + 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 { diff --git a/apps/web/src/shared/components/FlowchartStatisticsPanel.vue b/apps/web/src/shared/components/FlowchartStatisticsPanel.vue index fcbdaf4..641aba6 100644 --- a/apps/web/src/shared/components/FlowchartStatisticsPanel.vue +++ b/apps/web/src/shared/components/FlowchartStatisticsPanel.vue @@ -21,37 +21,37 @@ -