From 5ae830a7f9ee91c5282ad11e0067a80210a67dfb Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Sun, 27 Sep 2026 16:08:21 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E4=B8=80=E6=89=B9=E5=90=8D?= =?UTF-8?q?=E4=B8=8D=E5=89=AF=E5=AE=9E=E7=9A=84=E5=91=BD=E5=90=8D=E6=94=B9?= =?UTF-8?q?=E6=8E=89=EF=BC=88=E5=8F=AA=E5=8A=A8=E4=B8=80=E7=AB=AF=EF=BC=8C?= =?UTF-8?q?=E4=B8=8D=E7=A2=B0=E5=A5=91=E7=BA=A6=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端: - 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 --- apps/api/src/auth/session.ts | 11 ++--- apps/api/src/judge/run.ts | 23 ++++------- apps/api/src/routes/account.ts | 13 ++---- apps/api/src/routes/achievement.ts | 4 +- apps/api/src/routes/admin/conf.ts | 4 +- apps/api/src/routes/admin/problem.ts | 18 ++++---- apps/api/src/routes/admin/tutorial.ts | 4 +- apps/api/src/routes/ai.ts | 4 +- apps/api/src/routes/content.ts | 6 +-- apps/api/src/routes/contest.ts | 15 +++---- apps/api/src/routes/flowchart.ts | 16 ++++---- apps/api/src/routes/helpers.ts | 4 +- apps/api/src/routes/problem.ts | 28 +++++-------- apps/api/src/routes/problemset.ts | 6 +-- apps/api/src/routes/submission-statistics.ts | 4 +- apps/api/src/routes/submission.ts | 26 +++++------- apps/api/src/scripts/recount.ts | 10 ++--- apps/api/src/services/achievements.ts | 10 ++--- apps/api/src/services/hint-diagnosis.ts | 12 +++--- apps/api/src/services/problemset.ts | 10 ++--- apps/api/src/services/test-case.ts | 2 +- apps/api/src/time.ts | 9 ++-- apps/api/src/websocket.ts | 9 +--- apps/web/src/App.vue | 2 +- apps/web/src/admin/api.ts | 30 +++++++------- .../src/admin/contest/components/Actions.vue | 4 +- apps/web/src/admin/contest/helper.vue | 6 +-- apps/web/src/admin/contest/list.vue | 12 +++--- .../{Modal.vue => AddFromLibraryModal.vue} | 6 +-- .../problem/components/TagProblemsModal.vue | 10 ++--- apps/web/src/admin/problem/list.vue | 14 +++---- apps/web/src/admin/transforms.ts | 6 +-- .../web/src/admin/user/components/Actions.vue | 10 ++--- .../{Password.vue => PasswordCell.vue} | 4 +- .../components/{Name.vue => UserNameCell.vue} | 8 ++-- apps/web/src/admin/user/list.vue | 24 +++++------ apps/web/src/oj/api.ts | 20 ++++----- .../src/oj/contest/components/ContestInfo.vue | 4 +- apps/web/src/oj/contest/list.vue | 8 ++-- apps/web/src/oj/contest/pages/problems.vue | 10 ++--- apps/web/src/oj/contest/pages/rank.vue | 4 +- .../{ContestEditor.vue => BasicEditor.vue} | 7 +++- .../{Form.vue => EditorToolbar.vue} | 4 +- .../oj/problem/components/ProblemContent.vue | 8 ++-- .../oj/problem/components/ProblemEditor.vue | 13 +++--- .../problem/components/ProblemListTitle.vue | 4 +- .../oj/problem/components/SubmitFlowchart.vue | 8 ++-- apps/web/src/oj/problem/detail.vue | 12 +++--- apps/web/src/oj/problem/list.vue | 12 +++--- apps/web/src/oj/problem/utils/editTrace.ts | 2 +- .../components/{Index.vue => RankMedal.vue} | 0 apps/web/src/oj/rank/list.vue | 8 ++-- apps/web/src/oj/store/contest.ts | 6 +-- apps/web/src/oj/store/problem.ts | 4 +- apps/web/src/oj/submission/detail.vue | 4 +- apps/web/src/oj/transforms.ts | 8 ++-- .../{ContestType.vue => ContestAccessTag.vue} | 4 +- .../src/shared/components/ContestTitle.vue | 4 +- .../src/shared/components/HelpRequestList.vue | 2 +- apps/web/src/utils/functions.ts | 12 +++--- apps/web/src/utils/judge.ts | 11 +++-- apps/web/src/utils/types.ts | 41 ++++++------------- 62 files changed, 284 insertions(+), 320 deletions(-) rename apps/web/src/admin/problem/components/{Modal.vue => AddFromLibraryModal.vue} (91%) rename apps/web/src/admin/user/components/{Password.vue => PasswordCell.vue} (95%) rename apps/web/src/admin/user/components/{Name.vue => UserNameCell.vue} (88%) rename apps/web/src/oj/problem/components/{ContestEditor.vue => BasicEditor.vue} (92%) rename apps/web/src/oj/problem/components/{Form.vue => EditorToolbar.vue} (98%) rename apps/web/src/oj/rank/components/{Index.vue => RankMedal.vue} (100%) rename apps/web/src/shared/components/{ContestType.vue => ContestAccessTag.vue} (81%) diff --git a/apps/api/src/auth/session.ts b/apps/api/src/auth/session.ts index 4285ebe..d21585d 100644 --- a/apps/api/src/auth/session.ts +++ b/apps/api/src/auth/session.ts @@ -161,7 +161,7 @@ export type SessionResult = | { user: AuthUser; reason?: undefined } | { user: null; reason: "anonymous" | "disabled" } -async function getUserByToken( +async function authenticateToken( token: string | undefined, ): Promise { if (!token) return { user: null, reason: "anonymous" } @@ -228,7 +228,7 @@ async function getUserByToken( /** 要区分「未登录」和「已被禁用」的用这个 —— 目前只有鉴权中间件需要 */ 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) { - 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 * 走一次 pipeline,仍然只有一趟。 * - * 续期这件事本身是要的:HTTP 请求会走 getUserByToken 里的 redis.expire 续期, + * 续期这件事本身是要的:HTTP 请求会走 authenticateToken 里的 redis.expire 续期, * 而只开着页面挂 WebSocket 的人一次请求都不发,不该因此被算成不活跃踢下线。 * * **反向索引必须跟着一起续。** 走到这里的正是那种一次 HTTP 请求都不发的连接, - * 它碰不到 getUserByToken 里那两条并排的 expire。只续会话不续索引的话,索引先到期、 + * 它碰不到 authenticateToken 里那两条并排的 expire。只续会话不续索引的话,索引先到期、 * 会话却被巡检一直续着,之后改密码 / 禁用账号走 revokeUserSessions 就 SMEMBERS * 不到这张 token —— WebSocket 那边还有 publishSessionRevoked 按 userId 兜底能断掉, * 但 HTTP 一侧拿着那张 cookie 照用不误,而改密码要的恰恰是让 HTTP 立刻失效。 diff --git a/apps/api/src/judge/run.ts b/apps/api/src/judge/run.ts index 9af9e92..b790e99 100644 --- a/apps/api/src/judge/run.ts +++ b/apps/api/src/judge/run.ts @@ -13,6 +13,7 @@ import { import { recordSolvedProblem } from "../services/problemset" import { checkAst, type AstRule } from "./ast" import { publishSubmissionUpdate } from "./events" +import { asRecord } from "../routes/helpers" import type { JudgeJobData } from "./job" import { judgeConfigFor } from "./languages" import { isAccepted, JudgeStatus, type JudgeStatusValue } from "./status" @@ -37,12 +38,6 @@ interface JudgeResponse { data: JudgeCase[] | unknown } -function objectValue(value: unknown): Record { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : {} -} - function statusValue(value: number): JudgeStatusValue { const statuses = new Set(Object.values(JudgeStatus)) return statuses.has(value) @@ -51,7 +46,7 @@ function statusValue(value: number): JudgeStatusValue { } function templateForLanguage(value: unknown, language: string) { - const template = objectValue(value)[language] + const template = asRecord(value)[language] return typeof template === "string" ? template : null } @@ -62,7 +57,7 @@ function templateForLanguage(value: unknown, language: string) { * 这里、并且不再骗类型系统。 */ function astRulesForLanguage(value: unknown, language: string): AstRule[] { - const rules = objectValue(value)[language] + const rules = asRecord(value)[language] if (!Array.isArray(rules)) return [] return rules.flatMap((rule) => { const parsed = astRuleSchema.safeParse(rule) @@ -163,7 +158,7 @@ async function persistResult( .set({ result, info, statisticInfo }) .where(eq(schema.submission.id, submissionId)) - const problemStatistics = objectValue(problem.statisticInfo) + const problemStatistics = asRecord(problem.statisticInfo) const resultKey = String(result) const previousResultCount = problemStatistics[resultKey] problemStatistics[resultKey] = @@ -178,10 +173,10 @@ async function persistResult( }) .where(eq(schema.problem.id, problemId)) - const acmStatus = objectValue(profile.acmProblemsStatus) + const acmStatus = asRecord(profile.acmProblemsStatus) const statusKey = contestId === null ? "problems" : "contest_problems" - const problems = objectValue(acmStatus[statusKey]) - const previous = objectValue(problems[String(problemId)]) + const problems = asRecord(acmStatus[statusKey]) + const previous = asRecord(problems[String(problemId)]) const previousStatus = previous.status const wasAccepted = typeof previousStatus === "number" && isAccepted(previousStatus) @@ -591,14 +586,14 @@ async function judgeSqlSubmission( problem: typeof schema.problem.$inferSelect, studentSql: string, ): Promise { - const sqlConfig = objectValue(problem.sqlConfig) + const sqlConfig = asRecord(problem.sqlConfig) const mode = sqlConfig.mode if (mode !== "query" && mode !== "modify") { throw new Error("题目缺少 SQL 配置(题型)") } const answers = Array.isArray(problem.answers) ? problem.answers : [] const refSql = answers - .map((item) => objectValue(item)) + .map((item) => asRecord(item)) .find( (item) => item.language === "SQL" && diff --git a/apps/api/src/routes/account.ts b/apps/api/src/routes/account.ts index 9280a28..7fecdb5 100644 --- a/apps/api/src/routes/account.ts +++ b/apps/api/src/routes/account.ts @@ -54,12 +54,7 @@ import { type AttemptRule, } from "../services/throttling" import { localTime, weekStart } from "../time" -import { - isTeacherOrAbove, - objectValue, - queryInteger, - sampleUser, -} from "./helpers" +import { isTeacherOrAbove, asRecord, queryInteger, sampleUser } from "./helpers" export const accountRoutes = new Hono() @@ -632,8 +627,8 @@ accountRoutes.post( .from(schema.userProfile) .where(eq(schema.userProfile.userId, user.id)) .limit(1) - const status = objectValue(profile?.value) - const problems = objectValue(status.problems) + const status = asRecord(profile?.value) + const problems = asRecord(status.problems) const ids = Object.keys(problems).map(Number).filter(Number.isInteger) if (ids.length > 0) { const rows = await db @@ -649,7 +644,7 @@ accountRoutes.post( rows.map((row) => [String(row.id), row.displayId]), ) for (const [id, value] of Object.entries(problems)) { - const item = objectValue(value) + const item = asRecord(value) const displayId = displayIds.get(id) if (displayId) item._id = displayId problems[id] = item diff --git a/apps/api/src/routes/achievement.ts b/apps/api/src/routes/achievement.ts index ab62beb..af95f75 100644 --- a/apps/api/src/routes/achievement.ts +++ b/apps/api/src/routes/achievement.ts @@ -11,7 +11,7 @@ import { Hono } from "hono" import { requireAuth, type AppEnv } from "../auth/middleware" import { db, schema } from "../db" import { failure, parseBody, success } from "../http" -import { objectValue } from "./helpers" +import { asRecord } from "./helpers" export const achievementRoutes = new Hono() @@ -73,7 +73,7 @@ achievementRoutes.get("/achievements", requireAuth, async (c) => { .where(eq(schema.user.isDisabled, false)), ]) 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 result = achievements.map((achievement) => { const record = unlocked.get(achievement.id) diff --git a/apps/api/src/routes/admin/conf.ts b/apps/api/src/routes/admin/conf.ts index 4ad212e..355fa05 100644 --- a/apps/api/src/routes/admin/conf.ts +++ b/apps/api/src/routes/admin/conf.ts @@ -25,7 +25,7 @@ import { publishConfigUpdate } from "../../events" import { failure, parseBody, success } from "../../http" import { sniffImageExtension } from "../../services/image" import { getWebsiteOptions } from "../../services/options" -import { todayStart } from "../../time" +import { dayStart } from "../../time" import { queryInteger } from "../helpers" export const adminConfRoutes = new Hono() @@ -232,7 +232,7 @@ adminConfRoutes.get("/dashboard", requireSuperAdmin, async (c) => { db .select({ value: count() }) .from(schema.submission) - .where(gte(schema.submission.createTime, todayStart())), + .where(gte(schema.submission.createTime, dayStart())), db .select({ value: count() }) .from(schema.contest) diff --git a/apps/api/src/routes/admin/problem.ts b/apps/api/src/routes/admin/problem.ts index 15dee31..b3959b0 100644 --- a/apps/api/src/routes/admin/problem.ts +++ b/apps/api/src/routes/admin/problem.ts @@ -48,7 +48,7 @@ import { config } from "../../config" import { readFile } from "node:fs/promises" import { resolve } from "node:path" import { getTopReactions } from "../../services/reaction" -import { objectValue, queryInteger, sampleUser } from "../helpers" +import { asRecord, queryInteger, sampleUser } from "../helpers" export const adminProblemRoutes = new Hono() @@ -205,7 +205,7 @@ async function serialize(row: ProblemRow) { source: row.source, submissionNumber: row.submissionNumber, acceptedNumber: row.acceptedNumber, - statisticInfo: objectValue(row.statisticInfo), + statisticInfo: asRecord(row.statisticInfo), contestId: row.contestId, createdBy: sampleUser( creator ?? { id: row.createdById, username: "" }, @@ -616,9 +616,9 @@ adminProblemRoutes.delete( .where(eq(schema.problem.id, id)) .limit(1) 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))) { - 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) }, @@ -793,7 +793,7 @@ adminProblemRoutes.post( return failure(c, 404, "problem-not-found", "Problem does not exist") } 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 .select({ id: schema.problem.id }) @@ -936,9 +936,9 @@ adminProblemRoutes.get( ) .limit(1) 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))) { - return failure(c, 404, "problem-not-found", "Problem does not exists") + return failure(c, 404, "problem-not-found", "Problem does not exist") } try { const archive = await packTestCaseZip(problem.testCaseId) @@ -973,9 +973,9 @@ adminProblemRoutes.get( ) .limit(1) 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))) { - 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) if (!info) diff --git a/apps/api/src/routes/admin/tutorial.ts b/apps/api/src/routes/admin/tutorial.ts index 81d5c9d..b93854d 100644 --- a/apps/api/src/routes/admin/tutorial.ts +++ b/apps/api/src/routes/admin/tutorial.ts @@ -15,7 +15,7 @@ import { requireSuperAdmin, type AppEnv } from "../../auth/middleware" import { db, schema } from "../../db" import { failure, parseBody, success } from "../../http" import { exerciseDataError } from "../../services/exercise" -import { objectValue, queryInteger, sampleUser } from "../helpers" +import { asRecord, queryInteger, sampleUser } from "../helpers" export const adminTutorialRoutes = new Hono() @@ -156,7 +156,7 @@ function serializeExercise(row: typeof schema.exercise.$inferSelect) { return { id: row.id, type: row.type, - data: objectValue(row.data), + data: asRecord(row.data), order: row.order, } satisfies AdminExercise } diff --git a/apps/api/src/routes/ai.ts b/apps/api/src/routes/ai.ts index 0324dc5..e53e0ea 100644 --- a/apps/api/src/routes/ai.ts +++ b/apps/api/src/routes/ai.ts @@ -55,7 +55,7 @@ import { import { countFailedSubmissions, isTeacherOrAbove, - objectValue, + asRecord, queryInteger, } from "./helpers" @@ -317,7 +317,7 @@ aiRoutes.get("/ai/pinned", requireAuth, async (c) => { id: row.analysis.id, provider: row.analysis.provider, model: row.analysis.model, - data: objectValue(row.analysis.data), + data: asRecord(row.analysis.data), analysis: row.analysis.analysis, createTime: row.analysis.createTime, isPinned: row.analysis.isPinned, diff --git a/apps/api/src/routes/content.ts b/apps/api/src/routes/content.ts index 53066f1..2123726 100644 --- a/apps/api/src/routes/content.ts +++ b/apps/api/src/routes/content.ts @@ -24,7 +24,7 @@ import { requireAuth, requireSuperAdmin, type AppEnv } from "../auth/middleware" import { db, schema } from "../db" import { failure, parseBody, success } from "../http" import { JudgeStatus } from "../judge/status" -import { objectValue, queryInteger, sampleUser } from "./helpers" +import { asRecord, queryInteger, sampleUser } from "./helpers" export const contentRoutes = new Hono() @@ -167,7 +167,7 @@ contentRoutes.get("/messages", requireAuth, async (c) => { // info / ip / contestId 三个字段不在 embeddedSubmissionSchema 里,故不传 —— // 对齐旧后端 SubmissionSafeModelSerializer 的 exclude,这三个键不出现在响应中 language: submission.language, - statisticInfo: objectValue(submission.statisticInfo), + statisticInfo: asRecord(submission.statisticInfo), // 展示用题号而非数字主键,站内信页面拿它拼 /problem/<题号> problem: displayId, showLink: true, @@ -590,7 +590,7 @@ contentRoutes.get("/tutorials/:id/exercises", async (c) => { ({ id: row.id, type: row.type, - data: objectValue(row.data), + data: asRecord(row.data), order: row.order, }) satisfies Exercise, ), diff --git a/apps/api/src/routes/contest.ts b/apps/api/src/routes/contest.ts index e02a394..f7e42f1 100644 --- a/apps/api/src/routes/contest.ts +++ b/apps/api/src/routes/contest.ts @@ -43,12 +43,7 @@ import { lockoutRemaining, type AttemptRule, } from "../services/throttling" -import { - objectValue, - publicTemplates, - queryInteger, - sampleUser, -} from "./helpers" +import { asRecord, publicTemplates, queryInteger, sampleUser } from "./helpers" export const contestRoutes = new Hono() @@ -223,11 +218,11 @@ async function contestProblemStatuses(userId: number | undefined) { .from(schema.userProfile) .where(eq(schema.userProfile.userId, userId)) .limit(1) - return objectValue(objectValue(profile?.status).contest_problems) + return asRecord(asRecord(profile?.status).contest_problems) } function myStatusOf(statuses: Record, problemId: number) { - const status = objectValue(statuses[String(problemId)]).status + const status = asRecord(statuses[String(problemId)]).status return typeof status === "number" ? status : null } @@ -353,7 +348,7 @@ contestRoutes.get( prompt: row.problem.prompt, submissionNumber: allowed ? row.problem.submissionNumber : 0, acceptedNumber: allowed ? row.problem.acceptedNumber : 0, - statisticInfo: allowed ? objectValue(row.problem.statisticInfo) : {}, + statisticInfo: allowed ? asRecord(row.problem.statisticInfo) : {}, contestId: contest.id, tags: tags.get(row.problem.id) ?? [], createdBy: sampleUser(row.user, row.realName), @@ -365,7 +360,7 @@ contestRoutes.get( mermaidCode: row.problem.allowFlowchart ? null : row.problem.mermaidCode, flowchartData: row.problem.allowFlowchart ? null - : objectValue(row.problem.flowchartData), + : asRecord(row.problem.flowchartData), flowchartHint: row.problem.flowchartHint, sqlConfig: row.problem.sqlConfig, sqlDisplay: row.problem.sqlDisplay, diff --git a/apps/api/src/routes/flowchart.ts b/apps/api/src/routes/flowchart.ts index 1b43ce7..167a9d1 100644 --- a/apps/api/src/routes/flowchart.ts +++ b/apps/api/src/routes/flowchart.ts @@ -31,11 +31,11 @@ import { flowchartQueue } from "../queue" import { getBooleanOption } from "../services/options" import { consumeToken } from "../services/throttling" import { buildWordFrequencies } from "../services/word-frequency" -import { todayStart } from "../time" +import { dayStart } from "../time" import { isAdminRole, matchedUsers, - objectValue, + asRecord, queryInteger, rounded, stripClassPrefix, @@ -69,14 +69,14 @@ function flowchartData( username, problemId: flowchart.problemId, mermaidCode: flowchart.mermaidCode, - flowchartData: objectValue(flowchart.flowchartData), + flowchartData: asRecord(flowchart.flowchartData), status: flowchart.status, createTime: flowchart.createTime, aiScore: flowchart.aiScore, aiGrade: flowchart.aiGrade, aiFeedback: flowchart.aiFeedback, aiSuggestions: flowchart.aiSuggestions, - aiCriteriaDetails: objectValue(flowchart.aiCriteriaDetails), + aiCriteriaDetails: asRecord(flowchart.aiCriteriaDetails), aiProvider: flowchart.aiProvider, aiModel: flowchart.aiModel, processingTime: flowchart.processingTime, @@ -269,9 +269,7 @@ flowchartRoutes.get("/flowcharts", requireAuth, async (c) => { ) if (onlyMyself) filters.push(eq(schema.flowchartSubmission.userId, user.id)) if (c.req.query("today") === "1") - filters.push( - sql`${schema.flowchartSubmission.createTime} >= ${todayStart()}`, - ) + filters.push(sql`${schema.flowchartSubmission.createTime} >= ${dayStart()}`) if (["S", "A", "B", "C"].includes(grade ?? "")) filters.push(eq(schema.flowchartSubmission.aiGrade, grade!)) const where = and(...filters) @@ -520,8 +518,8 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => { if (texts.length < WORDCLOUD_TEXT_LIMIT) texts.push(value) } for (const row of textRows) { - for (const [key, value] of Object.entries(objectValue(row.criteria))) { - const detail = objectValue(value) + for (const [key, value] of Object.entries(asRecord(row.criteria))) { + const detail = asRecord(value) // 和上面那条聚合同一道闸:分数不是数字的项当没配过,满分和评语也都不收 if (typeof detail.score !== "number") continue if (!criteriaMax.has(key)) { diff --git a/apps/api/src/routes/helpers.ts b/apps/api/src/routes/helpers.ts index a559c95..0ab5a20 100644 --- a/apps/api/src/routes/helpers.ts +++ b/apps/api/src/routes/helpers.ts @@ -57,7 +57,7 @@ export function asFilterValue( return value as T } -export function objectValue(value: unknown): Record { +export function asRecord(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {} @@ -96,7 +96,7 @@ export function isSuperAdmin(user: AuthUser | null | undefined) { export function publicTemplates(value: unknown) { const templates: Record = {} - for (const [language, raw] of Object.entries(objectValue(value))) { + for (const [language, raw] of Object.entries(asRecord(value))) { if (typeof raw !== "string") continue const match = raw.match(/\/\/TEMPLATE BEGIN\n([\s\S]+?)\/\/TEMPLATE END/) templates[language] = match?.[1] ?? "" diff --git a/apps/api/src/routes/problem.ts b/apps/api/src/routes/problem.ts index 01b56e7..4e03ace 100644 --- a/apps/api/src/routes/problem.ts +++ b/apps/api/src/routes/problem.ts @@ -29,26 +29,20 @@ import { db, schema } from "../db" import { astRequirements } from "../judge/ast" import { failure, success } from "../http" import { JudgeStatus } from "../judge/status" -import { localTime, shiftMonthsByCalendar, todayStart } from "../time" +import { dayStart, localTime, shiftMonthsByCalendar } from "../time" import { asFilterValue, countFailedSubmissions, - objectValue as toObject, + asRecord, queryInteger, sampleUser, } from "./helpers" export const problemRoutes = new Hono() -function objectValue(value: unknown): Record { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : {} -} - function publicTemplates(value: unknown) { const templates: Record = {} - for (const [language, raw] of Object.entries(objectValue(value))) { + for (const [language, raw] of Object.entries(asRecord(value))) { if (typeof raw !== "string") continue const match = raw.match(/\/\/TEMPLATE BEGIN\n([\s\S]+?)\/\/TEMPLATE END/) templates[language] = match?.[1] ?? "" @@ -63,7 +57,7 @@ async function getProblemStatuses(userId: number | undefined) { .from(schema.userProfile) .where(eq(schema.userProfile.userId, userId)) .limit(1) - return toObject(toObject(profile?.value).problems) + return asRecord(asRecord(profile?.value).problems) } async function getProblemTags(problemIds: number[]) { @@ -94,7 +88,7 @@ function listItem( tags: Map, statuses: Record, ) { - const status = toObject(statuses[String(row.problem.id)]).status + const status = asRecord(statuses[String(row.problem.id)]).status return { id: row.problem.id, _id: row.problem.displayId, @@ -287,7 +281,7 @@ problemRoutes.get("/problems/:id/beat-count", optionalAuth, async (c) => { ) 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([ db .select({ value: count() }) @@ -341,7 +335,7 @@ problemRoutes.get("/problems/:displayId/similar", optionalAuth, async (c) => { // 而这个接口恰好只在**刚 AC** 或**连挂三次**时才被调用,正是候选最容易全中的时候。 const statuses = await getProblemStatuses(c.get("user")?.id) const solvedIds = Object.entries(statuses) - .filter(([, value]) => toObject(value).status === JudgeStatus.ACCEPTED) + .filter(([, value]) => asRecord(value).status === JudgeStatus.ACCEPTED) .map(([key]) => Number(key)) .filter((id) => Number.isInteger(id)) // difficulty 是 text(Low / Mid / High),直接 order by 走的是字典序 —— @@ -478,8 +472,8 @@ problemRoutes.get("/problems/:displayId", optionalAuth, async (c) => { .from(schema.userProfile) .where(eq(schema.userProfile.userId, user.id)) .limit(1) - const statuses = objectValue(objectValue(profile?.status).problems) - const problemStatus = objectValue(statuses[String(row.problem.id)]).status + const statuses = asRecord(asRecord(profile?.status).problems) + const problemStatus = asRecord(statuses[String(row.problem.id)]).status if (typeof problemStatus === "number") myStatus = problemStatus // 前端拿这个数决定「让 AI 分析我的代码」露不露面,口径必须和 POST /ai/hint @@ -508,7 +502,7 @@ problemRoutes.get("/problems/:displayId", optionalAuth, async (c) => { prompt: row.problem.prompt, submissionNumber: row.problem.submissionNumber, acceptedNumber: row.problem.acceptedNumber, - statisticInfo: objectValue(row.problem.statisticInfo), + statisticInfo: asRecord(row.problem.statisticInfo), contestId: row.problem.contestId, tags: tagRows.map((tag) => tag.name), createdBy: sampleUser( @@ -522,7 +516,7 @@ problemRoutes.get("/problems/:displayId", optionalAuth, async (c) => { mermaidCode: row.problem.allowFlowchart ? null : row.problem.mermaidCode, flowchartData: row.problem.allowFlowchart ? null - : objectValue(row.problem.flowchartData), + : asRecord(row.problem.flowchartData), flowchartHint: row.problem.flowchartHint, sqlConfig: row.problem.sqlConfig, sqlDisplay: row.problem.sqlDisplay, diff --git a/apps/api/src/routes/problemset.ts b/apps/api/src/routes/problemset.ts index c5d78f2..7e95227 100644 --- a/apps/api/src/routes/problemset.ts +++ b/apps/api/src/routes/problemset.ts @@ -33,7 +33,7 @@ import { import { db, schema } from "../db" import { failure, parseBody, success } from "../http" import { computeProgress } from "../services/problemset" -import { asFilterValue, objectValue, queryInteger, sampleUser } from "./helpers" +import { asFilterValue, asRecord, queryInteger, sampleUser } from "./helpers" export const problemsetRoutes = new Hono() @@ -299,7 +299,7 @@ problemsetRoutes.get("/problem-sets/:id/problems", optionalAuth, async (c) => { ) .limit(1) : [] - const completed = objectValue(progressRows[0]?.detail) + const completed = asRecord(progressRows[0]?.detail) return success( c, rows.map( @@ -582,7 +582,7 @@ problemsetRoutes.get( totalProblemsCount: progress.totalProblemsCount, totalScore: progress.totalScore, completedProblems: Object.keys( - objectValue(progress.progressDetail), + asRecord(progress.progressDetail), ).flatMap((key) => problemMap.get(key) ?? []), }) satisfies ProblemSetProgress, ) diff --git a/apps/api/src/routes/submission-statistics.ts b/apps/api/src/routes/submission-statistics.ts index 6805e60..3418276 100644 --- a/apps/api/src/routes/submission-statistics.ts +++ b/apps/api/src/routes/submission-statistics.ts @@ -35,7 +35,7 @@ import { } from "../judge/status" import { type ContestEnv } from "../services/contest" import { getBooleanOption } from "../services/options" -import { localTime, todayStart } from "../time" +import { localTime, dayStart } from "../time" import { isAdminRole, matchedUsers, rounded, stripClassPrefix } from "./helpers" export const submissionStatisticsRoutes = new Hono() @@ -68,7 +68,7 @@ submissionStatisticsRoutes.get( isAdminRole(c.get("user")) const where = and( 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 judgingFilter = sql`count(*) filter (where ${inArray(schema.submission.result, UNJUDGED_RESULTS)})` diff --git a/apps/api/src/routes/submission.ts b/apps/api/src/routes/submission.ts index 0ad1ff6..81c6378 100644 --- a/apps/api/src/routes/submission.ts +++ b/apps/api/src/routes/submission.ts @@ -44,8 +44,8 @@ import { import { CodeFormatError, formatCode } from "../services/format-code" import { getBooleanOption } from "../services/options" import { consumeToken } from "../services/throttling" -import { todayStart } from "../time" -import { asFilterValue, isAdminRole, queryInteger } from "./helpers" +import { dayStart } from "../time" +import { asFilterValue, asRecord, isAdminRole, queryInteger } from "./helpers" import { problemFilter, submissionStatisticsRoutes, @@ -54,12 +54,6 @@ import { export const submissionRoutes = new Hono() -function objectValue(value: unknown): Record { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : {} -} - /** * 落编辑过程信号。**失败只记日志、不影响提交** —— 这是附带的统计数据, * 提交已经进库了,不能因为它回一个 500 让学生以为没交上。 @@ -234,7 +228,7 @@ submissionRoutes.get("/submissions/today-count", async (c) => { const [row] = await db .select({ value: count() }) .from(schema.flowchartSubmission) - .where(sql`${schema.flowchartSubmission.createTime} >= ${todayStart()}`) + .where(sql`${schema.flowchartSubmission.createTime} >= ${dayStart()}`) return success(c, row?.value ?? 0) } const [row] = await db @@ -243,7 +237,7 @@ submissionRoutes.get("/submissions/today-count", async (c) => { .where( and( isNull(schema.submission.contestId), - sql`${schema.submission.createTime} >= ${todayStart()}`, + sql`${schema.submission.createTime} >= ${dayStart()}`, ), ) return success(c, row?.value ?? 0) @@ -452,10 +446,10 @@ const submissionListColumns = { function caseSummary(submission: typeof schema.submission.$inferSelect) { if (submission.contestId !== null || submission.language === "SQL") return null - const data = objectValue(submission.info).data + const data = asRecord(submission.info).data if (!Array.isArray(data) || data.length === 0) return null const passed = data.filter( - (item) => objectValue(item).result === JudgeStatus.ACCEPTED, + (item) => asRecord(item).result === JudgeStatus.ACCEPTED, ).length return { passed, total: data.length } } @@ -508,7 +502,7 @@ async function submissionDetail(id: string, user: AuthUser) { result: row.submission.result, info: full ? row.submission.info : {}, language: row.submission.language, - statisticInfo: objectValue(row.submission.statisticInfo), + statisticInfo: asRecord(row.submission.statisticInfo), // contest 也在旧后端的排除名单里,同样只给管理员 contestId: full ? row.submission.contestId : null, problemId: row.submission.problemId, @@ -652,7 +646,7 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => { if (language) filters.push(eq(schema.submission.language, asFilterValue(language))) if (c.req.query("today") === "1") - filters.push(sql`${schema.submission.createTime} >= ${todayStart()}`) + filters.push(sql`${schema.submission.createTime} >= ${dayStart()}`) const where = and(...filters) // count 不 join problem:无条件 join 会让计划器把 count 退化成 seq scan // (生产快照实测 7.5ms → 78ms)。题号已经解析成 problem_id,也用不着 join。 @@ -689,7 +683,7 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => { username: submission.username, result: submission.result, language: submission.language, - statisticInfo: objectValue(submission.statisticInfo), + statisticInfo: asRecord(submission.statisticInfo), // 题单被删掉之后外键把 problemset_id 置了空,这里自然就没标记了 problemSet: submission.problemsetId !== null && @@ -773,7 +767,7 @@ submissionRoutes.get( username: submission.username, result: submission.result, language: submission.language, - statisticInfo: objectValue(submission.statisticInfo), + statisticInfo: asRecord(submission.statisticInfo), // 比赛提交没有来源题单:题单只收非比赛题(admin/problemset.ts 加题时卡了 // isNull(problem.contestId)),提交接口那边也只在 contestId 为空时才认这个字段 problemSet: null, diff --git a/apps/api/src/scripts/recount.ts b/apps/api/src/scripts/recount.ts index c72cacd..d4f3493 100644 --- a/apps/api/src/scripts/recount.ts +++ b/apps/api/src/scripts/recount.ts @@ -2,7 +2,7 @@ import { eq, sql } from "drizzle-orm" import { db, schema } from "../db" import { JudgeStatus, isAccepted } from "../judge/status" -import { objectValue } from "../routes/helpers" +import { asRecord } from "../routes/helpers" import { metaAchievements, refreshUnlockedCount, @@ -305,7 +305,7 @@ async function computePlan(): Promise { }) } if ( - stable(objectValue(problem.statisticInfo)) !== stable(want.statisticInfo) + stable(asRecord(problem.statisticInfo)) !== stable(want.statisticInfo) ) { rows.push({ label, @@ -328,7 +328,7 @@ async function computePlan(): Promise { } // acm_problems_status 里除了 problems / contest_problems 之外的键原样保留 —— // persistResult 只写这两个桶,别的键是从哪来的没人说得清,重算不该顺手抹掉。 - const existing = objectValue(profile.acmProblemsStatus) + const existing = asRecord(profile.acmProblemsStatus) const merged: Record = { ...existing } delete merged.problems delete merged.contest_problems @@ -355,13 +355,13 @@ async function computePlan(): Promise { } if (stable(existing) !== stable(merged)) { const keys = new Set([ - ...Object.keys(objectValue(existing.problems)), + ...Object.keys(asRecord(existing.problems)), ...Object.keys(want.status.problems ?? {}), ]) rows.push({ label, field: "acm_problems_status", - before: `${Object.keys(objectValue(existing.problems)).length} 题`, + before: `${Object.keys(asRecord(existing.problems)).length} 题`, after: `${keys.size} 题(含比赛桶重建)`, }) } diff --git a/apps/api/src/services/achievements.ts b/apps/api/src/services/achievements.ts index b305896..e226f20 100644 --- a/apps/api/src/services/achievements.ts +++ b/apps/api/src/services/achievements.ts @@ -16,7 +16,7 @@ import { publishAchievementNotification } from "../events" import { calendarDay, dayNumber, localHour } from "../time" import { findMetric } from "./achievement-metrics" import { isAccepted, JudgeStatus } from "../judge/status" -import { objectValue } from "../routes/helpers" +import { asRecord } from "../routes/helpers" function numberMetric(metrics: Record, key: string) { const value = metrics[key] @@ -129,7 +129,7 @@ export async function updateAchievementsForSubmission(submissionId: string) { .where(eq(schema.userStat.userId, row.submission.userId)) .for("update") 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 if (firstAc) { value.accepted_count = numberMetric(value, "accepted_count") + 1 @@ -143,7 +143,7 @@ export async function updateAchievementsForSubmission(submissionId: string) { numberMetric(value, "max_wa_before_ac"), 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 value._ac_per_day = perDay value.max_ac_in_one_day = Math.max( @@ -264,7 +264,7 @@ export async function updateAchievementsForProblemSet(userId: number) { .for("update") .limit(1) 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.problemset_completed = completedRow?.value ?? 0 await tx @@ -345,7 +345,7 @@ export async function rescanAchievement(achievementId: number) { .from(schema.userStat) const eligible = stats.filter((stat) => { 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 return achievement.operator === "gte" ? value >= achievement.threshold diff --git a/apps/api/src/services/hint-diagnosis.ts b/apps/api/src/services/hint-diagnosis.ts index 40f7fad..873e58a 100644 --- a/apps/api/src/services/hint-diagnosis.ts +++ b/apps/api/src/services/hint-diagnosis.ts @@ -13,7 +13,7 @@ import { and, desc, eq, isNotNull } from "drizzle-orm" import { config } from "../config" import { db, schema } from "../db" import { JudgeStatus, judgeStatusName } from "../judge/status" -import { objectValue } from "../routes/helpers" +import { asRecord } from "../routes/helpers" import { completeChat } from "./ai" import { KEY_LINE_LEVEL } from "./hint-filter" import { readInfo } from "./test-case" @@ -80,7 +80,7 @@ const SINGLE_SYSTEM = "你是编程助教。指出学生代码最关键的一个问题,循序渐进地提示,绝不直接给出核心算法或完整解法。输入读取错误可以直接给出正确片段。使用 Markdown,不超过6句话。" 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 = ( Array.isArray(row.problem.samples) ? row.problem.samples : [] ) - .map((item) => objectValue(item)) + .map((item) => asRecord(item)) .filter( (item): item is { input: string; output: string } => typeof item.input === "string" && typeof item.output === "string", @@ -152,7 +152,7 @@ function numbered(code: string) { /** 同语言的标准答案优先;没有就拿别的语言的(思路一样,照样能帮诊断);再没有就 null */ export function referenceAnswer(row: HintRow) { 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( (item): item is { language: string; code: string } => @@ -174,10 +174,10 @@ export function referenceAnswer(row: HintRow) { */ async function firstFailedCase(row: HintRow) { 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 const failed = data - .map((item) => objectValue(item)) + .map((item) => asRecord(item)) .find((item) => typeof item.result === "number" && item.result !== 0) if (!failed || typeof failed.test_case !== "string") return null try { diff --git a/apps/api/src/services/problemset.ts b/apps/api/src/services/problemset.ts index 444bbcf..418ccc7 100644 --- a/apps/api/src/services/problemset.ts +++ b/apps/api/src/services/problemset.ts @@ -1,7 +1,7 @@ import { and, eq, notInArray, sql } from "drizzle-orm" import { db, schema } from "../db" -import { objectValue } from "../routes/helpers" +import { asRecord } from "../routes/helpers" type BadgeRow = typeof schema.problemsetBadge.$inferSelect type ProgressRow = typeof schema.problemsetProgress.$inferSelect @@ -40,7 +40,7 @@ export function computeProgress( if (score === undefined) continue totalScore += score // 分值以题单当前的设置为准,detail 里存的是做出那一刻的快照 - kept[key] = { ...objectValue(value), score } + kept[key] = { ...asRecord(value), score } } // 分母只算必做题。「(选做)」这个标签一直只是卡片上的一行字,进度分母和 all_problems // 奖章照样要求做完 —— 快照里 22 个人做完了全部必做题,界面却显示未完成、全通奖章也拿不到 @@ -137,7 +137,7 @@ export function eligibleForBadge(badge: BadgeRow, progress: BadgeCheck) { } if (badge.conditionType === "problem_count") { return ( - Object.keys(objectValue(progress.progressDetail)).length >= + Object.keys(asRecord(progress.progressDetail)).length >= badge.conditionValue ) } @@ -232,7 +232,7 @@ export async function resyncProgress(problemsetId: number) { const updated = progresses.map((progress) => ({ ...progress, ...computeProgress( - objectValue(progress.progressDetail), + asRecord(progress.progressDetail), links, progress.completeTime, now, @@ -309,7 +309,7 @@ export async function recordSolvedProblem( .values({ problemsetId, userId, submissionId, problemId }) } - const detail = objectValue(progress.progressDetail) + const detail = asRecord(progress.progressDetail) if (String(problemId) in detail) return [] const links = await tx .select({ diff --git a/apps/api/src/services/test-case.ts b/apps/api/src/services/test-case.ts index bced12d..8c4b972 100644 --- a/apps/api/src/services/test-case.ts +++ b/apps/api/src/services/test-case.ts @@ -224,7 +224,7 @@ export async function packTestCaseZip(testCaseId: string) { try { entries = await readdir(directory) } catch { - throw new TestCaseError("Test case does not exists") + throw new TestCaseError("Test case does not exist") } const names = new Set(entries) const isSql = await readInfo(testCaseId) diff --git a/apps/api/src/time.ts b/apps/api/src/time.ts index b1484cd..f021de7 100644 --- a/apps/api/src/time.ts +++ b/apps/api/src/time.ts @@ -64,10 +64,13 @@ export function localWeekday(day: number): number { 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( - dayNumber(calendarDay(now)) * DAY_MS - OFFSET_MS, + dayNumber(calendarDay(instant)) * DAY_MS - OFFSET_MS, ).toISOString() } diff --git a/apps/api/src/websocket.ts b/apps/api/src/websocket.ts index 0439367..6a70775 100644 --- a/apps/api/src/websocket.ts +++ b/apps/api/src/websocket.ts @@ -17,6 +17,7 @@ import { } from "./judge/events" import { JudgeStatus } from "./judge/status" import { createSubscriberRedis } from "./redis" +import { asRecord } from "./routes/helpers" import { configTopic, configUpdateChannel, @@ -204,12 +205,6 @@ export function startSessionSweep() { return timer } -function objectValue(value: unknown): Record { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : {} -} - export function submissionWebSocketHandler(): Bun.WebSocketHandler { return { open(ws) { @@ -386,7 +381,7 @@ async function handleMessage( return } - const statistics = objectValue(submission.statisticInfo) + const statistics = asRecord(submission.statisticInfo) const status = submission.result === JudgeStatus.PENDING ? "pending" diff --git a/apps/web/src/App.vue b/apps/web/src/App.vue index e8b5898..4af7fdf 100644 --- a/apps/web/src/App.vue +++ b/apps/web/src/App.vue @@ -33,7 +33,7 @@ useMaxKB() // 真实身份:连着的话这个人会被算进「在线老师」,学生因此拿到 pending 而不是 // no_teacher,排队等一个顶栏里根本没有求助列表的人;反过来他自己看到的求助 // 按钮点下去,服务端回的是「教师不能发起求助」。两头都不对,索性对演示模式 -// 关闭这个功能(Form.vue 的按钮同步隐藏)。 +// 关闭这个功能(EditorToolbar.vue 的按钮同步隐藏)。 watch( () => userStore.isAuthed && !userStore.demoMode, (available) => { diff --git a/apps/web/src/admin/api.ts b/apps/web/src/admin/api.ts index 5681e84..7e8bf4b 100644 --- a/apps/web/src/admin/api.ts +++ b/apps/web/src/admin/api.ts @@ -1,5 +1,5 @@ import api from "utils/api" -import { toProblemListItem } from "admin/transforms" +import { toAdminProblemRow } from "admin/transforms" import type { AcTrend, AdminProblemSetProgress, @@ -8,7 +8,7 @@ import type { RenameTagResponse, SqlTestCaseScript, AcmHelperItem, - SubmissionInfo, + ContestSubmissionInfo, AdminAiReport, AdminAiReportList, StuckProblem, @@ -30,13 +30,13 @@ import type { AdminAnnouncementListItem, BlankContest, BlankProblem, - Contest, + AdminContest, Exercise, ExerciseType, SqlDisplay, UploadTestCaseResponse, Tutorial, - User, + AdminUserForm, WebsiteConfig, AdminProblemSetBadge, AdminProblemSetList, @@ -70,7 +70,7 @@ export async function getProblemList( params: { offset, limit, keyword, author, tagId }, }) return { - results: res.results.map(toProblemListItem), + results: res.results.map(toAdminProblemRow), total: res.total, } } @@ -165,7 +165,7 @@ export function getUserList( } // 编辑用户 -export function editUser(user: User) { +export function editUser(user: AdminUserForm) { return api.put(`admin/users/${user.id}`, { username: user.username, email: user.email, @@ -297,7 +297,7 @@ export function createContestProblem(problem: BlankProblem) { } /** 组件里的比赛对象是 snake_case,出站转成新后端要的 camelCase */ -function toContestBody(contest: Contest | BlankContest) { +function toContestBody(contest: AdminContest | BlankContest) { return { title: contest.title, description: contest.description, @@ -310,22 +310,22 @@ function toContestBody(contest: Contest | BlankContest) { } export function createContest(contest: BlankContest) { - return api.post("admin/contests", toContestBody(contest)) + return api.post("admin/contests", toContestBody(contest)) } -export function editContest(contest: Contest | BlankContest) { - return api.put( - `admin/contests/${(contest as Contest).id}`, +export function editContest(contest: AdminContest | BlankContest) { + return api.put( + `admin/contests/${(contest as AdminContest).id}`, toContestBody(contest), ) } export function cloneContest(contestId: number) { - return api.post(`admin/contests/${contestId}/clone`) + return api.post(`admin/contests/${contestId}/clone`) } export function getContest(id: string) { - return api.get(`admin/contests/${id}`) + return api.get(`admin/contests/${id}`) } export function addProblemForContest( @@ -463,9 +463,9 @@ export function makeProblemPublic(id: number, displayId: string) { // 比赛辅助检查 export function getACMHelperList(contestId: number) { // acInfo 在契约里是 Record(acm_contest_rank 的 JSONB 原文), - // 组件侧按 SubmissionInfo 读,收窄放在这里 + // 组件侧按 ContestSubmissionInfo 读,收窄放在这里 return api.get< - Array & { acInfo: SubmissionInfo }> + Array & { acInfo: ContestSubmissionInfo }> >(`admin/contests/${contestId}/acm-helper`) } diff --git a/apps/web/src/admin/contest/components/Actions.vue b/apps/web/src/admin/contest/components/Actions.vue index 46fdd73..164dacd 100644 --- a/apps/web/src/admin/contest/components/Actions.vue +++ b/apps/web/src/admin/contest/components/Actions.vue @@ -1,9 +1,9 @@ @@ -18,7 +18,7 @@ const isNotRegularUser = computed( 封号中 diff --git a/apps/web/src/admin/user/list.vue b/apps/web/src/admin/user/list.vue index 09b9160..933e14b 100644 --- a/apps/web/src/admin/user/list.vue +++ b/apps/web/src/admin/user/list.vue @@ -4,7 +4,7 @@ import { DataTableRowKey, NFlex, NTag, SelectOption } from "naive-ui" import Pagination from "shared/components/Pagination.vue" import { usePagination } from "shared/composables/pagination" import { parseTime } from "utils/functions" -import type { User } from "utils/types" +import type { AdminUserForm } from "utils/types" import { deleteUsers, editUser, @@ -13,8 +13,8 @@ import { resetPassword, } from "../api" import Actions from "./components/Actions.vue" -import Name from "./components/Name.vue" -import Password from "./components/Password.vue" +import UserNameCell from "./components/UserNameCell.vue" +import PasswordCell from "./components/PasswordCell.vue" import { PROBLEM_PERMISSION, USER_TYPE } from "utils/constants" import { useRouteQuery } from "@vueuse/router" import TextCopy from "shared/components/TextCopy.vue" @@ -35,8 +35,8 @@ const { query, clearQuery } = usePagination({ }) const total = ref(0) -const users = ref([]) -const userEditing = ref(null) +const users = ref([]) +const userEditing = ref(null) const adminOptions = [ { label: "全部用户", value: "" }, @@ -57,16 +57,16 @@ const password = ref("") const revealedPasswords = ref(new Set()) const userIDs = ref([]) -const rowKey = (row: User) => row.id +const rowKey = (row: AdminUserForm) => row.id -const columns: DataTableColumn[] = [ +const columns: DataTableColumn[] = [ { type: "selection" }, { title: "ID", key: "id", width: 80 }, { title: "用户名", key: "username", width: 220, - render: (row) => h(Name, { user: row }), + render: (row) => h(UserNameCell, { user: row }), }, { // 显示出来的密码后面还跟着「隐藏」按钮,150 挤得下点点、挤不下密码 @@ -74,7 +74,7 @@ const columns: DataTableColumn[] = [ key: "raw_password", width: 180, render: (row) => - h(Password, { + h(PasswordCell, { user: row, revealed: revealedPasswords.value.has(row.id), onToggle: (id: number) => { @@ -165,7 +165,7 @@ async function onDeleteUsers(userIDs: DataTableRowKey[] | Ref) { listUsers() } -async function onResetPassword(user: User) { +async function onResetPassword(user: AdminUserForm) { const res = await resetPassword(user.id) message.success(`【${user.username}】的密码已重置成【${res}】`) 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) => { if (it.id === user.id) { it.isDisabled = user.isDisabled @@ -209,7 +209,7 @@ function createNewUser() { password.value = "" } -function onOpenEditModal(user: User) { +function onOpenEditModal(user: AdminUserForm) { userEditing.value = user password.value = "" } diff --git a/apps/web/src/oj/api.ts b/apps/web/src/oj/api.ts index 690c58b..16bd7f1 100644 --- a/apps/web/src/oj/api.ts +++ b/apps/web/src/oj/api.ts @@ -1,7 +1,7 @@ import { type AiAnalysisRecord, type AiHintFeedbackRequest, - type Contest as OjContest, + type Contest, type ContestAccess, type ContestList, type ActivityRankItem, @@ -43,7 +43,7 @@ import { } from "@oj2/contract" import api from "utils/api" import { contract } from "utils/contract" -import { filterResult } from "oj/transforms" +import { toProblemRow } from "oj/transforms" import type { Announcement, AnnouncementListItem, @@ -52,7 +52,7 @@ import type { Message, SubmissionListItem, Exercise, - Problem, + ProblemDetail, ReactionKey, ReactionState, Submission, @@ -67,11 +67,11 @@ import type { * 题目详情。走契约的 zod 解析,形状即契约 —— 之前这里手抄了一份 camel→snake 的 * 键名映射,抄漏一个字段就是静默 undefined。 * - * 走 `contract()` 而不是裸 `parse()`:原来是 `problemDetailSchema.parse(v) as Problem`, + * 走 `contract()` 而不是裸 `parse()`:原来是 `problemDetailSchema.parse(v) as ProblemDetail`, * `as` 把校验结果又断言回去、等于没校验,而 `parse` 抛错会让整个题目页白屏。 * 现在形状不符时记一条控制台分歧再放行原始数据。 */ -function detailProblem(value: unknown): Problem { +function detailProblem(value: unknown): ProblemDetail { return contract("GET /problems/:id", problemDetailSchema, value) } @@ -89,7 +89,7 @@ export async function getProblemList( params: { paging: true, offset, limit, ...searchParams }, }) return { - results: res.results.map(filterResult), + results: res.results.map(toProblemRow), total: res.total, } } @@ -250,7 +250,7 @@ export function getContestList(query: { } export function getContest(id: string) { - return api.get(`contests/${encodeURIComponent(id)}`) + return api.get(`contests/${encodeURIComponent(id)}`) } export function getContestAccess(id: string) { @@ -268,7 +268,7 @@ export async function getContestProblems(contestID: string) { const res = await api.get( `contests/${encodeURIComponent(contestID)}/problems`, ) - return res.map(filterResult) + return res.map(toProblemRow) } export function getContestRank( @@ -276,7 +276,7 @@ export function getContestRank( query: { limit: number; offset: number }, ) { // submissionInfo 在契约里是 Record(JSONB 原文), - // 前端在这里收窄成 SubmissionInfo,见 utils/types 的 ContestRank + // 前端在这里收窄成 ContestSubmissionInfo,见 utils/types 的 ContestRank return api.get<{ results: ContestRank[]; total: number }>( `contests/${encodeURIComponent(contestID)}/rank`, { params: query }, @@ -383,7 +383,7 @@ export function submitHintFeedback(hintId: number, helpful: boolean) { export function getSimilarProblems(problemId: string) { return api .get(`problems/${encodeURIComponent(problemId)}/similar`) - .then((response) => response.map(filterResult)) + .then((response) => response.map(toProblemRow)) } export type { YearlyAc as YearlyACData } from "@oj2/contract" diff --git a/apps/web/src/oj/contest/components/ContestInfo.vue b/apps/web/src/oj/contest/components/ContestInfo.vue index 4aeae7e..055f551 100644 --- a/apps/web/src/oj/contest/components/ContestInfo.vue +++ b/apps/web/src/oj/contest/components/ContestInfo.vue @@ -2,7 +2,7 @@ import { Icon } from "@iconify/vue" import { useContestStore } from "oj/store/contest" import { parseTime } from "utils/functions" -import ContestType from "shared/components/ContestType.vue" +import ContestAccessTag from "shared/components/ContestAccessTag.vue" const contestStore = useContestStore() @@ -30,7 +30,7 @@ const contestStore = useContestStore() {{ parseTime(contestStore.contest.endTime, "YYYY年M月D日 HH:mm:ss") }} - + {{ contestStore.contest.createdBy.username }} diff --git a/apps/web/src/oj/contest/list.vue b/apps/web/src/oj/contest/list.vue index 125bf52..770631c 100644 --- a/apps/web/src/oj/contest/list.vue +++ b/apps/web/src/oj/contest/list.vue @@ -3,7 +3,7 @@ import { useRouteQuery } from "@vueuse/router" import { NTag } from "naive-ui" import { getContestList } from "oj/api" 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 Pagination from "shared/components/Pagination.vue" import { useAuthModalStore } from "shared/store/authModal" @@ -29,7 +29,7 @@ const { query, clearQuery } = usePagination({ tag: useRouteQuery("tag", "").value, }) -const data = ref([]) +const data = ref([]) const total = ref(0) const options: SelectOption[] = [ @@ -46,7 +46,7 @@ const tags: SelectOption[] = [ { label: "期末", value: "期末" }, ] -const columns: DataTableColumn[] = [ +const columns: DataTableColumn[] = [ { title: renderTableTitle("状态", "streamline-emojis:collision"), key: "status", @@ -116,7 +116,7 @@ watchDebounced(() => query.keyword, listContests, { // 监听其他查询条件变化 watch(() => [query.page, query.limit, query.status, query.tag], listContests) -function rowProps(row: OjContest) { +function rowProps(row: Contest) { return { style: "cursor: pointer", onClick() { diff --git a/apps/web/src/oj/contest/pages/problems.vue b/apps/web/src/oj/contest/pages/problems.vue index c3bd47f..6bcc6cf 100644 --- a/apps/web/src/oj/contest/pages/problems.vue +++ b/apps/web/src/oj/contest/pages/problems.vue @@ -1,5 +1,5 @@