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 ClassActivity,
|
||||||
type ClassBoard,
|
type ClassBoard,
|
||||||
type ClassBoardStudent,
|
type ClassBoardStudent,
|
||||||
|
type ClassLesson,
|
||||||
type ClassActivityProblem,
|
type ClassActivityProblem,
|
||||||
type ClassComparison,
|
type ClassComparison,
|
||||||
type ClassComparisonResponse,
|
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) => {
|
classroomRoutes.put("/classroom/lesson", requireTeacher, async (c) => {
|
||||||
const parsed = await parseBody(c, classLessonRequestSchema, "题号格式不对")
|
const parsed = await parseBody(c, classLessonRequestSchema, "题号格式不对")
|
||||||
if (!parsed.success) return parsed.response
|
if (!parsed.success) return parsed.response
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
isAdminRole,
|
isAdminRole,
|
||||||
matchedUsers,
|
matchedUsers,
|
||||||
asRecord,
|
asRecord,
|
||||||
|
parseDisplayIds,
|
||||||
queryInteger,
|
queryInteger,
|
||||||
rounded,
|
rounded,
|
||||||
stripClassPrefix,
|
stripClassPrefix,
|
||||||
@@ -178,12 +179,15 @@ flowchartRoutes.post("/flowcharts", requireAuth, async (c) => {
|
|||||||
* 「查无此班」会变成「全站」。
|
* 「查无此班」会变成「全站」。
|
||||||
*/
|
*/
|
||||||
async function flowchartProblemFilter(displayId: string) {
|
async function flowchartProblemFilter(displayId: string) {
|
||||||
|
// 和代码提交列表一样可以一次筛几道
|
||||||
|
const lowered = parseDisplayIds(displayId).map((id) => id.toLowerCase())
|
||||||
|
if (!lowered.length) return sql`false`
|
||||||
const problems = await db
|
const problems = await db
|
||||||
.select({ id: schema.problem.id })
|
.select({ id: schema.problem.id })
|
||||||
.from(schema.problem)
|
.from(schema.problem)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
sql`lower(${schema.problem.displayId}) = lower(${displayId})`,
|
inArray(sql`lower(${schema.problem.displayId})`, lowered),
|
||||||
// 流程图题都是公开题(快照里那 12 道 contest_id 全为空),
|
// 流程图题都是公开题(快照里那 12 道 contest_id 全为空),
|
||||||
// 比赛题的 _id 撞号是常态,不该被筛进来
|
// 比赛题的 _id 撞号是常态,不该被筛进来
|
||||||
isNull(schema.problem.contestId),
|
isNull(schema.problem.contestId),
|
||||||
@@ -197,6 +201,20 @@ async function flowchartProblemFilter(displayId: string) {
|
|||||||
: sql`false`
|
: 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) {
|
async function flowchartUserFilter(username: string) {
|
||||||
const ids = (await matchedUsers(username)).map((row) => row.id)
|
const ids = (await matchedUsers(username)).map((row) => row.id)
|
||||||
return ids.length ? inArray(schema.flowchartSubmission.userId, ids) : sql`false`
|
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)
|
return success(c, { results: [], total: 0 } satisfies FlowchartList)
|
||||||
}
|
}
|
||||||
// 「只看自己」盖过用户名;普通学生不填用户名时也只看自己
|
// 「只看自己」盖过用户名;普通学生不填用户名时也只看自己
|
||||||
|
const className = c.req.query("className")?.trim()
|
||||||
const onlyMyself =
|
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> = []
|
const filters: Array<SQL | undefined> = []
|
||||||
filters.push(
|
filters.push(
|
||||||
...(await Promise.all([
|
...(await Promise.all([
|
||||||
displayId ? flowchartProblemFilter(displayId) : undefined,
|
displayId ? flowchartProblemFilter(displayId) : undefined,
|
||||||
!onlyMyself && username ? flowchartUserFilter(username) : undefined,
|
!onlyMyself && username ? flowchartUserFilter(username) : undefined,
|
||||||
|
!onlyMyself && className ? flowchartClassFilter(className) : undefined,
|
||||||
])),
|
])),
|
||||||
)
|
)
|
||||||
if (onlyMyself) filters.push(eq(schema.flowchartSubmission.userId, user.id))
|
if (onlyMyself) filters.push(eq(schema.flowchartSubmission.userId, user.id))
|
||||||
|
|||||||
@@ -171,3 +171,21 @@ export async function matchedUsers(username: string) {
|
|||||||
.from(schema.user)
|
.from(schema.user)
|
||||||
.where(ilike(schema.user.username, `%${username}%`))
|
.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 { type ContestEnv } from "../services/contest"
|
||||||
import { getBooleanOption } from "../services/options"
|
import { getBooleanOption } from "../services/options"
|
||||||
import { localTime, dayStart } from "../time"
|
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>()
|
export const submissionStatisticsRoutes = new Hono<ContestEnv>()
|
||||||
|
|
||||||
@@ -138,24 +138,6 @@ function statisticsRange(c: { req: { query(name: string): string | undefined } }
|
|||||||
/** 一次最多查几道题。课堂上一节课布置三五道,20 是留足了余量的上限 */
|
/** 一次最多查几道题。课堂上一节课布置三五道,20 是留足了余量的上限 */
|
||||||
const STATISTICS_MAX_PROBLEMS = 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)定位公开题目。**有一个找不到就整体报错**,不退化成「全部题目」——
|
* 按题号(展示用的 _id)定位公开题目。**有一个找不到就整体报错**,不退化成「全部题目」——
|
||||||
* 否则教师打错一个字就会看到全站数据还以为是这几道题的。
|
* 否则教师打错一个字就会看到全站数据还以为是这几道题的。
|
||||||
@@ -327,8 +309,27 @@ export async function exactUsernameFilter(username: string) {
|
|||||||
return row ? eq(schema.submission.userId, row.id) : sql`false`
|
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` 筛。
|
* 两条提交列表的题号筛选:先把题号解析成 problem.id,再按 `submission.problem_id` 筛。
|
||||||
|
* 可以一次填几道(`1021,1022`),**任一道**对上就算。
|
||||||
* 原来是 join problem 之后比 `lower(problem._id)`,条件落在 problem 表上,规划器只能
|
* 原来是 join problem 之后比 `lower(problem._id)`,条件落在 problem 表上,规划器只能
|
||||||
* 顺着时间索引倒扫、逐行回表比对,走不上 submission_public_problem_time_idx。
|
* 顺着时间索引倒扫、逐行回表比对,走不上 submission_public_problem_time_idx。
|
||||||
*
|
*
|
||||||
@@ -337,12 +338,15 @@ export async function exactUsernameFilter(username: string) {
|
|||||||
* 查无此题时留恒假条件,少推一个 filter 就成了「不筛」。
|
* 查无此题时留恒假条件,少推一个 filter 就成了「不筛」。
|
||||||
*/
|
*/
|
||||||
export async function problemFilter(displayId: string, contestId: number | null) {
|
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
|
const problems = await db
|
||||||
.select({ id: schema.problem.id })
|
.select({ id: schema.problem.id })
|
||||||
.from(schema.problem)
|
.from(schema.problem)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
sql`lower(${schema.problem.displayId}) = lower(${displayId})`,
|
inArray(sql`lower(${schema.problem.displayId})`, lowered),
|
||||||
contestId === null
|
contestId === null
|
||||||
? isNull(schema.problem.contestId)
|
? isNull(schema.problem.contestId)
|
||||||
: eq(schema.problem.contestId, 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 { and, count, desc, eq, inArray, isNull, sql, type SQL } from "drizzle-orm"
|
||||||
import { Hono } from "hono"
|
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 type { AuthUser } from "../auth/session"
|
||||||
import { db, schema } from "../db"
|
import { db, schema } from "../db"
|
||||||
import { failure, parseBody, success } from "../http"
|
import { failure, parseBody, success } from "../http"
|
||||||
@@ -33,6 +33,7 @@ import { consumeToken } from "../services/throttling"
|
|||||||
import { dayStart } from "../time"
|
import { dayStart } from "../time"
|
||||||
import { asFilterValue, asRecord, isAdminRole, queryInteger } from "./helpers"
|
import { asFilterValue, asRecord, isAdminRole, queryInteger } from "./helpers"
|
||||||
import {
|
import {
|
||||||
|
classNameFilter,
|
||||||
exactUsernameFilter,
|
exactUsernameFilter,
|
||||||
problemFilter,
|
problemFilter,
|
||||||
submissionStatisticsRoutes,
|
submissionStatisticsRoutes,
|
||||||
@@ -208,7 +209,9 @@ submissionRoutes.get("/submissions/today-count", async (c) => {
|
|||||||
|
|
||||||
submissionRoutes.route("/", submissionStatisticsRoutes)
|
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
|
const [row] = await db
|
||||||
.select({
|
.select({
|
||||||
id: schema.submission.id,
|
id: schema.submission.id,
|
||||||
@@ -315,6 +318,19 @@ const submissionListColumns = {
|
|||||||
// 只取 id,题单标题按页单独查一次(见 /submissions)——把 problemset 一起 join 进来
|
// 只取 id,题单标题按页单独查一次(见 /submissions)——把 problemset 一起 join 进来
|
||||||
// 会动到下面那条调过的分页查询,而每页最多两三个不同的题单,PK 查一次更便宜
|
// 会动到下面那条调过的分页查询,而每页最多两三个不同的题单,PK 查一次更便宜
|
||||||
problemsetId: schema.submission.problemsetId,
|
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: {
|
problem: {
|
||||||
displayId: schema.problem.displayId,
|
displayId: schema.problem.displayId,
|
||||||
@@ -335,6 +351,16 @@ function caseSummary(submission: typeof schema.submission.$inferSelect) {
|
|||||||
return { passed, total: data.length }
|
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) {
|
async function submissionDetail(id: string, user: AuthUser) {
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.select({
|
.select({
|
||||||
@@ -490,10 +516,13 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => {
|
|||||||
const username = myself ? undefined : c.req.query("username")?.trim()
|
const username = myself ? undefined : c.req.query("username")?.trim()
|
||||||
const result = c.req.query("result")
|
const result = c.req.query("result")
|
||||||
const language = c.req.query("language")?.trim()
|
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)]
|
const filters: Array<SQL | undefined> = [isNull(schema.submission.contestId)]
|
||||||
filters.push(
|
filters.push(
|
||||||
...(await Promise.all([
|
...(await Promise.all([
|
||||||
displayId ? problemFilter(displayId, null) : undefined,
|
displayId ? problemFilter(displayId, null) : undefined,
|
||||||
|
className ? classNameFilter(className) : undefined,
|
||||||
username
|
username
|
||||||
? c.req.query("exactUsername") === "1"
|
? c.req.query("exactUsername") === "1"
|
||||||
? exactUsernameFilter(username)
|
? exactUsernameFilter(username)
|
||||||
@@ -512,7 +541,8 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => {
|
|||||||
// (生产快照实测 7.5ms → 78ms)。题号已经解析成 problem_id,也用不着 join。
|
// (生产快照实测 7.5ms → 78ms)。题号已经解析成 problem_id,也用不着 join。
|
||||||
const [totalRows, rows] = await Promise.all([
|
const [totalRows, rows] = await Promise.all([
|
||||||
db.select({ value: count() }).from(schema.submission).where(where),
|
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([
|
const [joinTimes, problemsetTitles] = await Promise.all([
|
||||||
@@ -550,6 +580,7 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => {
|
|||||||
title: problemsetTitles.get(submission.problemsetId)!,
|
title: problemsetTitles.get(submission.problemsetId)!,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
|
caseSummary: listCaseSummary(submission),
|
||||||
}) satisfies SubmissionListItem,
|
}) satisfies SubmissionListItem,
|
||||||
),
|
),
|
||||||
total: totalRows[0]?.value ?? 0,
|
total: totalRows[0]?.value ?? 0,
|
||||||
@@ -617,6 +648,8 @@ submissionRoutes.get(
|
|||||||
// 比赛提交没有来源题单:题单只收非比赛题(admin/problemset.ts 加题时卡了
|
// 比赛提交没有来源题单:题单只收非比赛题(admin/problemset.ts 加题时卡了
|
||||||
// isNull(problem.contestId)),提交接口那边也只在 contestId 为空时才认这个字段
|
// isNull(problem.contestId)),提交接口那边也只在 contestId 为空时才认这个字段
|
||||||
problemSet: null,
|
problemSet: null,
|
||||||
|
// 比赛只报对错,不给通过几个点(口径见契约 caseSummarySchema)
|
||||||
|
caseSummary: null,
|
||||||
}) satisfies SubmissionListItem,
|
}) satisfies SubmissionListItem,
|
||||||
),
|
),
|
||||||
total: totalRows[0]?.value ?? 0,
|
total: totalRows[0]?.value ?? 0,
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
import { Icon } from "@iconify/vue"
|
|
||||||
import { USERNAME_CLASS_RE } from "utils/constants"
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
type: "题目" | "用户"
|
|
||||||
username?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
|
||||||
|
|
||||||
const emits = defineEmits(["click", "search", "filterClass"])
|
|
||||||
|
|
||||||
// 用同一个正则判断,避免 kstest 这类 ks 开头但没有班级号的用户名
|
|
||||||
// 也显示出按钮,点了却什么都不发生
|
|
||||||
const showFilterClass = computed(() => {
|
|
||||||
return props.type === "用户" && USERNAME_CLASS_RE.test(props.username ?? "")
|
|
||||||
})
|
|
||||||
|
|
||||||
function filterClass() {
|
|
||||||
const match = props.username!.match(USERNAME_CLASS_RE)
|
|
||||||
const classname = match ? match[0] : ""
|
|
||||||
if (!classname) return
|
|
||||||
emits("filterClass", classname)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
<template>
|
|
||||||
<n-flex align="center">
|
|
||||||
<n-button text type="info" @click="$emit('click')">
|
|
||||||
<slot></slot>
|
|
||||||
</n-button>
|
|
||||||
<n-tooltip>
|
|
||||||
<template #trigger>
|
|
||||||
<n-button text @click="$emit('search')">
|
|
||||||
<template #icon>
|
|
||||||
<Icon icon="streamline-emojis:magnifying-glass-tilted-left"></Icon>
|
|
||||||
</template>
|
|
||||||
</n-button>
|
|
||||||
</template>
|
|
||||||
{{ "搜索" + props.type }}
|
|
||||||
</n-tooltip>
|
|
||||||
<n-tooltip v-if="showFilterClass">
|
|
||||||
<template #trigger>
|
|
||||||
<n-button text @click="filterClass">
|
|
||||||
<template #icon>
|
|
||||||
<Icon icon="streamline-emojis:scroll"></Icon>
|
|
||||||
</template>
|
|
||||||
</n-button>
|
|
||||||
</template>
|
|
||||||
筛选班级
|
|
||||||
</n-tooltip>
|
|
||||||
</n-flex>
|
|
||||||
</template>
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
<template>
|
|
||||||
<n-button v-if="flowchart.showLink" type="info" text @click="handleClick">
|
|
||||||
{{ flowchart.id.slice(0, 12) }}
|
|
||||||
</n-button>
|
|
||||||
<!-- 没权限时不能挂 @click:后端 GET /flowcharts/:id 会以 404 挡下,
|
|
||||||
前端只会得到一个静默失败的空白面板 -->
|
|
||||||
<n-text v-else class="flowchart-id" depth="3">
|
|
||||||
{{ flowchart.id.slice(0, 12) }}
|
|
||||||
</n-text>
|
|
||||||
</template>
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { FlowchartSubmissionListItem } from "utils/types"
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
flowchart: FlowchartSubmissionListItem
|
|
||||||
}
|
|
||||||
const props = defineProps<Props>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
showDetail: [id: string]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
// showLink 由后端逐行下发(见 routes/flowchart.ts 的 canView),与
|
|
||||||
// GET /flowcharts/:id 的放行条件同源。原来前端自己按「超管或本人」算了一遍,
|
|
||||||
// 既漏了教师,也和后端对不上。
|
|
||||||
function handleClick() {
|
|
||||||
emit("showDetail", props.flowchart.id)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
@@ -1,226 +0,0 @@
|
|||||||
<template>
|
|
||||||
<n-grid v-if="submission" :cols="5" :x-gap="16">
|
|
||||||
<!-- 左侧:流程图预览区域 -->
|
|
||||||
<n-gi :span="3">
|
|
||||||
<n-card title="流程图预览">
|
|
||||||
<template #header-extra>
|
|
||||||
<n-button
|
|
||||||
v-if="!renderError && submission?.mermaidCode"
|
|
||||||
quaternary
|
|
||||||
size="small"
|
|
||||||
@click="showLargeImage = true"
|
|
||||||
>
|
|
||||||
<template #icon>
|
|
||||||
<Icon icon="ph:corners-out" />
|
|
||||||
</template>
|
|
||||||
查看大图
|
|
||||||
</n-button>
|
|
||||||
</template>
|
|
||||||
<div class="flowchart">
|
|
||||||
<n-alert v-if="renderError" type="error" title="流程图渲染失败">
|
|
||||||
{{ renderError }}
|
|
||||||
</n-alert>
|
|
||||||
<Teleport to="body" :disabled="!showLargeImage">
|
|
||||||
<div
|
|
||||||
v-show="!renderError"
|
|
||||||
:class="['flowchart', { 'flowchart-fullscreen': showLargeImage }]"
|
|
||||||
ref="mermaidContainer"
|
|
||||||
></div>
|
|
||||||
<div v-if="showLargeImage" class="fullscreen-toolbar">
|
|
||||||
<n-button secondary round @click="showLargeImage = false">
|
|
||||||
<template #icon>
|
|
||||||
<Icon icon="ph:corners-in" />
|
|
||||||
</template>
|
|
||||||
退出大图
|
|
||||||
</n-button>
|
|
||||||
</div>
|
|
||||||
</Teleport>
|
|
||||||
</div>
|
|
||||||
</n-card>
|
|
||||||
</n-gi>
|
|
||||||
|
|
||||||
<!-- 右侧:评分详情区域 -->
|
|
||||||
<n-gi :span="2">
|
|
||||||
<!-- AI反馈 -->
|
|
||||||
<n-card v-if="submission.aiFeedback" size="small" title="AI反馈" style="margin-bottom: 16px">
|
|
||||||
<n-text>{{ submission.aiFeedback }}</n-text>
|
|
||||||
</n-card>
|
|
||||||
|
|
||||||
<!-- 改进建议 -->
|
|
||||||
<n-card
|
|
||||||
v-if="suggestionLines.length"
|
|
||||||
size="small"
|
|
||||||
title="改进建议"
|
|
||||||
style="margin-bottom: 16px"
|
|
||||||
>
|
|
||||||
<n-flex vertical :size="6">
|
|
||||||
<n-text v-for="(suggestion, index) in suggestionLines" :key="`${index}-${suggestion}`">
|
|
||||||
{{ suggestion }}
|
|
||||||
</n-text>
|
|
||||||
</n-flex>
|
|
||||||
</n-card>
|
|
||||||
|
|
||||||
<!-- 详细评分 -->
|
|
||||||
<n-card v-if="sortedCriteria.length > 0" size="small" title="详细评分">
|
|
||||||
<div v-for="[key, detail] in sortedCriteria" :key="key" style="margin-bottom: 12px">
|
|
||||||
<!-- 评分项标题和分数 -->
|
|
||||||
<n-flex justify="space-between" align="center" style="margin-bottom: 4px">
|
|
||||||
<n-text strong>{{ key }}</n-text>
|
|
||||||
<n-tag :type="getPercentType(detail.score / detail.max)" size="small" round>
|
|
||||||
{{ detail.score || 0 }}分 / {{ detail.max }}分
|
|
||||||
</n-tag>
|
|
||||||
</n-flex>
|
|
||||||
<!-- 评分项详细说明 -->
|
|
||||||
<n-text v-if="detail.comment" depth="3" style="font-size: 12px">
|
|
||||||
{{ detail.comment }}
|
|
||||||
</n-text>
|
|
||||||
</div>
|
|
||||||
</n-card>
|
|
||||||
</n-gi>
|
|
||||||
</n-grid>
|
|
||||||
<n-spin v-else :show="loading" class="loading-container"> </n-spin>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { Icon } from "@iconify/vue"
|
|
||||||
import type { FlowchartSubmission } from "utils/types"
|
|
||||||
import { useMermaid } from "shared/composables/useMermaid"
|
|
||||||
import { sortFlowchartCriteria } from "utils/constants"
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
submissionId: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
|
||||||
const mermaidContainer = useTemplateRef<HTMLElement>("mermaidContainer")
|
|
||||||
const { renderError, renderFlowchart } = useMermaid()
|
|
||||||
|
|
||||||
const submission = ref<FlowchartSubmission | null>(null)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 评分项明细。契约里是 `Record<string, unknown>` —— 内容是 AI 模型原样吐出的 JSON,
|
|
||||||
* 后端不校验形状,所以这里只能按约定断言,字段缺失时用 0 / 空串兜底。
|
|
||||||
*/
|
|
||||||
const criteriaDetails = computed<Record<string, { score: number; max: number; comment: string }>>(
|
|
||||||
() => {
|
|
||||||
const raw = submission.value?.aiCriteriaDetails ?? {}
|
|
||||||
return Object.fromEntries(
|
|
||||||
Object.entries(raw).map(([key, value]) => {
|
|
||||||
const item = (value ?? {}) as Partial<{
|
|
||||||
score: number
|
|
||||||
max: number
|
|
||||||
comment: string
|
|
||||||
}>
|
|
||||||
return [
|
|
||||||
key,
|
|
||||||
{
|
|
||||||
score: item.score ?? 0,
|
|
||||||
max: item.max ?? 0,
|
|
||||||
comment: item.comment ?? "",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
// jsonb 不保留键序,直接遍历会把 40 分的「逻辑正确性」排到最后
|
|
||||||
const sortedCriteria = computed(() => sortFlowchartCriteria(criteriaDetails.value))
|
|
||||||
|
|
||||||
const loading = ref(false)
|
|
||||||
const rendering = ref(false)
|
|
||||||
const showLargeImage = ref(false)
|
|
||||||
const suggestionLines = computed(() => splitSuggestionLines(submission.value?.aiSuggestions))
|
|
||||||
|
|
||||||
function splitSuggestionLines(suggestions?: string | null) {
|
|
||||||
return suggestions
|
|
||||||
? suggestions
|
|
||||||
.split("\n")
|
|
||||||
.map((suggestion) => suggestion.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
: []
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPercentType(percent: number) {
|
|
||||||
if (percent >= 0.8) return "primary"
|
|
||||||
else if (percent >= 0.6) return "info"
|
|
||||||
else if (percent >= 0.4) return "warning"
|
|
||||||
return "error"
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadSubmission() {
|
|
||||||
if (!props.submissionId) return
|
|
||||||
showLargeImage.value = false
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const { getFlowchartSubmission } = await import("oj/api")
|
|
||||||
const res = await getFlowchartSubmission(props.submissionId)
|
|
||||||
submission.value = res
|
|
||||||
|
|
||||||
// 渲染流程图
|
|
||||||
if (submission.value?.mermaidCode) {
|
|
||||||
rendering.value = true
|
|
||||||
await nextTick()
|
|
||||||
await renderFlowchart(mermaidContainer.value, submission.value.mermaidCode)
|
|
||||||
rendering.value = false
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to load submission:", error)
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(() => props.submissionId, loadSubmission, { immediate: true })
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.flowchart {
|
|
||||||
height: 500px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 全屏大图:覆盖整个视口,脱离弹框宽度限制 */
|
|
||||||
.flowchart-fullscreen {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 4000;
|
|
||||||
width: 100vw;
|
|
||||||
height: 100vh;
|
|
||||||
padding: 32px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
background: #ffffff;
|
|
||||||
/* 改为可滚动块布局,超出视口的大图可以滚动查看 */
|
|
||||||
display: block;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullscreen-toolbar {
|
|
||||||
position: fixed;
|
|
||||||
top: 16px;
|
|
||||||
right: 16px;
|
|
||||||
z-index: 4001;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 确保 SVG 图表占满容器 */
|
|
||||||
:deep(.flowchart > svg) {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 全屏时按自然尺寸显示并水平居中,配合容器滚动 */
|
|
||||||
:deep(.flowchart-fullscreen > svg) {
|
|
||||||
display: block;
|
|
||||||
margin: 0 auto;
|
|
||||||
width: auto;
|
|
||||||
height: auto;
|
|
||||||
max-width: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-container {
|
|
||||||
min-height: 600px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
<template>
|
|
||||||
<n-text :type="gradeType(grade)">
|
|
||||||
<span>{{ score }}</span>
|
|
||||||
<span>({{ grade }})</span>
|
|
||||||
</n-text>
|
|
||||||
</template>
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { Grade } from "utils/types"
|
|
||||||
defineProps<{
|
|
||||||
score: number
|
|
||||||
grade: Grade
|
|
||||||
}>()
|
|
||||||
|
|
||||||
function gradeType(grade: Grade) {
|
|
||||||
return (
|
|
||||||
{
|
|
||||||
"": "default",
|
|
||||||
S: "success",
|
|
||||||
A: "info",
|
|
||||||
B: "warning",
|
|
||||||
C: "error",
|
|
||||||
} as const
|
|
||||||
)[grade]
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
<style scoped></style>
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
<template>
|
|
||||||
<n-flex v-if="props.submission.showLink" align="center">
|
|
||||||
<n-button text type="info" @click="$emit('showCode')">
|
|
||||||
{{ props.submission.id.slice(0, 12) }}
|
|
||||||
</n-button>
|
|
||||||
<n-tooltip>
|
|
||||||
<template #trigger>
|
|
||||||
<n-button text @click="goto">
|
|
||||||
<template #icon>
|
|
||||||
<Icon icon="catppuccin:folder-debug"></Icon>
|
|
||||||
</template>
|
|
||||||
</n-button>
|
|
||||||
</template>
|
|
||||||
查看测试详情
|
|
||||||
</n-tooltip>
|
|
||||||
</n-flex>
|
|
||||||
<n-flex v-else-if="isOwnSubmission" align="center">
|
|
||||||
<span>{{ props.submission.id.slice(0, 12) }}</span>
|
|
||||||
<n-tooltip>
|
|
||||||
<template #trigger>
|
|
||||||
<n-button text>
|
|
||||||
<template #icon>
|
|
||||||
<Icon icon="catppuccin:lock"></Icon>
|
|
||||||
</template>
|
|
||||||
</n-button>
|
|
||||||
</template>
|
|
||||||
这道题在你已经加入的题单中,只有在题单中完成此题,代码才可见。
|
|
||||||
</n-tooltip>
|
|
||||||
</n-flex>
|
|
||||||
<span v-else>{{ props.submission.id.slice(0, 12) }}</span>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { Icon } from "@iconify/vue"
|
|
||||||
import { useUserStore } from "shared/store/user"
|
|
||||||
import type { SubmissionListItem } from "utils/types"
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
submission: SubmissionListItem
|
|
||||||
}
|
|
||||||
const props = defineProps<Props>()
|
|
||||||
defineEmits(["showCode"])
|
|
||||||
|
|
||||||
const userStore = useUserStore()
|
|
||||||
const isOwnSubmission = computed(() => userStore.profile?.user?.id === props.submission.userId)
|
|
||||||
|
|
||||||
function goto() {
|
|
||||||
window.open("/submission/" + props.submission.id, "_blank")
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -136,6 +136,17 @@ export const classBoardSchema = z.object({
|
|||||||
recentLessons: z.number().int(),
|
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<typeof classRankItemSchema>
|
export type ClassRankItem = z.infer<typeof classRankItemSchema>
|
||||||
export type ClassUserRank = z.infer<typeof classUserRankSchema>
|
export type ClassUserRank = z.infer<typeof classUserRankSchema>
|
||||||
export type ClassComparison = z.infer<typeof classComparisonSchema>
|
export type ClassComparison = z.infer<typeof classComparisonSchema>
|
||||||
@@ -144,6 +155,7 @@ export type ClassComparisonResponse = z.infer<typeof classComparisonResponseSche
|
|||||||
export type ClassActivity = z.infer<typeof classActivitySchema>
|
export type ClassActivity = z.infer<typeof classActivitySchema>
|
||||||
export type ClassLessonRequest = z.infer<typeof classLessonRequestSchema>
|
export type ClassLessonRequest = z.infer<typeof classLessonRequestSchema>
|
||||||
export type ClassBoard = z.infer<typeof classBoardSchema>
|
export type ClassBoard = z.infer<typeof classBoardSchema>
|
||||||
|
export type ClassLesson = z.infer<typeof classLessonSchema>
|
||||||
export type ClassBoardProblem = z.infer<typeof classBoardProblemSchema>
|
export type ClassBoardProblem = z.infer<typeof classBoardProblemSchema>
|
||||||
export type ClassBoardStudent = z.infer<typeof classBoardStudentSchema>
|
export type ClassBoardStudent = z.infer<typeof classBoardStudentSchema>
|
||||||
export type ClassBoardCell = z.infer<typeof classBoardCellSchema>
|
export type ClassBoardCell = z.infer<typeof classBoardCellSchema>
|
||||||
|
|||||||
@@ -189,6 +189,21 @@ export const createSubmissionResponseSchema = z.object({
|
|||||||
submissionId: z.string(),
|
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({
|
export const submissionDetailSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
createTime: z.string(),
|
createTime: z.string(),
|
||||||
@@ -210,20 +225,8 @@ export const submissionDetailSchema = z.object({
|
|||||||
*/
|
*/
|
||||||
problemDisplayId: z.string(),
|
problemDisplayId: z.string(),
|
||||||
showLink: z.boolean(),
|
showLink: z.boolean(),
|
||||||
/**
|
/** 通过了几个测试点,口径见 caseSummarySchema */
|
||||||
* 通过了几个测试点,给学生看「离 AC 还差多远」。`info` 只给管理员(每个点带
|
caseSummary: caseSummarySchema,
|
||||||
* output_md5),所以这里只算出两个数下发,不放开原文。
|
|
||||||
*
|
|
||||||
* 为 null 的情形:比赛提交(ACM 只报对错,多给通过数等于变相放水,和 AI 提示同口径)、
|
|
||||||
* SQL 题(`judge/run.ts` 遇到被杀的测试点会 break,total 偏小)、没有逐点结果
|
|
||||||
* (待判、编译失败)。
|
|
||||||
*/
|
|
||||||
caseSummary: z
|
|
||||||
.object({
|
|
||||||
passed: z.number().int(),
|
|
||||||
total: z.number().int(),
|
|
||||||
})
|
|
||||||
.nullable(),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -277,6 +280,11 @@ export const submissionListItemSchema = z.object({
|
|||||||
* 回填的就是这些,其余老提交无从判断入口,一律留空。
|
* 回填的就是这些,其余老提交无从判断入口,一律留空。
|
||||||
*/
|
*/
|
||||||
problemSet: z.object({ id: z.number().int(), title: z.string() }).nullable(),
|
problemSet: z.object({ id: z.number().int(), title: z.string() }).nullable(),
|
||||||
|
/**
|
||||||
|
* 通过了几个测试点,和详情同一个口径(见 caseSummarySchema)。列表上老师一眼要分出
|
||||||
|
* 「差一个点」和「一个都没过」,不必逐条点开。
|
||||||
|
*/
|
||||||
|
caseSummary: caseSummarySchema,
|
||||||
})
|
})
|
||||||
|
|
||||||
export const submissionListSchema = paginatedSchema(submissionListItemSchema)
|
export const submissionListSchema = paginatedSchema(submissionListItemSchema)
|
||||||
|
|||||||
Reference in new issue
Block a user