fix(题目): 修掉代码审查查出的几处问题——流程图拿到的徽章不弹、点评弹窗读失败时关不掉等

- 流程图画到 A / S 拿到的题单徽章、成就原来不弹:流程图那条 WebSocket 只认评分完成 / 失败,
  成就消息被丢掉了,要等下次换路由拉 pending 才出来
- 强制点评弹窗读不出点评、或者一直交不上时,「下一题 / 继续」按钮也给出来:原来弹窗关不掉,
  只能按浏览器后退;拦下换题时就记上「继续」,按钮文字不会写错
- 「放回编辑器」走正常的换语言路子(画流程图时切回代码编辑器、读那门语言的草稿);
  这道题已经不收的语言(0020 摘掉的 Java / JS / Go)置灰,只能复制
- 举着手时刚交对、1.5 秒内点下一题:原来「确定要换题吗」要问两遍(点评完再问一次),
  现在同意过的那个地址不再问
- 课堂看板上评分中、评失败的流程图不再算「尝试过」,和学生的课堂条一个口径;
  新加的流程图查询和其他查询并行,不再多等一轮数据库
- 课堂条的数据只认最后发出的那次请求:进页面那次后到的话会把刚做完的那道盖回「没做」
- 「A / S 算做完」收进契约,前后端只留一份;协作中老师的判断统一用 useTeacherCollab;
  几处放错位置、说法过时的注释

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-09-28 19:35:07 +08:00
1 parent 10169f5792
commit 83b6b7a81a
16 files changed
+162 -99

No files matched your search

