feat(提交列表): 老师也能重新判题;列表能按班级、一次按几道题筛,还带「通过几个测试点」
- 重新判题从只放超管改成老师及以上:前端一直给老师亮着按钮,点了只会「权限不足」 - /submissions、/flowcharts 加 className(user.class_name 精确匹配),题号可以一次填几道 - 列表每行带 caseSummary,口径和详情一致,在库里数完只回两个数 - 新增 GET /classroom/lesson:提交列表「这节课」按钮用,班级和题的取法同课堂看板 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b76a35b5f2
commit
ea480d5e0c
12 files changed
+151
-425
No files matched your search
@@ -5,6 +5,7 @@ import {
|
||||
type ClassActivity,
|
||||
type ClassBoard,
|
||||
type ClassBoardStudent,
|
||||
type ClassLesson,
|
||||
type ClassActivityProblem,
|
||||
type ClassComparison,
|
||||
type ClassComparisonResponse,
|
||||
@@ -671,6 +672,22 @@ classroomRoutes.get("/classroom/board", requireTeacher, async (c) => {
|
||||
* 有对不上的题号就整个拒掉并点名是哪几个,免得存下半张单子老师还不知道。空数组 = 清掉,
|
||||
* 学生那边退回推断。
|
||||
*/
|
||||
classroomRoutes.get("/classroom/lesson", requireTeacher, async (c) => {
|
||||
const className = c.req.query("className")?.trim() || (await suggestActiveClass())
|
||||
if (!className)
|
||||
return success(c, { className: null, source: null, problems: [] } satisfies ClassLesson)
|
||||
// 和看板一样只回看今天:「这节课」不该冒出昨天的题
|
||||
const lesson = await classLessonProblems(className, 1)
|
||||
return success(c, {
|
||||
className,
|
||||
source: lesson.source,
|
||||
problems: lesson.problems.map((problem) => ({
|
||||
problemDisplayId: problem.displayId,
|
||||
title: problem.title,
|
||||
})),
|
||||
} satisfies ClassLesson)
|
||||
})
|
||||
|
||||
classroomRoutes.put("/classroom/lesson", requireTeacher, async (c) => {
|
||||
const parsed = await parseBody(c, classLessonRequestSchema, "题号格式不对")
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
isAdminRole,
|
||||
matchedUsers,
|
||||
asRecord,
|
||||
parseDisplayIds,
|
||||
queryInteger,
|
||||
rounded,
|
||||
stripClassPrefix,
|
||||
@@ -178,12 +179,15 @@ flowchartRoutes.post("/flowcharts", requireAuth, async (c) => {
|
||||
* 「查无此班」会变成「全站」。
|
||||
*/
|
||||
async function flowchartProblemFilter(displayId: string) {
|
||||
// 和代码提交列表一样可以一次筛几道
|
||||
const lowered = parseDisplayIds(displayId).map((id) => id.toLowerCase())
|
||||
if (!lowered.length) return sql`false`
|
||||
const problems = await db
|
||||
.select({ id: schema.problem.id })
|
||||
.from(schema.problem)
|
||||
.where(
|
||||
and(
|
||||
sql`lower(${schema.problem.displayId}) = lower(${displayId})`,
|
||||
inArray(sql`lower(${schema.problem.displayId})`, lowered),
|
||||
// 流程图题都是公开题(快照里那 12 道 contest_id 全为空),
|
||||
// 比赛题的 _id 撞号是常态,不该被筛进来
|
||||
isNull(schema.problem.contestId),
|
||||
@@ -197,6 +201,20 @@ async function flowchartProblemFilter(displayId: string) {
|
||||
: sql`false`
|
||||
}
|
||||
|
||||
/** 按班级(user.class_name)筛,口径同 submission-statistics.ts 的 classNameFilter */
|
||||
async function flowchartClassFilter(className: string) {
|
||||
const users = await db
|
||||
.select({ id: schema.user.id })
|
||||
.from(schema.user)
|
||||
.where(eq(schema.user.className, className))
|
||||
return users.length
|
||||
? inArray(
|
||||
schema.flowchartSubmission.userId,
|
||||
users.map((row) => row.id),
|
||||
)
|
||||
: sql`false`
|
||||
}
|
||||
|
||||
async function flowchartUserFilter(username: string) {
|
||||
const ids = (await matchedUsers(username)).map((row) => row.id)
|
||||
return ids.length ? inArray(schema.flowchartSubmission.userId, ids) : sql`false`
|
||||
@@ -250,13 +268,15 @@ flowchartRoutes.get("/flowcharts", requireAuth, async (c) => {
|
||||
return success(c, { results: [], total: 0 } satisfies FlowchartList)
|
||||
}
|
||||
// 「只看自己」盖过用户名;普通学生不填用户名时也只看自己
|
||||
const className = c.req.query("className")?.trim()
|
||||
const onlyMyself =
|
||||
c.req.query("myself") === "1" || (!username && user.adminType === "Regular User")
|
||||
c.req.query("myself") === "1" || (!username && !className && user.adminType === "Regular User")
|
||||
const filters: Array<SQL | undefined> = []
|
||||
filters.push(
|
||||
...(await Promise.all([
|
||||
displayId ? flowchartProblemFilter(displayId) : undefined,
|
||||
!onlyMyself && username ? flowchartUserFilter(username) : undefined,
|
||||
!onlyMyself && className ? flowchartClassFilter(className) : undefined,
|
||||
])),
|
||||
)
|
||||
if (onlyMyself) filters.push(eq(schema.flowchartSubmission.userId, user.id))
|
||||
|
||||
@@ -171,3 +171,21 @@ export async function matchedUsers(username: string) {
|
||||
.from(schema.user)
|
||||
.where(ilike(schema.user.username, `%${username}%`))
|
||||
}
|
||||
|
||||
/**
|
||||
* 题号框允许一次填几道:`1001,1005,1010`。中英文逗号、空格、分号都当分隔符 ——
|
||||
* 老师在投影前手敲,不该因为打了个全角逗号就查不出来。
|
||||
*/
|
||||
export function parseDisplayIds(raw: string) {
|
||||
const seen = new Set<string>()
|
||||
const ids: string[] = []
|
||||
for (const part of raw.split(/[,,;;\s]+/)) {
|
||||
const id = part.trim()
|
||||
if (!id) continue
|
||||
const key = id.toLowerCase()
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
ids.push(id)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import { JudgeStatus, UNJUDGED_RESULTS, type JudgeStatusValue } from "../judge/s
|
||||
import { type ContestEnv } from "../services/contest"
|
||||
import { getBooleanOption } from "../services/options"
|
||||
import { localTime, dayStart } from "../time"
|
||||
import { isAdminRole, matchedUsers, rounded, stripClassPrefix } from "./helpers"
|
||||
import { isAdminRole, matchedUsers, parseDisplayIds, rounded, stripClassPrefix } from "./helpers"
|
||||
|
||||
export const submissionStatisticsRoutes = new Hono<ContestEnv>()
|
||||
|
||||
@@ -138,24 +138,6 @@ function statisticsRange(c: { req: { query(name: string): string | undefined } }
|
||||
/** 一次最多查几道题。课堂上一节课布置三五道,20 是留足了余量的上限 */
|
||||
const STATISTICS_MAX_PROBLEMS = 20
|
||||
|
||||
/**
|
||||
* 题号框允许一次填几道:`1001,1005,1010`。中英文逗号、空格、分号都当分隔符 ——
|
||||
* 老师在投影前手敲,不该因为打了个全角逗号就查不出来。
|
||||
*/
|
||||
function parseDisplayIds(raw: string) {
|
||||
const seen = new Set<string>()
|
||||
const ids: string[] = []
|
||||
for (const part of raw.split(/[,,;;\s]+/)) {
|
||||
const id = part.trim()
|
||||
if (!id) continue
|
||||
const key = id.toLowerCase()
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
ids.push(id)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
/**
|
||||
* 按题号(展示用的 _id)定位公开题目。**有一个找不到就整体报错**,不退化成「全部题目」——
|
||||
* 否则教师打错一个字就会看到全站数据还以为是这几道题的。
|
||||
@@ -327,8 +309,27 @@ export async function exactUsernameFilter(username: string) {
|
||||
return row ? eq(schema.submission.userId, row.id) : sql`false`
|
||||
}
|
||||
|
||||
/**
|
||||
* 按班级筛:`user.class_name` 精确匹配。原来列表只能拿用户名前缀(`ks253`)当班级,
|
||||
* 那是「包含」匹配 —— ks253 会顺带捞进 ks2531 这类撞前缀的号,也要老师记得前缀怎么拼。
|
||||
* 查无此班留恒假条件
|
||||
*/
|
||||
export async function classNameFilter(className: string) {
|
||||
const users = await db
|
||||
.select({ id: schema.user.id })
|
||||
.from(schema.user)
|
||||
.where(eq(schema.user.className, className))
|
||||
return users.length
|
||||
? inArray(
|
||||
schema.submission.userId,
|
||||
users.map((row) => row.id),
|
||||
)
|
||||
: sql`false`
|
||||
}
|
||||
|
||||
/**
|
||||
* 两条提交列表的题号筛选:先把题号解析成 problem.id,再按 `submission.problem_id` 筛。
|
||||
* 可以一次填几道(`1021,1022`),**任一道**对上就算。
|
||||
* 原来是 join problem 之后比 `lower(problem._id)`,条件落在 problem 表上,规划器只能
|
||||
* 顺着时间索引倒扫、逐行回表比对,走不上 submission_public_problem_time_idx。
|
||||
*
|
||||
@@ -337,12 +338,15 @@ export async function exactUsernameFilter(username: string) {
|
||||
* 查无此题时留恒假条件,少推一个 filter 就成了「不筛」。
|
||||
*/
|
||||
export async function problemFilter(displayId: string, contestId: number | null) {
|
||||
// 一次可以筛几道(「这节课」那颗按钮一带就是五道),分隔符和统计面板同一套
|
||||
const lowered = parseDisplayIds(displayId).map((id) => id.toLowerCase())
|
||||
if (!lowered.length) return sql`false`
|
||||
const problems = await db
|
||||
.select({ id: schema.problem.id })
|
||||
.from(schema.problem)
|
||||
.where(
|
||||
and(
|
||||
sql`lower(${schema.problem.displayId}) = lower(${displayId})`,
|
||||
inArray(sql`lower(${schema.problem.displayId})`, lowered),
|
||||
contestId === null
|
||||
? isNull(schema.problem.contestId)
|
||||
: eq(schema.problem.contestId, contestId),
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import { and, count, desc, eq, inArray, isNull, sql, type SQL } from "drizzle-orm"
|
||||
import { Hono } from "hono"
|
||||
|
||||
import { optionalAuth, requireAuth, requireSuperAdmin } from "../auth/middleware"
|
||||
import { optionalAuth, requireAuth, requireTeacher } from "../auth/middleware"
|
||||
import type { AuthUser } from "../auth/session"
|
||||
import { db, schema } from "../db"
|
||||
import { failure, parseBody, success } from "../http"
|
||||
@@ -33,6 +33,7 @@ import { consumeToken } from "../services/throttling"
|
||||
import { dayStart } from "../time"
|
||||
import { asFilterValue, asRecord, isAdminRole, queryInteger } from "./helpers"
|
||||
import {
|
||||
classNameFilter,
|
||||
exactUsernameFilter,
|
||||
problemFilter,
|
||||
submissionStatisticsRoutes,
|
||||
@@ -208,7 +209,9 @@ submissionRoutes.get("/submissions/today-count", async (c) => {
|
||||
|
||||
submissionRoutes.route("/", submissionStatisticsRoutes)
|
||||
|
||||
submissionRoutes.post("/submissions/:id/rejudge", requireSuperAdmin, async (c) => {
|
||||
// 老师也能重判:旧栈只给超管,前端却一直给老师亮着按钮,老师点了只会「权限不足」。
|
||||
// 流程图那边老师本来就能重新评分,两边对齐。比赛提交不在此列(下面 isNull(contestId))
|
||||
submissionRoutes.post("/submissions/:id/rejudge", requireTeacher, async (c) => {
|
||||
const [row] = await db
|
||||
.select({
|
||||
id: schema.submission.id,
|
||||
@@ -315,6 +318,19 @@ const submissionListColumns = {
|
||||
// 只取 id,题单标题按页单独查一次(见 /submissions)——把 problemset 一起 join 进来
|
||||
// 会动到下面那条调过的分页查询,而每页最多两三个不同的题单,PK 查一次更便宜
|
||||
problemsetId: schema.submission.problemsetId,
|
||||
/**
|
||||
* 通过的测试点数 / 总数,口径同 caseSummary():只认 info.data 是数组、逐项 result 为 0。
|
||||
* 在库里数完只回两个整数 —— info 带着每个点的 output_md5,整列拉回来只为数个数不值当
|
||||
*/
|
||||
casesTotal: sql<
|
||||
number | null
|
||||
>`case when jsonb_typeof(${schema.submission.info} -> 'data') = 'array'
|
||||
then jsonb_array_length(${schema.submission.info} -> 'data') end`,
|
||||
casesPassed: sql<
|
||||
number | null
|
||||
>`case when jsonb_typeof(${schema.submission.info} -> 'data') = 'array'
|
||||
then (select count(*)::int from jsonb_array_elements(${schema.submission.info} -> 'data') as item
|
||||
where item ->> 'result' = '0') end`,
|
||||
},
|
||||
problem: {
|
||||
displayId: schema.problem.displayId,
|
||||
@@ -335,6 +351,16 @@ function caseSummary(submission: typeof schema.submission.$inferSelect) {
|
||||
return { passed, total: data.length }
|
||||
}
|
||||
|
||||
/** 列表行的 caseSummary:SQL 里数好的两个数,为 null 的情形和详情一致(SQL 题、没有逐点结果) */
|
||||
function listCaseSummary(row: {
|
||||
language: string
|
||||
casesTotal: number | null
|
||||
casesPassed: number | null
|
||||
}) {
|
||||
if (row.language === "SQL" || !row.casesTotal) return null
|
||||
return { passed: row.casesPassed ?? 0, total: row.casesTotal }
|
||||
}
|
||||
|
||||
async function submissionDetail(id: string, user: AuthUser) {
|
||||
const [row] = await db
|
||||
.select({
|
||||
@@ -490,10 +516,13 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => {
|
||||
const username = myself ? undefined : c.req.query("username")?.trim()
|
||||
const result = c.req.query("result")
|
||||
const language = c.req.query("language")?.trim()
|
||||
// 班级(user.class_name),和用户名可以同时用:「25计算机3班里名字带『王』的」
|
||||
const className = myself ? undefined : c.req.query("className")?.trim()
|
||||
const filters: Array<SQL | undefined> = [isNull(schema.submission.contestId)]
|
||||
filters.push(
|
||||
...(await Promise.all([
|
||||
displayId ? problemFilter(displayId, null) : undefined,
|
||||
className ? classNameFilter(className) : undefined,
|
||||
username
|
||||
? c.req.query("exactUsername") === "1"
|
||||
? exactUsernameFilter(username)
|
||||
@@ -512,7 +541,8 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => {
|
||||
// (生产快照实测 7.5ms → 78ms)。题号已经解析成 problem_id,也用不着 join。
|
||||
const [totalRows, rows] = await Promise.all([
|
||||
db.select({ value: count() }).from(schema.submission).where(where),
|
||||
paginateSubmissionRows(where, limit, offset, Boolean(username)),
|
||||
// 班级和用户名一样是「圈一小撮人」,走同一条圈选路(见 paginateSubmissionRows)
|
||||
paginateSubmissionRows(where, limit, offset, Boolean(username || className)),
|
||||
])
|
||||
// 闸门只对学生自己的提交生效,所以只拿这一页里属于他自己的题目去查,一页一次查询
|
||||
const [joinTimes, problemsetTitles] = await Promise.all([
|
||||
@@ -550,6 +580,7 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => {
|
||||
title: problemsetTitles.get(submission.problemsetId)!,
|
||||
}
|
||||
: null,
|
||||
caseSummary: listCaseSummary(submission),
|
||||
}) satisfies SubmissionListItem,
|
||||
),
|
||||
total: totalRows[0]?.value ?? 0,
|
||||
@@ -617,6 +648,8 @@ submissionRoutes.get(
|
||||
// 比赛提交没有来源题单:题单只收非比赛题(admin/problemset.ts 加题时卡了
|
||||
// isNull(problem.contestId)),提交接口那边也只在 contestId 为空时才认这个字段
|
||||
problemSet: null,
|
||||
// 比赛只报对错,不给通过几个点(口径见契约 caseSummarySchema)
|
||||
caseSummary: null,
|
||||
}) satisfies SubmissionListItem,
|
||||
),
|
||||
total: totalRows[0]?.value ?? 0,
|
||||
|
||||
Reference in new issue
Block a user