- 列表接口回带查不到的题号(公开列表只认公开题,比赛列表只认本场的题), 一次填好几道时打错的那道原来被悄悄忽略 - 方向键恢复成四个都能翻(9 月加的时候就是这样,老师已经用惯了) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0bcc981e6f
commit
52bf2d6111
9 files changed
+83
-15
No files matched your search
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -150,10 +150,13 @@ export function getSubmissions(params: Partial<SubmissionListPayload>) {
|
||||
: "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) {
|
||||
|
||||
@@ -243,7 +243,7 @@ const canRetry = computed(
|
||||
</div>
|
||||
|
||||
<div v-if="!narrow" class="foot">
|
||||
<span class="keys"><kbd>↑</kbd><kbd>↓</kbd></span>
|
||||
<span class="keys"><kbd>↑</kbd><kbd>↓</kbd><kbd>←</kbd><kbd>→</kbd></span>
|
||||
<span>换上一张 / 下一张</span>
|
||||
<div class="spacer"></div>
|
||||
<span v-if="position">本页第 {{ position.index }} / {{ position.count }} 条</span>
|
||||
|
||||
@@ -391,7 +391,7 @@ function openStandalone() {
|
||||
</template>
|
||||
|
||||
<div v-if="!narrow" class="foot">
|
||||
<span class="keys"><kbd>↑</kbd><kbd>↓</kbd></span>
|
||||
<span class="keys"><kbd>↑</kbd><kbd>↓</kbd><kbd>←</kbd><kbd>→</kbd></span>
|
||||
<span>
|
||||
{{
|
||||
teacher
|
||||
|
||||
@@ -96,6 +96,8 @@ const submissions = ref<SubmissionListItem[]>([])
|
||||
const flowcharts = ref<FlowchartSubmissionListItem[]>([])
|
||||
const total = ref(0)
|
||||
const todayCount = ref(0)
|
||||
/** 题号框里查不到的题号(打错了)。一次填好几道时打错的那道会被忽略,得告诉老师 */
|
||||
const unknownProblems = ref<string[]>([])
|
||||
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"
|
||||
>
|
||||
<template #prefix><Icon icon="ph:hash" /></template>
|
||||
</n-input>
|
||||
@@ -814,6 +819,10 @@ function dayBreak(index: number) {
|
||||
<div class="split">
|
||||
<!-- 左栏:列表 -->
|
||||
<section class="list">
|
||||
<div v-if="unknownProblems.length" class="unknown-bar">
|
||||
<Icon icon="ph:warning" :width="13" />
|
||||
题号 {{ unknownProblems.join("、") }} 没有这道题,下面只有其余题号的提交
|
||||
</div>
|
||||
<button v-if="newCount" class="new-bar" @click="showNew">
|
||||
<Icon icon="ph:arrow-up-bold" :width="13" />
|
||||
有 {{ newCount }}{{ newCountCapped ? "+" : "" }} 条新提交,点这里显示
|
||||
@@ -1224,6 +1233,20 @@ function dayBreak(index: number) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.unknown-bar {
|
||||
height: 32px;
|
||||
flex: none;
|
||||
box-sizing: border-box;
|
||||
padding: 0 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: v-bind("tone('warning').color");
|
||||
background: v-bind("tone('warning').background");
|
||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||
}
|
||||
|
||||
.rows {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
|
||||
@@ -58,7 +58,10 @@ export const flowchartListItemSchema = z.object({
|
||||
showLink: z.boolean(),
|
||||
})
|
||||
|
||||
export const flowchartListSchema = paginatedSchema(flowchartListItemSchema)
|
||||
export const flowchartListSchema = paginatedSchema(flowchartListItemSchema).extend({
|
||||
/** 题号框里查不到的题号,口径同 submissionListSchema.unknownProblems */
|
||||
unknownProblems: z.array(z.string()),
|
||||
})
|
||||
export const createFlowchartResponseSchema = z.object({
|
||||
submissionId: z.string(),
|
||||
status: z.literal("pending"),
|
||||
|
||||
@@ -287,7 +287,13 @@ export const submissionListItemSchema = z.object({
|
||||
caseSummary: caseSummarySchema,
|
||||
})
|
||||
|
||||
export const submissionListSchema = paginatedSchema(submissionListItemSchema)
|
||||
export const submissionListSchema = paginatedSchema(submissionListItemSchema).extend({
|
||||
/**
|
||||
* 题号框里填了、但查不到的题号(打错了,或不在这个范围里 —— 比赛列表只认本场的题)。
|
||||
* 一次填好几道时,打错的那道原来被悄悄忽略,老师看到的列表少一道自己却不知道
|
||||
*/
|
||||
unknownProblems: z.array(z.string()),
|
||||
})
|
||||
|
||||
/**
|
||||
* **一条都没交**的学生。`realName` 是从用户名里剥掉 `ks<班级号>` 前缀后剩下的那一段,
|
||||
|
||||
Reference in new issue
Block a user