+50 -47
View File
@@ -10,6 +10,7 @@ import {
type ClassComparisonResponse,
type ClassRankItem,
type ClassUserRank,
FLOWCHART_PASS_GRADES,
} from "@oj2/contract"
import { and, desc, eq, gte, inArray, isNotNull, isNull, like, lte, sql } from "drizzle-orm"
import { Hono } from "hono"
@@ -17,7 +18,6 @@ import { Hono } from "hono"
import { requireAuth, requireTeacher, type AppEnv } from "../auth/middleware"
import { db, schema } from "../db"
import { failure, parseBody, success } from "../http"
import { FLOWCHART_PASS_GRADES } from "../flowchart/grade"
import { JudgeStatus } from "../judge/status"
import { calendarDay, dayStart, localTime } from "../time"
import { queryInteger, rounded } from "./helpers"
@@ -309,43 +309,45 @@ classroomRoutes.get("/me/class-activity", requireAuth, async (c) => {
const lesson = await classLessonProblems(user.className, CLASS_ACTIVITY_LOOKBACK_DAYS)
const ids = lesson.problems.map((problem) => problem.id)
const mine = ids.length
? await db
.select({
problemId: schema.submission.problemId,
accepted: sql<boolean>`bool_or(${inArray(schema.submission.result, SOLVED_RESULTS)})`,
})
.from(schema.submission)
.where(
and(
eq(schema.submission.userId, user.id),
inArray(schema.submission.problemId, ids),
isNull(schema.submission.contestId),
),
)
.groupBy(schema.submission.problemId)
: []
// 代码提交和流程图提交两张表,互不依赖,一起查
const [mine, drawn] = await Promise.all([
ids.length
? db
.select({
problemId: schema.submission.problemId,
accepted: sql<boolean>`bool_or(${inArray(schema.submission.result, SOLVED_RESULTS)})`,
})
.from(schema.submission)
.where(
and(
eq(schema.submission.userId, user.id),
inArray(schema.submission.problemId, ids),
isNull(schema.submission.contestId),
),
)
.groupBy(schema.submission.problemId)
: [],
// 流程图作业:画到 A / S 也算做完(设计文档 2026-09-28-problem-page-redesign 第 3 节决定 6)。
// 有的题流程图交了几百次、代码个位数,只看代码提交的话,这些学生在课堂条上永远是「没做」
ids.length
? db
.select({
problemId: schema.flowchartSubmission.problemId,
passed: sql<boolean>`bool_or(${inArray(schema.flowchartSubmission.aiGrade, [...FLOWCHART_PASS_GRADES])})`,
})
.from(schema.flowchartSubmission)
.where(
and(
eq(schema.flowchartSubmission.userId, user.id),
inArray(schema.flowchartSubmission.problemId, ids),
// 只数评完了的:评分中、评失败的那次不算「做过」
eq(schema.flowchartSubmission.status, 2),
),
)
.groupBy(schema.flowchartSubmission.problemId)
: [],
])
const mineById = new Map(mine.map((row) => [row.problemId, row.accepted]))
// 流程图作业:画到 A / S 也算做完(设计文档 2026-09-28-problem-page-redesign 第 3 节决定 6)。
// 有的题流程图交了几百次、代码个位数,只看代码提交的话,这些学生在课堂条上永远是「没做」
const drawn = ids.length
? await db
.select({
problemId: schema.flowchartSubmission.problemId,
passed: sql<boolean>`bool_or(${inArray(schema.flowchartSubmission.aiGrade, FLOWCHART_PASS_GRADES)})`,
})
.from(schema.flowchartSubmission)
.where(
and(
eq(schema.flowchartSubmission.userId, user.id),
inArray(schema.flowchartSubmission.problemId, ids),
// 只数评完了的:评分中、评失败的那次不算「做过」
eq(schema.flowchartSubmission.status, 2),
),
)
.groupBy(schema.flowchartSubmission.problemId)
: []
for (const row of drawn) {
mineById.set(row.problemId, (mineById.get(row.problemId) ?? false) || row.passed)
}
@@ -462,7 +464,7 @@ classroomRoutes.get("/classroom/board", requireTeacher, async (c) => {
.slice(-RECENT_LESSONS)
const localDay = sql<string>`to_char(${localTime(schema.submission.createTime)}, 'YYYY-MM-DD')`
const [cells, lastSubmits, attendance] = await Promise.all([
const [cells, lastSubmits, attendance, drawn] = await Promise.all([
ids.length && userIds.length
? db
.select({
@@ -515,21 +517,19 @@ classroomRoutes.get("/classroom/board", requireTeacher, async (c) => {
)
.groupBy(schema.submission.userId)
: [],
])
/**
* 流程图作业:画到 A / S 也算这道题做完,和学生首页的课堂条同一个口径(/me/class-activity)。
* 有的题这节课整个班都在画流程图,只数代码提交的话,看板上满屏「没开始」
*/
const drawn =
/**
* 流程图作业:画到 A / S 也算这道题做完,和学生首页的课堂条同一个口径(/me/class-activity)。
* 有的题这节课整个班都在画流程图,只数代码提交的话,看板上满屏「没开始」
*/
ids.length && userIds.length
? await db
? db
.select({
userId: schema.flowchartSubmission.userId,
problemId: schema.flowchartSubmission.problemId,
attempts: sql<number>`count(*) filter (where ${gte(schema.flowchartSubmission.createTime, start)})::int`,
firstPassedAt: sql<
string | null
>`min(${schema.flowchartSubmission.createTime}) filter (where ${and(eq(schema.flowchartSubmission.status, 2), inArray(schema.flowchartSubmission.aiGrade, FLOWCHART_PASS_GRADES))})`,
>`min(${schema.flowchartSubmission.createTime}) filter (where ${and(eq(schema.flowchartSubmission.status, 2), inArray(schema.flowchartSubmission.aiGrade, [...FLOWCHART_PASS_GRADES]))})`,
lastAt: sql<
string | null
>`max(${schema.flowchartSubmission.createTime}) filter (where ${gte(schema.flowchartSubmission.createTime, start)})`,
@@ -539,10 +539,13 @@ classroomRoutes.get("/classroom/board", requireTeacher, async (c) => {
and(
inArray(schema.flowchartSubmission.userId, userIds),
inArray(schema.flowchartSubmission.problemId, ids),
// 只数评完了的:评分中、评失败的那次不算「做过」,和 /me/class-activity 一致
eq(schema.flowchartSubmission.status, 2),
),
)
.groupBy(schema.flowchartSubmission.userId, schema.flowchartSubmission.problemId)
: []
: [],
])
const cellByKey = new Map(cells.map((row) => [`${row.userId}:${row.problemId}`, row]))
const drawnByKey = new Map(drawn.map((row) => [`${row.userId}:${row.problemId}`, row]))
const lastByUser = new Map(lastSubmits.map((row) => [row.userId, row.lastAt]))
+13 -13
View File
@@ -296,19 +296,6 @@ async function astOnlyByUser(where: SQL | undefined, userIds: number[]) {
* 快照实测 count 65ms → 0.6ms。`ks2` 这种匹配上千个账号的宽前缀退回扫表,30~50ms,
* 和原来持平。
*/
/**
* 用户名**精确**匹配。usernameFilter 是「包含」,学号互相包含是常态(ks24a1 包含在 ks24a10 里),
* 协作中老师看「这个学生」的提交时不能把别人的也捞进来。查无此人留恒假条件
*/
export async function exactUsernameFilter(username: string) {
const [row] = await db
.select({ id: schema.user.id })
.from(schema.user)
.where(eq(schema.user.username, username))
.limit(1)
return row ? eq(schema.submission.userId, row.id) : sql`false`
}
export async function usernameFilter(username: string) {
const like = `%${username}%`
const users = await db
@@ -327,6 +314,19 @@ export async function usernameFilter(username: string) {
: frozen
}
/**
* 用户名**精确**匹配。usernameFilter 是「包含」,学号互相包含是常态(ks24a1 包含在 ks24a10 里),
* 协作中老师看「这个学生」的提交时不能把别人的也捞进来。查无此人留恒假条件
*/
export async function exactUsernameFilter(username: string) {
const [row] = await db
.select({ id: schema.user.id })
.from(schema.user)
.where(eq(schema.user.username, username))
.limit(1)
return row ? eq(schema.submission.userId, row.id) : sql`false`
}
/**
* 两条提交列表的题号筛选:先把题号解析成 problem.id,再按 `submission.problem_id` 筛。
* 原来是 join problem 之后比 `lower(problem._id)`,条件落在 problem 表上,规划器只能