diff --git a/apps/api/src/routes/flowchart.ts b/apps/api/src/routes/flowchart.ts index b8ae5b5..790be9e 100644 --- a/apps/api/src/routes/flowchart.ts +++ b/apps/api/src/routes/flowchart.ts @@ -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) }) diff --git a/apps/api/src/routes/submission-statistics.ts b/apps/api/src/routes/submission-statistics.ts index f878db1..c575f7c 100644 --- a/apps/api/src/routes/submission-statistics.ts +++ b/apps/api/src/routes/submission-statistics.ts @@ -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()) diff --git a/apps/api/src/routes/submission.ts b/apps/api/src/routes/submission.ts index 20879cc..c03fe6c 100644 --- a/apps/api/src/routes/submission.ts +++ b/apps/api/src/routes/submission.ts @@ -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) }, ) diff --git a/apps/web/src/oj/api.ts b/apps/web/src/oj/api.ts index 9ee7c1b..1e4b806 100644 --- a/apps/web/src/oj/api.ts +++ b/apps/web/src/oj/api.ts @@ -150,10 +150,13 @@ export function getSubmissions(params: Partial) { : "submissions" // 契约里 language 是 z.string()(语言是配置项,随时可能加,收紧成枚举会让 // 新加的语言在后端 parse 时直接抛),前端在这一处收窄成 LANGUAGE - return api.get<{ results: SubmissionListItem[]; total: number }>(endpoint, { - // contestId 走的是路径,page 只有前端分页器用 - params: { ...params, contestId: undefined, page: undefined }, - }) + return api.get<{ results: SubmissionListItem[]; total: number; unknownProblems: string[] }>( + endpoint, + { + // contestId 走的是路径,page 只有前端分页器用 + params: { ...params, contestId: undefined, page: undefined }, + }, + ) } export function getRankOfProblem(problemId: string) { diff --git a/apps/web/src/oj/submission/components/FlowchartPane.vue b/apps/web/src/oj/submission/components/FlowchartPane.vue index a5e106a..298f951 100644 --- a/apps/web/src/oj/submission/components/FlowchartPane.vue +++ b/apps/web/src/oj/submission/components/FlowchartPane.vue @@ -243,7 +243,7 @@ const canRetry = computed(
- ↑↓ + ↑↓←→ 换上一张 / 下一张
本页第 {{ position.index }} / {{ position.count }} 条 diff --git a/apps/web/src/oj/submission/components/SubmissionPane.vue b/apps/web/src/oj/submission/components/SubmissionPane.vue index 9b9f5af..6e529e3 100644 --- a/apps/web/src/oj/submission/components/SubmissionPane.vue +++ b/apps/web/src/oj/submission/components/SubmissionPane.vue @@ -391,7 +391,7 @@ function openStandalone() {
- ↑↓ + ↑↓←→ {{ teacher diff --git a/apps/web/src/oj/submission/list.vue b/apps/web/src/oj/submission/list.vue index 4698421..6781ce5 100644 --- a/apps/web/src/oj/submission/list.vue +++ b/apps/web/src/oj/submission/list.vue @@ -96,6 +96,8 @@ const submissions = ref([]) const flowcharts = ref([]) const total = ref(0) const todayCount = ref(0) +/** 题号框里查不到的题号(打错了)。一次填好几道时打错的那道会被忽略,得告诉老师 */ +const unknownProblems = ref([]) const loading = ref(false) const loaded = ref(false) @@ -162,6 +164,7 @@ async function listSubmissions() { if (mode) flowcharts.value = res.results as FlowchartSubmissionListItem[] else submissions.value = res.results as SubmissionListItem[] total.value = res.total + unknownProblems.value = res.unknownProblems newCount.value = 0 loaded.value = true // 翻了页、换了条件就回到列表顶上(往回翻到上一页末尾的,settleSelection 会滚到那一条) @@ -311,7 +314,8 @@ function move(step: 1 | -1) { const [todayPanel, toggleTodayPanel] = useToggle(false) -onKeyStroke(["ArrowUp", "ArrowDown"], (e: KeyboardEvent) => { +// ← → 和 ↑ ↓ 一样(9 月加方向键时就是四个键都能翻,老师已经用惯了) +onKeyStroke(["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"], (e: KeyboardEvent) => { if (!isDesktop.value || todayPanel.value) return // 别的控件已经处理了这次按键(下拉框自己会开菜单、换选项) if (e.defaultPrevented) return @@ -331,7 +335,7 @@ onKeyStroke(["ArrowUp", "ArrowDown"], (e: KeyboardEvent) => { } if (!rows.value.length) return e.preventDefault() - move(e.key === "ArrowUp" ? -1 : 1) + move(e.key === "ArrowUp" || e.key === "ArrowLeft" ? -1 : 1) }) // 手机上没有右栏:点一行从右边滑出整屏的详情 @@ -723,6 +727,7 @@ function dayBreak(index: number) { size="small" clearable placeholder="题号" + :status="unknownProblems.length ? 'warning' : undefined" > @@ -814,6 +819,10 @@ function dayBreak(index: number) {
+
+ + 题号 {{ unknownProblems.join("、") }} 没有这道题,下面只有其余题号的提交 +