refactor: 一批名不副实的命名改掉(只动一端,不碰契约)

后端:
- getUserByToken → authenticateToken:它会删会话、续 TTL、记在线,不是纯读
- todayStart → dayStart(instant):本来就接受任意时刻,不只是「今天」
- objectValue → asRecord,删掉 4 份一模一样的私有拷贝和 `as toObject` 别名
- 报错文案 "does not exists" / "Already be a public problem" 改通顺

前端:
- utils/types 里 Contest / User / Problem 三个别名删掉:Contest 原来指后台
  比赛、契约里同名的却是学生侧。现在 Contest = 学生侧、AdminContest = 后台,
  后台用户表单叫 AdminUserForm,题目详情直接用 ProblemDetail
- SubmissionInfo → ContestSubmissionInfo,免得和 submission.info 撞名
- utoa / atou → compressToBase64 / decompressFromBase64(实际是 zlib + base64),
  encode / decode → base64EncodeUtf8 / base64DecodeUtf8
- filterResult → toProblemRow、toProblemListItem → toAdminProblemRow,
  行字段 submission / rate → submissionCount / acRate
- 组件:Modal → AddFromLibraryModal、Form → EditorToolbar、
  ContestEditor → BasicEditor(题单也在用它)、rank 的 Index → RankMedal、
  Name → UserNameCell、Password → PasswordCell、ContestType → ContestAccessTag

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-09-27 16:08:21 +08:00
1 parent 572a818d49
commit 5ae830a7f9
62 files changed
+284 -320

No files matched your search

