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

@@ -14,6 +14,7 @@
"db:pull": "drizzle-kit pull" "db:pull": "drizzle-kit pull"
}, },
"dependencies": { "dependencies": {
"@node-rs/jieba": "^2.0.1",
"@oj2/contract": "workspace:*", "@oj2/contract": "workspace:*",
"bullmq": "^6.0.9", "bullmq": "^6.0.9",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",

View File

@@ -7,9 +7,10 @@ import {
flowchartDetailSchema, flowchartDetailSchema,
flowchartListItemSchema, flowchartListItemSchema,
flowchartListSchema, flowchartListSchema,
flowchartStatisticsSchema,
flowchartSubmissionSchema, flowchartSubmissionSchema,
} from "@oj2/contract" } 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 { Hono } from "hono"
import { requireAuth, type AppEnv } from "../auth/middleware" import { requireAuth, type AppEnv } from "../auth/middleware"
@@ -17,7 +18,16 @@ import { config } from "../config"
import { db, schema } from "../db" import { db, schema } from "../db"
import { failure, success } from "../http" import { failure, success } from "../http"
import { flowchartQueue } from "../queue" 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>() 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) => { flowchartRoutes.get("/flowcharts/:id", requireAuth, async (c) => {
const [row] = await db.select({ flowchart: schema.flowchartSubmission, username: schema.user.username, problem: schema.problem }) 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)) .from(schema.flowchartSubmission).innerJoin(schema.user, eq(schema.flowchartSubmission.userId, schema.user.id))

View File

@@ -24,6 +24,22 @@ export function sampleUser(
}) })
} }
/**
* 去掉用户名里的 `ks<班级号>` 前缀,得到学生本人那一段:`ks251张三` + `251` → `张三`。
* 对齐旧后端 `utils/shortcuts.py:52` 的 `strip_class_prefix`。
*
* 用 startsWith + slice 而不是 replacereplace 会删掉字符串中间的匹配,
* 前缀对不上时从中间截出乱码。前缀不匹配就原样返回。
*/
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> { export function objectValue(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>) ? (value as Record<string, unknown>)

View File

@@ -5,6 +5,7 @@ import { Hono } from "hono"
import { db, schema } from "../db" import { db, schema } from "../db"
import { failure, success } from "../http" import { failure, success } from "../http"
import { getWebsiteOptions } from "../services/options" import { getWebsiteOptions } from "../services/options"
import { stripClassPrefix } from "./helpers"
export const siteRoutes = new Hono() export const siteRoutes = new Hono()
@@ -43,5 +44,6 @@ siteRoutes.get("/classes/:className/usernames", async (c) => {
.from(schema.user) .from(schema.user)
.where(eq(schema.user.className, className)) .where(eq(schema.user.className, className))
.orderBy(desc(schema.user.createTime), asc(schema.user.id)) .orderBy(desc(schema.user.createTime), asc(schema.user.id))
return success(c, rows.map(({ username }) => username.replace(`ks${className}`, ""))) // 用 stripClassPrefix 而不是 replacereplace 会把中间的匹配也删掉,前缀对不上时截出乱码
return success(c, rows.map(({ username }) => stripClassPrefix(username, className)))
}) })

View File

@@ -9,6 +9,7 @@ import {
submissionDetailSchema, submissionDetailSchema,
submissionListItemSchema, submissionListItemSchema,
submissionListSchema, submissionListSchema,
submissionStatisticsSchema,
} from "@oj2/contract" } from "@oj2/contract"
import { and, count, desc, eq, ilike, inArray, isNull, sql } from "drizzle-orm" import { and, count, desc, eq, ilike, inArray, isNull, sql } from "drizzle-orm"
import { Hono } from "hono" import { Hono } from "hono"
@@ -29,7 +30,15 @@ import {
import { CodeFormatError, formatCode } from "../services/format-code" import { CodeFormatError, formatCode } from "../services/format-code"
import { getBooleanOption } from "../services/options" import { getBooleanOption } from "../services/options"
import { consumeToken } from "../services/throttling" 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>() export const submissionRoutes = new Hono<AppEnv>()
@@ -157,6 +166,196 @@ submissionRoutes.get("/submissions/today-count", async (c) => {
return success(c, row?.value ?? 0) 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) => { submissionRoutes.post("/code/format", requireAuth, async (c) => {
const parsed = formatCodeRequestSchema.safeParse(await c.req.json().catch(() => null)) const parsed = formatCodeRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid format payload") if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid format payload")

View 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 }))
}

View File

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

View File

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

View File

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

View File

