diff --git a/apps/api/src/routes/classroom.ts b/apps/api/src/routes/classroom.ts index 9128b7a..27239b5 100644 --- a/apps/api/src/routes/classroom.ts +++ b/apps/api/src/routes/classroom.ts @@ -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 diff --git a/apps/api/src/routes/flowchart.ts b/apps/api/src/routes/flowchart.ts index 3b4f9ea..38322de 100644 --- a/apps/api/src/routes/flowchart.ts +++ b/apps/api/src/routes/flowchart.ts @@ -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 = [] 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)) diff --git a/apps/api/src/routes/helpers.ts b/apps/api/src/routes/helpers.ts index 289a5b2..c845d2f 100644 --- a/apps/api/src/routes/helpers.ts +++ b/apps/api/src/routes/helpers.ts @@ -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() + 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 +} diff --git a/apps/api/src/routes/submission-statistics.ts b/apps/api/src/routes/submission-statistics.ts index 6b8d420..31965f9 100644 --- a/apps/api/src/routes/submission-statistics.ts +++ b/apps/api/src/routes/submission-statistics.ts @@ -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() @@ -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() - 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), diff --git a/apps/api/src/routes/submission.ts b/apps/api/src/routes/submission.ts index 56a545e..335417b 100644 --- a/apps/api/src/routes/submission.ts +++ b/apps/api/src/routes/submission.ts @@ -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 = [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, diff --git a/apps/web/src/oj/submission/components/ButtonWithSearch.vue b/apps/web/src/oj/submission/components/ButtonWithSearch.vue deleted file mode 100644 index d05b870..0000000 --- a/apps/web/src/oj/submission/components/ButtonWithSearch.vue +++ /dev/null @@ -1,53 +0,0 @@ - - diff --git a/apps/web/src/oj/submission/components/FlowchartLink.vue b/apps/web/src/oj/submission/components/FlowchartLink.vue deleted file mode 100644 index 04595c9..0000000 --- a/apps/web/src/oj/submission/components/FlowchartLink.vue +++ /dev/null @@ -1,31 +0,0 @@ - - - - diff --git a/apps/web/src/oj/submission/components/FlowchartScoreDetail.vue b/apps/web/src/oj/submission/components/FlowchartScoreDetail.vue deleted file mode 100644 index ab2a4d7..0000000 --- a/apps/web/src/oj/submission/components/FlowchartScoreDetail.vue +++ /dev/null @@ -1,226 +0,0 @@ - - - - - diff --git a/apps/web/src/oj/submission/components/Grade.vue b/apps/web/src/oj/submission/components/Grade.vue deleted file mode 100644 index c8e4d2d..0000000 --- a/apps/web/src/oj/submission/components/Grade.vue +++ /dev/null @@ -1,26 +0,0 @@ - - - diff --git a/apps/web/src/oj/submission/components/SubmissionLink.vue b/apps/web/src/oj/submission/components/SubmissionLink.vue deleted file mode 100644 index fd0aa93..0000000 --- a/apps/web/src/oj/submission/components/SubmissionLink.vue +++ /dev/null @@ -1,50 +0,0 @@ - - - diff --git a/packages/contract/src/classroom.ts b/packages/contract/src/classroom.ts index e486091..cc6757c 100644 --- a/packages/contract/src/classroom.ts +++ b/packages/contract/src/classroom.ts @@ -136,6 +136,17 @@ export const classBoardSchema = z.object({ recentLessons: z.number().int(), }) +/** + * 提交列表「这节课」那颗按钮要的:哪个班、这节课是哪几道题。和课堂看板同一套取法 + * (老师布置过就用布置的,否则按今天同班提交推断),但不带花名册 —— 列表只拿它当筛选条件。 + * className 为 null = 没指定、最近两小时也没有哪个班在交。 + */ +export const classLessonSchema = z.object({ + className: z.string().nullable(), + source: z.enum(["teacher", "inferred"]).nullable(), + problems: z.array(z.object({ problemDisplayId: z.string(), title: z.string() })), +}) + export type ClassRankItem = z.infer export type ClassUserRank = z.infer export type ClassComparison = z.infer @@ -144,6 +155,7 @@ export type ClassComparisonResponse = z.infer export type ClassLessonRequest = z.infer export type ClassBoard = z.infer +export type ClassLesson = z.infer export type ClassBoardProblem = z.infer export type ClassBoardStudent = z.infer export type ClassBoardCell = z.infer diff --git a/packages/contract/src/submission.ts b/packages/contract/src/submission.ts index 1d45c52..c7e1805 100644 --- a/packages/contract/src/submission.ts +++ b/packages/contract/src/submission.ts @@ -189,6 +189,21 @@ export const createSubmissionResponseSchema = z.object({ submissionId: z.string(), }) +/** + * 通过了几个测试点,给学生看「离 AC 还差多远」。`info` 只给管理员(每个点带 + * output_md5),所以这里只算出两个数下发,不放开原文。 + * + * 为 null 的情形:比赛提交(ACM 只报对错,多给通过数等于变相放水,和 AI 提示同口径)、 + * SQL 题(`judge/run.ts` 遇到被杀的测试点会 break,total 偏小)、没有逐点结果 + * (待判、编译失败)。 + */ +export const caseSummarySchema = z + .object({ + passed: z.number().int(), + total: z.number().int(), + }) + .nullable() + export const submissionDetailSchema = z.object({ id: z.string(), createTime: z.string(), @@ -210,20 +225,8 @@ export const submissionDetailSchema = z.object({ */ problemDisplayId: z.string(), showLink: z.boolean(), - /** - * 通过了几个测试点,给学生看「离 AC 还差多远」。`info` 只给管理员(每个点带 - * output_md5),所以这里只算出两个数下发,不放开原文。 - * - * 为 null 的情形:比赛提交(ACM 只报对错,多给通过数等于变相放水,和 AI 提示同口径)、 - * SQL 题(`judge/run.ts` 遇到被杀的测试点会 break,total 偏小)、没有逐点结果 - * (待判、编译失败)。 - */ - caseSummary: z - .object({ - passed: z.number().int(), - total: z.number().int(), - }) - .nullable(), + /** 通过了几个测试点,口径见 caseSummarySchema */ + caseSummary: caseSummarySchema, }) /** @@ -277,6 +280,11 @@ export const submissionListItemSchema = z.object({ * 回填的就是这些,其余老提交无从判断入口,一律留空。 */ problemSet: z.object({ id: z.number().int(), title: z.string() }).nullable(), + /** + * 通过了几个测试点,和详情同一个口径(见 caseSummarySchema)。列表上老师一眼要分出 + * 「差一个点」和「一个都没过」,不必逐条点开。 + */ + caseSummary: caseSummarySchema, }) export const submissionListSchema = paginatedSchema(submissionListItemSchema)