+6 -5
View File
@@ -161,7 +161,7 @@ export type SessionResult =
| { user: AuthUser; reason?: undefined } | { user: AuthUser; reason?: undefined }
| { user: null; reason: "anonymous" | "disabled" } | { user: null; reason: "anonymous" | "disabled" }
async function getUserByToken( async function authenticateToken(
token: string | undefined, token: string | undefined,
): Promise<SessionResult> { ): Promise<SessionResult> {
if (!token) return { user: null, reason: "anonymous" } if (!token) return { user: null, reason: "anonymous" }
@@ -228,7 +228,7 @@ async function getUserByToken(
/** 要区分「未登录」和「已被禁用」的用这个 —— 目前只有鉴权中间件需要 */ /** 要区分「未登录」和「已被禁用」的用这个 —— 目前只有鉴权中间件需要 */
export function resolveSession(c: Context) { export function resolveSession(c: Context) {
return getUserByToken(getCookie(c, config.sessionCookie)) return authenticateToken(getCookie(c, config.sessionCookie))
} }
/** 只关心「是谁」的调用方用这个 */ /** 只关心「是谁」的调用方用这个 */
@@ -237,7 +237,8 @@ export async function getSessionUser(c: Context) {
} }
export async function getRequestSessionUser(request: Request) { export async function getRequestSessionUser(request: Request) {
return (await getUserByToken(readCookie(request, config.sessionCookie))).user return (await authenticateToken(readCookie(request, config.sessionCookie)))
.user
} }
/** /**
@@ -254,11 +255,11 @@ export function readRequestSessionToken(request: Request) {
* 用 EXPIRE 同时完成「判断存在」和「续期」,比 GET + EXPIRE 少一趟往返;两条 EXPIRE * 用 EXPIRE 同时完成「判断存在」和「续期」,比 GET + EXPIRE 少一趟往返;两条 EXPIRE
* 走一次 pipeline,仍然只有一趟。 * 走一次 pipeline,仍然只有一趟。
* *
* 续期这件事本身是要的:HTTP 请求会走 getUserByToken 里的 redis.expire 续期, * 续期这件事本身是要的:HTTP 请求会走 authenticateToken 里的 redis.expire 续期,
* 而只开着页面挂 WebSocket 的人一次请求都不发,不该因此被算成不活跃踢下线。 * 而只开着页面挂 WebSocket 的人一次请求都不发,不该因此被算成不活跃踢下线。
* *
* **反向索引必须跟着一起续。** 走到这里的正是那种一次 HTTP 请求都不发的连接, * **反向索引必须跟着一起续。** 走到这里的正是那种一次 HTTP 请求都不发的连接,
* 它碰不到 getUserByToken 里那两条并排的 expire。只续会话不续索引的话,索引先到期、 * 它碰不到 authenticateToken 里那两条并排的 expire。只续会话不续索引的话,索引先到期、
* 会话却被巡检一直续着,之后改密码 / 禁用账号走 revokeUserSessions 就 SMEMBERS * 会话却被巡检一直续着,之后改密码 / 禁用账号走 revokeUserSessions 就 SMEMBERS
* 不到这张 token —— WebSocket 那边还有 publishSessionRevoked 按 userId 兜底能断掉, * 不到这张 token —— WebSocket 那边还有 publishSessionRevoked 按 userId 兜底能断掉,
* 但 HTTP 一侧拿着那张 cookie 照用不误,而改密码要的恰恰是让 HTTP 立刻失效。 * 但 HTTP 一侧拿着那张 cookie 照用不误,而改密码要的恰恰是让 HTTP 立刻失效。
+9 -14
View File
@@ -13,6 +13,7 @@ import {
import { recordSolvedProblem } from "../services/problemset" import { recordSolvedProblem } from "../services/problemset"
import { checkAst, type AstRule } from "./ast" import { checkAst, type AstRule } from "./ast"
import { publishSubmissionUpdate } from "./events" import { publishSubmissionUpdate } from "./events"
import { asRecord } from "../routes/helpers"
import type { JudgeJobData } from "./job" import type { JudgeJobData } from "./job"
import { judgeConfigFor } from "./languages" import { judgeConfigFor } from "./languages"
import { isAccepted, JudgeStatus, type JudgeStatusValue } from "./status" import { isAccepted, JudgeStatus, type JudgeStatusValue } from "./status"
@@ -37,12 +38,6 @@ interface JudgeResponse {
data: JudgeCase[] | unknown data: JudgeCase[] | unknown
} }
function objectValue(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {}
}
function statusValue(value: number): JudgeStatusValue { function statusValue(value: number): JudgeStatusValue {
const statuses = new Set<number>(Object.values(JudgeStatus)) const statuses = new Set<number>(Object.values(JudgeStatus))
return statuses.has(value) return statuses.has(value)
@@ -51,7 +46,7 @@ function statusValue(value: number): JudgeStatusValue {
} }
function templateForLanguage(value: unknown, language: string) { function templateForLanguage(value: unknown, language: string) {
const template = objectValue(value)[language] const template = asRecord(value)[language]
return typeof template === "string" ? template : null return typeof template === "string" ? template : null
} }
@@ -62,7 +57,7 @@ function templateForLanguage(value: unknown, language: string) {
* 这里、并且不再骗类型系统。 * 这里、并且不再骗类型系统。
*/ */
function astRulesForLanguage(value: unknown, language: string): AstRule[] { function astRulesForLanguage(value: unknown, language: string): AstRule[] {
const rules = objectValue(value)[language] const rules = asRecord(value)[language]
if (!Array.isArray(rules)) return [] if (!Array.isArray(rules)) return []
return rules.flatMap((rule) => { return rules.flatMap((rule) => {
const parsed = astRuleSchema.safeParse(rule) const parsed = astRuleSchema.safeParse(rule)
@@ -163,7 +158,7 @@ async function persistResult(
.set({ result, info, statisticInfo }) .set({ result, info, statisticInfo })
.where(eq(schema.submission.id, submissionId)) .where(eq(schema.submission.id, submissionId))
const problemStatistics = objectValue(problem.statisticInfo) const problemStatistics = asRecord(problem.statisticInfo)
const resultKey = String(result) const resultKey = String(result)
const previousResultCount = problemStatistics[resultKey] const previousResultCount = problemStatistics[resultKey]
problemStatistics[resultKey] = problemStatistics[resultKey] =
@@ -178,10 +173,10 @@ async function persistResult(
}) })
.where(eq(schema.problem.id, problemId)) .where(eq(schema.problem.id, problemId))
const acmStatus = objectValue(profile.acmProblemsStatus) const acmStatus = asRecord(profile.acmProblemsStatus)
const statusKey = contestId === null ? "problems" : "contest_problems" const statusKey = contestId === null ? "problems" : "contest_problems"
const problems = objectValue(acmStatus[statusKey]) const problems = asRecord(acmStatus[statusKey])
const previous = objectValue(problems[String(problemId)]) const previous = asRecord(problems[String(problemId)])
const previousStatus = previous.status const previousStatus = previous.status
const wasAccepted = const wasAccepted =
typeof previousStatus === "number" && isAccepted(previousStatus) typeof previousStatus === "number" && isAccepted(previousStatus)
@@ -591,14 +586,14 @@ async function judgeSqlSubmission(
problem: typeof schema.problem.$inferSelect, problem: typeof schema.problem.$inferSelect,
studentSql: string, studentSql: string,
): Promise<JudgeResponse> { ): Promise<JudgeResponse> {
const sqlConfig = objectValue(problem.sqlConfig) const sqlConfig = asRecord(problem.sqlConfig)
const mode = sqlConfig.mode const mode = sqlConfig.mode
if (mode !== "query" && mode !== "modify") { if (mode !== "query" && mode !== "modify") {
throw new Error("题目缺少 SQL 配置(题型)") throw new Error("题目缺少 SQL 配置(题型)")
} }
const answers = Array.isArray(problem.answers) ? problem.answers : [] const answers = Array.isArray(problem.answers) ? problem.answers : []
const refSql = answers const refSql = answers
.map((item) => objectValue(item)) .map((item) => asRecord(item))
.find( .find(
(item) => (item) =>
item.language === "SQL" && item.language === "SQL" &&
+4 -9
View File
@@ -54,12 +54,7 @@ import {
type AttemptRule, type AttemptRule,
} from "../services/throttling" } from "../services/throttling"
import { localTime, weekStart } from "../time" import { localTime, weekStart } from "../time"
import { import { isTeacherOrAbove, asRecord, queryInteger, sampleUser } from "./helpers"
isTeacherOrAbove,
objectValue,
queryInteger,
sampleUser,
} from "./helpers"
export const accountRoutes = new Hono<AppEnv>() export const accountRoutes = new Hono<AppEnv>()
@@ -632,8 +627,8 @@ accountRoutes.post(
.from(schema.userProfile) .from(schema.userProfile)
.where(eq(schema.userProfile.userId, user.id)) .where(eq(schema.userProfile.userId, user.id))
.limit(1) .limit(1)
const status = objectValue(profile?.value) const status = asRecord(profile?.value)
const problems = objectValue(status.problems) const problems = asRecord(status.problems)
const ids = Object.keys(problems).map(Number).filter(Number.isInteger) const ids = Object.keys(problems).map(Number).filter(Number.isInteger)
if (ids.length > 0) { if (ids.length > 0) {
const rows = await db const rows = await db
@@ -649,7 +644,7 @@ accountRoutes.post(
rows.map((row) => [String(row.id), row.displayId]), rows.map((row) => [String(row.id), row.displayId]),
) )
for (const [id, value] of Object.entries(problems)) { for (const [id, value] of Object.entries(problems)) {
const item = objectValue(value) const item = asRecord(value)
const displayId = displayIds.get(id) const displayId = displayIds.get(id)
if (displayId) item._id = displayId if (displayId) item._id = displayId
problems[id] = item problems[id] = item
+2 -2
View File
@@ -11,7 +11,7 @@ import { Hono } from "hono"
import { requireAuth, type AppEnv } from "../auth/middleware" import { requireAuth, type AppEnv } from "../auth/middleware"
import { db, schema } from "../db" import { db, schema } from "../db"
import { failure, parseBody, success } from "../http" import { failure, parseBody, success } from "../http"
import { objectValue } from "./helpers" import { asRecord } from "./helpers"
export const achievementRoutes = new Hono<AppEnv>() export const achievementRoutes = new Hono<AppEnv>()
@@ -73,7 +73,7 @@ achievementRoutes.get("/achievements", requireAuth, async (c) => {
.where(eq(schema.user.isDisabled, false)), .where(eq(schema.user.isDisabled, false)),
]) ])
const unlocked = new Map(unlockedRows.map((row) => [row.achievementId, row])) const unlocked = new Map(unlockedRows.map((row) => [row.achievementId, row]))
const metrics = objectValue(statRows[0]?.metrics) const metrics = asRecord(statRows[0]?.metrics)
const active = activeRows[0]?.value ?? 0 const active = activeRows[0]?.value ?? 0
const result = achievements.map((achievement) => { const result = achievements.map((achievement) => {
const record = unlocked.get(achievement.id) const record = unlocked.get(achievement.id)
+2 -2
View File
@@ -25,7 +25,7 @@ import { publishConfigUpdate } from "../../events"
import { failure, parseBody, success } from "../../http" import { failure, parseBody, success } from "../../http"
import { sniffImageExtension } from "../../services/image" import { sniffImageExtension } from "../../services/image"
import { getWebsiteOptions } from "../../services/options" import { getWebsiteOptions } from "../../services/options"
import { todayStart } from "../../time" import { dayStart } from "../../time"
import { queryInteger } from "../helpers" import { queryInteger } from "../helpers"
export const adminConfRoutes = new Hono<AppEnv>() export const adminConfRoutes = new Hono<AppEnv>()
@@ -232,7 +232,7 @@ adminConfRoutes.get("/dashboard", requireSuperAdmin, async (c) => {
db db
.select({ value: count() }) .select({ value: count() })
.from(schema.submission) .from(schema.submission)
.where(gte(schema.submission.createTime, todayStart())), .where(gte(schema.submission.createTime, dayStart())),
db db
.select({ value: count() }) .select({ value: count() })
.from(schema.contest) .from(schema.contest)
+9 -9
View File
@@ -48,7 +48,7 @@ import { config } from "../../config"
import { readFile } from "node:fs/promises" import { readFile } from "node:fs/promises"
import { resolve } from "node:path" import { resolve } from "node:path"
import { getTopReactions } from "../../services/reaction" import { getTopReactions } from "../../services/reaction"
import { objectValue, queryInteger, sampleUser } from "../helpers" import { asRecord, queryInteger, sampleUser } from "../helpers"
export const adminProblemRoutes = new Hono<AppEnv>() export const adminProblemRoutes = new Hono<AppEnv>()
@@ -205,7 +205,7 @@ async function serialize(row: ProblemRow) {
source: row.source, source: row.source,
submissionNumber: row.submissionNumber, submissionNumber: row.submissionNumber,
acceptedNumber: row.acceptedNumber, acceptedNumber: row.acceptedNumber,
statisticInfo: objectValue(row.statisticInfo), statisticInfo: asRecord(row.statisticInfo),
contestId: row.contestId, contestId: row.contestId,
createdBy: sampleUser( createdBy: sampleUser(
creator ?? { id: row.createdById, username: "" }, creator ?? { id: row.createdById, username: "" },
@@ -616,9 +616,9 @@ adminProblemRoutes.delete(
.where(eq(schema.problem.id, id)) .where(eq(schema.problem.id, id))
.limit(1) .limit(1)
if (!existing) if (!existing)
return failure(c, 404, "problem-not-found", "Problem does not exists") return failure(c, 404, "problem-not-found", "Problem does not exist")
if (!(await canEdit(c.get("user")!, existing))) { if (!(await canEdit(c.get("user")!, existing))) {
return failure(c, 404, "problem-not-found", "Problem does not exists") return failure(c, 404, "problem-not-found", "Problem does not exist")
} }
return deleteProblem(c, id) return deleteProblem(c, id)
}, },
@@ -793,7 +793,7 @@ adminProblemRoutes.post(
return failure(c, 404, "problem-not-found", "Problem does not exist") return failure(c, 404, "problem-not-found", "Problem does not exist")
} }
if (!problem.contestId || problem.isPublic) { if (!problem.contestId || problem.isPublic) {
return failure(c, 409, "already-public", "Already be a public problem") return failure(c, 409, "already-public", "Already a public problem")
} }
const [duplicate] = await db const [duplicate] = await db
.select({ id: schema.problem.id }) .select({ id: schema.problem.id })
@@ -936,9 +936,9 @@ adminProblemRoutes.get(
) )
.limit(1) .limit(1)
if (!problem) if (!problem)
return failure(c, 404, "problem-not-found", "Problem does not exists") return failure(c, 404, "problem-not-found", "Problem does not exist")
if (!(await canEdit(c.get("user")!, problem))) { if (!(await canEdit(c.get("user")!, problem))) {
return failure(c, 404, "problem-not-found", "Problem does not exists") return failure(c, 404, "problem-not-found", "Problem does not exist")
} }
try { try {
const archive = await packTestCaseZip(problem.testCaseId) const archive = await packTestCaseZip(problem.testCaseId)
@@ -973,9 +973,9 @@ adminProblemRoutes.get(
) )
.limit(1) .limit(1)
if (!problem) if (!problem)
return failure(c, 404, "problem-not-found", "Problem does not exists") return failure(c, 404, "problem-not-found", "Problem does not exist")
if (!(await canEdit(c.get("user")!, problem))) { if (!(await canEdit(c.get("user")!, problem))) {
return failure(c, 404, "problem-not-found", "Problem does not exists") return failure(c, 404, "problem-not-found", "Problem does not exist")
} }
const info = await readInfo(problem.testCaseId) const info = await readInfo(problem.testCaseId)
if (!info) if (!info)
+2 -2
View File
@@ -15,7 +15,7 @@ import { requireSuperAdmin, type AppEnv } from "../../auth/middleware"
import { db, schema } from "../../db" import { db, schema } from "../../db"
import { failure, parseBody, success } from "../../http" import { failure, parseBody, success } from "../../http"
import { exerciseDataError } from "../../services/exercise" import { exerciseDataError } from "../../services/exercise"
import { objectValue, queryInteger, sampleUser } from "../helpers" import { asRecord, queryInteger, sampleUser } from "../helpers"
export const adminTutorialRoutes = new Hono<AppEnv>() export const adminTutorialRoutes = new Hono<AppEnv>()
@@ -156,7 +156,7 @@ function serializeExercise(row: typeof schema.exercise.$inferSelect) {
return { return {
id: row.id, id: row.id,
type: row.type, type: row.type,
data: objectValue(row.data), data: asRecord(row.data),
order: row.order, order: row.order,
} satisfies AdminExercise } satisfies AdminExercise
} }
+2 -2
View File
@@ -55,7 +55,7 @@ import {
import { import {
countFailedSubmissions, countFailedSubmissions,
isTeacherOrAbove, isTeacherOrAbove,
objectValue, asRecord,
queryInteger, queryInteger,
} from "./helpers" } from "./helpers"
@@ -317,7 +317,7 @@ aiRoutes.get("/ai/pinned", requireAuth, async (c) => {
id: row.analysis.id, id: row.analysis.id,
provider: row.analysis.provider, provider: row.analysis.provider,
model: row.analysis.model, model: row.analysis.model,
data: objectValue(row.analysis.data), data: asRecord(row.analysis.data),
analysis: row.analysis.analysis, analysis: row.analysis.analysis,
createTime: row.analysis.createTime, createTime: row.analysis.createTime,
isPinned: row.analysis.isPinned, isPinned: row.analysis.isPinned,
+3 -3
View File
@@ -24,7 +24,7 @@ import { requireAuth, requireSuperAdmin, type AppEnv } from "../auth/middleware"
import { db, schema } from "../db" import { db, schema } from "../db"
import { failure, parseBody, success } from "../http" import { failure, parseBody, success } from "../http"
import { JudgeStatus } from "../judge/status" import { JudgeStatus } from "../judge/status"
import { objectValue, queryInteger, sampleUser } from "./helpers" import { asRecord, queryInteger, sampleUser } from "./helpers"
export const contentRoutes = new Hono<AppEnv>() export const contentRoutes = new Hono<AppEnv>()
@@ -167,7 +167,7 @@ contentRoutes.get("/messages", requireAuth, async (c) => {
// info / ip / contestId 三个字段不在 embeddedSubmissionSchema 里,故不传 —— // info / ip / contestId 三个字段不在 embeddedSubmissionSchema 里,故不传 ——
// 对齐旧后端 SubmissionSafeModelSerializer 的 exclude,这三个键不出现在响应中 // 对齐旧后端 SubmissionSafeModelSerializer 的 exclude,这三个键不出现在响应中
language: submission.language, language: submission.language,
statisticInfo: objectValue(submission.statisticInfo), statisticInfo: asRecord(submission.statisticInfo),
// 展示用题号而非数字主键,站内信页面拿它拼 /problem/<题号> // 展示用题号而非数字主键,站内信页面拿它拼 /problem/<题号>
problem: displayId, problem: displayId,
showLink: true, showLink: true,
@@ -590,7 +590,7 @@ contentRoutes.get("/tutorials/:id/exercises", async (c) => {
({ ({
id: row.id, id: row.id,
type: row.type, type: row.type,
data: objectValue(row.data), data: asRecord(row.data),
order: row.order, order: row.order,
}) satisfies Exercise, }) satisfies Exercise,
), ),
+5 -10
View File
@@ -43,12 +43,7 @@ import {
lockoutRemaining, lockoutRemaining,
type AttemptRule, type AttemptRule,
} from "../services/throttling" } from "../services/throttling"
import { import { asRecord, publicTemplates, queryInteger, sampleUser } from "./helpers"
objectValue,
publicTemplates,
queryInteger,
sampleUser,
} from "./helpers"
export const contestRoutes = new Hono<ContestEnv>() export const contestRoutes = new Hono<ContestEnv>()
@@ -223,11 +218,11 @@ async function contestProblemStatuses(userId: number | undefined) {
.from(schema.userProfile) .from(schema.userProfile)
.where(eq(schema.userProfile.userId, userId)) .where(eq(schema.userProfile.userId, userId))
.limit(1) .limit(1)
return objectValue(objectValue(profile?.status).contest_problems) return asRecord(asRecord(profile?.status).contest_problems)
} }
function myStatusOf(statuses: Record<string, unknown>, problemId: number) { function myStatusOf(statuses: Record<string, unknown>, problemId: number) {
const status = objectValue(statuses[String(problemId)]).status const status = asRecord(statuses[String(problemId)]).status
return typeof status === "number" ? status : null return typeof status === "number" ? status : null
} }
@@ -353,7 +348,7 @@ contestRoutes.get(
prompt: row.problem.prompt, prompt: row.problem.prompt,
submissionNumber: allowed ? row.problem.submissionNumber : 0, submissionNumber: allowed ? row.problem.submissionNumber : 0,
acceptedNumber: allowed ? row.problem.acceptedNumber : 0, acceptedNumber: allowed ? row.problem.acceptedNumber : 0,
statisticInfo: allowed ? objectValue(row.problem.statisticInfo) : {}, statisticInfo: allowed ? asRecord(row.problem.statisticInfo) : {},
contestId: contest.id, contestId: contest.id,
tags: tags.get(row.problem.id) ?? [], tags: tags.get(row.problem.id) ?? [],
createdBy: sampleUser(row.user, row.realName), createdBy: sampleUser(row.user, row.realName),
@@ -365,7 +360,7 @@ contestRoutes.get(
mermaidCode: row.problem.allowFlowchart ? null : row.problem.mermaidCode, mermaidCode: row.problem.allowFlowchart ? null : row.problem.mermaidCode,
flowchartData: row.problem.allowFlowchart flowchartData: row.problem.allowFlowchart
? null ? null
: objectValue(row.problem.flowchartData), : asRecord(row.problem.flowchartData),
flowchartHint: row.problem.flowchartHint, flowchartHint: row.problem.flowchartHint,
sqlConfig: row.problem.sqlConfig, sqlConfig: row.problem.sqlConfig,
sqlDisplay: row.problem.sqlDisplay, sqlDisplay: row.problem.sqlDisplay,
+7 -9
View File
@@ -31,11 +31,11 @@ import { flowchartQueue } from "../queue"
import { getBooleanOption } from "../services/options" import { getBooleanOption } from "../services/options"
import { consumeToken } from "../services/throttling" import { consumeToken } from "../services/throttling"
import { buildWordFrequencies } from "../services/word-frequency" import { buildWordFrequencies } from "../services/word-frequency"
import { todayStart } from "../time" import { dayStart } from "../time"
import { import {
isAdminRole, isAdminRole,
matchedUsers, matchedUsers,
objectValue, asRecord,
queryInteger, queryInteger,
rounded, rounded,
stripClassPrefix, stripClassPrefix,
@@ -69,14 +69,14 @@ function flowchartData(
username, username,
problemId: flowchart.problemId, problemId: flowchart.problemId,
mermaidCode: flowchart.mermaidCode, mermaidCode: flowchart.mermaidCode,
flowchartData: objectValue(flowchart.flowchartData), flowchartData: asRecord(flowchart.flowchartData),
status: flowchart.status, status: flowchart.status,
createTime: flowchart.createTime, createTime: flowchart.createTime,
aiScore: flowchart.aiScore, aiScore: flowchart.aiScore,
aiGrade: flowchart.aiGrade, aiGrade: flowchart.aiGrade,
aiFeedback: flowchart.aiFeedback, aiFeedback: flowchart.aiFeedback,
aiSuggestions: flowchart.aiSuggestions, aiSuggestions: flowchart.aiSuggestions,
aiCriteriaDetails: objectValue(flowchart.aiCriteriaDetails), aiCriteriaDetails: asRecord(flowchart.aiCriteriaDetails),
aiProvider: flowchart.aiProvider, aiProvider: flowchart.aiProvider,
aiModel: flowchart.aiModel, aiModel: flowchart.aiModel,
processingTime: flowchart.processingTime, processingTime: flowchart.processingTime,
@@ -269,9 +269,7 @@ flowchartRoutes.get("/flowcharts", requireAuth, async (c) => {
) )
if (onlyMyself) filters.push(eq(schema.flowchartSubmission.userId, user.id)) if (onlyMyself) filters.push(eq(schema.flowchartSubmission.userId, user.id))
if (c.req.query("today") === "1") if (c.req.query("today") === "1")
filters.push( filters.push(sql`${schema.flowchartSubmission.createTime} >= ${dayStart()}`)
sql`${schema.flowchartSubmission.createTime} >= ${todayStart()}`,
)
if (["S", "A", "B", "C"].includes(grade ?? "")) if (["S", "A", "B", "C"].includes(grade ?? ""))
filters.push(eq(schema.flowchartSubmission.aiGrade, grade!)) filters.push(eq(schema.flowchartSubmission.aiGrade, grade!))
const where = and(...filters) const where = and(...filters)
@@ -520,8 +518,8 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
if (texts.length < WORDCLOUD_TEXT_LIMIT) texts.push(value) if (texts.length < WORDCLOUD_TEXT_LIMIT) texts.push(value)
} }
for (const row of textRows) { for (const row of textRows) {
for (const [key, value] of Object.entries(objectValue(row.criteria))) { for (const [key, value] of Object.entries(asRecord(row.criteria))) {
const detail = objectValue(value) const detail = asRecord(value)
// 和上面那条聚合同一道闸:分数不是数字的项当没配过,满分和评语也都不收 // 和上面那条聚合同一道闸:分数不是数字的项当没配过,满分和评语也都不收
if (typeof detail.score !== "number") continue if (typeof detail.score !== "number") continue
if (!criteriaMax.has(key)) { if (!criteriaMax.has(key)) {
+2 -2
View File
@@ -57,7 +57,7 @@ export function asFilterValue<T extends string | number>(
return value as T return value as T
} }
export function objectValue(value: unknown): Record<string, unknown> { export function asRecord(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>)
: {} : {}
@@ -96,7 +96,7 @@ export function isSuperAdmin(user: AuthUser | null | undefined) {
export function publicTemplates(value: unknown) { export function publicTemplates(value: unknown) {
const templates: Record<string, string> = {} const templates: Record<string, string> = {}
for (const [language, raw] of Object.entries(objectValue(value))) { for (const [language, raw] of Object.entries(asRecord(value))) {
if (typeof raw !== "string") continue if (typeof raw !== "string") continue
const match = raw.match(/\/\/TEMPLATE BEGIN\n([\s\S]+?)\/\/TEMPLATE END/) const match = raw.match(/\/\/TEMPLATE BEGIN\n([\s\S]+?)\/\/TEMPLATE END/)
templates[language] = match?.[1] ?? "" templates[language] = match?.[1] ?? ""
+11 -17
View File
@@ -29,26 +29,20 @@ import { db, schema } from "../db"
import { astRequirements } from "../judge/ast" import { astRequirements } from "../judge/ast"
import { failure, success } from "../http" import { failure, success } from "../http"
import { JudgeStatus } from "../judge/status" import { JudgeStatus } from "../judge/status"
import { localTime, shiftMonthsByCalendar, todayStart } from "../time" import { dayStart, localTime, shiftMonthsByCalendar } from "../time"
import { import {
asFilterValue, asFilterValue,
countFailedSubmissions, countFailedSubmissions,
objectValue as toObject, asRecord,
queryInteger, queryInteger,
sampleUser, sampleUser,
} from "./helpers" } from "./helpers"
export const problemRoutes = new Hono<AppEnv>() export const problemRoutes = new Hono<AppEnv>()
function objectValue(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {}
}
function publicTemplates(value: unknown) { function publicTemplates(value: unknown) {
const templates: Record<string, string> = {} const templates: Record<string, string> = {}
for (const [language, raw] of Object.entries(objectValue(value))) { for (const [language, raw] of Object.entries(asRecord(value))) {
if (typeof raw !== "string") continue if (typeof raw !== "string") continue
const match = raw.match(/\/\/TEMPLATE BEGIN\n([\s\S]+?)\/\/TEMPLATE END/) const match = raw.match(/\/\/TEMPLATE BEGIN\n([\s\S]+?)\/\/TEMPLATE END/)
templates[language] = match?.[1] ?? "" templates[language] = match?.[1] ?? ""
@@ -63,7 +57,7 @@ async function getProblemStatuses(userId: number | undefined) {
.from(schema.userProfile) .from(schema.userProfile)
.where(eq(schema.userProfile.userId, userId)) .where(eq(schema.userProfile.userId, userId))
.limit(1) .limit(1)
return toObject(toObject(profile?.value).problems) return asRecord(asRecord(profile?.value).problems)
} }
async function getProblemTags(problemIds: number[]) { async function getProblemTags(problemIds: number[]) {
@@ -94,7 +88,7 @@ function listItem(
tags: Map<number, string[]>, tags: Map<number, string[]>,
statuses: Record<string, unknown>, statuses: Record<string, unknown>,
) { ) {
const status = toObject(statuses[String(row.problem.id)]).status const status = asRecord(statuses[String(row.problem.id)]).status
return { return {
id: row.problem.id, id: row.problem.id,
_id: row.problem.displayId, _id: row.problem.displayId,
@@ -287,7 +281,7 @@ problemRoutes.get("/problems/:id/beat-count", optionalAuth, async (c) => {
) )
if (!mine?.value) return success(c, "0") if (!mine?.value) return success(c, "0")
// 「近两年」按东八区日历算到当天零点 // 「近两年」按东八区日历算到当天零点
const since = todayStart(shiftMonthsByCalendar(new Date(), -24)) const since = dayStart(shiftMonthsByCalendar(new Date(), -24))
const [active, accepted] = await Promise.all([ const [active, accepted] = await Promise.all([
db db
.select({ value: count() }) .select({ value: count() })
@@ -341,7 +335,7 @@ problemRoutes.get("/problems/:displayId/similar", optionalAuth, async (c) => {
// 而这个接口恰好只在**刚 AC** 或**连挂三次**时才被调用,正是候选最容易全中的时候。 // 而这个接口恰好只在**刚 AC** 或**连挂三次**时才被调用,正是候选最容易全中的时候。
const statuses = await getProblemStatuses(c.get("user")?.id) const statuses = await getProblemStatuses(c.get("user")?.id)
const solvedIds = Object.entries(statuses) const solvedIds = Object.entries(statuses)
.filter(([, value]) => toObject(value).status === JudgeStatus.ACCEPTED) .filter(([, value]) => asRecord(value).status === JudgeStatus.ACCEPTED)
.map(([key]) => Number(key)) .map(([key]) => Number(key))
.filter((id) => Number.isInteger(id)) .filter((id) => Number.isInteger(id))
// difficulty 是 text(Low / Mid / High),直接 order by 走的是字典序 —— // difficulty 是 text(Low / Mid / High),直接 order by 走的是字典序 ——
@@ -478,8 +472,8 @@ problemRoutes.get("/problems/:displayId", optionalAuth, async (c) => {
.from(schema.userProfile) .from(schema.userProfile)
.where(eq(schema.userProfile.userId, user.id)) .where(eq(schema.userProfile.userId, user.id))
.limit(1) .limit(1)
const statuses = objectValue(objectValue(profile?.status).problems) const statuses = asRecord(asRecord(profile?.status).problems)
const problemStatus = objectValue(statuses[String(row.problem.id)]).status const problemStatus = asRecord(statuses[String(row.problem.id)]).status
if (typeof problemStatus === "number") myStatus = problemStatus if (typeof problemStatus === "number") myStatus = problemStatus
// 前端拿这个数决定「让 AI 分析我的代码」露不露面,口径必须和 POST /ai/hint // 前端拿这个数决定「让 AI 分析我的代码」露不露面,口径必须和 POST /ai/hint
@@ -508,7 +502,7 @@ problemRoutes.get("/problems/:displayId", optionalAuth, async (c) => {
prompt: row.problem.prompt, prompt: row.problem.prompt,
submissionNumber: row.problem.submissionNumber, submissionNumber: row.problem.submissionNumber,
acceptedNumber: row.problem.acceptedNumber, acceptedNumber: row.problem.acceptedNumber,
statisticInfo: objectValue(row.problem.statisticInfo), statisticInfo: asRecord(row.problem.statisticInfo),
contestId: row.problem.contestId, contestId: row.problem.contestId,
tags: tagRows.map((tag) => tag.name), tags: tagRows.map((tag) => tag.name),
createdBy: sampleUser( createdBy: sampleUser(
@@ -522,7 +516,7 @@ problemRoutes.get("/problems/:displayId", optionalAuth, async (c) => {
mermaidCode: row.problem.allowFlowchart ? null : row.problem.mermaidCode, mermaidCode: row.problem.allowFlowchart ? null : row.problem.mermaidCode,
flowchartData: row.problem.allowFlowchart flowchartData: row.problem.allowFlowchart
? null ? null
: objectValue(row.problem.flowchartData), : asRecord(row.problem.flowchartData),
flowchartHint: row.problem.flowchartHint, flowchartHint: row.problem.flowchartHint,
sqlConfig: row.problem.sqlConfig, sqlConfig: row.problem.sqlConfig,
sqlDisplay: row.problem.sqlDisplay, sqlDisplay: row.problem.sqlDisplay,
+3 -3
View File
@@ -33,7 +33,7 @@ import {
import { db, schema } from "../db" import { db, schema } from "../db"
import { failure, parseBody, success } from "../http" import { failure, parseBody, success } from "../http"
import { computeProgress } from "../services/problemset" import { computeProgress } from "../services/problemset"
import { asFilterValue, objectValue, queryInteger, sampleUser } from "./helpers" import { asFilterValue, asRecord, queryInteger, sampleUser } from "./helpers"
export const problemsetRoutes = new Hono<AppEnv>() export const problemsetRoutes = new Hono<AppEnv>()
@@ -299,7 +299,7 @@ problemsetRoutes.get("/problem-sets/:id/problems", optionalAuth, async (c) => {
) )
.limit(1) .limit(1)
: [] : []
const completed = objectValue(progressRows[0]?.detail) const completed = asRecord(progressRows[0]?.detail)
return success( return success(
c, c,
rows.map( rows.map(
@@ -582,7 +582,7 @@ problemsetRoutes.get(
totalProblemsCount: progress.totalProblemsCount, totalProblemsCount: progress.totalProblemsCount,
totalScore: progress.totalScore, totalScore: progress.totalScore,
completedProblems: Object.keys( completedProblems: Object.keys(
objectValue(progress.progressDetail), asRecord(progress.progressDetail),
).flatMap((key) => problemMap.get(key) ?? []), ).flatMap((key) => problemMap.get(key) ?? []),
}) satisfies ProblemSetProgress, }) satisfies ProblemSetProgress,
) )
+2 -2
View File
@@ -35,7 +35,7 @@ import {
} from "../judge/status" } from "../judge/status"
import { type ContestEnv } from "../services/contest" import { type ContestEnv } from "../services/contest"
import { getBooleanOption } from "../services/options" import { getBooleanOption } from "../services/options"
import { localTime, todayStart } from "../time" import { localTime, dayStart } from "../time"
import { isAdminRole, matchedUsers, rounded, stripClassPrefix } from "./helpers" import { isAdminRole, matchedUsers, rounded, stripClassPrefix } from "./helpers"
export const submissionStatisticsRoutes = new Hono<ContestEnv>() export const submissionStatisticsRoutes = new Hono<ContestEnv>()
@@ -68,7 +68,7 @@ submissionStatisticsRoutes.get(
isAdminRole(c.get("user")) isAdminRole(c.get("user"))
const where = and( const where = and(
isNull(schema.submission.contestId), isNull(schema.submission.contestId),
sql`${schema.submission.createTime} >= ${todayStart()}`, sql`${schema.submission.createTime} >= ${dayStart()}`,
) )
const acceptedFilter = sql`count(*) filter (where ${inArray(schema.submission.result, ACCEPTED_RESULTS)})` const acceptedFilter = sql`count(*) filter (where ${inArray(schema.submission.result, ACCEPTED_RESULTS)})`
const judgingFilter = sql`count(*) filter (where ${inArray(schema.submission.result, UNJUDGED_RESULTS)})` const judgingFilter = sql`count(*) filter (where ${inArray(schema.submission.result, UNJUDGED_RESULTS)})`
+10 -16
View File
@@ -44,8 +44,8 @@ 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 { todayStart } from "../time" import { dayStart } from "../time"
import { asFilterValue, isAdminRole, queryInteger } from "./helpers" import { asFilterValue, asRecord, isAdminRole, queryInteger } from "./helpers"
import { import {
problemFilter, problemFilter,
submissionStatisticsRoutes, submissionStatisticsRoutes,
@@ -54,12 +54,6 @@ import {
export const submissionRoutes = new Hono<ContestEnv>() export const submissionRoutes = new Hono<ContestEnv>()
function objectValue(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {}
}
/** /**
* 落编辑过程信号。**失败只记日志、不影响提交** —— 这是附带的统计数据, * 落编辑过程信号。**失败只记日志、不影响提交** —— 这是附带的统计数据,
* 提交已经进库了,不能因为它回一个 500 让学生以为没交上。 * 提交已经进库了,不能因为它回一个 500 让学生以为没交上。
@@ -234,7 +228,7 @@ submissionRoutes.get("/submissions/today-count", async (c) => {
const [row] = await db const [row] = await db
.select({ value: count() }) .select({ value: count() })
.from(schema.flowchartSubmission) .from(schema.flowchartSubmission)
.where(sql`${schema.flowchartSubmission.createTime} >= ${todayStart()}`) .where(sql`${schema.flowchartSubmission.createTime} >= ${dayStart()}`)
return success(c, row?.value ?? 0) return success(c, row?.value ?? 0)
} }
const [row] = await db const [row] = await db
@@ -243,7 +237,7 @@ submissionRoutes.get("/submissions/today-count", async (c) => {
.where( .where(
and( and(
isNull(schema.submission.contestId), isNull(schema.submission.contestId),
sql`${schema.submission.createTime} >= ${todayStart()}`, sql`${schema.submission.createTime} >= ${dayStart()}`,
), ),
) )
return success(c, row?.value ?? 0) return success(c, row?.value ?? 0)
@@ -452,10 +446,10 @@ const submissionListColumns = {
function caseSummary(submission: typeof schema.submission.$inferSelect) { function caseSummary(submission: typeof schema.submission.$inferSelect) {
if (submission.contestId !== null || submission.language === "SQL") if (submission.contestId !== null || submission.language === "SQL")
return null return null
const data = objectValue(submission.info).data const data = asRecord(submission.info).data
if (!Array.isArray(data) || data.length === 0) return null if (!Array.isArray(data) || data.length === 0) return null
const passed = data.filter( const passed = data.filter(
(item) => objectValue(item).result === JudgeStatus.ACCEPTED, (item) => asRecord(item).result === JudgeStatus.ACCEPTED,
).length ).length
return { passed, total: data.length } return { passed, total: data.length }
} }
@@ -508,7 +502,7 @@ async function submissionDetail(id: string, user: AuthUser) {
result: row.submission.result, result: row.submission.result,
info: full ? row.submission.info : {}, info: full ? row.submission.info : {},
language: row.submission.language, language: row.submission.language,
statisticInfo: objectValue(row.submission.statisticInfo), statisticInfo: asRecord(row.submission.statisticInfo),
// contest 也在旧后端的排除名单里,同样只给管理员 // contest 也在旧后端的排除名单里,同样只给管理员
contestId: full ? row.submission.contestId : null, contestId: full ? row.submission.contestId : null,
problemId: row.submission.problemId, problemId: row.submission.problemId,
@@ -652,7 +646,7 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => {
if (language) if (language)
filters.push(eq(schema.submission.language, asFilterValue(language))) filters.push(eq(schema.submission.language, asFilterValue(language)))
if (c.req.query("today") === "1") if (c.req.query("today") === "1")
filters.push(sql`${schema.submission.createTime} >= ${todayStart()}`) filters.push(sql`${schema.submission.createTime} >= ${dayStart()}`)
const where = and(...filters) const where = and(...filters)
// count 不 join problem:无条件 join 会让计划器把 count 退化成 seq scan // count 不 join problem:无条件 join 会让计划器把 count 退化成 seq scan
// (生产快照实测 7.5ms → 78ms)。题号已经解析成 problem_id,也用不着 join。 // (生产快照实测 7.5ms → 78ms)。题号已经解析成 problem_id,也用不着 join。
@@ -689,7 +683,7 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => {
username: submission.username, username: submission.username,
result: submission.result, result: submission.result,
language: submission.language, language: submission.language,
statisticInfo: objectValue(submission.statisticInfo), statisticInfo: asRecord(submission.statisticInfo),
// 题单被删掉之后外键把 problemset_id 置了空,这里自然就没标记了 // 题单被删掉之后外键把 problemset_id 置了空,这里自然就没标记了
problemSet: problemSet:
submission.problemsetId !== null && submission.problemsetId !== null &&
@@ -773,7 +767,7 @@ submissionRoutes.get(
username: submission.username, username: submission.username,
result: submission.result, result: submission.result,
language: submission.language, language: submission.language,
statisticInfo: objectValue(submission.statisticInfo), statisticInfo: asRecord(submission.statisticInfo),
// 比赛提交没有来源题单:题单只收非比赛题(admin/problemset.ts 加题时卡了 // 比赛提交没有来源题单:题单只收非比赛题(admin/problemset.ts 加题时卡了
// isNull(problem.contestId)),提交接口那边也只在 contestId 为空时才认这个字段 // isNull(problem.contestId)),提交接口那边也只在 contestId 为空时才认这个字段
problemSet: null, problemSet: null,
+5 -5
View File
@@ -2,7 +2,7 @@ import { eq, sql } from "drizzle-orm"
import { db, schema } from "../db" import { db, schema } from "../db"
import { JudgeStatus, isAccepted } from "../judge/status" import { JudgeStatus, isAccepted } from "../judge/status"
import { objectValue } from "../routes/helpers" import { asRecord } from "../routes/helpers"
import { import {
metaAchievements, metaAchievements,
refreshUnlockedCount, refreshUnlockedCount,
@@ -305,7 +305,7 @@ async function computePlan(): Promise<Plan> {
}) })
} }
if ( if (
stable(objectValue(problem.statisticInfo)) !== stable(want.statisticInfo) stable(asRecord(problem.statisticInfo)) !== stable(want.statisticInfo)
) { ) {
rows.push({ rows.push({
label, label,
@@ -328,7 +328,7 @@ async function computePlan(): Promise<Plan> {
} }
// acm_problems_status 里除了 problems / contest_problems 之外的键原样保留 —— // acm_problems_status 里除了 problems / contest_problems 之外的键原样保留 ——
// persistResult 只写这两个桶,别的键是从哪来的没人说得清,重算不该顺手抹掉。 // persistResult 只写这两个桶,别的键是从哪来的没人说得清,重算不该顺手抹掉。
const existing = objectValue(profile.acmProblemsStatus) const existing = asRecord(profile.acmProblemsStatus)
const merged: Record<string, unknown> = { ...existing } const merged: Record<string, unknown> = { ...existing }
delete merged.problems delete merged.problems
delete merged.contest_problems delete merged.contest_problems
@@ -355,13 +355,13 @@ async function computePlan(): Promise<Plan> {
} }
if (stable(existing) !== stable(merged)) { if (stable(existing) !== stable(merged)) {
const keys = new Set([ const keys = new Set([
...Object.keys(objectValue(existing.problems)), ...Object.keys(asRecord(existing.problems)),
...Object.keys(want.status.problems ?? {}), ...Object.keys(want.status.problems ?? {}),
]) ])
rows.push({ rows.push({
label, label,
field: "acm_problems_status", field: "acm_problems_status",
before: `${Object.keys(objectValue(existing.problems)).length} 题`, before: `${Object.keys(asRecord(existing.problems)).length} 题`,
after: `${keys.size} 题(含比赛桶重建)`, after: `${keys.size} 题(含比赛桶重建)`,
}) })
} }
+5 -5
View File
@@ -16,7 +16,7 @@ import { publishAchievementNotification } from "../events"
import { calendarDay, dayNumber, localHour } from "../time" import { calendarDay, dayNumber, localHour } from "../time"
import { findMetric } from "./achievement-metrics" import { findMetric } from "./achievement-metrics"
import { isAccepted, JudgeStatus } from "../judge/status" import { isAccepted, JudgeStatus } from "../judge/status"
import { objectValue } from "../routes/helpers" import { asRecord } from "../routes/helpers"
function numberMetric(metrics: Record<string, unknown>, key: string) { function numberMetric(metrics: Record<string, unknown>, key: string) {
const value = metrics[key] const value = metrics[key]
@@ -129,7 +129,7 @@ export async function updateAchievementsForSubmission(submissionId: string) {
.where(eq(schema.userStat.userId, row.submission.userId)) .where(eq(schema.userStat.userId, row.submission.userId))
.for("update") .for("update")
if (!stat) throw new Error("User achievement stat could not be created") if (!stat) throw new Error("User achievement stat could not be created")
const value = objectValue(stat.metrics) const value = asRecord(stat.metrics)
value.submission_count = numberMetric(value, "submission_count") + 1 value.submission_count = numberMetric(value, "submission_count") + 1
if (firstAc) { if (firstAc) {
value.accepted_count = numberMetric(value, "accepted_count") + 1 value.accepted_count = numberMetric(value, "accepted_count") + 1
@@ -143,7 +143,7 @@ export async function updateAchievementsForSubmission(submissionId: string) {
numberMetric(value, "max_wa_before_ac"), numberMetric(value, "max_wa_before_ac"),
priorRows.length, priorRows.length,
) )
const perDay = objectValue(value._ac_per_day) const perDay = asRecord(value._ac_per_day)
perDay[date] = (typeof perDay[date] === "number" ? perDay[date] : 0) + 1 perDay[date] = (typeof perDay[date] === "number" ? perDay[date] : 0) + 1
value._ac_per_day = perDay value._ac_per_day = perDay
value.max_ac_in_one_day = Math.max( value.max_ac_in_one_day = Math.max(
@@ -264,7 +264,7 @@ export async function updateAchievementsForProblemSet(userId: number) {
.for("update") .for("update")
.limit(1) .limit(1)
if (!stat) throw new Error("User achievement stat could not be created") if (!stat) throw new Error("User achievement stat could not be created")
const value = objectValue(stat.metrics) const value = asRecord(stat.metrics)
value.badge_count = badgeRow?.value ?? 0 value.badge_count = badgeRow?.value ?? 0
value.problemset_completed = completedRow?.value ?? 0 value.problemset_completed = completedRow?.value ?? 0
await tx await tx
@@ -345,7 +345,7 @@ export async function rescanAchievement(achievementId: number) {
.from(schema.userStat) .from(schema.userStat)
const eligible = stats.filter((stat) => { const eligible = stats.filter((stat) => {
if (already.has(stat.userId)) return false if (already.has(stat.userId)) return false
const value = objectValue(stat.metrics)[achievement.metric] const value = asRecord(stat.metrics)[achievement.metric]
if (typeof value !== "number") return false if (typeof value !== "number") return false
return achievement.operator === "gte" return achievement.operator === "gte"
? value >= achievement.threshold ? value >= achievement.threshold
+6 -6
View File
@@ -13,7 +13,7 @@ import { and, desc, eq, isNotNull } from "drizzle-orm"
import { config } from "../config" import { config } from "../config"
import { db, schema } from "../db" import { db, schema } from "../db"
import { JudgeStatus, judgeStatusName } from "../judge/status" import { JudgeStatus, judgeStatusName } from "../judge/status"
import { objectValue } from "../routes/helpers" import { asRecord } from "../routes/helpers"
import { completeChat } from "./ai" import { completeChat } from "./ai"
import { KEY_LINE_LEVEL } from "./hint-filter" import { KEY_LINE_LEVEL } from "./hint-filter"
import { readInfo } from "./test-case" import { readInfo } from "./test-case"
@@ -80,7 +80,7 @@ const SINGLE_SYSTEM =
"你是编程助教。指出学生代码最关键的一个问题,循序渐进地提示,绝不直接给出核心算法或完整解法。输入读取错误可以直接给出正确片段。使用 Markdown,不超过6句话。" "你是编程助教。指出学生代码最关键的一个问题,循序渐进地提示,绝不直接给出核心算法或完整解法。输入读取错误可以直接给出正确片段。使用 Markdown,不超过6句话。"
function errInfo(row: HintRow) { function errInfo(row: HintRow) {
return String(objectValue(row.submission.statisticInfo).err_info ?? "无") return String(asRecord(row.submission.statisticInfo).err_info ?? "无")
} }
/** /**
@@ -122,7 +122,7 @@ function problemBrief(row: HintRow) {
const samples = ( const samples = (
Array.isArray(row.problem.samples) ? row.problem.samples : [] Array.isArray(row.problem.samples) ? row.problem.samples : []
) )
.map((item) => objectValue(item)) .map((item) => asRecord(item))
.filter( .filter(
(item): item is { input: string; output: string } => (item): item is { input: string; output: string } =>
typeof item.input === "string" && typeof item.output === "string", typeof item.input === "string" && typeof item.output === "string",
@@ -152,7 +152,7 @@ function numbered(code: string) {
/** 同语言的标准答案优先;没有就拿别的语言的(思路一样,照样能帮诊断);再没有就 null */ /** 同语言的标准答案优先;没有就拿别的语言的(思路一样,照样能帮诊断);再没有就 null */
export function referenceAnswer(row: HintRow) { export function referenceAnswer(row: HintRow) {
const answers = Array.isArray(row.problem.answers) const answers = Array.isArray(row.problem.answers)
? row.problem.answers.map((item) => objectValue(item)) ? row.problem.answers.map((item) => asRecord(item))
: [] : []
const usable = answers.filter( const usable = answers.filter(
(item): item is { language: string; code: string } => (item): item is { language: string; code: string } =>
@@ -174,10 +174,10 @@ export function referenceAnswer(row: HintRow) {
*/ */
async function firstFailedCase(row: HintRow) { async function firstFailedCase(row: HintRow) {
if (row.submission.language === "SQL") return null if (row.submission.language === "SQL") return null
const data = objectValue(row.submission.info).data const data = asRecord(row.submission.info).data
if (!Array.isArray(data)) return null if (!Array.isArray(data)) return null
const failed = data const failed = data
.map((item) => objectValue(item)) .map((item) => asRecord(item))
.find((item) => typeof item.result === "number" && item.result !== 0) .find((item) => typeof item.result === "number" && item.result !== 0)
if (!failed || typeof failed.test_case !== "string") return null if (!failed || typeof failed.test_case !== "string") return null
try { try {
+5 -5
View File
@@ -1,7 +1,7 @@
import { and, eq, notInArray, sql } from "drizzle-orm" import { and, eq, notInArray, sql } from "drizzle-orm"
import { db, schema } from "../db" import { db, schema } from "../db"
import { objectValue } from "../routes/helpers" import { asRecord } from "../routes/helpers"
type BadgeRow = typeof schema.problemsetBadge.$inferSelect type BadgeRow = typeof schema.problemsetBadge.$inferSelect
type ProgressRow = typeof schema.problemsetProgress.$inferSelect type ProgressRow = typeof schema.problemsetProgress.$inferSelect
@@ -40,7 +40,7 @@ export function computeProgress(
if (score === undefined) continue if (score === undefined) continue
totalScore += score totalScore += score
// 分值以题单当前的设置为准,detail 里存的是做出那一刻的快照 // 分值以题单当前的设置为准,detail 里存的是做出那一刻的快照
kept[key] = { ...objectValue(value), score } kept[key] = { ...asRecord(value), score }
} }
// 分母只算必做题。「(选做)」这个标签一直只是卡片上的一行字,进度分母和 all_problems // 分母只算必做题。「(选做)」这个标签一直只是卡片上的一行字,进度分母和 all_problems
// 奖章照样要求做完 —— 快照里 22 个人做完了全部必做题,界面却显示未完成、全通奖章也拿不到 // 奖章照样要求做完 —— 快照里 22 个人做完了全部必做题,界面却显示未完成、全通奖章也拿不到
@@ -137,7 +137,7 @@ export function eligibleForBadge(badge: BadgeRow, progress: BadgeCheck) {
} }
if (badge.conditionType === "problem_count") { if (badge.conditionType === "problem_count") {
return ( return (
Object.keys(objectValue(progress.progressDetail)).length >= Object.keys(asRecord(progress.progressDetail)).length >=
badge.conditionValue badge.conditionValue
) )
} }
@@ -232,7 +232,7 @@ export async function resyncProgress(problemsetId: number) {
const updated = progresses.map((progress) => ({ const updated = progresses.map((progress) => ({
...progress, ...progress,
...computeProgress( ...computeProgress(
objectValue(progress.progressDetail), asRecord(progress.progressDetail),
links, links,
progress.completeTime, progress.completeTime,
now, now,
@@ -309,7 +309,7 @@ export async function recordSolvedProblem(
.values({ problemsetId, userId, submissionId, problemId }) .values({ problemsetId, userId, submissionId, problemId })
} }
const detail = objectValue(progress.progressDetail) const detail = asRecord(progress.progressDetail)
if (String(problemId) in detail) return [] if (String(problemId) in detail) return []
const links = await tx const links = await tx
.select({ .select({
+1 -1
View File
@@ -224,7 +224,7 @@ export async function packTestCaseZip(testCaseId: string) {
try { try {
entries = await readdir(directory) entries = await readdir(directory)
} catch { } catch {
throw new TestCaseError("Test case does not exists") throw new TestCaseError("Test case does not exist")
} }
const names = new Set(entries) const names = new Set(entries)
const isSql = await readInfo(testCaseId) const isSql = await readInfo(testCaseId)
+6 -3
View File
@@ -64,10 +64,13 @@ export function localWeekday(day: number): number {
return new Date(day * DAY_MS).getUTCDay() return new Date(day * DAY_MS).getUTCDay()
} }
/** 「东八区今天」的零点,返回 ISO 字符串。提交列表、流程图列表的 `?today=1` 和后台「今日提交数」用它 */ /**
export function todayStart(now: Date | number | string = new Date()): string { * `instant` 所在东八区日历日的零点,返回 ISO 字符串,缺省是今天。
* 提交列表、流程图列表的 `?today=1` 和后台「今日提交数」用它
*/
export function dayStart(instant: Date | number | string = new Date()): string {
return new Date( return new Date(
dayNumber(calendarDay(now)) * DAY_MS - OFFSET_MS, dayNumber(calendarDay(instant)) * DAY_MS - OFFSET_MS,
).toISOString() ).toISOString()
} }
+2 -7
View File
@@ -17,6 +17,7 @@ import {
} from "./judge/events" } from "./judge/events"
import { JudgeStatus } from "./judge/status" import { JudgeStatus } from "./judge/status"
import { createSubscriberRedis } from "./redis" import { createSubscriberRedis } from "./redis"
import { asRecord } from "./routes/helpers"
import { import {
configTopic, configTopic,
configUpdateChannel, configUpdateChannel,
@@ -204,12 +205,6 @@ export function startSessionSweep() {
return timer return timer
} }
function objectValue(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {}
}
export function submissionWebSocketHandler(): Bun.WebSocketHandler<SubmissionSocketData> { export function submissionWebSocketHandler(): Bun.WebSocketHandler<SubmissionSocketData> {
return { return {
open(ws) { open(ws) {
@@ -386,7 +381,7 @@ async function handleMessage(
return return
} }
const statistics = objectValue(submission.statisticInfo) const statistics = asRecord(submission.statisticInfo)
const status = const status =
submission.result === JudgeStatus.PENDING submission.result === JudgeStatus.PENDING
? "pending" ? "pending"
+1 -1
View File
@@ -33,7 +33,7 @@ useMaxKB()
// 真实身份:连着的话这个人会被算进「在线老师」,学生因此拿到 pending 而不是 // 真实身份:连着的话这个人会被算进「在线老师」,学生因此拿到 pending 而不是
// no_teacher,排队等一个顶栏里根本没有求助列表的人;反过来他自己看到的求助 // no_teacher,排队等一个顶栏里根本没有求助列表的人;反过来他自己看到的求助
// 按钮点下去,服务端回的是「教师不能发起求助」。两头都不对,索性对演示模式 // 按钮点下去,服务端回的是「教师不能发起求助」。两头都不对,索性对演示模式
// 关闭这个功能(Form.vue 的按钮同步隐藏)。 // 关闭这个功能(EditorToolbar.vue 的按钮同步隐藏)。
watch( watch(
() => userStore.isAuthed && !userStore.demoMode, () => userStore.isAuthed && !userStore.demoMode,
(available) => { (available) => {
+15 -15
View File
@@ -1,5 +1,5 @@
import api from "utils/api" import api from "utils/api"
import { toProblemListItem } from "admin/transforms" import { toAdminProblemRow } from "admin/transforms"
import type { import type {
AcTrend, AcTrend,
AdminProblemSetProgress, AdminProblemSetProgress,
@@ -8,7 +8,7 @@ import type {
RenameTagResponse, RenameTagResponse,
SqlTestCaseScript, SqlTestCaseScript,
AcmHelperItem, AcmHelperItem,
SubmissionInfo, ContestSubmissionInfo,
AdminAiReport, AdminAiReport,
AdminAiReportList, AdminAiReportList,
StuckProblem, StuckProblem,
@@ -30,13 +30,13 @@ import type {
AdminAnnouncementListItem, AdminAnnouncementListItem,
BlankContest, BlankContest,
BlankProblem, BlankProblem,
Contest, AdminContest,
Exercise, Exercise,
ExerciseType, ExerciseType,
SqlDisplay, SqlDisplay,
UploadTestCaseResponse, UploadTestCaseResponse,
Tutorial, Tutorial,
User, AdminUserForm,
WebsiteConfig, WebsiteConfig,
AdminProblemSetBadge, AdminProblemSetBadge,
AdminProblemSetList, AdminProblemSetList,
@@ -70,7 +70,7 @@ export async function getProblemList(
params: { offset, limit, keyword, author, tagId }, params: { offset, limit, keyword, author, tagId },
}) })
return { return {
results: res.results.map(toProblemListItem), results: res.results.map(toAdminProblemRow),
total: res.total, total: res.total,
} }
} }
@@ -165,7 +165,7 @@ export function getUserList(
} }
// 编辑用户 // 编辑用户
export function editUser(user: User) { export function editUser(user: AdminUserForm) {
return api.put<AdminUser>(`admin/users/${user.id}`, { return api.put<AdminUser>(`admin/users/${user.id}`, {
username: user.username, username: user.username,
email: user.email, email: user.email,
@@ -297,7 +297,7 @@ export function createContestProblem(problem: BlankProblem) {
} }
/** 组件里的比赛对象是 snake_case,出站转成新后端要的 camelCase */ /** 组件里的比赛对象是 snake_case,出站转成新后端要的 camelCase */
function toContestBody(contest: Contest | BlankContest) { function toContestBody(contest: AdminContest | BlankContest) {
return { return {
title: contest.title, title: contest.title,
description: contest.description, description: contest.description,
@@ -310,22 +310,22 @@ function toContestBody(contest: Contest | BlankContest) {
} }
export function createContest(contest: BlankContest) { export function createContest(contest: BlankContest) {
return api.post<Contest>("admin/contests", toContestBody(contest)) return api.post<AdminContest>("admin/contests", toContestBody(contest))
} }
export function editContest(contest: Contest | BlankContest) { export function editContest(contest: AdminContest | BlankContest) {
return api.put<Contest>( return api.put<AdminContest>(
`admin/contests/${(contest as Contest).id}`, `admin/contests/${(contest as AdminContest).id}`,
toContestBody(contest), toContestBody(contest),
) )
} }
export function cloneContest(contestId: number) { export function cloneContest(contestId: number) {
return api.post<Contest>(`admin/contests/${contestId}/clone`) return api.post<AdminContest>(`admin/contests/${contestId}/clone`)
} }
export function getContest(id: string) { export function getContest(id: string) {
return api.get<Contest>(`admin/contests/${id}`) return api.get<AdminContest>(`admin/contests/${id}`)
} }
export function addProblemForContest( export function addProblemForContest(
@@ -463,9 +463,9 @@ export function makeProblemPublic(id: number, displayId: string) {
// 比赛辅助检查 // 比赛辅助检查
export function getACMHelperList(contestId: number) { export function getACMHelperList(contestId: number) {
// acInfo 在契约里是 Record<string, unknown>(acm_contest_rank 的 JSONB 原文), // acInfo 在契约里是 Record<string, unknown>(acm_contest_rank 的 JSONB 原文),
// 组件侧按 SubmissionInfo 读,收窄放在这里 // 组件侧按 ContestSubmissionInfo 读,收窄放在这里
return api.get< return api.get<
Array<Omit<AcmHelperItem, "acInfo"> & { acInfo: SubmissionInfo }> Array<Omit<AcmHelperItem, "acInfo"> & { acInfo: ContestSubmissionInfo }>
>(`admin/contests/${contestId}/acm-helper`) >(`admin/contests/${contestId}/acm-helper`)
} }
@@ -1,9 +1,9 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { Contest } from "utils/types" import type { AdminContest } from "utils/types"
import { cloneContest } from "../../api" import { cloneContest } from "../../api"
interface Props { interface Props {
contest: Contest contest: AdminContest
} }
const props = defineProps<Props>() const props = defineProps<Props>()
const router = useRouter() const router = useRouter()
+3 -3
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { errorMessage } from "utils/api" import { errorMessage } from "utils/api"
import type { AcmHelperItem, SubmissionInfo } from "utils/types" import type { AcmHelperItem, ContestSubmissionInfo } from "utils/types"
import { NButton, NCheckbox, NSelect, NTag } from "naive-ui" import { NButton, NCheckbox, NSelect, NTag } from "naive-ui"
import { parseTime } from "utils/functions" import { parseTime } from "utils/functions"
import { getACMHelperList, getContest, updateACMHelperChecked } from "../api" import { getACMHelperList, getContest, updateACMHelperChecked } from "../api"
@@ -14,10 +14,10 @@ interface Props {
/** /**
* ACM 助手行。`acInfo` 的**内容**是 acm_contest_rank.submission_info 的 JSONB 原文, * ACM 助手行。`acInfo` 的**内容**是 acm_contest_rank.submission_info 的 JSONB 原文,
* 键名保持 snake_case —— 判题写进去的就是这套键名,见 utils/types.ts 的 SubmissionInfo。 * 键名保持 snake_case —— 判题写进去的就是这套键名,见 utils/types.ts 的 ContestSubmissionInfo。
*/ */
type HelperItem = Omit<AcmHelperItem, "acInfo"> & { type HelperItem = Omit<AcmHelperItem, "acInfo"> & {
acInfo: SubmissionInfo acInfo: ContestSubmissionInfo
} }
const props = defineProps<Props>() const props = defineProps<Props>()
+6 -6
View File
@@ -1,15 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { NSwitch, NTag } from "naive-ui" import { NSwitch, NTag } from "naive-ui"
import ContestTitle from "shared/components/ContestTitle.vue" import ContestTitle from "shared/components/ContestTitle.vue"
import ContestType from "shared/components/ContestType.vue" import ContestAccessTag from "shared/components/ContestAccessTag.vue"
import Pagination from "shared/components/Pagination.vue" import Pagination from "shared/components/Pagination.vue"
import { CONTEST_STATUS } from "utils/constants" import { CONTEST_STATUS } from "utils/constants"
import { parseTime } from "utils/functions" import { parseTime } from "utils/functions"
import type { Contest } from "utils/types" import type { AdminContest } from "utils/types"
import { editContest, getContestList } from "../api" import { editContest, getContestList } from "../api"
import Actions from "./components/Actions.vue" import Actions from "./components/Actions.vue"
const contests = ref<Contest[]>([]) const contests = ref<AdminContest[]>([])
const total = ref(0) const total = ref(0)
const query = reactive({ const query = reactive({
limit: 10, limit: 10,
@@ -17,12 +17,12 @@ const query = reactive({
keyword: "", keyword: "",
}) })
function toggleVisible(contest: Contest) { function toggleVisible(contest: AdminContest) {
contest.visible = !contest.visible contest.visible = !contest.visible
editContest(contest) editContest(contest)
} }
const columns: DataTableColumn<Contest>[] = [ const columns: DataTableColumn<AdminContest>[] = [
{ title: "ID", key: "id", width: 60 }, { title: "ID", key: "id", width: 60 },
{ {
title: "比赛", title: "比赛",
@@ -39,7 +39,7 @@ const columns: DataTableColumn<Contest>[] = [
title: "类型", title: "类型",
key: "contest_type", key: "contest_type",
width: 100, width: 100,
render: (row) => h(ContestType, { contest: row, size: "small" }), render: (row) => h(ContestAccessTag, { contest: row, size: "small" }),
}, },
{ {
title: "状态", title: "状态",
@@ -1,7 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { getProblemList } from "admin/api" import { getProblemList } from "admin/api"
import Pagination from "shared/components/Pagination.vue" import Pagination from "shared/components/Pagination.vue"
import type { AdminProblemFiltered } from "utils/types" import type { AdminProblemRow } from "utils/types"
import AddButton from "./AddButton.vue" import AddButton from "./AddButton.vue"
interface Props { interface Props {
@@ -23,9 +23,9 @@ const query = reactive({
keyword: "", keyword: "",
}) })
const total = ref(0) const total = ref(0)
const problems = shallowRef<AdminProblemFiltered[]>([]) const problems = shallowRef<AdminProblemRow[]>([])
const columns: DataTableColumn<AdminProblemFiltered>[] = [ const columns: DataTableColumn<AdminProblemRow>[] = [
{ title: "编号", key: "_id", width: 80 }, { title: "编号", key: "_id", width: 80 },
{ title: "标题", key: "title" }, { title: "标题", key: "title" },
{ {
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { NButton, NTag } from "naive-ui" import { NButton, NTag } from "naive-ui"
import Pagination from "shared/components/Pagination.vue" import Pagination from "shared/components/Pagination.vue"
import type { AdminProblemFiltered } from "utils/types" import type { AdminProblemRow } from "utils/types"
import { batchTagProblems, getProblemList } from "admin/api" import { batchTagProblems, getProblemList } from "admin/api"
interface Props { interface Props {
@@ -19,13 +19,13 @@ const emit = defineEmits<{
const router = useRouter() const router = useRouter()
const message = useMessage() const message = useMessage()
const problems = ref<AdminProblemFiltered[]>([]) const problems = ref<AdminProblemRow[]>([])
const total = ref(0) const total = ref(0)
const page = ref(1) const page = ref(1)
const limit = ref(10) const limit = ref(10)
const keyword = ref("") const keyword = ref("")
const columns: DataTableColumn<AdminProblemFiltered>[] = [ const columns: DataTableColumn<AdminProblemRow>[] = [
{ title: "显示编号", key: "_id", width: 100 }, { title: "显示编号", key: "_id", width: 100 },
{ {
title: "标题", title: "标题",
@@ -81,12 +81,12 @@ function close() {
emit("update:show", false) emit("update:show", false)
} }
function goEdit(row: AdminProblemFiltered) { function goEdit(row: AdminProblemRow) {
close() close()
router.push({ name: "admin problem edit", params: { problemID: row.id } }) router.push({ name: "admin problem edit", params: { problemID: row.id } })
} }
async function removeTag(row: AdminProblemFiltered) { async function removeTag(row: AdminProblemRow) {
await batchTagProblems([row.id], [props.tagName], "remove") await batchTagProblems([row.id], [props.tagName], "remove")
message.success(`已移除「${row.title}」的标签`) message.success(`已移除「${row.title}」的标签`)
emit("changed") emit("changed")
+7 -7
View File
@@ -4,11 +4,11 @@ import { Icon } from "@iconify/vue"
import Pagination from "shared/components/Pagination.vue" import Pagination from "shared/components/Pagination.vue"
import { usePagination } from "shared/composables/pagination" import { usePagination } from "shared/composables/pagination"
import { getTagColor, parseTime } from "utils/functions" import { getTagColor, parseTime } from "utils/functions"
import type { AdminProblemFiltered } from "utils/types" import type { AdminProblemRow } from "utils/types"
import { DIFFICULTY, REACTIONS } from "utils/constants" import { DIFFICULTY, REACTIONS } from "utils/constants"
import { getProblemList, toggleProblemVisible } from "../api" import { getProblemList, toggleProblemVisible } from "../api"
import Actions from "./components/Actions.vue" import Actions from "./components/Actions.vue"
import Modal from "./components/Modal.vue" import AddFromLibraryModal from "./components/AddFromLibraryModal.vue"
import { useRouteQuery } from "@vueuse/router" import { useRouteQuery } from "@vueuse/router"
import AuthorSelect from "shared/components/AuthorSelect.vue" import AuthorSelect from "shared/components/AuthorSelect.vue"
import type { DataTableRowKey } from "naive-ui" import type { DataTableRowKey } from "naive-ui"
@@ -36,7 +36,7 @@ const isContestProblemList = computed(
const [show, toggleShow] = useToggle() const [show, toggleShow] = useToggle()
const { count, inc } = useCounter(0) const { count, inc } = useCounter(0)
const total = ref(0) const total = ref(0)
const problems = ref<AdminProblemFiltered[]>([]) const problems = ref<AdminProblemRow[]>([])
const selectedRowKeys = ref<DataTableRowKey[]>([]) const selectedRowKeys = ref<DataTableRowKey[]>([])
const batchTagAction = ref<"add" | "remove">("add") const batchTagAction = ref<"add" | "remove">("add")
@@ -46,7 +46,7 @@ const selectedProblemIds = computed(() =>
selectedRowKeys.value.map((key) => Number(key)), selectedRowKeys.value.map((key) => Number(key)),
) )
const rowKey = (row: AdminProblemFiltered) => row.id const rowKey = (row: AdminProblemRow) => row.id
function chooseProblems(rowKeys: DataTableRowKey[]) { function chooseProblems(rowKeys: DataTableRowKey[]) {
selectedRowKeys.value = rowKeys selectedRowKeys.value = rowKeys
@@ -83,7 +83,7 @@ const { query, clearQuery } = usePagination<ProblemQuery>({
author: useRouteQuery("author", "").value, author: useRouteQuery("author", "").value,
}) })
const baseColumns: DataTableColumn<AdminProblemFiltered>[] = [ const baseColumns: DataTableColumn<AdminProblemRow>[] = [
{ title: "ID", key: "id", width: 100 }, { title: "ID", key: "id", width: 100 },
{ title: "显示编号", key: "_id", width: 100 }, { title: "显示编号", key: "_id", width: 100 },
{ title: "标题", key: "title", minWidth: 200 }, { title: "标题", key: "title", minWidth: 200 },
@@ -183,7 +183,7 @@ const baseColumns: DataTableColumn<AdminProblemFiltered>[] = [
] ]
// 比赛题目接口不返回 top_reaction,这一列只在普通题目列表里显示 // 比赛题目接口不返回 top_reaction,这一列只在普通题目列表里显示
const columns = computed<DataTableColumn<AdminProblemFiltered>[]>(() => const columns = computed<DataTableColumn<AdminProblemRow>[]>(() =>
isContestProblemList.value isContestProblemList.value
? baseColumns.filter((it) => !("key" in it) || it.key !== "topReaction") ? baseColumns.filter((it) => !("key" in it) || it.key !== "topReaction")
: [{ type: "selection" }, ...baseColumns], : [{ type: "selection" }, ...baseColumns],
@@ -310,7 +310,7 @@ watch(() => [query.page, query.limit, query.author], listProblems)
v-model:limit="query.limit" v-model:limit="query.limit"
v-model:page="query.page" v-model:page="query.page"
/> />
<Modal <AddFromLibraryModal
v-model:show="show" v-model:show="show"
:count="count" :count="count"
:next-display-id="nextDisplayID" :next-display-id="nextDisplayID"
+3 -3
View File
@@ -1,9 +1,9 @@
import type { AdminProblemFiltered, AdminProblemListItem } from "utils/types" import type { AdminProblemRow, AdminProblemListItem } from "utils/types"
// 把后端的列表项塑形成管理端列表行,与请求逻辑解耦。 // 把后端的列表项塑形成管理端列表行,与请求逻辑解耦。
export function toProblemListItem( export function toAdminProblemRow(
result: AdminProblemListItem, result: AdminProblemListItem,
): AdminProblemFiltered { ): AdminProblemRow {
return { return {
id: result.id, id: result.id,
_id: result._id, _id: result._id,
@@ -1,16 +1,16 @@
<script lang="ts" setup> <script lang="ts" setup>
import { editUser } from "admin/api" import { editUser } from "admin/api"
import type { User } from "utils/types" import type { AdminUserForm } from "utils/types"
interface Props { interface Props {
user: User user: AdminUserForm
} }
const props = defineProps<Props>() const props = defineProps<Props>()
const emit = defineEmits<{ const emit = defineEmits<{
(e: "deleteUser", value: number[]): void (e: "deleteUser", value: number[]): void
(e: "userBanned", value: User): void (e: "userBanned", value: AdminUserForm): void
(e: "openEditModal", value: User): void (e: "openEditModal", value: AdminUserForm): void
(e: "resetPassword", value: User): void (e: "resetPassword", value: AdminUserForm): void
}>() }>()
async function banUser() { async function banUser() {
@@ -1,10 +1,10 @@
<script lang="ts" setup> <script lang="ts" setup>
import TextCopy from "shared/components/TextCopy.vue" import TextCopy from "shared/components/TextCopy.vue"
import { USER_TYPE } from "utils/constants" import { USER_TYPE } from "utils/constants"
import type { User } from "utils/types" import type { AdminUserForm } from "utils/types"
interface Props { interface Props {
user: User user: AdminUserForm
revealed: boolean revealed: boolean
} }
const props = defineProps<Props>() const props = defineProps<Props>()
@@ -1,14 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { PROBLEM_PERMISSION, USER_TYPE } from "utils/constants" import { PROBLEM_PERMISSION, USER_TYPE } from "utils/constants"
import { getUserRole } from "utils/functions" import { getUserRole } from "utils/functions"
import type { User } from "utils/types" import type { AdminUserForm } from "utils/types"
import TextCopy from "shared/components/TextCopy.vue" import TextCopy from "shared/components/TextCopy.vue"
interface Props { interface Props {
user: User user: AdminUserForm
} }
const props = defineProps<Props>() const props = defineProps<Props>()
const isNotRegularUser = computed( const hasRoleTag = computed(
() => props.user.adminType !== USER_TYPE.REGULAR_USER, () => props.user.adminType !== USER_TYPE.REGULAR_USER,
) )
</script> </script>
@@ -18,7 +18,7 @@ const isNotRegularUser = computed(
封号中 封号中
</n-tag> </n-tag>
<n-tag <n-tag
v-if="isNotRegularUser" v-if="hasRoleTag"
:type="getUserRole(props.user.adminType).type" :type="getUserRole(props.user.adminType).type"
size="small" size="small"
> >
+12 -12
View File
@@ -4,7 +4,7 @@ import { DataTableRowKey, NFlex, NTag, SelectOption } from "naive-ui"
import Pagination from "shared/components/Pagination.vue" import Pagination from "shared/components/Pagination.vue"
import { usePagination } from "shared/composables/pagination" import { usePagination } from "shared/composables/pagination"
import { parseTime } from "utils/functions" import { parseTime } from "utils/functions"
import type { User } from "utils/types" import type { AdminUserForm } from "utils/types"
import { import {
deleteUsers, deleteUsers,
editUser, editUser,
@@ -13,8 +13,8 @@ import {
resetPassword, resetPassword,
} from "../api" } from "../api"
import Actions from "./components/Actions.vue" import Actions from "./components/Actions.vue"
import Name from "./components/Name.vue" import UserNameCell from "./components/UserNameCell.vue"
import Password from "./components/Password.vue" import PasswordCell from "./components/PasswordCell.vue"
import { PROBLEM_PERMISSION, USER_TYPE } from "utils/constants" import { PROBLEM_PERMISSION, USER_TYPE } from "utils/constants"
import { useRouteQuery } from "@vueuse/router" import { useRouteQuery } from "@vueuse/router"
import TextCopy from "shared/components/TextCopy.vue" import TextCopy from "shared/components/TextCopy.vue"
@@ -35,8 +35,8 @@ const { query, clearQuery } = usePagination<UserQuery>({
}) })
const total = ref(0) const total = ref(0)
const users = ref<User[]>([]) const users = ref<AdminUserForm[]>([])
const userEditing = ref<User | null>(null) const userEditing = ref<AdminUserForm | null>(null)
const adminOptions = [ const adminOptions = [
{ label: "全部用户", value: "" }, { label: "全部用户", value: "" },
@@ -57,16 +57,16 @@ const password = ref("")
const revealedPasswords = ref(new Set<number>()) const revealedPasswords = ref(new Set<number>())
const userIDs = ref<DataTableRowKey[]>([]) const userIDs = ref<DataTableRowKey[]>([])
const rowKey = (row: User) => row.id const rowKey = (row: AdminUserForm) => row.id
const columns: DataTableColumn<User>[] = [ const columns: DataTableColumn<AdminUserForm>[] = [
{ type: "selection" }, { type: "selection" },
{ title: "ID", key: "id", width: 80 }, { title: "ID", key: "id", width: 80 },
{ {
title: "用户名", title: "用户名",
key: "username", key: "username",
width: 220, width: 220,
render: (row) => h(Name, { user: row }), render: (row) => h(UserNameCell, { user: row }),
}, },
{ {
// 显示出来的密码后面还跟着「隐藏」按钮,150 挤得下点点、挤不下密码 // 显示出来的密码后面还跟着「隐藏」按钮,150 挤得下点点、挤不下密码
@@ -74,7 +74,7 @@ const columns: DataTableColumn<User>[] = [
key: "raw_password", key: "raw_password",
width: 180, width: 180,
render: (row) => render: (row) =>
h(Password, { h(PasswordCell, {
user: row, user: row,
revealed: revealedPasswords.value.has(row.id), revealed: revealedPasswords.value.has(row.id),
onToggle: (id: number) => { onToggle: (id: number) => {
@@ -165,7 +165,7 @@ async function onDeleteUsers(userIDs: DataTableRowKey[] | Ref<number[]>) {
listUsers() listUsers()
} }
async function onResetPassword(user: User) { async function onResetPassword(user: AdminUserForm) {
const res = await resetPassword(user.id) const res = await resetPassword(user.id)
message.success(`【${user.username}】的密码已重置成【${res}】`) message.success(`【${user.username}】的密码已重置成【${res}】`)
users.value = users.value.map((it) => { users.value = users.value.map((it) => {
@@ -176,7 +176,7 @@ async function onResetPassword(user: User) {
}) })
} }
async function onUserBanned(user: User) { async function onUserBanned(user: AdminUserForm) {
users.value = users.value.map((it) => { users.value = users.value.map((it) => {
if (it.id === user.id) { if (it.id === user.id) {
it.isDisabled = user.isDisabled it.isDisabled = user.isDisabled
@@ -209,7 +209,7 @@ function createNewUser() {
password.value = "" password.value = ""
} }
function onOpenEditModal(user: User) { function onOpenEditModal(user: AdminUserForm) {
userEditing.value = user userEditing.value = user
password.value = "" password.value = ""
} }
+10 -10
View File
@@ -1,7 +1,7 @@
import { import {
type AiAnalysisRecord, type AiAnalysisRecord,
type AiHintFeedbackRequest, type AiHintFeedbackRequest,
type Contest as OjContest, type Contest,
type ContestAccess, type ContestAccess,
type ContestList, type ContestList,
type ActivityRankItem, type ActivityRankItem,
@@ -43,7 +43,7 @@ import {
} from "@oj2/contract" } from "@oj2/contract"
import api from "utils/api" import api from "utils/api"
import { contract } from "utils/contract" import { contract } from "utils/contract"
import { filterResult } from "oj/transforms" import { toProblemRow } from "oj/transforms"
import type { import type {
Announcement, Announcement,
AnnouncementListItem, AnnouncementListItem,
@@ -52,7 +52,7 @@ import type {
Message, Message,
SubmissionListItem, SubmissionListItem,
Exercise, Exercise,
Problem, ProblemDetail,
ReactionKey, ReactionKey,
ReactionState, ReactionState,
Submission, Submission,
@@ -67,11 +67,11 @@ import type {
* 题目详情。走契约的 zod 解析,形状即契约 —— 之前这里手抄了一份 camel→snake 的 * 题目详情。走契约的 zod 解析,形状即契约 —— 之前这里手抄了一份 camel→snake 的
* 键名映射,抄漏一个字段就是静默 undefined。 * 键名映射,抄漏一个字段就是静默 undefined。
* *
* 走 `contract()` 而不是裸 `parse()`:原来是 `problemDetailSchema.parse(v) as Problem`, * 走 `contract()` 而不是裸 `parse()`:原来是 `problemDetailSchema.parse(v) as ProblemDetail`,
* `as` 把校验结果又断言回去、等于没校验,而 `parse` 抛错会让整个题目页白屏。 * `as` 把校验结果又断言回去、等于没校验,而 `parse` 抛错会让整个题目页白屏。
* 现在形状不符时记一条控制台分歧再放行原始数据。 * 现在形状不符时记一条控制台分歧再放行原始数据。
*/ */
function detailProblem(value: unknown): Problem { function detailProblem(value: unknown): ProblemDetail {
return contract("GET /problems/:id", problemDetailSchema, value) return contract("GET /problems/:id", problemDetailSchema, value)
} }
@@ -89,7 +89,7 @@ export async function getProblemList(
params: { paging: true, offset, limit, ...searchParams }, params: { paging: true, offset, limit, ...searchParams },
}) })
return { return {
results: res.results.map(filterResult), results: res.results.map(toProblemRow),
total: res.total, total: res.total,
} }
} }
@@ -250,7 +250,7 @@ export function getContestList(query: {
} }
export function getContest(id: string) { export function getContest(id: string) {
return api.get<OjContest>(`contests/${encodeURIComponent(id)}`) return api.get<Contest>(`contests/${encodeURIComponent(id)}`)
} }
export function getContestAccess(id: string) { export function getContestAccess(id: string) {
@@ -268,7 +268,7 @@ export async function getContestProblems(contestID: string) {
const res = await api.get<ProblemListItem[]>( const res = await api.get<ProblemListItem[]>(
`contests/${encodeURIComponent(contestID)}/problems`, `contests/${encodeURIComponent(contestID)}/problems`,
) )
return res.map(filterResult) return res.map(toProblemRow)
} }
export function getContestRank( export function getContestRank(
@@ -276,7 +276,7 @@ export function getContestRank(
query: { limit: number; offset: number }, query: { limit: number; offset: number },
) { ) {
// submissionInfo 在契约里是 Record<string, unknown>(JSONB 原文), // submissionInfo 在契约里是 Record<string, unknown>(JSONB 原文),
// 前端在这里收窄成 SubmissionInfo,见 utils/types 的 ContestRank // 前端在这里收窄成 ContestSubmissionInfo,见 utils/types 的 ContestRank
return api.get<{ results: ContestRank[]; total: number }>( return api.get<{ results: ContestRank[]; total: number }>(
`contests/${encodeURIComponent(contestID)}/rank`, `contests/${encodeURIComponent(contestID)}/rank`,
{ params: query }, { params: query },
@@ -383,7 +383,7 @@ export function submitHintFeedback(hintId: number, helpful: boolean) {
export function getSimilarProblems(problemId: string) { export function getSimilarProblems(problemId: string) {
return api return api
.get<ProblemListItem[]>(`problems/${encodeURIComponent(problemId)}/similar`) .get<ProblemListItem[]>(`problems/${encodeURIComponent(problemId)}/similar`)
.then((response) => response.map(filterResult)) .then((response) => response.map(toProblemRow))
} }
export type { YearlyAc as YearlyACData } from "@oj2/contract" export type { YearlyAc as YearlyACData } from "@oj2/contract"
@@ -2,7 +2,7 @@
import { Icon } from "@iconify/vue" import { Icon } from "@iconify/vue"
import { useContestStore } from "oj/store/contest" import { useContestStore } from "oj/store/contest"
import { parseTime } from "utils/functions" import { parseTime } from "utils/functions"
import ContestType from "shared/components/ContestType.vue" import ContestAccessTag from "shared/components/ContestAccessTag.vue"
const contestStore = useContestStore() const contestStore = useContestStore()
</script> </script>
@@ -30,7 +30,7 @@ const contestStore = useContestStore()
{{ parseTime(contestStore.contest.endTime, "YYYY年M月D日 HH:mm:ss") }} {{ parseTime(contestStore.contest.endTime, "YYYY年M月D日 HH:mm:ss") }}
</n-descriptions-item> </n-descriptions-item>
<n-descriptions-item label="比赛类型"> <n-descriptions-item label="比赛类型">
<ContestType :contest="contestStore.contest" /> <ContestAccessTag :contest="contestStore.contest" />
</n-descriptions-item> </n-descriptions-item>
<n-descriptions-item label="发起人"> <n-descriptions-item label="发起人">
{{ contestStore.contest.createdBy.username }} {{ contestStore.contest.createdBy.username }}
+4 -4
View File
@@ -3,7 +3,7 @@ import { useRouteQuery } from "@vueuse/router"
import { NTag } from "naive-ui" import { NTag } from "naive-ui"
import { getContestList } from "oj/api" import { getContestList } from "oj/api"
import { duration, parseTime } from "utils/functions" import { duration, parseTime } from "utils/functions"
import type { OjContest } from "utils/types" import type { Contest } from "utils/types"
import ContestTitle from "shared/components/ContestTitle.vue" import ContestTitle from "shared/components/ContestTitle.vue"
import Pagination from "shared/components/Pagination.vue" import Pagination from "shared/components/Pagination.vue"
import { useAuthModalStore } from "shared/store/authModal" import { useAuthModalStore } from "shared/store/authModal"
@@ -29,7 +29,7 @@ const { query, clearQuery } = usePagination<ContestQuery>({
tag: useRouteQuery("tag", "").value, tag: useRouteQuery("tag", "").value,
}) })
const data = ref<OjContest[]>([]) const data = ref<Contest[]>([])
const total = ref(0) const total = ref(0)
const options: SelectOption[] = [ const options: SelectOption[] = [
@@ -46,7 +46,7 @@ const tags: SelectOption[] = [
{ label: "期末", value: "期末" }, { label: "期末", value: "期末" },
] ]
const columns: DataTableColumn<OjContest>[] = [ const columns: DataTableColumn<Contest>[] = [
{ {
title: renderTableTitle("状态", "streamline-emojis:collision"), title: renderTableTitle("状态", "streamline-emojis:collision"),
key: "status", key: "status",
@@ -116,7 +116,7 @@ watchDebounced(() => query.keyword, listContests, {
// 监听其他查询条件变化 // 监听其他查询条件变化
watch(() => [query.page, query.limit, query.status, query.tag], listContests) watch(() => [query.page, query.limit, query.status, query.tag], listContests)
function rowProps(row: OjContest) { function rowProps(row: Contest) {
return { return {
style: "cursor: pointer", style: "cursor: pointer",
onClick() { onClick() {
+5 -5
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ProblemFiltered } from "utils/types" import type { ProblemRow } from "utils/types"
import ProblemStatus from "oj/problem/components/ProblemStatus.vue" import ProblemStatus from "oj/problem/components/ProblemStatus.vue"
import { useContestStore } from "oj/store/contest" import { useContestStore } from "oj/store/contest"
import { renderTableTitle } from "utils/renders" import { renderTableTitle } from "utils/renders"
@@ -8,7 +8,7 @@ const props = defineProps<{ contestID: string }>()
const router = useRouter() const router = useRouter()
const contestStore = useContestStore() const contestStore = useContestStore()
const problemsColumns: DataTableColumn<ProblemFiltered>[] = [ const problemsColumns: DataTableColumn<ProblemRow>[] = [
{ {
title: renderTableTitle("状态", "streamline-ultimate-color:music-note-1"), title: renderTableTitle("状态", "streamline-ultimate-color:music-note-1"),
key: "status", key: "status",
@@ -27,19 +27,19 @@ const problemsColumns: DataTableColumn<ProblemFiltered>[] = [
}, },
{ {
title: renderTableTitle("提交数", "streamline-emojis:clinking-beer-mugs"), title: renderTableTitle("提交数", "streamline-emojis:clinking-beer-mugs"),
key: "submission", key: "submissionCount",
align: "center", align: "center",
width: 120, width: 120,
}, },
{ {
title: renderTableTitle("通过率", "streamline-emojis:clapping-hands-1"), title: renderTableTitle("通过率", "streamline-emojis:clapping-hands-1"),
key: "rate", key: "acRate",
align: "center", align: "center",
width: 120, width: 120,
}, },
] ]
function rowProps(row: ProblemFiltered) { function rowProps(row: ProblemRow) {
return { return {
style: "cursor: pointer", style: "cursor: pointer",
onClick() { onClick() {
+2 -2
View File
@@ -8,7 +8,7 @@ import Pagination from "shared/components/Pagination.vue"
import { usePagination } from "shared/composables/pagination" import { usePagination } from "shared/composables/pagination"
import { ContestStatus } from "utils/constants" import { ContestStatus } from "utils/constants"
import { renderTableTitle } from "utils/renders" import { renderTableTitle } from "utils/renders"
import type { ContestRank, ProblemFiltered } from "utils/types" import type { ContestRank, ProblemRow } from "utils/types"
import AcAndSubmission from "../components/AcAndSubmission.vue" import AcAndSubmission from "../components/AcAndSubmission.vue"
import LineChart from "../components/LineChart.vue" import LineChart from "../components/LineChart.vue"
@@ -27,7 +27,7 @@ const contestStore = useContestStore()
const total = ref(0) const total = ref(0)
const data = ref<ContestRank[]>([]) const data = ref<ContestRank[]>([])
const chart = ref<ContestRank[]>([]) const chart = ref<ContestRank[]>([])
const problems = ref<ProblemFiltered[]>([]) const problems = ref<ProblemRow[]>([])
const [autoRefresh] = useToggle(true) const [autoRefresh] = useToggle(true)
const { resume, pause } = useIntervalFn( const { resume, pause } = useIntervalFn(
() => { () => {
@@ -8,7 +8,7 @@ import { useBreakpoints } from "shared/composables/breakpoints"
import storage from "utils/storage" import storage from "utils/storage"
import type { LANGUAGE } from "utils/types" import type { LANGUAGE } from "utils/types"
import { beginEditTrace, editTraceExtensions } from "oj/problem/utils/editTrace" import { beginEditTrace, editTraceExtensions } from "oj/problem/utils/editTrace"
import Form from "./Form.vue" import EditorToolbar from "./EditorToolbar.vue"
const route = useRoute() const route = useRoute()
@@ -58,7 +58,10 @@ const changeLanguage = (v: LANGUAGE) => {
<template> <template>
<n-flex vertical> <n-flex vertical>
<Form :storage-key="storageKey" @change-language="changeLanguage" /> <EditorToolbar
:storage-key="storageKey"
@change-language="changeLanguage"
/>
<CodeEditor <CodeEditor
v-model:value="codeStore.code.value" v-model:value="codeStore.code.value"
:language="codeStore.code.language" :language="codeStore.code.language"
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { storeToRefs } from "pinia" import { storeToRefs } from "pinia"
import { copyToClipboard, utoa } from "utils/functions" import { copyToClipboard, compressToBase64 } from "utils/functions"
import { useCodeStore } from "oj/store/code" import { useCodeStore } from "oj/store/code"
import { useProblemStore } from "oj/store/problem" import { useProblemStore } from "oj/store/problem"
import { useCollabStore } from "shared/store/collab" import { useCollabStore } from "shared/store/collab"
@@ -227,7 +227,7 @@ const goTestCat = () => {
code: codeStore.code.value, code: codeStore.code.value,
input: problemStore.problem?.samples[0].input, input: problemStore.problem?.samples[0].input,
} }
const base64 = utoa(JSON.stringify(data)) const base64 = compressToBase64(JSON.stringify(data))
const url = `${import.meta.env.PUBLIC_CODE_URL}?share=${encodeURIComponent(base64)}` const url = `${import.meta.env.PUBLIC_CODE_URL}?share=${encodeURIComponent(base64)}`
window.open(url, "_blank") window.open(url, "_blank")
} }
@@ -5,7 +5,7 @@ import { storeToRefs } from "pinia"
import { useCodeStore } from "oj/store/code" import { useCodeStore } from "oj/store/code"
import { useProblemStore } from "oj/store/problem" import { useProblemStore } from "oj/store/problem"
import { createTestSubmission } from "utils/judge" import { createTestSubmission } from "utils/judge"
import type { Problem, ProblemFiltered, ProblemStatus } from "utils/types" import type { ProblemDetail, ProblemRow, ProblemStatus } from "utils/types"
import Copy from "shared/components/Copy.vue" import Copy from "shared/components/Copy.vue"
import { useDark } from "@vueuse/core" import { useDark } from "@vueuse/core"
import { MdPreview } from "md-editor-v3" import { MdPreview } from "md-editor-v3"
@@ -13,7 +13,7 @@ import "md-editor-v3/lib/preview.css"
import { getSimilarProblems } from "oj/api" import { getSimilarProblems } from "oj/api"
import SQLDataTable from "./SQLDataTable.vue" import SQLDataTable from "./SQLDataTable.vue"
type Sample = Problem["samples"][number] & { type Sample = ProblemDetail["samples"][number] & {
id: number id: number
msg: string msg: string
status: ProblemStatus status: ProblemStatus
@@ -45,7 +45,7 @@ const sqlChangedTables = computed(() => {
const router = useRouter() const router = useRouter()
// 相似题目推荐 // 相似题目推荐
const similarProblems = ref<ProblemFiltered[]>([]) const similarProblems = ref<ProblemRow[]>([])
const similarLoaded = ref(false) const similarLoaded = ref(false)
async function loadSimilarProblems() { async function loadSimilarProblems() {
@@ -409,7 +409,7 @@ function type(status: ProblemStatus) {
{{ sp.title }} {{ sp.title }}
</n-button> </n-button>
</n-flex> </n-flex>
<!-- getSimilarProblems 已经过 filterResult,难度是中文,不是 Low/Mid/High --> <!-- getSimilarProblems 已经过 toProblemRow,难度是中文,不是 Low/Mid/High -->
<n-tag <n-tag
v-if="sp.difficulty" v-if="sp.difficulty"
size="small" size="small"
@@ -9,7 +9,7 @@ import { useBreakpoints } from "shared/composables/breakpoints"
import storage from "utils/storage" import storage from "utils/storage"
import type { LANGUAGE } from "utils/types" import type { LANGUAGE } from "utils/types"
import { beginEditTrace, editTraceExtensions } from "oj/problem/utils/editTrace" import { beginEditTrace, editTraceExtensions } from "oj/problem/utils/editTrace"
import Form from "./Form.vue" import EditorToolbar from "./EditorToolbar.vue"
const FlowchartEditor = defineAsyncComponent( const FlowchartEditor = defineAsyncComponent(
() => import("shared/components/FlowchartEditor/index.vue"), () => import("shared/components/FlowchartEditor/index.vue"),
@@ -43,14 +43,14 @@ const teacherCollab = computed(() => collabHere.value && collabStore.isTeacher)
let teacherLanguageBefore: LANGUAGE | null = null let teacherLanguageBefore: LANGUAGE | null = null
/** /**
* 协作中**教师的语言选择跟着学生走**(选择器同时禁用,见 Form.vue),学生中途切了 * 协作中**教师的语言选择跟着学生走**(选择器同时禁用,见 EditorToolbar.vue),学生中途切了
* 还会再同步一次。 * 还会再同步一次。
* *
* 写的是 codeStore 而不是只改编辑器的高亮:提交、语法检查、去自测猫读的都是 * 写的是 codeStore 而不是只改编辑器的高亮:提交、语法检查、去自测猫读的都是
* `codeStore.code.language` —— 只改高亮的话,老师会拿着自己那档语言提交学生的代码 * `codeStore.code.language` —— 只改高亮的话,老师会拿着自己那档语言提交学生的代码
* (学生写 C、老师选的是 Python,当场 CE),工具栏还可能显示「提交流程图」。 * (学生写 C、老师选的是 Python,当场 CE),工具栏还可能显示「提交流程图」。
* *
* 直接写 store 不会连带重载模板代码:那是 Form 里选择器的 `update:value` 才做的事。 * 直接写 store 不会连带重载模板代码:那是 EditorToolbar 里选择器的 `update:value` 才做的事。
* *
* **学生端一个字都不动**(早退出),他的语言本来就是权威那一份 —— 服务端的 * **学生端一个字都不动**(早退出),他的语言本来就是权威那一份 —— 服务端的
* `room_language` 只发给教师,学生本地那份 `room.language` 停在建房那一刻, * `room_language` 只发给教师,学生本地那份 `room.language` 停在建房那一刻,
@@ -139,10 +139,13 @@ provide("flowchartEditorRef", flowchartEditorRef)
<template> <template>
<n-flex vertical> <n-flex vertical>
<Form :storage-key="storageKey" @change-language="changeLanguage" /> <EditorToolbar
:storage-key="storageKey"
@change-language="changeLanguage"
/>
<!-- <!--
协作中教师这边不会落到流程图分支:上面那个 watch 已经把他的语言换成了学生的, 协作中教师这边不会落到流程图分支:上面那个 watch 已经把他的语言换成了学生的,
而求助入口本身就排掉了流程图(Form.vue 的 showHelpButton、服务端的 而求助入口本身就排掉了流程图(EditorToolbar.vue 的 showHelpButton、服务端的
COLLAB_LANGUAGES),所以 room.language 不可能是 Flowchart。 COLLAB_LANGUAGES),所以 room.language 不可能是 Flowchart。
学生自己切到流程图就是不写代码了,编辑器卸载、协作正常结束(SyncCodeEditor 学生自己切到流程图就是不写代码了,编辑器卸载、协作正常结束(SyncCodeEditor
的 detach),这是原来就有的语义。 的 detach),这是原来就有的语义。
@@ -1,9 +1,9 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { ProblemFiltered } from "utils/types" import type { ProblemRow } from "utils/types"
import { Icon } from "@iconify/vue" import { Icon } from "@iconify/vue"
defineProps<{ defineProps<{
problem: ProblemFiltered problem: ProblemRow
}>() }>()
</script> </script>
<template> <template>
@@ -3,7 +3,7 @@ import { errorCode } from "utils/api"
import { toRefs } from "vue" import { toRefs } from "vue"
// 工具函数 // 工具函数
import { atou, utoa } from "utils/functions" import { decompressFromBase64, compressToBase64 } from "utils/functions"
import { sortFlowchartCriteria } from "utils/constants" import { sortFlowchartCriteria } from "utils/constants"
// 组合式函数 // 组合式函数
@@ -235,7 +235,7 @@ async function submitFlowchartData() {
const mermaidCode = convertToMermaid(flowchartData) const mermaidCode = convertToMermaid(flowchartData)
lastSubmittedMermaidCode.value = mermaidCode lastSubmittedMermaidCode.value = mermaidCode
const compressed = utoa(JSON.stringify(flowchartData)) const compressed = compressToBase64(JSON.stringify(flowchartData))
loading.value = true loading.value = true
latestRating.value = { score: 0, grade: "" } latestRating.value = { score: 0, grade: "" }
@@ -372,9 +372,9 @@ function closeModal() {
function loadToEditor() { function loadToEditor() {
if (myFlowchartZippedStr.value) { if (myFlowchartZippedStr.value) {
// 老提交的压缩数据可能是坏的(格式换过、存了一半), // 老提交的压缩数据可能是坏的(格式换过、存了一半),
// 不兜住的话 atou/JSON.parse 直接抛,按钮点了毫无反应 // 不兜住的话 decompressFromBase64/JSON.parse 直接抛,按钮点了毫无反应
try { try {
const json = JSON.parse(atou(myFlowchartZippedStr.value)) const json = JSON.parse(decompressFromBase64(myFlowchartZippedStr.value))
flowchartEditorRef?.value?.setFlowchartData({ flowchartEditorRef?.value?.setFlowchartData({
nodes: json.nodes || [], nodes: json.nodes || [],
edges: json.edges || [], edges: json.edges || [],
+6 -6
View File
@@ -10,9 +10,9 @@ import { useUserStore } from "shared/store/user"
// 抽成具名 loader,便于进页面时与接口并行预取编辑器 chunk // 抽成具名 loader,便于进页面时与接口并行预取编辑器 chunk
const loadProblemEditor = () => import("./components/ProblemEditor.vue") const loadProblemEditor = () => import("./components/ProblemEditor.vue")
const loadContestEditor = () => import("./components/ContestEditor.vue") const loadContestEditor = () => import("./components/BasicEditor.vue")
const ProblemEditor = defineAsyncComponent(loadProblemEditor) const ProblemEditor = defineAsyncComponent(loadProblemEditor)
const ContestEditor = defineAsyncComponent(loadContestEditor) const BasicEditor = defineAsyncComponent(loadContestEditor)
const EditorForTest = defineAsyncComponent( const EditorForTest = defineAsyncComponent(
() => import("./components/EditorForTest.vue"), () => import("./components/EditorForTest.vue"),
) )
@@ -85,7 +85,7 @@ const tabOptions = computed(() => {
const currentTab = ref("content") const currentTab = ref("content")
const inProblem = computed(() => route.name === "problem") const isPublicProblemRoute = computed(() => route.name === "problem")
watch( watch(
[() => route.query.tab, () => tabOptions.value], [() => route.query.tab, () => tabOptions.value],
@@ -117,7 +117,7 @@ async function init() {
screenModeStore.resetScreenMode() screenModeStore.resetScreenMode()
// 并行预取右侧编辑器 chunk(CodeMirror ~370K+), // 并行预取右侧编辑器 chunk(CodeMirror ~370K+),
// 避免等 getProblem 返回后才串行下载,编辑器才迟迟出现 // 避免等 getProblem 返回后才串行下载,编辑器才迟迟出现
;(inProblem.value ? loadProblemEditor : loadContestEditor)() ;(isPublicProblemRoute.value ? loadProblemEditor : loadContestEditor)()
try { try {
const res = await getProblem(problemID, contestID) const res = await getProblem(problemID, contestID)
problem.value = res problem.value = res
@@ -215,7 +215,7 @@ watch(
</n-scrollbar> </n-scrollbar>
</template> </template>
<template #2> <template #2>
<component :is="inProblem ? ProblemEditor : ContestEditor" /> <component :is="isPublicProblemRoute ? ProblemEditor : BasicEditor" />
</template> </template>
</n-split> </n-split>
@@ -272,7 +272,7 @@ watch(
<ProblemFlowchart /> <ProblemFlowchart />
</n-tab-pane> </n-tab-pane>
<n-tab-pane name="editor" tab="代码"> <n-tab-pane name="editor" tab="代码">
<component :is="inProblem ? ProblemEditor : ContestEditor" /> <component :is="isPublicProblemRoute ? ProblemEditor : BasicEditor" />
</n-tab-pane> </n-tab-pane>
<n-tab-pane name="info" tab="统计" :disabled="!!problemSetId"> <n-tab-pane name="info" tab="统计" :disabled="!!problemSetId">
<ProblemInfo /> <ProblemInfo />
+6 -6
View File
@@ -6,7 +6,7 @@ import { getProblemList } from "oj/api"
import { STORAGE_KEY } from "utils/constants" import { STORAGE_KEY } from "utils/constants"
import storage from "utils/storage" import storage from "utils/storage"
import { getTagColor } from "utils/functions" import { getTagColor } from "utils/functions"
import type { ProblemFiltered, Tag as ContractTag } from "utils/types" import type { ProblemRow, Tag as ContractTag } from "utils/types"
import { getProblemTagList } from "shared/api" import { getProblemTagList } from "shared/api"
import Hitokoto from "shared/components/Hitokoto.vue" import Hitokoto from "shared/components/Hitokoto.vue"
import Pagination from "shared/components/Pagination.vue" import Pagination from "shared/components/Pagination.vue"
@@ -66,7 +66,7 @@ const userStore = useUserStore()
const { isDesktop } = useBreakpoints() const { isDesktop } = useBreakpoints()
const problems = ref<ProblemFiltered[]>([]) const problems = ref<ProblemRow[]>([])
const total = ref(0) const total = ref(0)
const tags = ref<Tag[]>([]) const tags = ref<Tag[]>([])
const [showTag, toggleShowTag] = useToggle(isDesktop.value) const [showTag, toggleShowTag] = useToggle(isDesktop.value)
@@ -165,7 +165,7 @@ onMounted(() => {
listTags() listTags()
}) })
const baseColumns: DataTableColumn<ProblemFiltered>[] = [ const baseColumns: DataTableColumn<ProblemRow>[] = [
{ {
title: renderTableTitle("状态", "streamline-emojis:high-voltage"), title: renderTableTitle("状态", "streamline-emojis:high-voltage"),
key: "status", key: "status",
@@ -213,13 +213,13 @@ const baseColumns: DataTableColumn<ProblemFiltered>[] = [
}, },
{ {
title: renderTableTitle("提交数", "streamline-ultimate-color:paper-write"), title: renderTableTitle("提交数", "streamline-ultimate-color:paper-write"),
key: "submission", key: "submissionCount",
align: "center", align: "center",
width: 100, width: 100,
}, },
{ {
title: renderTableTitle("通过率", "streamline-emojis:victory-hand-2"), title: renderTableTitle("通过率", "streamline-emojis:victory-hand-2"),
key: "rate", key: "acRate",
width: 100, width: 100,
align: "center", align: "center",
}, },
@@ -231,7 +231,7 @@ const columns = computed(() =>
: baseColumns.filter((c: any) => c.key !== "status"), : baseColumns.filter((c: any) => c.key !== "status"),
) )
function rowProps(row: ProblemFiltered) { function rowProps(row: ProblemRow) {
return { return {
style: "cursor: pointer", style: "cursor: pointer",
onClick() { onClick() {
+1 -1
View File
@@ -5,7 +5,7 @@ import type { SubmissionTrace } from "@oj2/contract"
* 编辑过程信号的采集,随提交一起报给后端(落进 `submission_trace`)。 * 编辑过程信号的采集,随提交一起报给后端(落进 `submission_trace`)。
* 字段含义见契约的 `submissionTraceSchema`。**只数字符数和次数,不留任何按键内容。** * 字段含义见契约的 `submissionTraceSchema`。**只数字符数和次数,不留任何按键内容。**
* *
* 是模块单例而不是 Pinia store:编辑器(ProblemEditor / ContestEditor 里)和 * 是模块单例而不是 Pinia store:编辑器(ProblemEditor / BasicEditor 里)和
* 提交按钮(Form → SubmitCode)是兄弟组件,得共用一份计数;而 CodeMirror 的 * 提交按钮(Form → SubmitCode)是兄弟组件,得共用一份计数;而 CodeMirror 的
* 扩展对象一旦经过 store 就会被包成响应式代理,facet 靠身份比较,代理过的扩展 * 扩展对象一旦经过 store 就会被包成响应式代理,facet 靠身份比较,代理过的扩展
* 直接失效。计数本身也不需要响应式。 * 直接失效。计数本身也不需要响应式。
+4 -4
View File
@@ -24,7 +24,7 @@ import Pagination from "shared/components/Pagination.vue"
import { ChartType, LONG_DURATION_OPTIONS } from "utils/constants" import { ChartType, LONG_DURATION_OPTIONS } from "utils/constants"
import { renderTableTitle } from "utils/renders" import { renderTableTitle } from "utils/renders"
import Chart from "./components/Chart.vue" import Chart from "./components/Chart.vue"
import Index from "./components/Index.vue" import RankMedal from "./components/RankMedal.vue"
import { useUserStore } from "shared/store/user" import { useUserStore } from "shared/store/user"
import { Icon } from "@iconify/vue" import { Icon } from "@iconify/vue"
import { MdPreview } from "md-editor-v3" import { MdPreview } from "md-editor-v3"
@@ -162,7 +162,7 @@ const columns: DataTableColumn<Rank>[] = [
width: 100, width: 100,
align: "center", align: "center",
render: (_, index) => render: (_, index) =>
h(Index, { index, page: query.page, limit: query.limit }), h(RankMedal, { index, page: query.page, limit: query.limit }),
}, },
{ {
title: renderTableTitle( title: renderTableTitle(
@@ -287,8 +287,8 @@ const weeklyColumns: DataTableColumn<WeeklyRankItem>[] = [
key: "rank", key: "rank",
width: 100, width: 100,
align: "center", align: "center",
// rank 是服务端给的周榜名次,不是行号 —— 换算回 Index 要的 0 基下标 // rank 是服务端给的周榜名次,不是行号 —— 换算回 RankMedal 要的 0 基下标
render: (row) => h(Index, { index: row.rank - 1, page: 1, limit: 10 }), render: (row) => h(RankMedal, { index: row.rank - 1, page: 1, limit: 10 }),
}, },
{ {
title: renderTableTitle( title: renderTableTitle(
+3 -3
View File
@@ -3,7 +3,7 @@ import { formatISO, getTime, parseISO } from "date-fns"
import { useUserStore } from "shared/store/user" import { useUserStore } from "shared/store/user"
import { ContestStatus, ContestType } from "utils/constants" import { ContestStatus, ContestType } from "utils/constants"
import { duration } from "utils/functions" import { duration } from "utils/functions"
import type { OjContest, ProblemFiltered } from "utils/types" import type { Contest, ProblemRow } from "utils/types"
import { import {
checkContestPassword, checkContestPassword,
getContest, getContest,
@@ -14,8 +14,8 @@ import {
export const useContestStore = defineStore("contest", () => { export const useContestStore = defineStore("contest", () => {
const userStore = useUserStore() const userStore = useUserStore()
const [access, toggleAccess] = useToggle(false) const [access, toggleAccess] = useToggle(false)
const contest = ref<OjContest | null>(null) const contest = ref<Contest | null>(null)
const problems = ref<ProblemFiltered[]>([]) const problems = ref<ProblemRow[]>([])
const now = ref(0) const now = ref(0)
let timer = 0 let timer = 0
+2 -2
View File
@@ -1,8 +1,8 @@
import { defineStore } from "pinia" import { defineStore } from "pinia"
import type { LANGUAGE, Problem } from "utils/types" import type { LANGUAGE, ProblemDetail } from "utils/types"
export const useProblemStore = defineStore("problem", () => { export const useProblemStore = defineStore("problem", () => {
const problem = ref<Problem | null>(null) const problem = ref<ProblemDetail | null>(null)
const route = useRoute() const route = useRoute()
/** /**
+2 -2
View File
@@ -12,7 +12,7 @@ import {
submissionMemoryFormat, submissionMemoryFormat,
submissionResultTitle, submissionResultTitle,
submissionTimeFormat, submissionTimeFormat,
utoa, compressToBase64,
} from "utils/functions" } from "utils/functions"
import type { Submission } from "utils/types" import type { Submission } from "utils/types"
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue" import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
@@ -82,7 +82,7 @@ function copyToCat() {
code: submission.value!.code, code: submission.value!.code,
input: "", input: "",
} }
const base64 = utoa(JSON.stringify(data)) const base64 = compressToBase64(JSON.stringify(data))
const url = `${import.meta.env.PUBLIC_CODE_URL}?share=${encodeURIComponent(base64)}` const url = `${import.meta.env.PUBLIC_CODE_URL}?share=${encodeURIComponent(base64)}`
window.open(url, "_blank") window.open(url, "_blank")
} }
+4 -4
View File
@@ -1,17 +1,17 @@
import { DIFFICULTY } from "utils/constants" import { DIFFICULTY } from "utils/constants"
import { getACRate } from "utils/functions" import { getACRate } from "utils/functions"
import type { ProblemFiltered, ProblemListItem } from "utils/types" import type { ProblemRow, ProblemListItem } from "utils/types"
// 把后端的列表项塑形成列表页需要的形状,与请求逻辑解耦。 // 把后端的列表项塑形成列表页需要的形状,与请求逻辑解耦。
export function filterResult(result: ProblemListItem): ProblemFiltered { export function toProblemRow(result: ProblemListItem): ProblemRow {
return { return {
id: result.id, id: result.id,
_id: result._id, _id: result._id,
title: result.title, title: result.title,
difficulty: result.difficulty ? DIFFICULTY[result.difficulty] : null, difficulty: result.difficulty ? DIFFICULTY[result.difficulty] : null,
tags: result.tags, tags: result.tags,
submission: result.submissionNumber, submissionCount: result.submissionNumber,
rate: getACRate(result.acceptedNumber, result.submissionNumber), acRate: getACRate(result.acceptedNumber, result.submissionNumber),
// null / undefined 都表示「没做过」 // null / undefined 都表示「没做过」
status: status:
result.myStatus === null || result.myStatus === undefined result.myStatus === null || result.myStatus === undefined
@@ -1,9 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { ContestType } from "utils/constants" import { ContestType } from "utils/constants"
import type { Contest, OjContest } from "utils/types" import type { AdminContest, Contest } from "utils/types"
interface Props { interface Props {
contest: Contest | OjContest contest: AdminContest | Contest
size?: "small" size?: "small"
} }
@@ -1,9 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from "@iconify/vue" import { Icon } from "@iconify/vue"
import { ContestType } from "utils/constants" import { ContestType } from "utils/constants"
import type { Contest, OjContest } from "utils/types" import type { AdminContest, Contest } from "utils/types"
defineProps<{ contest: Contest | OjContest }>() defineProps<{ contest: AdminContest | Contest }>()
</script> </script>
<template> <template>
<n-flex> <n-flex>
@@ -55,7 +55,7 @@ const waited = (createdAt: number) => {
* 那个弹框按一下 Esc 就关、协作跟着结束,上课时太容易误触;题面也只能看不能用。 * 那个弹框按一下 Esc 就关、协作跟着结束,上课时太容易误触;题面也只能看不能用。
* 直接跳题目页之后,老师看到的就是学生看到的那一页。 * 直接跳题目页之后,老师看到的就是学生看到的那一页。
* *
* 求助入口在比赛里是关掉的(Form.vue 的 showHelpButton),problemId 一定是公开 * 求助入口在比赛里是关掉的(EditorToolbar.vue 的 showHelpButton),problemId 一定是公开
* 题目的展示 ID,走 /problem/:id 就行。 * 题目的展示 ID,走 /problem/:id 就行。
*/ */
const handleAccept = (studentId: number, problemId: string, status: string) => { const handleAccept = (studentId: number, problemId: string, status: string) => {
+6 -6
View File
@@ -1,7 +1,7 @@
import { TIME_ZONE_OFFSET_MINUTES, toAdminType } from "@oj2/contract" import { TIME_ZONE_OFFSET_MINUTES, toAdminType } from "@oj2/contract"
import type { JudgeCaseResult, JudgeInfo } from "@oj2/contract" import type { JudgeCaseResult, JudgeInfo } from "@oj2/contract"
import { getTime, intervalToDuration, parseISO, type Duration } from "date-fns" import { getTime, intervalToDuration, parseISO, type Duration } from "date-fns"
import { Submission, User } from "./types" import { AdminUser, Submission } from "./types"
import { JUDGE_STATUS, USER_TYPE } from "./constants" import { JUDGE_STATUS, USER_TYPE } from "./constants"
import { import {
strFromU8, strFromU8,
@@ -283,7 +283,7 @@ export function debounce<T extends (...args: any[]) => any>(
} }
} }
export function getUserRole(role: User["adminType"]): { export function getUserRole(role: AdminUser["adminType"]): {
type: "default" | "info" | "warning" | "error" type: "default" | "info" | "warning" | "error"
label: "普通" | "生管" | "师管" | "超管" label: "普通" | "生管" | "师管" | "超管"
} { } {
@@ -315,7 +315,7 @@ export function unique<T>(arr: T[]): T[] {
return [...new Set(arr)] return [...new Set(arr)]
} }
export function encode(string?: string): string { export function base64EncodeUtf8(string?: string): string {
try { try {
return btoa(String.fromCharCode(...new TextEncoder().encode(string ?? ""))) return btoa(String.fromCharCode(...new TextEncoder().encode(string ?? "")))
} catch (error) { } catch (error) {
@@ -324,7 +324,7 @@ export function encode(string?: string): string {
} }
} }
export function decode(bytes?: string): string { export function base64DecodeUtf8(bytes?: string): string {
try { try {
if (!bytes) return "" if (!bytes) return ""
const latin = atob(bytes) const latin = atob(bytes)
@@ -339,14 +339,14 @@ export function decode(bytes?: string): string {
} }
} }
export function utoa(data: string): string { export function compressToBase64(data: string): string {
const buffer = strToU8(data) const buffer = strToU8(data)
const zipped = zlibSync(buffer, { level: 9 }) const zipped = zlibSync(buffer, { level: 9 })
const binary = strFromU8(zipped, true) const binary = strFromU8(zipped, true)
return btoa(binary) return btoa(binary)
} }
export function atou(base64: string): string { export function decompressFromBase64(base64: string): string {
const binary = atob(base64) const binary = atob(base64)
const buffer = strToU8(binary, true) const buffer = strToU8(binary, true)
const unzipped = unzlibSync(buffer) const unzipped = unzlibSync(buffer)
+7 -4
View File
@@ -1,5 +1,5 @@
import axios from "axios" import axios from "axios"
import { decode, encode } from "./functions" import { base64DecodeUtf8, base64EncodeUtf8 } from "./functions"
import type { Code, LANGUAGE } from "./types" import type { Code, LANGUAGE } from "./types"
const http = axios.create({ baseURL: import.meta.env.PUBLIC_JUDGE0_URL }) const http = axios.create({ baseURL: import.meta.env.PUBLIC_JUDGE0_URL })
@@ -16,7 +16,7 @@ const JUDGE0_LANGUAGE_ID: Partial<Record<LANGUAGE, number>> = {
} }
export async function createTestSubmission(code: Code, input: string) { export async function createTestSubmission(code: Code, input: string) {
const encodedCode = encode(code.value) const encodedCode = base64EncodeUtf8(code.value)
const id = JUDGE0_LANGUAGE_ID[code.language] const id = JUDGE0_LANGUAGE_ID[code.language]
if (id === undefined) { if (id === undefined) {
return { status: null, output: `${code.language} 不支持在线试运行` } return { status: null, output: `${code.language} 不支持在线试运行` }
@@ -26,7 +26,7 @@ export async function createTestSubmission(code: Code, input: string) {
const payload = { const payload = {
source_code: encodedCode, source_code: encodedCode,
language_id: id, language_id: id,
stdin: encode(input), stdin: base64EncodeUtf8(input),
redirect_stderr_to_stdout: true, redirect_stderr_to_stdout: true,
compiler_options: compilerOptions, compiler_options: compilerOptions,
} }
@@ -36,7 +36,10 @@ export async function createTestSubmission(code: Code, input: string) {
const data = response.data const data = response.data
return { return {
status: data.status && data.status.id, status: data.status && data.status.id,
output: [decode(data.compile_output), decode(data.stdout)] output: [
base64DecodeUtf8(data.compile_output),
base64DecodeUtf8(data.stdout),
]
.join("\n") .join("\n")
.trim(), .trim(),
} }
+13 -28
View File
@@ -10,7 +10,6 @@ import type {
EmbeddedSubmission as ContractEmbeddedSubmission, EmbeddedSubmission as ContractEmbeddedSubmission,
Message as ContractMessage, Message as ContractMessage,
Grade, Grade,
ProblemDetail,
ProblemDifficulty, ProblemDifficulty,
JudgeStatus, JudgeStatus,
AstRules, AstRules,
@@ -41,7 +40,7 @@ export interface AcmProblemsStatus {
* 后台用户管理里的用户。`rawPassword` 是明文密码,只有超管专属接口下发 —— * 后台用户管理里的用户。`rawPassword` 是明文密码,只有超管专属接口下发 ——
* 老师要能查学生密码,见契约 adminUserSchema 的注释。 * 老师要能查学生密码,见契约 adminUserSchema 的注释。
*/ */
export type User = AdminUser & { export type AdminUserForm = AdminUser & {
// 编辑表单里临时填的新密码,不在响应里 // 编辑表单里临时填的新密码,不在响应里
password?: string password?: string
} }
@@ -111,20 +110,6 @@ export type { UploadTestCaseResponse } from "@oj2/contract"
*/ */
export type { ProblemTestCaseScore as Testcase } from "@oj2/contract" export type { ProblemTestCaseScore as Testcase } from "@oj2/contract"
/**
* 题目详情。**直接取契约** —— `languages` / `template` 的收窄已经搬进
* `problemDetailSchema`(`z.array(problemLanguageSchema)` 与
* `z.partialRecord(problemLanguageSchema, …)`),这里原来那份
* `Omit<ProblemDetail, "languages" | "template"> & {...}` 与契约**双向可赋值**,
* 即完全等价,是一层没有内容的重复(已用类型探针验证)。
*
* 原来它还多挂三个可选字段(`hasAstRules` / `visible` / `answers`)。它们在
* 契约里**本来就有**(`hasAstRules` 在题目列表项上、`ProblemDetail` 带的是
* `astRequirements`),少数管理端调用点需要补充时应该就地声明自己的类型,
* 不该让一个全局别名对所有调用方声称这些字段存在。
*/
export type Problem = ProblemDetail
/** /**
* AST 代码要求。原来这里手抄的那份少了 label / exact / outer / inner —— * AST 代码要求。原来这里手抄的那份少了 label / exact / outer / inner ——
* 编辑器写得出 label 和 exact,判题机也认,只有这个类型不认。形状在契约里。 * 编辑器写得出 label 和 exact,判题机也认,只有这个类型不认。形状在契约里。
@@ -174,15 +159,15 @@ export type BlankProblem = Omit<
mermaidCode: string mermaidCode: string
} }
export interface ProblemFiltered { export interface ProblemRow {
_id: string _id: string
id: number id: number
title: string title: string
// 比赛进行中难度不下发,见 contract 的 maskedProblemDifficultySchema // 比赛进行中难度不下发,见 contract 的 maskedProblemDifficultySchema
difficulty: "简单" | "中等" | "困难" | null difficulty: "简单" | "中等" | "困难" | null
tags: string[] tags: string[]
submission: number submissionCount: number
rate: string acRate: string
status: "not_test" | "passed" | "failed" status: "not_test" | "passed" | "failed"
author: string author: string
allowFlowchart: boolean allowFlowchart: boolean
@@ -190,7 +175,7 @@ export interface ProblemFiltered {
hasAstRules: boolean hasAstRules: boolean
} }
export interface AdminProblemFiltered { export interface AdminProblemRow {
_id: string _id: string
id: number id: number
title: string title: string
@@ -314,11 +299,11 @@ export type {
ClassUserRank, ClassUserRank,
} from "@oj2/contract" } from "@oj2/contract"
/** 后台比赛。oj 侧的 contestSchema 永远不含 password,后台要能看到(告诉学生) */ /**
export type Contest = AdminContest * 学生侧的比赛:不含 password / visible。后台用 `AdminContest` ——
* oj 侧的 contestSchema 永远不含 password,后台要能看到(告诉学生)
/** 学生侧的比赛:不含 password / visible */ */
export type { Contest as OjContest } from "@oj2/contract" export type { Contest, AdminContest } from "@oj2/contract"
export type BlankContest = Omit< export type BlankContest = Omit<
AdminContest, AdminContest,
@@ -331,10 +316,10 @@ export type BlankContest = Omit<
> >
/** /**
* `acm_contest_rank.submission_info` 的 JSONB 内容。**形状已搬进契约** * `acm_contest_rank.submission_info` 的 JSONB 内容,形状在契约
* (`contestSubmissionInfoSchema`),这里保留别名给既有调用点。 * (`contestSubmissionInfoSchema`)。别和 `submission.info`(判题结果)混了。
*/ */
export type { ContestSubmissionInfo as SubmissionInfo } from "@oj2/contract" export type { ContestSubmissionInfo } from "@oj2/contract"
/** /**
* 榜单行。`submissionInfo` 的收窄(JSONB 原文的 snake_case 形状)已经搬进 * 榜单行。`submissionInfo` 的收窄(JSONB 原文的 snake_case 形状)已经搬进