feat(提交列表): 题号填错一道时顶上提示「没有这道题」;← → 也能翻上一条下一条
Deploy / deploy (push) Canceled after 0s

- 列表接口回带查不到的题号(公开列表只认公开题,比赛列表只认本场的题),
  一次填好几道时打错的那道原来被悄悄忽略
- 方向键恢复成四个都能翻(9 月加的时候就是这样,老师已经用惯了)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-09-29 18:58:31 +08:00
1 parent 0bcc981e6f
commit 52bf2d6111
9 files changed
+83 -15

No files matched your search

+5 -2
View File
@@ -25,6 +25,7 @@ import { problemSetJoinTimes } from "../services/problemset"
import { consumeToken } from "../services/throttling"
import { buildWordFrequencies } from "../services/word-frequency"
import { dayStart } from "../time"
import { unknownDisplayIds } from "./submission-statistics"
import {
isAdminRole,
matchedUsers,
@@ -278,7 +279,7 @@ flowchartRoutes.get("/flowcharts", requireAuth, async (c) => {
// submission_list_show_all 时非管理员看不到列表。流程图这边一直漏了这道门,
// 学生把语言切成「流程图」、用户名随便填一个字就能翻出全班的 AI 评分。
if (!(await getBooleanOption("submission_list_show_all", true)) && !isAdminRole(user)) {
return success(c, { results: [], total: 0 } satisfies FlowchartList)
return success(c, { results: [], total: 0, unknownProblems: [] } satisfies FlowchartList)
}
// 「只看自己」盖过用户名;普通学生不填用户名时也只看自己
const className = c.req.query("className")?.trim()
@@ -300,7 +301,7 @@ flowchartRoutes.get("/flowcharts", requireAuth, async (c) => {
if (["S", "A", "B", "C"].includes(grade ?? ""))
filters.push(eq(schema.flowchartSubmission.aiGrade, grade!))
const where = and(...filters)
const [totalRows, rows] = await Promise.all([
const [totalRows, rows, unknownProblems] = await Promise.all([
// 筛条件已经全落在 flowchart_submission 自己的列上,count 不挂任何 join
db.select({ value: count() }).from(schema.flowchartSubmission).where(where),
db
@@ -312,6 +313,7 @@ flowchartRoutes.get("/flowcharts", requireAuth, async (c) => {
.orderBy(desc(schema.flowchartSubmission.createTime))
.limit(limit)
.offset(offset),
displayId ? unknownDisplayIds(displayId, null) : [],
])
const joinTimes = await joinTimesFor(
user,
@@ -337,6 +339,7 @@ flowchartRoutes.get("/flowcharts", requireAuth, async (c) => {
}) satisfies FlowchartListItem,
),
total: totalRows[0]?.value ?? 0,
unknownProblems,
} satisfies FlowchartList)
})
@@ -236,6 +236,31 @@ export async function classNameFilter(className: string) {
* 和公开题撞号是常态),而公开提交从不指向比赛题(快照核过,0 条)。
* 查无此题时留恒假条件,少推一个 filter 就成了「不筛」。
*/
/**
* 题号框里查不到的那几个(范围同 problemFilter:公开列表只认公开题,比赛列表只认本场的题)。
* 列表把它们回给前端,打错一个字的时候老师能看到提示
*/
export async function unknownDisplayIds(displayId: string, contestId: number | null) {
const ids = parseDisplayIds(displayId)
if (!ids.length) return []
const rows = await db
.select({ displayId: schema.problem.displayId })
.from(schema.problem)
.where(
and(
inArray(
sql`lower(${schema.problem.displayId})`,
ids.map((id) => id.toLowerCase()),
),
contestId === null
? isNull(schema.problem.contestId)
: eq(schema.problem.contestId, contestId),
),
)
const found = new Set(rows.map((row) => row.displayId.toLowerCase()))
return ids.filter((id) => !found.has(id.toLowerCase()))
}
export async function problemFilter(displayId: string, contestId: number | null) {
// 一次可以筛几道(「这节课」那颗按钮一带就是五道),分隔符和统计面板同一套
const lowered = parseDisplayIds(displayId).map((id) => id.toLowerCase())
+8 -3
View File
@@ -37,6 +37,7 @@ import {
exactUsernameFilter,
problemFilter,
submissionStatisticsRoutes,
unknownDisplayIds,
usernameFilter,
} from "./submission-statistics"
@@ -511,7 +512,7 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => {
// 「非管理员即受限」,不能写成「是普通用户才受限」——
// 后者对匿名用户(user 为 null)会短路,匿名反而能看到全部提交,权限大于登录学生。
if (!(await getBooleanOption("submission_list_show_all", true)) && !isAdminRole(user)) {
return success(c, { results: [], total: 0 } satisfies SubmissionList)
return success(c, { results: [], total: 0, unknownProblems: [] } satisfies SubmissionList)
}
const displayId = c.req.query("problemDisplayId")?.trim()
const myself = c.req.query("myself") === "1" ? user : null
@@ -542,10 +543,11 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => {
const where = and(...filters)
// count 不 join problem:无条件 join 会让计划器把 count 退化成 seq scan
// (生产快照实测 7.5ms → 78ms)。题号已经解析成 problem_id,也用不着 join。
const [totalRows, rows] = await Promise.all([
const [totalRows, rows, unknownProblems] = await Promise.all([
db.select({ value: count() }).from(schema.submission).where(where),
// 班级和用户名一样是「圈一小撮人」,走同一条圈选路(见 paginateSubmissionRows)
paginateSubmissionRows(where, limit, offset, Boolean(username || className)),
displayId ? unknownDisplayIds(displayId, null) : [],
])
// 闸门只对学生自己的提交生效,所以只拿这一页里属于他自己的题目去查,一页一次查询
const [joinTimes, problemsetTitles] = await Promise.all([
@@ -587,6 +589,7 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => {
}) satisfies SubmissionListItem,
),
total: totalRows[0]?.value ?? 0,
unknownProblems,
} satisfies SubmissionList)
})
@@ -618,7 +621,7 @@ submissionRoutes.get(
const where = and(...filters)
// 一场比赛最多一两千条提交,按 contest_create_time_idx 定位之后怎么滤都不贵,
// 所以不像公开列表那样分游标 / 圈选两条路
const [totalRows, rows] = await Promise.all([
const [totalRows, rows, unknownProblems] = await Promise.all([
db.select({ value: count() }).from(schema.submission).where(where),
db
.select(submissionListColumns)
@@ -629,6 +632,7 @@ submissionRoutes.get(
.orderBy(desc(schema.submission.createTime))
.limit(limit)
.offset(offset),
displayId ? unknownDisplayIds(displayId, contest.id) : [],
])
// 这里不挂题单防作弊闸门(对比公开列表):题单里的题必定是非比赛题——加题时卡了
// `isNull(problem.contestId)`(admin/problemset.ts:232)——而这条列表只出比赛提交,
@@ -656,6 +660,7 @@ submissionRoutes.get(
}) satisfies SubmissionListItem,
),
total: totalRows[0]?.value ?? 0,
unknownProblems,
} satisfies SubmissionList)
},
)