@@ -13,6 +13,7 @@
"name": "@oj2/api", "name": "@oj2/api",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@node-rs/jieba": "^2.0.1",
"@oj2/contract": "workspace:*", "@oj2/contract": "workspace:*",
"bullmq": "^6.0.9", "bullmq": "^6.0.9",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
@@ -349,6 +350,12 @@
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "https://registry.npmjs.com/@drizzle-team/brocli/-/brocli-0.10.2.tgz", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "https://registry.npmjs.com/@drizzle-team/brocli/-/brocli-0.10.2.tgz", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
"@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" } }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="],
"@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g=="],
"@emotion/hash": ["@emotion/hash@0.8.0", "https://registry.npmjs.com/@emotion/hash/-/hash-0.8.0.tgz", {}, "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow=="], "@emotion/hash": ["@emotion/hash@0.8.0", "https://registry.npmjs.com/@emotion/hash/-/hash-0.8.0.tgz", {}, "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow=="],
"@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "https://registry.npmjs.com/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="], "@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "https://registry.npmjs.com/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="],
@@ -413,7 +420,7 @@
"@iconify/vue": ["@iconify/vue@5.0.1", "https://registry.npmjs.com/@iconify/vue/-/vue-5.0.1.tgz", { "dependencies": { "@iconify/types": "^2.0.0" }, "peerDependencies": { "vue": ">=3.0.0" } }, "sha512-aumwwooJlFJ5H5qYWB6ZTAyM0C8hpfcSVLB9/a3qnH1GGvIJ+FEbpEs4s/HfErYe/M5qZeLjwmESR5fFm3lXEw=="], "@iconify/vue": ["@iconify/vue@5.0.1", "https://registry.npmjs.com/@iconify/vue/-/vue-5.0.1.tgz", { "dependencies": { "@iconify/types": "^2.0.0" }, "peerDependencies": { "vue": ">=3.0.0" } }, "sha512-aumwwooJlFJ5H5qYWB6ZTAyM0C8hpfcSVLB9/a3qnH1GGvIJ+FEbpEs4s/HfErYe/M5qZeLjwmESR5fFm3lXEw=="],
"@ioredis/commands": ["@ioredis/commands@2.0.0", "", {}, "sha512-vrx0AE/T0h7cRZwfo1M39Cr+ZhZrkf0V8mQN75wucKCxCLD9l/VX6no3gFvrLqD1IlG/1LtzWovqEw3t0Vr9zg=="], "@ioredis/commands": ["@ioredis/commands@2.0.0", "https://registry.npmjs.com/@ioredis/commands/-/commands-2.0.0.tgz", {}, "sha512-vrx0AE/T0h7cRZwfo1M39Cr+ZhZrkf0V8mQN75wucKCxCLD9l/VX6no3gFvrLqD1IlG/1LtzWovqEw3t0Vr9zg=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "https://registry.npmjs.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "https://registry.npmjs.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
@@ -471,17 +478,49 @@
"@mermaid-js/parser": ["@mermaid-js/parser@1.2.0", "https://registry.npmjs.com/@mermaid-js/parser/-/parser-1.2.0.tgz", { "dependencies": { "@chevrotain/types": "~11.1.2" } }, "sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA=="], "@mermaid-js/parser": ["@mermaid-js/parser@1.2.0", "https://registry.npmjs.com/@mermaid-js/parser/-/parser-1.2.0.tgz", { "dependencies": { "@chevrotain/types": "~11.1.2" } }, "sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA=="],
"@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="], "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "https://registry.npmjs.com/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="],
"@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w=="], "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4", "https://registry.npmjs.com/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w=="],
"@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4", "", { "os": "linux", "cpu": "arm" }, "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw=="], "@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4", "https://registry.npmjs.com/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", { "os": "linux", "cpu": "arm" }, "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw=="],
"@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw=="], "@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4", "https://registry.npmjs.com/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw=="],
"@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ=="], "@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4", "https://registry.npmjs.com/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", { "os": "linux", "cpu": "x64" }, "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ=="],
"@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="], "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "https://registry.npmjs.com/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
"@node-rs/jieba": ["@node-rs/jieba@2.0.1", "", { "optionalDependencies": { "@node-rs/jieba-android-arm-eabi": "2.0.1", "@node-rs/jieba-android-arm64": "2.0.1", "@node-rs/jieba-darwin-arm64": "2.0.1", "@node-rs/jieba-darwin-x64": "2.0.1", "@node-rs/jieba-freebsd-x64": "2.0.1", "@node-rs/jieba-linux-arm-gnueabihf": "2.0.1", "@node-rs/jieba-linux-arm64-gnu": "2.0.1", "@node-rs/jieba-linux-arm64-musl": "2.0.1", "@node-rs/jieba-linux-x64-gnu": "2.0.1", "@node-rs/jieba-linux-x64-musl": "2.0.1", "@node-rs/jieba-wasm32-wasi": "2.0.1", "@node-rs/jieba-win32-arm64-msvc": "2.0.1", "@node-rs/jieba-win32-ia32-msvc": "2.0.1", "@node-rs/jieba-win32-x64-msvc": "2.0.1" } }, "sha512-tnfzXOMqzVQF2dSKMhPC9HrHzzWmN6KheL/zYtGenhOpq/bCKHJWVASSggEnHlkmHgXGeIJHR2N/IuPzewz1BQ=="],
"@node-rs/jieba-android-arm-eabi": ["@node-rs/jieba-android-arm-eabi@2.0.1", "", { "os": "android", "cpu": "arm" }, "sha512-tavsIaxybnlA9tRbJ+oc3NW3zhx0d5rNiCGdpIdGWjflwS7HyeUTVAZmAFDlg58Mc6EjTdVKZH+RolBbAJtgcQ=="],
"@node-rs/jieba-android-arm64": ["@node-rs/jieba-android-arm64@2.0.1", "", { "os": "android", "cpu": "arm64" }, "sha512-AwdyqKvVNuSDnDq3anUfq+nJ5J/kzXjkfbr/1WY6TfaAlTNuuGVskuQv72/wIx/jn7NoXfm/UPuJrWYG16NC6w=="],
"@node-rs/jieba-darwin-arm64": ["@node-rs/jieba-darwin-arm64@2.0.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-10+nwGQ6KzXXJlIL/sELA6Fi6m7eJ7xJksBiKuw1kxKUgaJwtVfAG0iqRF+NRQv0Sdq7r3k5ew9K9y0+IYaEcA=="],
"@node-rs/jieba-darwin-x64": ["@node-rs/jieba-darwin-x64@2.0.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-IJ5RK0X/uPQa1XRmTvwKSieya+w1IJeiKLw0EekoBFJKybXQdvo8/uqM/8z2eVJ8vQxW9X6K2vkVGFvYQa9dYA=="],
"@node-rs/jieba-freebsd-x64": ["@node-rs/jieba-freebsd-x64@2.0.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-yg7vyhqzP2weJu5DJ3q9q4pb0b4GWWRwcv54zK7MSSA6KNJ/uQv2a4R9/qmptLU/fZv14gWuJBEMFdL7y1Dv2w=="],
"@node-rs/jieba-linux-arm-gnueabihf": ["@node-rs/jieba-linux-arm-gnueabihf@2.0.1", "", { "os": "linux", "cpu": "arm" }, "sha512-fxQYunS7w2tv8XV9GigkWJPzHnbcw6tjrUdDu5/qU0FdQVEzGuEYG85DjlNf8lZTDGSUKHBVyAQs7bBIvq8yqg=="],
"@node-rs/jieba-linux-arm64-gnu": ["@node-rs/jieba-linux-arm64-gnu@2.0.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-VnLU630hQIyO/fwyxh2vqZi72mO+hXkVUC3jVLPfOAlppinmsGX9N81tpTPUK3840hbV8WLtbYTWN1XodI38eg=="],
"@node-rs/jieba-linux-arm64-musl": ["@node-rs/jieba-linux-arm64-musl@2.0.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-K4EDyNixSLVdTNYnHwD+7I/ytvzpo7tt+vdCLqwQViiek2PMpL/FFRvA39uU2tk99jXIxvkczdxARG20BRZppg=="],
"@node-rs/jieba-linux-x64-gnu": ["@node-rs/jieba-linux-x64-gnu@2.0.1", "", { "os": "linux", "cpu": "x64" }, "sha512-sq3J6L2ANTE25I9eVFq/nb57OtXcvUIeUD1CTKJxwgTKIVmcB2LyOZpWf20AjHRUfbMER9Klqg5dgyyO+Six+w=="],
"@node-rs/jieba-linux-x64-musl": ["@node-rs/jieba-linux-x64-musl@2.0.1", "", { "os": "linux", "cpu": "x64" }, "sha512-0zfP9Qy68yEXrhBFknfhF6WUJDPU/8eRuyIrkMGdMjfRpxhpSbr2fMfnsqhOQLvhuK4w3iDFvTy4t5d0s6JKMA=="],
"@node-rs/jieba-wasm32-wasi": ["@node-rs/jieba-wasm32-wasi@2.0.1", "", { "dependencies": { "@napi-rs/wasm-runtime": "^0.2.5" }, "cpu": "none" }, "sha512-7I5rJya5rlQNJIhv8PvPzIVT1/gVc0vFzHmlfRGwCPGDJ3tHVxkSPW34dDx3OgDmbIeadNpmgIyC1RaS9djPJg=="],
"@node-rs/jieba-win32-arm64-msvc": ["@node-rs/jieba-win32-arm64-msvc@2.0.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-Aj/2EwYSaPgAbKnSl+vKM/2kOaZNMZWnShiZzbSNyzlLy3eIOyOYVLbYRDno4547KngRxer8uzROhIQIwXwkvw=="],
"@node-rs/jieba-win32-ia32-msvc": ["@node-rs/jieba-win32-ia32-msvc@2.0.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-tpJt3uuBlGrcOInQLTYvcgamQgfadl5cwExLYU+CX9rXKpXLDO31dIujUDBgNWoiQq3tOiU1/AKbT7ZdNd4lBQ=="],
"@node-rs/jieba-win32-x64-msvc": ["@node-rs/jieba-win32-x64-msvc@2.0.1", "", { "os": "win32", "cpu": "x64" }, "sha512-LDOyo2/2CO8UnpSGLJdgqtH8mOnsABPhNxkfIky7UT9cyLEzOaU44nbA5YzPGpBI3qzMbWcwJYQsjBcgK2VqAg=="],
"@oj2/api": ["@oj2/api@workspace:apps/api"], "@oj2/api": ["@oj2/api@workspace:apps/api"],
@@ -523,6 +562,8 @@
"@transloadit/prettier-bytes": ["@transloadit/prettier-bytes@0.3.5", "https://registry.npmjs.com/@transloadit/prettier-bytes/-/prettier-bytes-0.3.5.tgz", {}, "sha512-xF4A3d/ZyX2LJWeQZREZQw+qFX4TGQ8bGVP97OLRt6sPO6T0TNHBFTuRHOJh7RNmYOBmQ9MHxpolD9bXihpuVA=="], "@transloadit/prettier-bytes": ["@transloadit/prettier-bytes@0.3.5", "https://registry.npmjs.com/@transloadit/prettier-bytes/-/prettier-bytes-0.3.5.tgz", {}, "sha512-xF4A3d/ZyX2LJWeQZREZQw+qFX4TGQ8bGVP97OLRt6sPO6T0TNHBFTuRHOJh7RNmYOBmQ9MHxpolD9bXihpuVA=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
"@types/bun": ["@types/bun@1.3.14", "https://registry.npmjs.com/@types/bun/-/bun-1.3.14.tgz", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], "@types/bun": ["@types/bun@1.3.14", "https://registry.npmjs.com/@types/bun/-/bun-1.3.14.tgz", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
"@types/canvas-confetti": ["@types/canvas-confetti@1.9.0", "https://registry.npmjs.com/@types/canvas-confetti/-/canvas-confetti-1.9.0.tgz", {}, "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg=="], "@types/canvas-confetti": ["@types/canvas-confetti@1.9.0", "https://registry.npmjs.com/@types/canvas-confetti/-/canvas-confetti-1.9.0.tgz", {}, "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg=="],
@@ -781,7 +822,7 @@
"buffer-from": ["buffer-from@1.1.2", "https://registry.npmjs.com/buffer-from/-/buffer-from-1.1.2.tgz", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], "buffer-from": ["buffer-from@1.1.2", "https://registry.npmjs.com/buffer-from/-/buffer-from-1.1.2.tgz", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"bullmq": ["bullmq@6.0.9", "", { "dependencies": { "cron-parser": "5.7.0", "msgpackr": "2.0.5", "node-abort-controller": "3.1.1", "semver": "7.8.5", "tslib": "2.8.1" }, "peerDependencies": { "bullmq-otel": ">=2.0.0", "ioredis": ">=5.0.0", "pg": ">=8.0.0", "redis": ">=5.0.0" }, "optionalPeers": ["bullmq-otel", "ioredis", "pg", "redis"] }, "sha512-l7kNlirauwdZrqsViO6owHUAhSSN/aCDkrUwZQ2futIItlbJGAxgU+6IOQtTl5Re39GZJEPU6jelGuY1gQD28w=="], "bullmq": ["bullmq@6.0.9", "https://registry.npmjs.com/bullmq/-/bullmq-6.0.9.tgz", { "dependencies": { "cron-parser": "5.7.0", "msgpackr": "2.0.5", "node-abort-controller": "3.1.1", "semver": "7.8.5", "tslib": "2.8.1" }, "peerDependencies": { "bullmq-otel": ">=2.0.0", "ioredis": ">=5.0.0", "pg": ">=8.0.0", "redis": ">=5.0.0" }, "optionalPeers": ["bullmq-otel", "ioredis", "pg", "redis"] }, "sha512-l7kNlirauwdZrqsViO6owHUAhSSN/aCDkrUwZQ2futIItlbJGAxgU+6IOQtTl5Re39GZJEPU6jelGuY1gQD28w=="],
"bun-types": ["bun-types@1.3.14", "https://registry.npmjs.com/bun-types/-/bun-types-1.3.14.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], "bun-types": ["bun-types@1.3.14", "https://registry.npmjs.com/bun-types/-/bun-types-1.3.14.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
@@ -797,7 +838,7 @@
"chokidar": ["chokidar@5.0.0", "https://registry.npmjs.com/chokidar/-/chokidar-5.0.0.tgz", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], "chokidar": ["chokidar@5.0.0", "https://registry.npmjs.com/chokidar/-/chokidar-5.0.0.tgz", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
"cluster-key-slot": ["cluster-key-slot@1.1.1", "", {}, "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw=="], "cluster-key-slot": ["cluster-key-slot@1.1.1", "https://registry.npmjs.com/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz", {}, "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw=="],
"codemirror": ["codemirror@6.0.2", "https://registry.npmjs.com/codemirror/-/codemirror-6.0.2.tgz", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/commands": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/lint": "^6.0.0", "@codemirror/search": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0" } }, "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw=="], "codemirror": ["codemirror@6.0.2", "https://registry.npmjs.com/codemirror/-/codemirror-6.0.2.tgz", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/commands": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/lint": "^6.0.0", "@codemirror/search": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0" } }, "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw=="],
@@ -821,7 +862,7 @@
"crelt": ["crelt@1.0.7", "https://registry.npmjs.com/crelt/-/crelt-1.0.7.tgz", {}, "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA=="], "crelt": ["crelt@1.0.7", "https://registry.npmjs.com/crelt/-/crelt-1.0.7.tgz", {}, "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA=="],
"cron-parser": ["cron-parser@5.7.0", "", { "dependencies": { "luxon": "^3.7.2" } }, "sha512-iSpDHpwwW/GhIg4JVODYlWUEpMNSimaHvqOhHpOz1W+Y97z1lL1nf+dpcF17cNwFRpTtKN9devgi1fxflp3Phw=="], "cron-parser": ["cron-parser@5.7.0", "https://registry.npmjs.com/cron-parser/-/cron-parser-5.7.0.tgz", { "dependencies": { "luxon": "^3.7.2" } }, "sha512-iSpDHpwwW/GhIg4JVODYlWUEpMNSimaHvqOhHpOz1W+Y97z1lL1nf+dpcF17cNwFRpTtKN9devgi1fxflp3Phw=="],
"css-render": ["css-render@0.15.14", "https://registry.npmjs.com/css-render/-/css-render-0.15.14.tgz", { "dependencies": { "@emotion/hash": "~0.8.0", "csstype": "~3.0.5" } }, "sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg=="], "css-render": ["css-render@0.15.14", "https://registry.npmjs.com/css-render/-/css-render-0.15.14.tgz", { "dependencies": { "@emotion/hash": "~0.8.0", "csstype": "~3.0.5" } }, "sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg=="],
@@ -917,7 +958,7 @@
"delayed-stream": ["delayed-stream@1.0.0", "https://registry.npmjs.com/delayed-stream/-/delayed-stream-1.0.0.tgz", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], "delayed-stream": ["delayed-stream@1.0.0", "https://registry.npmjs.com/delayed-stream/-/delayed-stream-1.0.0.tgz", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
"denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="], "denque": ["denque@2.1.0", "https://registry.npmjs.com/denque/-/denque-2.1.0.tgz", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="],
"detect-libc": ["detect-libc@2.1.2", "https://registry.npmjs.com/detect-libc/-/detect-libc-2.1.2.tgz", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "detect-libc": ["detect-libc@2.1.2", "https://registry.npmjs.com/detect-libc/-/detect-libc-2.1.2.tgz", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
@@ -1029,7 +1070,7 @@
"internmap": ["internmap@2.0.3", "https://registry.npmjs.com/internmap/-/internmap-2.0.3.tgz", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], "internmap": ["internmap@2.0.3", "https://registry.npmjs.com/internmap/-/internmap-2.0.3.tgz", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
"ioredis": ["ioredis@6.0.0", "", { "dependencies": { "@ioredis/commands": "2.0.0", "cluster-key-slot": "1.1.1", "debug": "4.4.3", "denque": "2.1.0", "redis-errors": "1.2.0", "standard-as-callback": "2.1.0" } }, "sha512-f+Dtubxfpf6KYFq7WVXJoOLn0bk4TJrMrN9SzeE+jrWrCWj7XX3fA6vkryafhADX+GMymRxgDJDOI33COkJc0w=="], "ioredis": ["ioredis@6.0.0", "https://registry.npmjs.com/ioredis/-/ioredis-6.0.0.tgz", { "dependencies": { "@ioredis/commands": "2.0.0", "cluster-key-slot": "1.1.1", "debug": "4.4.3", "denque": "2.1.0", "redis-errors": "1.2.0", "standard-as-callback": "2.1.0" } }, "sha512-f+Dtubxfpf6KYFq7WVXJoOLn0bk4TJrMrN9SzeE+jrWrCWj7XX3fA6vkryafhADX+GMymRxgDJDOI33COkJc0w=="],
"is-core-module": ["is-core-module@2.16.2", "https://registry.npmjs.com/is-core-module/-/is-core-module-2.16.2.tgz", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="], "is-core-module": ["is-core-module@2.16.2", "https://registry.npmjs.com/is-core-module/-/is-core-module-2.16.2.tgz", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="],
@@ -1103,7 +1144,7 @@
"lru-cache": ["lru-cache@5.1.1", "https://registry.npmjs.com/lru-cache/-/lru-cache-5.1.1.tgz", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "lru-cache": ["lru-cache@5.1.1", "https://registry.npmjs.com/lru-cache/-/lru-cache-5.1.1.tgz", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"luxon": ["luxon@3.7.2", "", {}, "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew=="], "luxon": ["luxon@3.7.2", "https://registry.npmjs.com/luxon/-/luxon-3.7.2.tgz", {}, "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew=="],
"magic-string": ["magic-string@1.1.0", "https://registry.npmjs.com/magic-string/-/magic-string-1.1.0.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-kS3VHe0nEPST2saQV4Rbkchcd3UBRkVTQHo1D3h/ZTwFDhai/mfKkmtPAtD129EOI7K3HlHIsFOt0WrI2/oU9g=="], "magic-string": ["magic-string@1.1.0", "https://registry.npmjs.com/magic-string/-/magic-string-1.1.0.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-kS3VHe0nEPST2saQV4Rbkchcd3UBRkVTQHo1D3h/ZTwFDhai/mfKkmtPAtD129EOI7K3HlHIsFOt0WrI2/oU9g=="],
@@ -1143,9 +1184,9 @@
"ms": ["ms@2.1.3", "https://registry.npmjs.com/ms/-/ms-2.1.3.tgz", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "ms": ["ms@2.1.3", "https://registry.npmjs.com/ms/-/ms-2.1.3.tgz", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"msgpackr": ["msgpackr@2.0.5", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA=="], "msgpackr": ["msgpackr@2.0.5", "https://registry.npmjs.com/msgpackr/-/msgpackr-2.0.5.tgz", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA=="],
"msgpackr-extract": ["msgpackr-extract@3.0.4", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw=="], "msgpackr-extract": ["msgpackr-extract@3.0.4", "https://registry.npmjs.com/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw=="],
"muggle-string": ["muggle-string@0.4.1", "https://registry.npmjs.com/muggle-string/-/muggle-string-0.4.1.tgz", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="], "muggle-string": ["muggle-string@0.4.1", "https://registry.npmjs.com/muggle-string/-/muggle-string-0.4.1.tgz", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="],
@@ -1157,13 +1198,13 @@
"next-tick": ["next-tick@1.1.0", "https://registry.npmjs.com/next-tick/-/next-tick-1.1.0.tgz", {}, "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ=="], "next-tick": ["next-tick@1.1.0", "https://registry.npmjs.com/next-tick/-/next-tick-1.1.0.tgz", {}, "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ=="],
"node-abort-controller": ["node-abort-controller@3.1.1", "", {}, "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ=="], "node-abort-controller": ["node-abort-controller@3.1.1", "https://registry.npmjs.com/node-abort-controller/-/node-abort-controller-3.1.1.tgz", {}, "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ=="],
"node-addon-api": ["node-addon-api@8.9.1", "", {}, "sha512-4eUQWVPCUUUiBjLnHS3cXWeC6ryoPUc0U3rP7IuzapoGbzMqd/r6KKO0clr0b+snQhsrueFEhCZDdK+LK7hxKg=="], "node-addon-api": ["node-addon-api@8.9.1", "https://registry.npmjs.com/node-addon-api/-/node-addon-api-8.9.1.tgz", {}, "sha512-4eUQWVPCUUUiBjLnHS3cXWeC6ryoPUc0U3rP7IuzapoGbzMqd/r6KKO0clr0b+snQhsrueFEhCZDdK+LK7hxKg=="],
"node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="], "node-gyp-build": ["node-gyp-build@4.8.4", "https://registry.npmjs.com/node-gyp-build/-/node-gyp-build-4.8.4.tgz", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="],
"node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="], "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "https://registry.npmjs.com/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="],
"node-releases": ["node-releases@2.0.53", "https://registry.npmjs.com/node-releases/-/node-releases-2.0.53.tgz", {}, "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ=="], "node-releases": ["node-releases@2.0.53", "https://registry.npmjs.com/node-releases/-/node-releases-2.0.53.tgz", {}, "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ=="],
@@ -1223,7 +1264,7 @@
"readdirp": ["readdirp@5.1.1", "https://registry.npmjs.com/readdirp/-/readdirp-5.1.1.tgz", {}, "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA=="], "readdirp": ["readdirp@5.1.1", "https://registry.npmjs.com/readdirp/-/readdirp-5.1.1.tgz", {}, "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA=="],
"redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="], "redis-errors": ["redis-errors@1.2.0", "https://registry.npmjs.com/redis-errors/-/redis-errors-1.2.0.tgz", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="],
"regenerate": ["regenerate@1.4.2", "https://registry.npmjs.com/regenerate/-/regenerate-1.4.2.tgz", {}, "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A=="], "regenerate": ["regenerate@1.4.2", "https://registry.npmjs.com/regenerate/-/regenerate-1.4.2.tgz", {}, "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A=="],
@@ -1261,7 +1302,7 @@
"seemly": ["seemly@0.3.10", "https://registry.npmjs.com/seemly/-/seemly-0.3.10.tgz", {}, "sha512-2+SMxtG1PcsL0uyhkumlOU6Qo9TAQ/WyH7tthnPIOQB05/12jz9naq6GZ6iZ6ApVsO3rr2gsnTf3++OV63kE1Q=="], "seemly": ["seemly@0.3.10", "https://registry.npmjs.com/seemly/-/seemly-0.3.10.tgz", {}, "sha512-2+SMxtG1PcsL0uyhkumlOU6Qo9TAQ/WyH7tthnPIOQB05/12jz9naq6GZ6iZ6ApVsO3rr2gsnTf3++OV63kE1Q=="],
"semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "semver": ["semver@7.8.5", "https://registry.npmjs.com/semver/-/semver-7.8.5.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
"simple-peer": ["simple-peer@9.11.1", "https://registry.npmjs.com/simple-peer/-/simple-peer-9.11.1.tgz", { "dependencies": { "buffer": "^6.0.3", "debug": "^4.3.2", "err-code": "^3.0.1", "get-browser-rtc": "^1.1.0", "queue-microtask": "^1.2.3", "randombytes": "^2.1.0", "readable-stream": "^3.6.0" } }, "sha512-D1SaWpOW8afq1CZGWB8xTfrT3FekjQmPValrqncJMX7QFl8YwhrPTZvMCANLtgBwwdS+7zURyqxDDEmY558tTw=="], "simple-peer": ["simple-peer@9.11.1", "https://registry.npmjs.com/simple-peer/-/simple-peer-9.11.1.tgz", { "dependencies": { "buffer": "^6.0.3", "debug": "^4.3.2", "err-code": "^3.0.1", "get-browser-rtc": "^1.1.0", "queue-microtask": "^1.2.3", "randombytes": "^2.1.0", "readable-stream": "^3.6.0" } }, "sha512-D1SaWpOW8afq1CZGWB8xTfrT3FekjQmPValrqncJMX7QFl8YwhrPTZvMCANLtgBwwdS+7zURyqxDDEmY558tTw=="],
@@ -1281,7 +1322,7 @@
"ssr-window": ["ssr-window@4.0.2", "https://registry.npmjs.com/ssr-window/-/ssr-window-4.0.2.tgz", {}, "sha512-ISv/Ch+ig7SOtw7G2+qkwfVASzazUnvlDTwypdLoPoySv+6MqlOV10VwPSE6EWkGjhW50lUmghPmpYZXMu/+AQ=="], "ssr-window": ["ssr-window@4.0.2", "https://registry.npmjs.com/ssr-window/-/ssr-window-4.0.2.tgz", {}, "sha512-ISv/Ch+ig7SOtw7G2+qkwfVASzazUnvlDTwypdLoPoySv+6MqlOV10VwPSE6EWkGjhW50lUmghPmpYZXMu/+AQ=="],
"standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="], "standard-as-callback": ["standard-as-callback@2.1.0", "https://registry.npmjs.com/standard-as-callback/-/standard-as-callback-2.1.0.tgz", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="],
"string_decoder": ["string_decoder@1.3.0", "https://registry.npmjs.com/string_decoder/-/string_decoder-1.3.0.tgz", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], "string_decoder": ["string_decoder@1.3.0", "https://registry.npmjs.com/string_decoder/-/string_decoder-1.3.0.tgz", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
@@ -1301,15 +1342,15 @@
"tinyglobby": ["tinyglobby@0.2.17", "https://registry.npmjs.com/tinyglobby/-/tinyglobby-0.2.17.tgz", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], "tinyglobby": ["tinyglobby@0.2.17", "https://registry.npmjs.com/tinyglobby/-/tinyglobby-0.2.17.tgz", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"tree-sitter-c": ["tree-sitter-c@0.24.1", "", { "dependencies": { "node-addon-api": "^8.3.1", "node-gyp-build": "^4.8.4" }, "peerDependencies": { "tree-sitter": "^0.22.4" }, "optionalPeers": ["tree-sitter"] }, "sha512-lkYwWN3SRecpvaeqmFKkuPNR3ZbtnvHU+4XAEEkJdrp3JfSp2pBrhXOtvfsENUneye76g889Y0ddF2DM0gEDpA=="], "tree-sitter-c": ["tree-sitter-c@0.24.1", "https://registry.npmjs.com/tree-sitter-c/-/tree-sitter-c-0.24.1.tgz", { "dependencies": { "node-addon-api": "^8.3.1", "node-gyp-build": "^4.8.4" }, "peerDependencies": { "tree-sitter": "^0.22.4" }, "optionalPeers": ["tree-sitter"] }, "sha512-lkYwWN3SRecpvaeqmFKkuPNR3ZbtnvHU+4XAEEkJdrp3JfSp2pBrhXOtvfsENUneye76g889Y0ddF2DM0gEDpA=="],
"tree-sitter-python": ["tree-sitter-python@0.25.0", "", { "dependencies": { "node-addon-api": "^8.5.0", "node-gyp-build": "^4.8.4" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-eCmJx6zQa35GxaCtQD+wXHOhYqBxEL+bp71W/s3fcDMu06MrtzkVXR437dRrCrbrDbyLuUDJpAgycs7ncngLXw=="], "tree-sitter-python": ["tree-sitter-python@0.25.0", "https://registry.npmjs.com/tree-sitter-python/-/tree-sitter-python-0.25.0.tgz", { "dependencies": { "node-addon-api": "^8.5.0", "node-gyp-build": "^4.8.4" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-eCmJx6zQa35GxaCtQD+wXHOhYqBxEL+bp71W/s3fcDMu06MrtzkVXR437dRrCrbrDbyLuUDJpAgycs7ncngLXw=="],
"treemate": ["treemate@0.3.11", "https://registry.npmjs.com/treemate/-/treemate-0.3.11.tgz", {}, "sha512-M8RGFoKtZ8dF+iwJfAJTOH/SM4KluKOKRJpjCMhI8bG3qB74zrFoArKZ62ll0Fr3mqkMJiQOmWYkdYgDeITYQg=="], "treemate": ["treemate@0.3.11", "https://registry.npmjs.com/treemate/-/treemate-0.3.11.tgz", {}, "sha512-M8RGFoKtZ8dF+iwJfAJTOH/SM4KluKOKRJpjCMhI8bG3qB74zrFoArKZ62ll0Fr3mqkMJiQOmWYkdYgDeITYQg=="],
"ts-dedent": ["ts-dedent@2.3.0", "https://registry.npmjs.com/ts-dedent/-/ts-dedent-2.3.0.tgz", {}, "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg=="], "ts-dedent": ["ts-dedent@2.3.0", "https://registry.npmjs.com/ts-dedent/-/ts-dedent-2.3.0.tgz", {}, "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "tslib": ["tslib@2.8.1", "https://registry.npmjs.com/tslib/-/tslib-2.8.1.tgz", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tsx": ["tsx@4.23.9", "https://registry.npmjs.com/tsx/-/tsx-4.23.9.tgz", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-6q8uTORRGauQVjqMQnKUucLFoeXZAfw6zKvG35GLbdKWbLdeOtZ3H4mhyA5mxuUd2o2cRTskhj59nLLQseUvUw=="], "tsx": ["tsx@4.23.9", "https://registry.npmjs.com/tsx/-/tsx-4.23.9.tgz", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-6q8uTORRGauQVjqMQnKUucLFoeXZAfw6zKvG35GLbdKWbLdeOtZ3H4mhyA5mxuUd2o2cRTskhj59nLLQseUvUw=="],
@@ -1367,7 +1408,7 @@
"w3c-keyname": ["w3c-keyname@2.2.8", "https://registry.npmjs.com/w3c-keyname/-/w3c-keyname-2.2.8.tgz", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="], "w3c-keyname": ["w3c-keyname@2.2.8", "https://registry.npmjs.com/w3c-keyname/-/w3c-keyname-2.2.8.tgz", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="],
"web-tree-sitter": ["web-tree-sitter@0.26.11", "", {}, "sha512-Q5Dm3YTIXSXuH6FxX6RuzX2Qwpc4DPGiYMU87Wg5Z8OIStiQFiUex4zMDc0vBTw78EphaYJacncJghCHzbZptg=="], "web-tree-sitter": ["web-tree-sitter@0.26.11", "https://registry.npmjs.com/web-tree-sitter/-/web-tree-sitter-0.26.11.tgz", {}, "sha512-Q5Dm3YTIXSXuH6FxX6RuzX2Qwpc4DPGiYMU87Wg5Z8OIStiQFiUex4zMDc0vBTw78EphaYJacncJghCHzbZptg=="],
"web-worker": ["web-worker@1.5.0", "https://registry.npmjs.com/web-worker/-/web-worker-1.5.0.tgz", {}, "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw=="], "web-worker": ["web-worker@1.5.0", "https://registry.npmjs.com/web-worker/-/web-worker-1.5.0.tgz", {}, "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw=="],

View File

@@ -53,6 +53,25 @@ export const createFlowchartResponseSchema = z.object({ submissionId: z.string()
export const flowchartCurrentSchema = z.object({ count: z.number().int(), score: z.number(), grade: z.string() }) export const flowchartCurrentSchema = z.object({ count: z.number().int(), score: z.number(), grade: z.string() })
export const flowchartDetailSchema = z.object({ submission: flowchartSubmissionSchema.nullable(), count: z.number().int() }) export const flowchartDetailSchema = z.object({ submission: flowchartSubmissionSchema.nullable(), count: z.number().int() })
export const flowchartStatisticsSchema = z.object({
totalCount: z.number().int(),
avgScore: z.number(),
gradeDistribution: z.record(z.string(), z.number().int()),
criteriaAverages: z.record(
z.string(),
z.object({ avg: z.number(), max: z.number() }),
),
personCount: z.number().int(),
completedCount: z.number().int(),
wordFrequencies: z.array(
z.object({ word: z.string(), count: z.number().int() }),
),
// 与提交统计共用「未完成学生」的形状,见 submission.ts 的 unacceptedStudentSchema
dataUnaccepted: z.array(
z.object({ username: z.string(), realName: z.string() }),
),
})
export const flowchartUpdateSchema = z.object({ export const flowchartUpdateSchema = z.object({
type: z.enum([ type: z.enum([
"flowchart_evaluation_completed", "flowchart_evaluation_completed",
@@ -69,3 +88,4 @@ export const flowchartUpdateSchema = z.object({
}) })
export type FlowchartUpdate = z.infer<typeof flowchartUpdateSchema> export type FlowchartUpdate = z.infer<typeof flowchartUpdateSchema>
export type FlowchartStatistics = z.infer<typeof flowchartStatisticsSchema>

View File

@@ -75,6 +75,38 @@ export const submissionListSchema = paginatedSchema(submissionListItemSchema)
export const shareSubmissionRequestSchema = z.object({ shared: z.boolean() }) export const shareSubmissionRequestSchema = z.object({ shared: z.boolean() })
/**
* 未完成学生。`realName` 是从用户名里剥掉 `ks<班级号>` 前缀后剩下的那一段,
* 不是 user.real_name 列 —— 与 F2「真名默认不下发」不冲突这里只有教师能看到
* 且教师面板的用途正是点名谁没做。
*/
export const unacceptedStudentSchema = z.object({
username: z.string(),
realName: z.string(),
})
export const submissionStatisticsUserSchema = z.object({
username: z.string(),
className: z.string().nullable(),
submissionCount: z.number().int(),
acceptedCount: z.number().int(),
// 百分比数值,不带 %。旧后端返回 "85.5%" 字符串,展示格式化交给前端。
correctRate: z.number(),
submissionItems: z.array(
z.object({ id: z.string(), result: judgeStatusSchema }),
),
})
export const submissionStatisticsSchema = z.object({
submissionCount: z.number().int(),
acceptedCount: z.number().int(),
correctRate: z.number(),
personCount: z.number().int(),
personRate: z.number(),
data: z.array(submissionStatisticsUserSchema),
dataUnaccepted: z.array(unacceptedStudentSchema),
})
export const formatCodeRequestSchema = z.object({ export const formatCodeRequestSchema = z.object({
code: z.string().max(1024 * 1024), code: z.string().max(1024 * 1024),
language: z.enum(["python", "c", "cpp", "sql"]), language: z.enum(["python", "c", "cpp", "sql"]),
@@ -88,3 +120,8 @@ export type CreateSubmissionRequest = z.infer<
> >
export type SubmissionDetail = z.infer<typeof submissionDetailSchema> export type SubmissionDetail = z.infer<typeof submissionDetailSchema>
export type SubmissionUpdate = z.infer<typeof submissionUpdateSchema> export type SubmissionUpdate = z.infer<typeof submissionUpdateSchema>
export type SubmissionStatistics = z.infer<typeof submissionStatisticsSchema>
export type SubmissionStatisticsUser = z.infer<
typeof submissionStatisticsUserSchema
>
export type UnacceptedStudent = z.infer<typeof unacceptedStudentSchema>