Compare commits

..
5 Commits
Author SHA1 Message Date
xuyueandClaude Opus 5.5 52bf2d6111 feat(提交列表): 题号填错一道时顶上提示「没有这道题」;← → 也能翻上一条下一条
Deploy / deploy (push) Canceled after 0s
- 列表接口回带查不到的题号(公开列表只认公开题,比赛列表只认本场的题),
  一次填好几道时打错的那道原来被悄悄忽略
- 方向键恢复成四个都能翻(9 月加的时候就是这样,老师已经用惯了)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 18:58:31 +08:00
xuyueandClaude Opus 5.5 0bcc981e6f fix(提交列表/统计): 按方向键会顺手切掉「代码/流程图」、重判后右栏还是旧内容、没人交时统计说「全都做完了」
提交列表:
- 焦点在单选框、下拉框上时方向键不再一边切控件一边换条;跨页没回来前不再连跳一整页
- 重判、重新评分之后右栏重新拉,不再命中旧缓存;同一条新旧请求不再互相覆盖
- 从「我的提交」点顶栏「提交」、浏览器后退时,地址里没有的筛选回到默认
- 找回点用户名开个人主页;「只看他 / 只看这题」学生也能用;重判不用等代码拉下来
- 手机上老师也能重判、能按语言筛,流程图行有手机排版;学生界面带着的班级筛选显示成能点掉的标签
- mermaid 画不出来时先说中文、原始报错折起来

统计页:
- 没人交时说「这段时间没有人交」,不再说「全都做完了」;没填题号时各处统一叫「做对过题」
- 班级按 class_name 单独传;查询失败清掉旧数据并说清原因;标签跟着查出来的那一份走
- 「语法未过」的格子单独标「语」;正确率不算还在判的;截断提示说清哪些数字受影响
- 看板的请假隐藏只藏还没交过、没在举手的人,「恢复」只恢复这个班的
- 手机上筛选折行、上下排,不再挤出屏幕

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 18:52:58 +08:00
xuyueandClaude Opus 5.5 26c8d65e89 fix(判题/统计): 老师重判一次题目提交数就多一;统计选 231 班会把 2311、2312 班也算进来
- 重判带上原来的结果,落库只做差量:提交数不变、旧结果那一格减一新结果加一,
  通过数按新旧结果调整(原来重判当新提交记账,连点三次就多三)。重判时一起清掉 info,
  判题中那几秒不再按旧结果显示「通过 2/3」
- 三个统计接口加 className,按 class_name 精确匹配(原来拼 ks231 做包含匹配)
- 流程图统计查几道题时要每道都拿到 A/S 才算完成;「每人最好的一次」截断时有标记,
  题号上限 20 道和代码统计一致;流程图列表支持按整名匹配用户
- 删掉统计弹框走后没人用的展开行明细接口,以及每次都白算的「语法未过题数」「最近一次错在哪」

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 18:52:58 +08:00
xuyueandClaude Opus 5.5 ed55ee5af6 feat(统计): 数据统计从弹框改成独立页面,左边题目、右边学生,一道题看全班每人的每一次
- 课堂看板管这节课,统计管回头看:时间段默认「两节课」,还有这节课、今天、7 天、30 天、这学期、全部、自己选日子;不再自动刷新
- 选一道题看每人在这道题上的方块串,鼠标停上去看那次的代码;选「全部」每人一行、展开按题看
- 没选班级(从题目页进来)先按班级汇总;流程图统计有等级分布、四项雷达、各项平均分、词云和每人最好的一次
- 提交列表、题目页的「数据统计」改成新标签打开这个页面
- 「请假隐藏」挪到课堂看板:点名字旁的 × 藏两个小时,做完人数的分母一起减掉

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 18:28:27 +08:00
xuyueandClaude Opus 5.5 f09da88ff7 feat(统计): 统计接口补上每人每道题的每一次提交、流程图每人最好的一次,流程图也能一次查几道题
- 新增 GET /submissions/statistics/grid:范围内每个学生的每次提交(只含普通学生,最多 5000 条),
  没选班级时附带各班花名册人数,统计页按「人 × 题」画方块串、按班级汇总
- /flowcharts/statistics 题号可以一次填几道,多返回 people(每人分数最高的那张)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 18:28:27 +08:00
36 changed files with 3267 additions and 1917 deletions

No files matched your search

+6
View File
@@ -3,4 +3,10 @@ export const judgeQueueName = "judge-submission"
export interface JudgeJobData { export interface JudgeJobData {
submissionId: string submissionId: string
problemId: number problemId: number
/**
* 重判时带上**重判之前**的结果。落库时靠它只做差量(见 run.ts 的 persistResult):
* 原来这条已经记过一次账,重判再当成新提交记一遍,题目和个人的提交数就多一。
* 新提交不带。
*/
rejudgedFrom?: number
} }
+40 -7
View File
@@ -134,6 +134,8 @@ async function persistResult(
statisticInfo: Record<string, unknown>, statisticInfo: Record<string, unknown>,
contestId: number | null, contestId: number | null,
submissionCreateTime: string, submissionCreateTime: string,
/** 重判之前**已经记过账**的结果;新提交、或原来没记过账(系统错误)时为 null */
counted: JudgeStatusValue | null,
) { ) {
return db.transaction(async (tx) => { return db.transaction(async (tx) => {
const [currentSubmission] = await tx const [currentSubmission] = await tx
@@ -174,17 +176,26 @@ async function persistResult(
.set({ result, info, statisticInfo }) .set({ result, info, statisticInfo })
.where(eq(schema.submission.id, submissionId)) .where(eq(schema.submission.id, submissionId))
/**
* 重判只做差量:提交数不动,旧结果那一格减一、新结果那一格加一,通过数按新旧结果
* 调整。原来重判一律当新提交记账,老师上课对一条连点三次重判,这道题的提交数和
* 通过数就各多三(旧栈的 update_problem_status_rejudge 也是只做差量)
*/
const problemStatistics = asRecord(problem.statisticInfo) const problemStatistics = asRecord(problem.statisticInfo)
const resultKey = String(result) const bump = (key: string, delta: number) => {
const previousResultCount = problemStatistics[resultKey] const value = problemStatistics[key]
problemStatistics[resultKey] = problemStatistics[key] = Math.max(0, (typeof value === "number" ? value : 0) + delta)
(typeof previousResultCount === "number" ? previousResultCount : 0) + 1 }
bump(String(result), 1)
if (counted !== null) bump(String(counted), -1)
const acceptedDelta =
(isAccepted(result) ? 1 : 0) - (counted !== null && isAccepted(counted) ? 1 : 0)
await tx await tx
.update(schema.problem) .update(schema.problem)
.set({ .set({
submissionNumber: problem.submissionNumber + 1, submissionNumber: problem.submissionNumber + (counted === null ? 1 : 0),
acceptedNumber: problem.acceptedNumber + (isAccepted(result) ? 1 : 0), acceptedNumber: Math.max(0, problem.acceptedNumber + acceptedDelta),
statisticInfo: problemStatistics, statisticInfo: problemStatistics,
}) })
.where(eq(schema.problem.id, problemId)) .where(eq(schema.problem.id, problemId))
@@ -214,7 +225,10 @@ async function persistResult(
await tx await tx
.update(schema.userProfile) .update(schema.userProfile)
.set({ .set({
submissionNumber: profile.submissionNumber + (contestId === null ? 1 : 0), // 重判不是又交了一次。个人的通过数只在「第一次做对」时加,重判改错了也不收回
// (他可能别的提交也对了),和 acm_problems_status 同一个口径
submissionNumber:
profile.submissionNumber + (contestId === null && counted === null ? 1 : 0),
acceptedNumber: acceptedNumber:
profile.acceptedNumber + (contestId === null && acceptedNow && !wasAccepted ? 1 : 0), profile.acceptedNumber + (contestId === null && acceptedNow && !wasAccepted ? 1 : 0),
acmProblemsStatus: acmStatus, acmProblemsStatus: acmStatus,
@@ -452,6 +466,24 @@ async function checkSamples(
} }
} }
/**
* 重判之前的结果**有没有记过账**。判完的结果都走过 persistResult、记过一次;
* 还在判的(卡在队列里被重判)和系统错误(markSystemError 只改结果、不动计数)没有,
* 重判时要当新提交记一次。判题机自己回的系统错误其实记过账,这里分不出来,按没记过算 ——
* 多记一次好过把别人的计数减掉
*/
function countedBefore(rejudgedFrom: number | undefined): JudgeStatusValue | null {
if (rejudgedFrom === undefined) return null
if (
rejudgedFrom === JudgeStatus.PENDING ||
rejudgedFrom === JudgeStatus.JUDGING ||
rejudgedFrom === JudgeStatus.SYSTEM_ERROR
) {
return null
}
return rejudgedFrom as JudgeStatusValue
}
export async function judgeSubmission(job: JudgeJobData) { export async function judgeSubmission(job: JudgeJobData) {
const [row] = await db const [row] = await db
.select({ .select({
@@ -583,6 +615,7 @@ export async function judgeSubmission(job: JudgeJobData) {
statisticInfo, statisticInfo,
row.submission.contestId, row.submission.contestId,
row.submission.createTime, row.submission.createTime,
countedBefore(job.rejudgedFrom),
) )
if (!saved) return if (!saved) return
+8 -4
View File
@@ -452,7 +452,7 @@ const ACTIVE_CLASS_WINDOW_MS = 2 * 60 * 60 * 1000
/** /**
* 从实际提交猜正在上课的班,而**不是记住老师上次选的**:统计面板吃过那个亏(上一节课 * 从实际提交猜正在上课的班,而**不是记住老师上次选的**:统计面板吃过那个亏(上一节课
* 的班悄悄留在框里,老师看的整个是别人的班,见 StatisticsPanel.vue 的注释)。 * 的班悄悄留在框里,老师看的整个是别人的班)。
*/ */
async function suggestActiveClass() { async function suggestActiveClass() {
// 流程图也算:一节课全班都在画流程图时,只数代码提交就猜成了别的班 // 流程图也算:一节课全班都在画流程图时,只数代码提交就猜成了别的班
@@ -668,9 +668,8 @@ classroomRoutes.get("/classroom/board", requireTeacher, async (c) => {
}) })
/** /**
* 老师给这个班布置今天的题。按展示题号给、不分大小写,存 problem.id 并保留输入顺序; * 提交列表「这节课」按钮:这个班这节课是哪几道题,取法同课堂看板(老师布置的优先,
* 有对不上的题号就整个拒掉并点名是哪几个,免得存下半张单子老师还不知道。空数组 = 清掉, * 否则按今天同班提交推断)。不传班级就猜最近两小时在交题的班
* 学生那边退回推断。
*/ */
classroomRoutes.get("/classroom/lesson", requireTeacher, async (c) => { classroomRoutes.get("/classroom/lesson", requireTeacher, async (c) => {
const className = c.req.query("className")?.trim() || (await suggestActiveClass()) const className = c.req.query("className")?.trim() || (await suggestActiveClass())
@@ -688,6 +687,11 @@ classroomRoutes.get("/classroom/lesson", requireTeacher, async (c) => {
} satisfies ClassLesson) } satisfies ClassLesson)
}) })
/**
* 老师给这个班布置今天的题。按展示题号给、不分大小写,存 problem.id 并保留输入顺序;
* 有对不上的题号就整个拒掉并点名是哪几个,免得存下半张单子老师还不知道。空数组 = 清掉,
* 学生那边退回推断。
*/
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
+159 -83
View File
@@ -25,12 +25,14 @@ import { problemSetJoinTimes } from "../services/problemset"
import { consumeToken } from "../services/throttling" import { consumeToken } from "../services/throttling"
import { buildWordFrequencies } from "../services/word-frequency" import { buildWordFrequencies } from "../services/word-frequency"
import { dayStart } from "../time" import { dayStart } from "../time"
import { unknownDisplayIds } from "./submission-statistics"
import { import {
isAdminRole, isAdminRole,
matchedUsers, matchedUsers,
asRecord, asRecord,
parseDisplayIds, parseDisplayIds,
queryInteger, queryInteger,
scopedUsers,
rounded, rounded,
stripClassPrefix, stripClassPrefix,
} from "./helpers" } from "./helpers"
@@ -215,8 +217,20 @@ async function flowchartClassFilter(className: string) {
: sql`false` : sql`false`
} }
async function flowchartUserFilter(username: string) { /**
const ids = (await matchedUsers(username)).map((row) => row.id) * 用户名筛选。默认是「包含」;`exact` 时整名匹配(提交列表的「只看他」、协作条带进来的),
* 学号互相包含是常态,ks24a1 会混进 ks24a10–ks24a19
*/
async function flowchartUserFilter(username: string, exact = false) {
const ids = exact
? (
await db
.select({ id: schema.user.id })
.from(schema.user)
.where(eq(schema.user.username, username))
.limit(1)
).map((row) => row.id)
: (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`
} }
@@ -265,7 +279,7 @@ flowchartRoutes.get("/flowcharts", requireAuth, async (c) => {
// submission_list_show_all 时非管理员看不到列表。流程图这边一直漏了这道门, // submission_list_show_all 时非管理员看不到列表。流程图这边一直漏了这道门,
// 学生把语言切成「流程图」、用户名随便填一个字就能翻出全班的 AI 评分。 // 学生把语言切成「流程图」、用户名随便填一个字就能翻出全班的 AI 评分。
if (!(await getBooleanOption("submission_list_show_all", true)) && !isAdminRole(user)) { 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() const className = c.req.query("className")?.trim()
@@ -275,7 +289,9 @@ flowchartRoutes.get("/flowcharts", requireAuth, async (c) => {
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, c.req.query("exactUsername") === "1")
: undefined,
!onlyMyself && className ? flowchartClassFilter(className) : undefined, !onlyMyself && className ? flowchartClassFilter(className) : undefined,
])), ])),
) )
@@ -285,7 +301,7 @@ flowchartRoutes.get("/flowcharts", requireAuth, async (c) => {
if (["S", "A", "B", "C"].includes(grade ?? "")) if (["S", "A", "B", "C"].includes(grade ?? ""))
filters.push(eq(schema.flowchartSubmission.aiGrade, grade!)) filters.push(eq(schema.flowchartSubmission.aiGrade, grade!))
const where = and(...filters) const where = and(...filters)
const [totalRows, rows] = await Promise.all([ const [totalRows, rows, unknownProblems] = await Promise.all([
// 筛条件已经全落在 flowchart_submission 自己的列上,count 不挂任何 join // 筛条件已经全落在 flowchart_submission 自己的列上,count 不挂任何 join
db.select({ value: count() }).from(schema.flowchartSubmission).where(where), db.select({ value: count() }).from(schema.flowchartSubmission).where(where),
db db
@@ -297,6 +313,7 @@ flowchartRoutes.get("/flowcharts", requireAuth, async (c) => {
.orderBy(desc(schema.flowchartSubmission.createTime)) .orderBy(desc(schema.flowchartSubmission.createTime))
.limit(limit) .limit(limit)
.offset(offset), .offset(offset),
displayId ? unknownDisplayIds(displayId, null) : [],
]) ])
const joinTimes = await joinTimesFor( const joinTimes = await joinTimesFor(
user, user,
@@ -322,6 +339,7 @@ flowchartRoutes.get("/flowcharts", requireAuth, async (c) => {
}) satisfies FlowchartListItem, }) satisfies FlowchartListItem,
), ),
total: totalRows[0]?.value ?? 0, total: totalRows[0]?.value ?? 0,
unknownProblems,
} satisfies FlowchartList) } satisfies FlowchartList)
}) })
@@ -335,8 +353,8 @@ const FLOWCHART_COMPLETED = 2
* 看到的完成率和均分就是错的,而且从界面上看不出来。 * 看到的完成率和均分就是错的,而且从界面上看不出来。
* *
* 会随数据量线性变重的只剩词云:每条 feedback / suggestions / comment 都要走一遍 * 会随数据量线性变重的只剩词云:每条 feedback / suggestions / comment 都要走一遍
* jieba,而前端的「全部时段」是不带 start 的(FlowchartStatisticsPanel.vue 那个 * jieba,而前端的「全部时段」是不带 start 的(统计页 oj/statistics/ 的
* `duration === "all"`),攒一学年就得把所有评语重新 cut 一遍。词云是辅助性的, * 「全部」时段),攒一学年就得把所有评语重新 cut 一遍。词云是辅助性的,
* 看的是高频问题,取最近这些条足够。 * 看的是高频问题,取最近这些条足够。
* *
* 这里**同时**卡了两道:SQL 侧 `order by create_time desc limit N` 只取最近 N 条提交, * 这里**同时**卡了两道:SQL 侧 `order by create_time desc limit N` 只取最近 N 条提交,
@@ -347,6 +365,9 @@ const FLOWCHART_COMPLETED = 2
*/ */
const WORDCLOUD_TEXT_LIMIT = 3000 const WORDCLOUD_TEXT_LIMIT = 3000
/** 「每人最好的一次」最多给多少人。快照里有流程图的学生一共 203 人 */
const PEOPLE_LIMIT = 500
flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => { flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
const end = c.req.query("end")?.trim() const end = c.req.query("end")?.trim()
if (!end) return failure(c, 400, "invalid-request", "end is required") if (!end) return failure(c, 400, "invalid-request", "end is required")
@@ -358,28 +379,43 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
] ]
if (start) filters.push(sql`${schema.flowchartSubmission.createTime} >= ${start}`) if (start) filters.push(sql`${schema.flowchartSubmission.createTime} >= ${start}`)
const displayId = c.req.query("problemDisplayId")?.trim() // 可以一次查几道(统计页的题号框和代码统计同一个),有一道找不到就整体报错,
if (displayId) { // 不退化成「全部题目」—— 否则打错一个字看到的是全站数据
const [problem] = await db const displayIds = parseDisplayIds(c.req.query("problemDisplayId") ?? "")
.select({ id: schema.problem.id }) // 和代码统计同一个上限(submission-statistics.ts 的 STATISTICS_MAX_PROBLEMS)
if (displayIds.length > 20) return failure(c, 400, "invalid-request", "At most 20 problems")
if (displayIds.length) {
const lowered = displayIds.map((id) => id.toLowerCase())
const problems = await db
.select({ id: schema.problem.id, displayId: schema.problem.displayId })
.from(schema.problem) .from(schema.problem)
.where( .where(
and( and(
sql`lower(${schema.problem.displayId}) = lower(${displayId})`, inArray(sql`lower(${schema.problem.displayId})`, lowered),
isNull(schema.problem.contestId), isNull(schema.problem.contestId),
eq(schema.problem.visible, true), eq(schema.problem.visible, true),
), ),
) )
.limit(1) const found = new Set(problems.map((row) => row.displayId.toLowerCase()))
if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist") const missing = displayIds.find((id) => !found.has(id.toLowerCase()))
filters.push(eq(schema.flowchartSubmission.problemId, problem.id)) if (missing) return failure(c, 404, "problem-not-found", `Problem ${missing} does not exist`)
filters.push(
inArray(
schema.flowchartSubmission.problemId,
problems.map((row) => row.id),
),
)
} }
const username = c.req.query("username")?.trim() // 用户名 / 班级先解析成账号,再拿 user_id 去筛 —— 理由同代码提交的统计接口
// 用户名先解析成账号,再拿 user_id 去筛 —— 理由同代码提交的统计接口 // (submission-statistics.ts 的 GET /submissions/statistics),顺带让下面这几条一个 join 都不用挂。
// (submission.ts 的 GET /submissions/statistics),顺带让下面这几条一个 join 都不用挂 // 班级按 class_name 精确匹配(scopedUsers 的注释说了为什么不能拿 ks231 模糊匹配)
const matched = username ? await matchedUsers(username) : [] const scoped = await scopedUsers(
if (username) { c.req.query("username")?.trim() || undefined,
c.req.query("className")?.trim() || undefined,
)
const matched = scoped ?? []
if (scoped) {
const ids = matched.map((row) => row.id) const ids = matched.map((row) => row.id)
// 一个账号都没匹配上时得留个恒假条件,否则「查无此班」变成「全站统计」 // 一个账号都没匹配上时得留个恒假条件,否则「查无此班」变成「全站统计」
filters.push(ids.length ? inArray(schema.flowchartSubmission.userId, ids) : sql`false`) filters.push(ids.length ? inArray(schema.flowchartSubmission.userId, ids) : sql`false`)
@@ -387,7 +423,7 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
const where = and(...filters) const where = and(...filters)
// 花名册:只有指定了用户名才谈得上「班级人数」,不指定时分母无意义。 // 花名册:只有指定了用户名才谈得上「班级人数」,不指定时分母无意义。
// 未禁用的普通用户才进分母,教师和管理员不算 // 未禁用的普通用户才进分母,教师和管理员不算
const roster = username const roster = scoped
? matched.filter((row) => !row.isDisabled && row.adminType === "Regular User") ? matched.filter((row) => !row.isDisabled && row.adminType === "Regular User")
: [] : []
@@ -400,49 +436,52 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
* (criteria 255 + suggestions 64 + feedback 47),现在 2134 条无感,5 万条就是一次 * (criteria 255 + suggestions 64 + feedback 47),现在 2134 条无感,5 万条就是一次
* 点击 18MB,而老师是开着面板反复切时段、切班的。 * 点击 18MB,而老师是开着面板反复切时段、切班的。
*/ */
const [[totals], gradeRows, criteriaRows, textRows, submittedRows] = await Promise.all([ const [[totals], gradeRows, criteriaRows, textRows, submittedRows, peopleRows] =
db await Promise.all([
.select({ db
total: count(), .select({
/** total: count(),
* 均分拆成 sum / count 两项,不直接用 `avg()`:分母是**有分数的条数**而不是 /**
* 总条数(对齐 Django 的 Avg(),它跳过 NULL),拆开之后这个口径在代码里是 * 均分拆成 sum / count 两项,不直接用 `avg()`:分母是**有分数的条数**而不是
* 写明的,也省掉 avg() 在空集上回 NULL 还要兜底。 * 总条数(对齐 Django 的 Avg(),它跳过 NULL),拆开之后这个口径在代码里是
*/ * 写明的,也省掉 avg() 在空集上回 NULL 还要兜底。
scoreSum: sql<number>`coalesce(sum(${schema.flowchartSubmission.aiScore}), 0)`.mapWith( */
Number, scoreSum: sql<number>`coalesce(sum(${schema.flowchartSubmission.aiScore}), 0)`.mapWith(
), Number,
scoreCount: sql<number>`count(${schema.flowchartSubmission.aiScore})::int`.mapWith(Number), ),
// 完成人数。user_id 和 username 一一对应,按哪个 distinct 都一样, scoreCount: sql<number>`count(${schema.flowchartSubmission.aiScore})::int`.mapWith(
// 按 user_id 就不必 join user Number,
completedCount: ),
sql<number>`count(distinct ${schema.flowchartSubmission.userId})::int`.mapWith(Number), // 完成人数。user_id 和 username 一一对应,按哪个 distinct 都一样,
}) // 按 user_id 就不必 join user
.from(schema.flowchartSubmission) completedCount:
.where(where), sql<number>`count(distinct ${schema.flowchartSubmission.userId})::int`.mapWith(Number),
db })
.select({ grade: schema.flowchartSubmission.aiGrade, n: count() }) .from(schema.flowchartSubmission)
.from(schema.flowchartSubmission) .where(where),
.where(where) db
.groupBy(schema.flowchartSubmission.aiGrade), .select({ grade: schema.flowchartSubmission.aiGrade, n: count() })
/** .from(schema.flowchartSubmission)
* 各项**平均分**。`ai_criteria_details` 是 `{ 项名: { score, max, comment } }`, .where(where)
* 用 jsonb_each 展开之后按项名分组。分数不是数字的项整项跳过,和原来 JS 那句 .groupBy(schema.flowchartSubmission.aiGrade),
* `typeof detail.score !== "number"` 的 continue 一致。 /**
* * 各项**平均分**。`ai_criteria_details` 是 `{ 项名: { score, max, comment } }`,
* **那道 `jsonb_typeof(...) = 'object'` 的闸不能省,而且要写在 jsonb_each 的参数里。** * 用 jsonb_each 展开之后按项名分组。分数不是数字的项整项跳过,和原来 JS 那句
* 不能省:撞上标量(历史脏数据)jsonb_each 直接抛错,整个面板 500 —— * `typeof detail.score !== "number"` 的 continue 一致。
* 拿 `'5'::jsonb` 和 `'[1,2]'::jsonb` 各插一行验过。 *
* * **那道 `jsonb_typeof(...) = 'object'` 的闸不能省,而且要写在 jsonb_each 的参数里。**
* 写在哪儿则纯是规划器的脸色:挪进 where 当基表过滤条件时,53350 行的探针上 * 不能省:撞上标量(历史脏数据)jsonb_each 直接抛错,整个面板 500 ——
* 实测 180ms → 360ms,因为计划从「并行 Partial HashAggregate」换成了「串行 * 拿 `'5'::jsonb` 和 `'[1,2]'::jsonb` 各插一行验过。
* GroupAggregate + 21 万行外部归并排序、落盘 26MB」。两种写法都正确,选快的那个。 *
* * 写在哪儿则纯是规划器的脸色:挪进 where 当基表过滤条件时,53350 行的探针上
* 每项的**满分**不在这里取,见下面 criteriaMax 的注释:在这条 SQL 里按 * 实测 180ms → 360ms,因为计划从「并行 Partial HashAggregate」换成了「串行
* create_time 取「最新那条」要给 21 万行(4 项 × 5 万条)排序,同一个探针上 * GroupAggregate + 21 万行外部归并排序、落盘 26MB」。两种写法都正确,选快的那个。
* 实测 254ms → 842ms,而满分本来就是几个常数。 *
*/ * 每项的**满分**不在这里取,见下面 criteriaMax 的注释:在这条 SQL 里按
db.execute<{ key: string; avg: number }>(sql` * create_time 取「最新那条」要给 21 万行(4 项 × 5 万条)排序,同一个探针上
* 实测 254ms → 842ms,而满分本来就是几个常数。
*/
db.execute<{ key: string; avg: number }>(sql`
select e.key as key, avg((e.value->>'score')::double precision) as avg select e.key as key, avg((e.value->>'score')::double precision) as avg
from ${schema.flowchartSubmission} from ${schema.flowchartSubmission}
cross join lateral jsonb_each( cross join lateral jsonb_each(
@@ -453,25 +492,56 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
where ${where} and jsonb_typeof(e.value->'score') = 'number' where ${where} and jsonb_typeof(e.value->'score') = 'number'
group by e.key group by e.key
`), `),
// 词云的原料。只有这条要读大列,所以只有它按时间倒序取最近的 N 条 // 词云的原料。只有这条要读大列,所以只有它按时间倒序取最近的 N 条
db db
.select({ .select({
criteria: schema.flowchartSubmission.aiCriteriaDetails, criteria: schema.flowchartSubmission.aiCriteriaDetails,
feedback: schema.flowchartSubmission.aiFeedback, feedback: schema.flowchartSubmission.aiFeedback,
suggestions: schema.flowchartSubmission.aiSuggestions, suggestions: schema.flowchartSubmission.aiSuggestions,
}) })
.from(schema.flowchartSubmission) .from(schema.flowchartSubmission)
.where(where) .where(where)
.orderBy(desc(schema.flowchartSubmission.createTime)) .orderBy(desc(schema.flowchartSubmission.createTime))
.limit(WORDCLOUD_TEXT_LIMIT), .limit(WORDCLOUD_TEXT_LIMIT),
// 「谁没做」只在有花名册时算得出来,行数也就一个班 // 「谁没做」只在有花名册时算得出来,行数也就一个班
roster.length roster.length
? db ? db
.selectDistinct({ userId: schema.flowchartSubmission.userId }) .selectDistinct({ userId: schema.flowchartSubmission.userId })
.from(schema.flowchartSubmission) .from(schema.flowchartSubmission)
.where(where) .where(where)
: [], : [],
]) // 每人最好的一次。等级取分数最高那张的等级(同分取最近的),只算普通学生
db
.select({
username: schema.user.username,
className: schema.user.className,
bestScore: sql<number | null>`max(${schema.flowchartSubmission.aiScore})`,
bestGrade: sql<string | null>`(array_agg(${schema.flowchartSubmission.aiGrade}
order by ${schema.flowchartSubmission.aiScore} desc nulls last, ${schema.flowchartSubmission.createTime} desc))[1]`,
count: count(),
// 拿到 A / S 的题数。查几道题时要每道都拿到才算完成,和代码统计「几道都做完」同口径
goodProblems: sql<number>`count(distinct ${schema.flowchartSubmission.problemId})
filter (where ${schema.flowchartSubmission.aiGrade} in ('S', 'A'))`.mapWith(Number),
})
.from(schema.flowchartSubmission)
.innerJoin(schema.user, eq(schema.user.id, schema.flowchartSubmission.userId))
.where(
and(where, eq(schema.user.adminType, "Regular User"), eq(schema.user.isDisabled, false)),
)
.groupBy(schema.user.id, schema.user.username, schema.user.className)
.orderBy(sql`max(${schema.flowchartSubmission.aiScore}) asc nulls first`)
.limit(500),
])
const peopleTruncated = peopleRows.length > PEOPLE_LIMIT
const people = peopleRows.slice(0, PEOPLE_LIMIT).map((row) => ({
username: row.username,
realName: stripClassPrefix(row.username, row.className),
bestScore: row.bestScore === null ? null : Number(row.bestScore),
bestGrade: row.bestGrade,
count: row.count,
goodProblems: row.goodProblems,
}))
if (!totals || totals.total === 0) { if (!totals || totals.total === 0) {
return success(c, { return success(c, {
@@ -488,6 +558,9 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
username: row.username, username: row.username,
realName: stripClassPrefix(row.username, row.className), realName: stripClassPrefix(row.username, row.className),
})), })),
people: [],
peopleTruncated: false,
problemCount: displayIds.length,
} satisfies FlowchartStatistics) } satisfies FlowchartStatistics)
} }
@@ -551,6 +624,9 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
username: row.username, username: row.username,
realName: stripClassPrefix(row.username, row.className), realName: stripClassPrefix(row.username, row.className),
})), })),
people,
peopleTruncated,
problemCount: displayIds.length,
} satisfies FlowchartStatistics) } satisfies FlowchartStatistics)
}) })
+26
View File
@@ -159,6 +159,32 @@ export async function countFailedSubmissions(userId: number, problemId: number)
* 代码提交和流程图两条统计都走这里。流程图那张表连冻结用户名都没有(只有 * 代码提交和流程图两条统计都走这里。流程图那张表连冻结用户名都没有(只有
* `user_id`),更是只能从这儿拿 id。 * `user_id`),更是只能从这儿拿 id。
*/ */
/**
* 统计接口「圈哪些人」:班级按 `user.class_name` **精确**匹配,学生名按包含匹配,两个都给
* 就取交集,都没给返回 null(不圈人)。
*
* 原来统计只收一个 username、前端拼成 `ks231` 去做包含匹配 —— 231 班会连带 2311、2312 班
* (快照里 37 人变成 138 人),而提交列表的班级筛选是精确的,两边的人对不上
*/
export async function scopedUsers(username: string | undefined, className: string | undefined) {
if (!username && !className) return null
return db
.select({
id: schema.user.id,
username: schema.user.username,
className: schema.user.className,
isDisabled: schema.user.isDisabled,
adminType: schema.user.adminType,
})
.from(schema.user)
.where(
and(
className ? eq(schema.user.className, className) : undefined,
username ? ilike(schema.user.username, `%${username}%`) : undefined,
),
)
}
export async function matchedUsers(username: string) { export async function matchedUsers(username: string) {
return db return db
.select({ .select({
+152 -162
View File
@@ -8,7 +8,7 @@
import { import {
type SubmissionStatistics, type SubmissionStatistics,
type SubmissionStatisticsItems, type SubmissionStatisticsGrid,
type TodaySubmissionStatistics, type TodaySubmissionStatistics,
} from "@oj2/contract" } from "@oj2/contract"
import { and, count, desc, eq, ilike, inArray, isNull, or, sql, type SQL } from "drizzle-orm" import { and, count, desc, eq, ilike, inArray, isNull, or, sql, type SQL } from "drizzle-orm"
@@ -17,11 +17,11 @@ import { Hono } from "hono"
import { optionalAuth, requireTeacher } from "../auth/middleware" import { optionalAuth, requireTeacher } from "../auth/middleware"
import { db, schema } from "../db" import { db, schema } from "../db"
import { failure, success } from "../http" import { failure, success } from "../http"
import { JudgeStatus, UNJUDGED_RESULTS, type JudgeStatusValue } from "../judge/status" import { JudgeStatus, UNJUDGED_RESULTS } from "../judge/status"
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, parseDisplayIds, rounded, stripClassPrefix } from "./helpers" import { isAdminRole, parseDisplayIds, rounded, scopedUsers, stripClassPrefix } from "./helpers"
export const submissionStatisticsRoutes = new Hono<ContestEnv>() export const submissionStatisticsRoutes = new Hono<ContestEnv>()
@@ -159,107 +159,6 @@ async function findPublicProblemsByDisplayIds(displayIds: string[]) {
return { ids: rows.map((row) => row.id), missing: missing ?? null } return { ids: rows.map((row) => row.id), missing: missing ?? null }
} }
/**
* 展开行一次只看一个人(表格的 updateExpandedRowKeys 只留最后一个 key),所以明细
* **按需拉**,不再随统计一起下发。
*
* 原来是随 data 一起给所有人各带一份:生产快照实测,「全部时段 + 不填条件」要搬
* 49108 行(最早那版不截断是 105631 行),而其中真正被人看到的最多一个人的那几十条。
*/
const STATISTICS_ITEMS_LIMIT = 200
/** 错误摘要截断长度。编译错误能刷几十行,弹层里放不下,也没必要 */
const FAILURE_MESSAGE_LIMIT = 400
/**
* 「交了没对」那一栏点开要看的:这个人**最近一条**提交错在哪。
*
* 有了它,老师看到「张三 12次」之后不用再切到提交列表、翻到这个人、点开代码 ——
* 点一下名字就知道是编译错了还是答案错了、报的什么。err_info 是判题机塞进
* statistic_info 的那一段,提交详情页读的也是它。
*/
async function lastFailureByUser(where: SQL | undefined, userIds: number[]) {
// result 手写成 JudgeStatusValue:这条裸 SQL 读的就是 submission.result 那一列,
// 口径要和列上的 $type 一致
const byUser = new Map<
number,
{
id: string
problemDisplayId: string
result: JudgeStatusValue
error: string | null
}
>()
if (!userIds.length) return byUser
// 不给 submission 起别名:where 里的条件是 drizzle 拼的,引用的是 "submission"."x"
const rows = await db.execute<{
user_id: number
id: string
problem: string
result: JudgeStatusValue
error: string | null
}>(sql`
select user_id, id, problem, result, error from (
select
${schema.submission.userId} as user_id,
${schema.submission.id} as id,
${schema.problem.displayId} as problem,
${schema.submission.result} as result,
left(${schema.submission.statisticInfo}->>'err_info', ${FAILURE_MESSAGE_LIMIT}) as error,
row_number() over (
partition by ${schema.submission.userId}
order by ${schema.submission.createTime} desc
) as rn
from ${schema.submission}
join ${schema.problem} on ${schema.problem.id} = ${schema.submission.problemId}
where ${and(where, inArray(schema.submission.userId, userIds))}
) t
where rn = 1
`)
for (const row of rows) {
byUser.set(row.user_id, {
id: row.id,
problemDisplayId: row.problem,
result: row.result,
error: row.error,
})
}
return byUser
}
/**
* 「答案对了,但没按要求的语法写」的题数(AST_CHECK_FAILED)。
*
* 只算**最后也没改对**的:同一道题上既有 AST_CHECK_FAILED 又有 ACCEPTED,说明学生后来
* 改成要求的写法了,不该再拿这个提醒老师。所以要先按「人 × 题」聚一层,不能直接
* `count(distinct problem_id) filter (result = 10)`。
*
* 口径本身不动 —— AST_CHECK_FAILED 仍然算通过(答案确实对了,全站一致)。这里只是
* 让教师看得见「这几个人是绕过要求做出来的」,教学上那不算达标。
*/
async function astOnlyByUser(where: SQL | undefined, userIds: number[]) {
const byUser = new Map<number, number>()
if (!userIds.length) return byUser
const rows = await db.execute<{ user_id: number; n: number }>(sql`
select user_id, count(*)::int as n from (
select
${schema.submission.userId} as user_id,
bool_or(${schema.submission.result} = ${JudgeStatus.AST_CHECK_FAILED}) as has_ast,
bool_or(${schema.submission.result} = ${JudgeStatus.ACCEPTED}) as has_ac
from ${schema.submission}
where ${and(where, inArray(schema.submission.userId, userIds))}
group by ${schema.submission.userId}, ${schema.submission.problemId}
) t
where has_ast and not has_ac
group by user_id
`)
for (const row of rows) byUser.set(row.user_id, row.n)
return byUser
}
/** /**
* 两条提交列表的用户名筛选。**两边都要匹配**: * 两条提交列表的用户名筛选。**两边都要匹配**:
* *
@@ -337,6 +236,31 @@ export async function classNameFilter(className: string) {
* 和公开题撞号是常态),而公开提交从不指向比赛题(快照核过,0 条)。 * 和公开题撞号是常态),而公开提交从不指向比赛题(快照核过,0 条)。
* 查无此题时留恒假条件,少推一个 filter 就成了「不筛」。 * 查无此题时留恒假条件,少推一个 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) { export async function problemFilter(displayId: string, contestId: number | null) {
// 一次可以筛几道(「这节课」那颗按钮一带就是五道),分隔符和统计面板同一套 // 一次可以筛几道(「这节课」那颗按钮一带就是五道),分隔符和统计面板同一套
const lowered = parseDisplayIds(displayId).map((id) => id.toLowerCase()) const lowered = parseDisplayIds(displayId).map((id) => id.toLowerCase())
@@ -418,13 +342,15 @@ submissionStatisticsRoutes.get("/submissions/statistics", requireTeacher, async
if (!scope.ok) return failure(c, scope.status, scope.code, scope.message) if (!scope.ok) return failure(c, scope.status, scope.code, scope.message)
const filters = scope.filters const filters = scope.filters
const username = c.req.query("username")?.trim() const username = c.req.query("username")?.trim() || undefined
// 用户名先解析成账号,再拿 user_id 去筛提交。这一趟查询挡在 Promise.all 前面, const className = c.req.query("className")?.trim() || undefined
// 用户名 / 班级先解析成账号,再拿 user_id 去筛提交。这一趟查询挡在 Promise.all 前面,
// 但换掉的是下面**四条**语句各一次的 submission 全表扫:`ilike` 走不了索引, // 但换掉的是下面**四条**语句各一次的 submission 全表扫:`ilike` 走不了索引,
// 换成 `user_id in (...)` 之后四条全走索引(生产快照实测单条 18448 → 537 // 换成 `user_id in (...)` 之后四条全走索引(生产快照实测单条 18448 → 537
// buffers;同一个快照上整个接口查一个班 120~250ms → 10ms 上下),多这一次往返是赚的。 // buffers;同一个快照上整个接口查一个班 120~250ms → 10ms 上下),多这一次往返是赚的。
const matched = username ? await matchedUsers(username) : [] const scoped = await scopedUsers(username, className)
if (username) { const matched = scoped ?? []
if (scoped) {
const matchedIds = matched.map((row) => row.id) const matchedIds = matched.map((row) => row.id)
// 一个账号都没匹配上时得留个恒假条件。少推一个 filter 的话过滤条件整个消失, // 一个账号都没匹配上时得留个恒假条件。少推一个 filter 的话过滤条件整个消失,
// 「查无此班」会变成「全站统计」 // 「查无此班」会变成「全站统计」
@@ -511,16 +437,11 @@ submissionStatisticsRoutes.get("/submissions/statistics", requireTeacher, async
* 「提交记录」那张表列的是**窗口里交过东西的所有人**,`done` 标出谁做完了 —— * 「提交记录」那张表列的是**窗口里交过东西的所有人**,`done` 标出谁做完了 ——
* 原来只给做完的人,于是一次没对的学生连同他的提交在这张表里根本不存在, * 原来只给做完的人,于是一次没对的学生连同他的提交在这张表里根本不存在,
* 教师想看「他到底错在哪」得切到提交列表再翻。展开一行拉的是那个人的全部 * 教师想看「他到底错在哪」得切到提交列表再翻。展开一行拉的是那个人的全部
* 提交(GET /submissions/statistics/items 不按结果过滤),对错都在里面。 * 提交,对错都在里面。
* *
* 「完成人数」这些数字跟着 `done` 算,不是 `data.length`。 * 「完成人数」这些数字跟着 `done` 算,不是 `data.length`。
*/ */
const doneCount = perUser.filter(isDone).length const doneCount = perUser.filter(isDone).length
// 要等 perUser 回来才能查,所以进不了上面那个 Promise.all
const astOnlyByUserMap = await astOnlyByUser(
where,
perUser.map((row) => row.userId),
)
const submittedUserIds = new Set(perUser.map((row) => row.userId)) const submittedUserIds = new Set(perUser.map((row) => row.userId))
@@ -530,7 +451,6 @@ submissionStatisticsRoutes.get("/submissions/statistics", requireTeacher, async
submissionCount: row.submissionCount, submissionCount: row.submissionCount,
acceptedCount: row.acceptedCount, acceptedCount: row.acceptedCount,
solvedCount: row.solvedCount, solvedCount: row.solvedCount,
astOnlyCount: astOnlyByUserMap.get(row.userId) ?? 0,
judgingCount: row.judgingCount, judgingCount: row.judgingCount,
correctRate: judgedRate(row.acceptedCount, row.submissionCount - row.judgingCount), correctRate: judgedRate(row.acceptedCount, row.submissionCount - row.judgingCount),
done: isDone(row), done: isDone(row),
@@ -559,14 +479,8 @@ submissionStatisticsRoutes.get("/submissions/statistics", requireTeacher, async
const rosterIds = new Set(rosterRows.map((row) => row.id)) const rosterIds = new Set(rosterRows.map((row) => row.id))
const attemptedRows = perUser.filter((row) => { const attemptedRows = perUser.filter((row) => {
if (isDone(row)) return false if (isDone(row)) return false
return username return scoped ? rosterIds.has(row.userId) : !row.isDisabled && row.adminType === "Regular User"
? rosterIds.has(row.userId)
: !row.isDisabled && row.adminType === "Regular User"
}) })
const failureByUser = await lastFailureByUser(
where,
attemptedRows.map((row) => row.userId),
)
const dataAttempted = attemptedRows.map((row) => ({ const dataAttempted = attemptedRows.map((row) => ({
username: row.username, username: row.username,
/** /**
@@ -574,10 +488,9 @@ submissionStatisticsRoutes.get("/submissions/statistics", requireTeacher, async
* 不传用户名的全站视图里各班混在一起,剥完只剩一串重名的名字,反而认不出谁, * 不传用户名的全站视图里各班混在一起,剥完只剩一串重名的名字,反而认不出谁,
* 所以原样给完整用户名。班名取 perUser join 出来的那一列,和花名册同一份数据。 * 所以原样给完整用户名。班名取 perUser join 出来的那一列,和花名册同一份数据。
*/ */
realName: username ? stripClassPrefix(row.username, row.className) : row.username, realName: scoped ? stripClassPrefix(row.username, row.className) : row.username,
submissionCount: row.submissionCount, submissionCount: row.submissionCount,
solvedCount: row.solvedCount, solvedCount: row.solvedCount,
lastFailure: failureByUser.get(row.userId) ?? null,
})) }))
// 「学生已删号但提交记录还在」时完成人数会大于花名册人数,分母兜到完成人数为止。 // 「学生已删号但提交记录还在」时完成人数会大于花名册人数,分母兜到完成人数为止。
@@ -598,57 +511,134 @@ submissionStatisticsRoutes.get("/submissions/statistics", requireTeacher, async
} satisfies SubmissionStatistics) } satisfies SubmissionStatistics)
}) })
/** /** 方块串一次最多给多少条。一个班一个月七八百条,5000 是全年级查一周的量 */
* 统计面板展开一行时拉这个人的提交明细。 const GRID_LIMIT = 5000
*
* 用户名这里是**精确匹配**,不是统计接口那种 ilike —— 那边填 `ks251` 要圈出整个班,
* 这边是「点开的这一行是谁」。时间窗和题号沿用同一个 scope,不然展开行看到的
* 会是另一个范围的数据。
*/
submissionStatisticsRoutes.get("/submissions/statistics/items", requireTeacher, async (c) => {
const username = c.req.query("username")?.trim()
if (!username) return failure(c, 400, "invalid-request", "username is required")
/**
* 统计页的方块串:范围内每个学生的每一次提交(口径见契约 submissionStatisticsGridSchema)。
* 用户名的匹配和统计接口同一套(ilike,填 `ks253` 圈一个班)。
*/
submissionStatisticsRoutes.get("/submissions/statistics/grid", requireTeacher, async (c) => {
const scope = await statisticsScope(c) const scope = await statisticsScope(c)
if (!scope.ok) return failure(c, scope.status, scope.code, scope.message) if (!scope.ok) return failure(c, scope.status, scope.code, scope.message)
const filters = [...scope.filters]
/** const username = c.req.query("username")?.trim() || undefined
* 展开的那一行给的是**当前**用户名,先换成 user_id 再查 —— 直接按 const scoped = await scopedUsers(username, c.req.query("className")?.trim() || undefined)
* `submission.username` 精确匹配的话,改过名的学生展开来是空的(他的提交 if (scoped) {
* 全挂在旧名字下)。 const ids = scoped.map((row) => row.id)
* filters.push(ids.length ? inArray(schema.submission.userId, ids) : sql`false`)
* 查不到账号才退回按提交里冻结的用户名匹配:已删号的学生仍然会出现在统计 }
* 表格里(那一行的名字取自提交),展开行不能因此空着。
*/
const [account] = await db
.select({ id: schema.user.id })
.from(schema.user)
.where(eq(schema.user.username, username))
.limit(1)
const identity = account
? eq(schema.submission.userId, account.id)
: eq(schema.submission.username, username)
// 多取一条,好知道是不是被截断了
// innerJoin 不会漏行:submission.problem_id 是 NOT NULL 且外键是 NO ACTION,
// 题目删不掉(真要删会被外键拦住并提示改为隐藏)
const rows = await db const rows = await db
.select({ .select({
id: schema.submission.id, id: schema.submission.id,
userId: schema.submission.userId,
problemId: schema.submission.problemId,
result: schema.submission.result, result: schema.submission.result,
createTime: schema.submission.createTime, createTime: schema.submission.createTime,
problemDisplayId: schema.problem.displayId, username: schema.user.username,
problemTitle: schema.problem.title, className: schema.user.className,
}) })
.from(schema.submission) .from(schema.submission)
.innerJoin(schema.problem, eq(schema.problem.id, schema.submission.problemId)) // 只要普通学生:老师试题的提交不该出现在「谁做了几次」里
.where(and(...scope.filters, identity)) .innerJoin(schema.user, eq(schema.user.id, schema.submission.userId))
.orderBy(desc(schema.submission.createTime), desc(schema.submission.id)) .where(
.limit(STATISTICS_ITEMS_LIMIT + 1) and(...filters, eq(schema.user.adminType, "Regular User"), eq(schema.user.isDisabled, false)),
)
.orderBy(desc(schema.submission.createTime))
.limit(GRID_LIMIT + 1)
const truncated = rows.length > GRID_LIMIT
const kept = rows.slice(0, GRID_LIMIT).reverse()
// 题目:传了就按传的顺序,没传就按范围里第一次有人交的时间
const requested = parseDisplayIds(c.req.query("problemDisplayId") ?? "")
const problemIds = [...new Set(kept.map((row) => row.problemId))]
const problems = problemIds.length
? await db
.select({
id: schema.problem.id,
displayId: schema.problem.displayId,
title: schema.problem.title,
})
.from(schema.problem)
.where(inArray(schema.problem.id, problemIds))
: []
const problemById = new Map(problems.map((row) => [row.id, row]))
const ordered = requested.length
? requested.flatMap((id) => {
const hit = problems.find((row) => row.displayId.toLowerCase() === id.toLowerCase())
return hit ? [hit] : []
})
: problemIds.map((id) => problemById.get(id)!).filter(Boolean)
// 传了题号但这些题在范围里没人交:表头仍然要有它们
if (requested.length && ordered.length < requested.length) {
const missing = requested.filter(
(id) => !ordered.some((row) => row.displayId.toLowerCase() === id.toLowerCase()),
)
const { ids } = await findPublicProblemsByDisplayIds(missing)
if (ids.length) {
const extra = await db
.select({
id: schema.problem.id,
displayId: schema.problem.displayId,
title: schema.problem.title,
})
.from(schema.problem)
.where(inArray(schema.problem.id, ids))
ordered.push(...extra)
const rank = new Map(requested.map((id, i) => [id.toLowerCase(), i]))
ordered.sort(
(a, b) =>
(rank.get(a.displayId.toLowerCase()) ?? 0) - (rank.get(b.displayId.toLowerCase()) ?? 0),
)
}
}
const byUser = new Map<number, SubmissionStatisticsGrid["rows"][number]>()
for (const row of kept) {
let entry = byUser.get(row.userId)
if (!entry) {
entry = {
username: row.username,
realName: stripClassPrefix(row.username, row.className),
className: row.className,
submissions: [],
}
byUser.set(row.userId, entry)
}
entry.submissions.push({
id: row.id,
problemDisplayId: problemById.get(row.problemId)?.displayId ?? "",
result: row.result,
createTime: row.createTime,
})
}
// 按班级汇总时要知道每个班多少人,「一道没交」才算得出来
const classSizes: Record<string, number> = {}
if (!scoped) {
const names = [...new Set(kept.map((row) => row.className).filter((name) => name !== null))]
if (names.length) {
const sizes = await db
.select({ className: schema.user.className, value: count() })
.from(schema.user)
.where(
and(
inArray(schema.user.className, names as string[]),
eq(schema.user.adminType, "Regular User"),
eq(schema.user.isDisabled, false),
),
)
.groupBy(schema.user.className)
for (const row of sizes) if (row.className) classSizes[row.className] = row.value
}
}
const truncated = rows.length > STATISTICS_ITEMS_LIMIT
return success(c, { return success(c, {
items: rows.slice(0, STATISTICS_ITEMS_LIMIT), problems: ordered.map((row) => ({ problemDisplayId: row.displayId, title: row.title })),
rows: [...byUser.values()],
classSizes,
truncated, truncated,
} satisfies SubmissionStatisticsItems) } satisfies SubmissionStatisticsGrid)
}) })
+13 -5
View File
@@ -37,6 +37,7 @@ import {
exactUsernameFilter, exactUsernameFilter,
problemFilter, problemFilter,
submissionStatisticsRoutes, submissionStatisticsRoutes,
unknownDisplayIds,
usernameFilter, usernameFilter,
} from "./submission-statistics" } from "./submission-statistics"
@@ -216,22 +217,25 @@ submissionRoutes.post("/submissions/:id/rejudge", requireTeacher, async (c) => {
.select({ .select({
id: schema.submission.id, id: schema.submission.id,
problemId: schema.submission.problemId, problemId: schema.submission.problemId,
result: schema.submission.result,
}) })
.from(schema.submission) .from(schema.submission)
.where(and(eq(schema.submission.id, c.req.param("id")), isNull(schema.submission.contestId))) .where(and(eq(schema.submission.id, c.req.param("id")), isNull(schema.submission.contestId)))
.limit(1) .limit(1)
if (!row) return failure(c, 404, "submission-not-found", "Submission does not exist") if (!row) return failure(c, 404, "submission-not-found", "Submission does not exist")
// info 一起清:不清的话判题中那几秒,列表和详情按旧的逐点结果算出「通过 2/3」
await db await db
.update(schema.submission) .update(schema.submission)
.set({ statisticInfo: {}, result: JudgeStatus.PENDING }) .set({ statisticInfo: {}, info: {}, result: JudgeStatus.PENDING })
.where(eq(schema.submission.id, row.id)) .where(eq(schema.submission.id, row.id))
// jobId 必须带时间戳。队列保留最近 100 个已完成任务,沿用 submissionId 做 jobId 的话 // jobId 必须带时间戳。队列保留最近 100 个已完成任务,沿用 submissionId 做 jobId 的话
// BullMQ 会认为这个任务已经存在,重判静默变成空操作。与 flowcharts/:id/retry 同一处理。 // BullMQ 会认为这个任务已经存在,重判静默变成空操作。与 flowcharts/:id/retry 同一处理。
await judgeQueue.add( await judgeQueue.add(
"judge", "judge",
{ submissionId: row.id, problemId: row.problemId }, // 带上重判之前的结果,落库时只做差量(见 judge/run.ts 的 persistResult)
{ submissionId: row.id, problemId: row.problemId, rejudgedFrom: row.result },
{ jobId: `${row.id}:rejudge:${Date.now()}` }, { jobId: `${row.id}:rejudge:${Date.now()}` },
) )
return success(c, null) return success(c, null)
@@ -508,7 +512,7 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => {
// 「非管理员即受限」,不能写成「是普通用户才受限」—— // 「非管理员即受限」,不能写成「是普通用户才受限」——
// 后者对匿名用户(user 为 null)会短路,匿名反而能看到全部提交,权限大于登录学生。 // 后者对匿名用户(user 为 null)会短路,匿名反而能看到全部提交,权限大于登录学生。
if (!(await getBooleanOption("submission_list_show_all", true)) && !isAdminRole(user)) { 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 displayId = c.req.query("problemDisplayId")?.trim()
const myself = c.req.query("myself") === "1" ? user : null const myself = c.req.query("myself") === "1" ? user : null
@@ -539,10 +543,11 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => {
const where = and(...filters) const where = and(...filters)
// count 不 join problem:无条件 join 会让计划器把 count 退化成 seq scan // count 不 join problem:无条件 join 会让计划器把 count 退化成 seq scan
// (生产快照实测 7.5ms → 78ms)。题号已经解析成 problem_id,也用不着 join。 // (生产快照实测 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), db.select({ value: count() }).from(schema.submission).where(where),
// 班级和用户名一样是「圈一小撮人」,走同一条圈选路(见 paginateSubmissionRows) // 班级和用户名一样是「圈一小撮人」,走同一条圈选路(见 paginateSubmissionRows)
paginateSubmissionRows(where, limit, offset, Boolean(username || className)), paginateSubmissionRows(where, limit, offset, Boolean(username || className)),
displayId ? unknownDisplayIds(displayId, null) : [],
]) ])
// 闸门只对学生自己的提交生效,所以只拿这一页里属于他自己的题目去查,一页一次查询 // 闸门只对学生自己的提交生效,所以只拿这一页里属于他自己的题目去查,一页一次查询
const [joinTimes, problemsetTitles] = await Promise.all([ const [joinTimes, problemsetTitles] = await Promise.all([
@@ -584,6 +589,7 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => {
}) satisfies SubmissionListItem, }) satisfies SubmissionListItem,
), ),
total: totalRows[0]?.value ?? 0, total: totalRows[0]?.value ?? 0,
unknownProblems,
} satisfies SubmissionList) } satisfies SubmissionList)
}) })
@@ -615,7 +621,7 @@ submissionRoutes.get(
const where = and(...filters) const where = and(...filters)
// 一场比赛最多一两千条提交,按 contest_create_time_idx 定位之后怎么滤都不贵, // 一场比赛最多一两千条提交,按 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({ value: count() }).from(schema.submission).where(where),
db db
.select(submissionListColumns) .select(submissionListColumns)
@@ -626,6 +632,7 @@ submissionRoutes.get(
.orderBy(desc(schema.submission.createTime)) .orderBy(desc(schema.submission.createTime))
.limit(limit) .limit(limit)
.offset(offset), .offset(offset),
displayId ? unknownDisplayIds(displayId, contest.id) : [],
]) ])
// 这里不挂题单防作弊闸门(对比公开列表):题单里的题必定是非比赛题——加题时卡了 // 这里不挂题单防作弊闸门(对比公开列表):题单里的题必定是非比赛题——加题时卡了
// `isNull(problem.contestId)`(admin/problemset.ts:232)——而这条列表只出比赛提交, // `isNull(problem.contestId)`(admin/problemset.ts:232)——而这条列表只出比赛提交,
@@ -653,6 +660,7 @@ submissionRoutes.get(
}) satisfies SubmissionListItem, }) satisfies SubmissionListItem,
), ),
total: totalRows[0]?.value ?? 0, total: totalRows[0]?.value ?? 0,
unknownProblems,
} satisfies SubmissionList) } satisfies SubmissionList)
}, },
) )
+4 -3
View File
@@ -9,9 +9,10 @@ import { metaAchievements, refreshUnlockedCount, rescanAchievement } from "../se
* 把反范式的计数列重算回与 submission 表一致。 * 把反范式的计数列重算回与 submission 表一致。
* *
* 这几个列不是缓存、是真值的副本:判题落库时由 `judge/run.ts` 的 persistResult 手工 * 这几个列不是缓存、是真值的副本:判题落库时由 `judge/run.ts` 的 persistResult 手工
* 加减,谁都没在事后核对过。已知的漂移来源是**重判**——`routes/submission.ts` 的 * 加减,谁都没在事后核对过。已知的漂移来源是**重判**—— 2026-09-29 之前 rejudge 把
* rejudge 把 result 打回 PENDING 就重新入队,**不回退任何计数**,于是 persistResult * result 打回 PENDING 就重新入队、不回退任何计数,persistResult 再加一次:重判一条题目
* 再加一次:重判一条题目的 submission_number 就永久多一。删提交、直接改库同理。 * 的 submission_number 就永久多一。现在 rejudge 带上旧结果、persistResult 只做差量,
* 这条不再新增漂移,存量靠这里订正。删提交、直接改库仍然会漂。
* *
* 管的六个列: * 管的六个列:
* problem.submission_number / accepted_number / statistic_info * problem.submission_number / accepted_number / statistic_info
+1 -1
View File
@@ -59,7 +59,7 @@ API 调用不按模块分:学生端全在 `oj/api.ts`、后台全在 `admin/ap
Shared logic lives in `shared/`: Shared logic lives in `shared/`:
- `store/` — Pinia stores: `user` (auth/roles), `config` (site-wide settings), `authModal` (login/signup form state), `loginSummary` (AI activity summary), `collab` (help-request queue + collab room), `achievement` (解锁弹窗队列), `myFlowchart` (题面「你画的流程图」那张 A/S 图的 mermaid 源码) - `store/` — Pinia stores: `user` (auth/roles), `config` (site-wide settings), `authModal` (login/signup form state), `loginSummary` (AI activity summary), `collab` (help-request queue + collab room), `achievement` (解锁弹窗队列), `myFlowchart` (题面「你画的流程图」那张 A/S 图的 mermaid 源码)
- `composables/` — `aiStream`, `pagination` (URL-synced), `websocket` (reconnect + heartbeat), `collabDoc` (Yjs binding for the collab channel), `configUpdate` (WS-pushed config sync), `useMermaid` (lazy Mermaid render), `darkTransition` (View Transitions,111 以下走降级分支), `hiddenStudents` (统计面板的「请假隐藏」), `chartTheme`, `breakpoints`, `maxkb`, `learnProgress`, `rarity` - `composables/` — `aiStream`, `pagination` (URL-synced), `websocket` (reconnect + heartbeat), `collabDoc` (Yjs binding for the collab channel), `configUpdate` (WS-pushed config sync), `useMermaid` (lazy Mermaid render), `darkTransition` (View Transitions,111 以下走降级分支), `hiddenStudents` (课堂看板的「请假隐藏」), `chartTheme`, `breakpoints`, `maxkb`, `learnProgress`, `rarity`
- `layout/` — `default.vue` and `admin.vue` layout wrappers - `layout/` — `default.vue` and `admin.vue` layout wrappers
- `api.ts` — shared API calls (auth, profile, tags, captcha) - `api.ts` — shared API calls (auth, profile, tags, captcha)
-1
View File
@@ -282,7 +282,6 @@ onMounted(loadData)
<SubmissionDetail <SubmissionDetail
v-if="currentSubmission" v-if="currentSubmission"
:submission="currentSubmission" :submission="currentSubmission"
:problemID="currentSubmission.problemDisplayId"
:submissionID="currentSubmission.id" :submissionID="currentSubmission.id"
hideList hideList
@copied="toggleCodePanel(false)" @copied="toggleCodePanel(false)"
+27 -21
View File
@@ -39,7 +39,7 @@ import {
type UserBadge, type UserBadge,
type FlowchartStatistics, type FlowchartStatistics,
type SubmissionStatistics, type SubmissionStatistics,
type SubmissionStatisticsItems, type SubmissionStatisticsGrid,
type TodaySubmissionStatistics, type TodaySubmissionStatistics,
} from "@oj2/contract" } from "@oj2/contract"
import api from "utils/api" import api from "utils/api"
@@ -150,10 +150,13 @@ export function getSubmissions(params: Partial<SubmissionListPayload>) {
: "submissions" : "submissions"
// 契约里 language 是 z.string()(语言是配置项,随时可能加,收紧成枚举会让 // 契约里 language 是 z.string()(语言是配置项,随时可能加,收紧成枚举会让
// 新加的语言在后端 parse 时直接抛),前端在这一处收窄成 LANGUAGE // 新加的语言在后端 parse 时直接抛),前端在这一处收窄成 LANGUAGE
return api.get<{ results: SubmissionListItem[]; total: number }>(endpoint, { return api.get<{ results: SubmissionListItem[]; total: number; unknownProblems: string[] }>(
// contestId 走的是路径,page 只有前端分页器用 endpoint,
params: { ...params, contestId: undefined, page: undefined }, {
}) // contestId 走的是路径,page 只有前端分页器用
params: { ...params, contestId: undefined, page: undefined },
},
)
} }
export function getRankOfProblem(problemId: string) { export function getRankOfProblem(problemId: string) {
@@ -173,27 +176,27 @@ export function adminRejudge(id: string) {
return api.post<{ ok: boolean }>(`submissions/${encodeURIComponent(id)}/rejudge`) return api.post<{ ok: boolean }>(`submissions/${encodeURIComponent(id)}/rejudge`)
} }
/**
* 统计面板展开一行时拉这个人的明细。username 这里要**精确**到人,
* 和上面那个按班级模糊匹配的不是一回事。
*/
export function getSubmissionStatisticsItems(
duration: { start?: string; end: string },
username: string,
problemDisplayId?: string,
) {
return api.get<SubmissionStatisticsItems>("submissions/statistics/items", {
params: { ...duration, problemDisplayId, username },
})
}
export function getSubmissionStatistics( export function getSubmissionStatistics(
duration: { start?: string; end: string }, duration: { start?: string; end: string },
problemDisplayId?: string, problemDisplayId?: string,
username?: string, username?: string,
/** 班级(user.class_name)精确匹配。别再拼 `ks231` 塞进 username —— 会连带 2311、2312 班 */
className?: string,
) { ) {
return api.get<SubmissionStatistics>("submissions/statistics", { return api.get<SubmissionStatistics>("submissions/statistics", {
params: { ...duration, problemDisplayId, username }, params: { ...duration, problemDisplayId, username, className },
})
}
/** 统计页的方块串:范围内每个学生的每一次提交(口径见契约 submissionStatisticsGridSchema) */
export function getSubmissionStatisticsGrid(
duration: { start?: string; end: string },
problemDisplayId?: string,
username?: string,
className?: string,
) {
return api.get<SubmissionStatisticsGrid>("submissions/statistics/grid", {
params: { ...duration, problemDisplayId, username, className },
}) })
} }
@@ -418,6 +421,8 @@ export function getFlowchartSubmission(id: string) {
export function getFlowchartSubmissions(params: { export function getFlowchartSubmissions(params: {
username?: string username?: string
/** "1" = username 整名匹配,同代码提交列表 */
exactUsername?: "1"
className?: string className?: string
problemDisplayId?: string problemDisplayId?: string
myself?: string myself?: string
@@ -433,9 +438,10 @@ export function getFlowchartStatistics(
duration: { start?: string; end: string }, duration: { start?: string; end: string },
problemDisplayId?: string, problemDisplayId?: string,
username?: string, username?: string,
className?: string,
) { ) {
return api.get<FlowchartStatistics>("flowcharts/statistics", { return api.get<FlowchartStatistics>("flowcharts/statistics", {
params: { ...duration, problemDisplayId, username }, params: { ...duration, problemDisplayId, username, className },
}) })
} }
+74 -20
View File
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { getClassBoard, setClassLesson } from "oj/api" import { getClassBoard, setClassLesson } from "oj/api"
import { useHiddenStudents } from "shared/composables/hiddenStudents"
import { useCollabStore } from "shared/store/collab" import { useCollabStore } from "shared/store/collab"
import { useConfigStore } from "shared/store/config" import { useConfigStore } from "shared/store/config"
import { errorMessage } from "utils/api" import { errorMessage } from "utils/api"
@@ -14,7 +15,7 @@ import type { ClassBoard, ClassBoardCell, ClassBoardStudent } from "utils/types"
* 没交过 → 卡住 → 在做 → 做完,没交过的名单还单独摆在最上面,老师扫一眼就能走过去。 * 没交过 → 卡住 → 在做 → 做完,没交过的名单还单独摆在最上面,老师扫一眼就能走过去。
* *
* 班级**不记本地**,默认由后端猜最近两小时在交题的班 —— 记本地的话,上一节课的班会 * 班级**不记本地**,默认由后端猜最近两小时在交题的班 —— 记本地的话,上一节课的班会
* 悄悄留在框里,老师看的整个是别人的班。(提交统计弹窗是记一小时的,见 composables/statisticsQuery.ts, * 悄悄留在框里,老师看的整个是别人的班。(原来的提交统计弹窗是记一小时的,
* 那边是老师自己手选的查询;这里后端本来就猜得出,没必要冒这个险。) * 那边是老师自己手选的查询;这里后端本来就猜得出,没必要冒这个险。)
*/ */
@@ -137,14 +138,34 @@ function nameOf(student: ClassBoardStudent) {
return student.realName || student.username return student.realName || student.username
} }
/**
* 请假隐藏(原来在「数据统计」弹框里,统计改成回头看之后挪到这儿 —— 盯这节课的是看板)。
* 请假、转班、学号错了的那几个一直挂在「还没交过」里,会盖住真正要去看的人。
* 藏两个小时(够一节课),下节课自动回来;只记在这台电脑上,换人换机器都不继承。
* 键沿用统计弹框那份,老师上午藏的人这里接着藏着。
*
* **只藏「还没交过」的人**:一个学生只要今天交过、或者正在举手,就不算请假,照常显示、
* 照常算进人数。迟到的学生后来交了题、卡住了、举手了,老师得看得见
*/
const { hideMode, hideStudent, unhide, isHidden } = useHiddenStudents("oj_hidden_students")
function hiddenNow(student: ClassBoardStudent) {
return !student.lastSubmitAt && !helpOf(student) && isHidden(student.username)
}
const hiddenStudents = computed(() => (board.value?.students ?? []).filter(hiddenNow))
function restoreHidden() {
unhide(hiddenStudents.value.map((student) => student.username))
}
const students = computed(() => const students = computed(() =>
[...(board.value?.students ?? [])].sort( [...(board.value?.students ?? [])]
(a, b) => .filter((student) => !isHidden(student.username))
GROUP_ORDER[groupOf(a)] - GROUP_ORDER[groupOf(b)] || .sort(
// 同样是没交过,经常不动手的排前面 (a, b) =>
Number(oftenIdle(b)) - Number(oftenIdle(a)) || GROUP_ORDER[groupOf(a)] - GROUP_ORDER[groupOf(b)] ||
nameOf(a).localeCompare(nameOf(b), "zh-CN"), // 同样是没交过,经常不动手的排前面
), Number(oftenIdle(b)) - Number(oftenIdle(a)) ||
nameOf(a).localeCompare(nameOf(b), "zh-CN"),
),
) )
const idle = computed(() => students.value.filter((student) => groupOf(student) === "idle")) const idle = computed(() => students.value.filter((student) => groupOf(student) === "idle"))
@@ -164,7 +185,8 @@ const stuckCount = computed(
) )
const summary = computed(() => { const summary = computed(() => {
const all = board.value?.students ?? [] // 分母也减掉请假的,不然「做完 30/38」永远到不了头
const all = students.value
return (board.value?.problems ?? []).map((problem, i) => ({ return (board.value?.problems ?? []).map((problem, i) => ({
...problem, ...problem,
done: all.filter((student) => student.cells[i]?.status === "accepted").length, done: all.filter((student) => student.cells[i]?.status === "accepted").length,
@@ -311,20 +333,48 @@ function submissionsHref(student: ClassBoardStudent, problemDisplayId?: string)
<template v-if="!projector"> <template v-if="!projector">
<n-alert <n-alert
v-if="idle.length" v-if="idle.length || hiddenStudents.length"
type="warning" type="warning"
:title="`今天还没交过(${idle.length} 人)`" :title="idle.length ? `今天还没交过(${idle.length} 人)` : '今天没交过的都隐藏了'"
class="section" class="section"
> >
<div v-if="idleOften.length"> <n-flex align="center" :size="10" class="hide-bar">
<b>经常没交</b>(最近 {{ board.recentLessons }} 节课最多交过 1 节):{{ <n-switch v-model:value="hideMode" size="small">
idleOften.map(nameOf).join("、") <template #checked>请假隐藏中 · 点名字旁的 × 藏起来</template>
}} <template #unchecked>请假隐藏</template>
</div> </n-switch>
<div v-if="idleOthers.length"> <n-button
<template v-if="idleOften.length"><b>其他</b>:</template v-if="hiddenStudents.length"
>{{ idleOthers.map(nameOf).join("、") }} size="tiny"
</div> text
type="primary"
@click="restoreHidden"
>
隐藏了 {{ hiddenStudents.length }} 位:{{ hiddenStudents.map(nameOf).join("、") }} ·
恢复
</n-button>
</n-flex>
<n-flex v-if="hideMode" :size="8">
<n-tag
v-for="student in idle"
:key="student.userId"
closable
@close="hideStudent(student.username)"
>
{{ nameOf(student) }}
</n-tag>
</n-flex>
<template v-else>
<div v-if="idleOften.length">
<b>经常没交</b>(最近 {{ board.recentLessons }} 节课最多交过 1 节):{{
idleOften.map(nameOf).join("、")
}}
</div>
<div v-if="idleOthers.length">
<template v-if="idleOften.length"><b>其他</b>:</template
>{{ idleOthers.map(nameOf).join("、") }}
</div>
</template>
</n-alert> </n-alert>
<n-text v-if="stuckCount" type="error" class="meta"> <n-text v-if="stuckCount" type="error" class="meta">
@@ -420,6 +470,10 @@ function submissionsHref(student: ClassBoardStudent, problemDisplayId?: string)
text-decoration: underline; text-decoration: underline;
} }
.hide-bar {
margin-bottom: 6px;
}
.board { .board {
max-width: 1400px; max-width: 1400px;
margin: 0 auto; margin: 0 auto;
@@ -13,7 +13,7 @@ import { Icon } from "@iconify/vue"
import { NFlex, useThemeVars } from "naive-ui" import { NFlex, useThemeVars } from "naive-ui"
import SubmitCode from "./SubmitCode.vue" import SubmitCode from "./SubmitCode.vue"
import { useProblemPageContext } from "../composables/problemPageContext" import { useProblemPageContext } from "../composables/problemPageContext"
import { statisticsOpen, useEditorMenu } from "../composables/editorMenu" import { openStatistics, useEditorMenu } from "../composables/editorMenu"
import { useTeacherCollab } from "../composables/teacherCollab" import { useTeacherCollab } from "../composables/teacherCollab"
const SubmitFlowchart = defineAsyncComponent(() => import("./SubmitFlowchart.vue")) const SubmitFlowchart = defineAsyncComponent(() => import("./SubmitFlowchart.vue"))
@@ -228,7 +228,7 @@ const changeLanguage = (v: LANGUAGE) => {
<n-button <n-button
v-if="ctx.classStats && statisticsInline && userStore.isTeacherOrAbove" v-if="ctx.classStats && statisticsInline && userStore.isTeacherOrAbove"
:size="buttonSize" :size="buttonSize"
@click="statisticsOpen = true" @click="openStatistics(problem!._id)"
> >
课堂统计 课堂统计
</n-button> </n-button>
@@ -1,31 +0,0 @@
<script setup lang="ts">
import { storeToRefs } from "pinia"
import { useProblemStore } from "oj/store/problem"
import { useBreakpoints } from "shared/composables/breakpoints"
import { useUserStore } from "shared/store/user"
import { statisticsOpen } from "../composables/editorMenu"
/**
* 教师的「课堂统计」弹窗。入口有两个(工具栏的按钮、「更多」菜单 / 手机「⋯」),
* 弹窗只在题目页上挂一个。
*/
// 只有老师看得见,静态 import 的话每个学生打开题目都要白拉一份 chart.js(~68KB gzip)
const StatisticsPanel = defineAsyncComponent(() => import("shared/components/StatisticsPanel.vue"))
const userStore = useUserStore()
const { problem } = storeToRefs(useProblemStore())
const { isDesktop } = useBreakpoints()
</script>
<template>
<n-modal
v-if="userStore.isTeacherOrAbove && problem"
v-model:show="statisticsOpen"
preset="card"
title="提交记录的统计"
:style="{ maxWidth: isDesktop && '800px', maxHeight: '80vh' }"
:content-style="{ overflow: 'auto' }"
>
<StatisticsPanel :problem="problem._id" username="" />
</n-modal>
</template>
@@ -12,10 +12,12 @@ import { useTeacherCollab } from "./teacherCollab"
import { useProblemPageContext } from "./problemPageContext" import { useProblemPageContext } from "./problemPageContext"
/** /**
* 「课堂统计」弹窗开着没有。弹窗挂在题目页上(StatisticsModal),菜单可能在工具栏里、 * 「课堂统计」:新标签打开数据统计页,带上这道题(原来是题目页上的弹框)。
* 也可能在手机页签行的「⋯」里,所以这个开关放在模块级,大家共用一个 * 工具栏按钮和手机页签行的「⋯」都走这一个。新标签是因为老师多半正开着这道题在看
*/ */
export const statisticsOpen = ref(false) export function openStatistics(problemDisplayId: string) {
window.open(`/statistics?problem=${encodeURIComponent(problemDisplayId)}`, "_blank")
}
/** /**
* 编辑器的「更多」菜单:去自测猫 / 复制代码 / 重置代码 / 编辑题目,放不下时再加上课堂统计。 * 编辑器的「更多」菜单:去自测猫 / 复制代码 / 重置代码 / 编辑题目,放不下时再加上课堂统计。
@@ -111,7 +113,7 @@ export function useEditorMenu(statisticsInline: Ref<boolean>) {
function select(key: string) { function select(key: string) {
switch (key) { switch (key) {
case "statistics": case "statistics":
statisticsOpen.value = true openStatistics(problem.value!._id)
return true return true
case "testcat": case "testcat":
goTestCat() goTestCat()
-2
View File
@@ -15,7 +15,6 @@ import type { RouteLocationNormalized } from "vue-router"
import SubmissionEffects from "./components/SubmissionEffects.vue" import SubmissionEffects from "./components/SubmissionEffects.vue"
import ResultTabLabel from "./components/ResultTabLabel.vue" import ResultTabLabel from "./components/ResultTabLabel.vue"
import ContextBar from "./components/ContextBar.vue" import ContextBar from "./components/ContextBar.vue"
import StatisticsModal from "./components/StatisticsModal.vue"
import { useEditorMenu } from "./composables/editorMenu" import { useEditorMenu } from "./composables/editorMenu"
import { import {
DRAWER_TITLE, DRAWER_TITLE,
@@ -255,7 +254,6 @@ onBeforeUnmount(() => {
<template> <template>
<template v-if="problem"> <template v-if="problem">
<SubmissionEffects /> <SubmissionEffects />
<StatisticsModal />
<!-- <!--
桌面:两栏顶满整个内容区(设计稿「机房(方案 B)」),中间一条 1px 分隔线、可拖动。 桌面:两栏顶满整个内容区(设计稿「机房(方案 B)」),中间一条 1px 分隔线、可拖动。
布局给内容区留了 16px 内边距,这里用负边距抵掉 布局给内容区留了 16px 内边距,这里用负边距抵掉
@@ -0,0 +1,166 @@
<script lang="ts">
import type { Submission } from "utils/types"
/**
* 代码按提交 id 缓存,整页共用:交了就不会再变,老师在一排方块之间来回对比时
* 不用每次都等网络。失败的不进缓存,下次停上去再试。
*/
const codeCache = new Map<string, Submission>()
</script>
<script setup lang="ts">
import { useThemeVars } from "naive-ui"
import { getSubmission } from "oj/api"
import { JUDGE_STATUS, LANGUAGE_FORMAT_VALUE, SubmissionStatus } from "utils/constants"
import { parseTime } from "utils/functions"
import type { SUBMISSION_RESULT } from "utils/types"
import { useTone, type Tone } from "oj/submission/composables/tone"
/**
* 一个人在一道题上的每一次提交,一格一次、从早到晚(设计稿「统计重设计」)。
* 鼠标停在一格上看那一次的代码,点一下新页面打开那条提交。
*/
const props = withDefaults(
defineProps<{
items: Array<{ id: string; result: SUBMISSION_RESULT; createTime: string }>
size?: number
}>(),
{ size: 14 },
)
const tone = useTone()
const theme = useThemeVars()
function toneOf(result: number): Tone {
if (result === SubmissionStatus.accepted) return "success"
if (
result === SubmissionStatus.compile_error ||
result === SubmissionStatus.runtime_error ||
result === SubmissionStatus.ast_check_failed ||
result === SubmissionStatus.partial_accepted
) {
return "warning"
}
if (result === SubmissionStatus.pending || result === SubmissionStatus.judging) return "info"
return "error"
}
const code = reactive<Record<string, Submission | "loading" | "failed">>({})
async function load(id: string) {
const hit = codeCache.get(id)
if (hit) {
code[id] = hit
return
}
if (code[id] === "loading") return
code[id] = "loading"
try {
const res = await getSubmission(id)
codeCache.set(id, res)
code[id] = res
} catch {
code[id] = "failed"
}
}
function open(id: string) {
window.open("/submission/" + id, "_blank", "noopener")
}
const cells = computed(() =>
props.items.map((item, index) => ({
...item,
index: index + 1,
color: tone(toneOf(item.result)).solid,
})),
)
</script>
<template>
<span class="squares">
<n-popover
v-for="cell in cells"
:key="cell.id"
:delay="200"
:style="{ maxWidth: '560px' }"
@update:show="(show: boolean) => show && load(cell.id)"
>
<template #trigger>
<button
class="square"
:style="{ width: `${size}px`, height: `${size}px`, background: cell.color }"
:aria-label="`第 ${cell.index} 次:${JUDGE_STATUS[cell.result]?.name ?? ''}`"
@click.stop="open(cell.id)"
></button>
</template>
<div class="pop">
<div class="pop-head">
<b>{{ JUDGE_STATUS[cell.result]?.name }}</b>
<span class="muted"
>{{ parseTime(cell.createTime, "M月D日 HH:mm") }} · 第 {{ cell.index }} 次</span
>
</div>
<n-code
v-if="typeof code[cell.id] === 'object'"
class="pop-code"
:code="(code[cell.id] as Submission).code"
:language="LANGUAGE_FORMAT_VALUE[(code[cell.id] as Submission).language]"
show-line-numbers
/>
<span v-else-if="code[cell.id] === 'failed'" class="muted">代码没拉下来,点方块打开看</span>
<span v-else class="muted">代码加载中…</span>
<span class="muted small">点方块在新页面打开这一条</span>
</div>
</n-popover>
</span>
</template>
<style scoped>
.squares {
display: inline-flex;
flex-wrap: wrap;
gap: 4px;
align-items: center;
}
.square {
flex: none;
padding: 0;
border: 0;
border-radius: 3px;
cursor: pointer;
}
.square:hover {
/* button 的 color 是浏览器默认的黑,暗色下 currentColor 描边会看不见 */
outline: 2px solid v-bind("theme.textColor2");
outline-offset: 1px;
}
.pop {
display: flex;
flex-direction: column;
gap: 8px;
}
.pop-head {
display: flex;
align-items: center;
gap: 10px;
}
.pop-code {
max-height: 360px;
overflow: auto;
font-size: 14px;
}
.muted {
opacity: 0.65;
}
.small {
font-size: 12px;
}
</style>
@@ -0,0 +1,233 @@
<script setup lang="ts">
import { useThemeVars } from "naive-ui"
import type { SubmissionStatisticsGrid } from "@oj2/contract"
import { SubmissionStatus } from "utils/constants"
import { useTone } from "oj/submission/composables/tone"
import { classLabel, submissionDayText } from "oj/submission/utils"
/**
* 没选班级、也没填学生时(多半是从题目页进来,只带了题号):先按班级列出来,
* 点一个班再看人。「一道没交」要花名册人数,后端在这种情况下给 classSizes。
*/
const props = defineProps<{
grid: SubmissionStatisticsGrid
/** 题号是老师填的。没填时「做完」没法谈(范围里各班各做各的题),改成「做对过题」 */
explicit: boolean
}>()
const emit = defineEmits<{ pick: [className: string] }>()
const theme = useThemeVars()
const tone = useTone()
function isAc(result: number) {
return result === SubmissionStatus.accepted || result === SubmissionStatus.ast_check_failed
}
const classes = computed(() => {
const need = props.grid.problems.map((problem) => problem.problemDisplayId)
const groups = new Map<string, SubmissionStatisticsGrid["rows"]>()
for (const row of props.grid.rows) {
const key = row.className ?? ""
groups.set(key, [...(groups.get(key) ?? []), row])
}
return [...groups.entries()]
.map(([className, rows]) => {
let submissions = 0
let accepted = 0
let last = ""
let done = 0
for (const row of rows) {
const solved = new Set(
row.submissions.filter((item) => isAc(item.result)).map((item) => item.problemDisplayId),
)
if (props.explicit ? need.every((pid) => solved.has(pid)) : solved.size > 0) done++
// 正确率的分母不算还在判的,和数字行一个口径
submissions += row.submissions.filter(
(item) =>
item.result !== SubmissionStatus.pending && item.result !== SubmissionStatus.judging,
).length
accepted += row.submissions.filter((item) => isAc(item.result)).length
const lastTime = row.submissions.at(-1)?.createTime ?? ""
if (lastTime > last) last = lastTime
}
const size = props.grid.classSizes[className] ?? rows.length
return {
className,
label: className ? classLabel(className) : "没有班级",
size,
done,
tried: rows.length - done,
none: Math.max(0, size - rows.length),
rate: submissions ? `${Math.round((accepted / submissions) * 100)}%` : "—",
last,
}
})
.sort((a, b) => b.last.localeCompare(a.last))
})
</script>
<template>
<div class="by-class">
<div class="cols">
<span class="c-class">班级</span>
<span class="c-done">{{ explicit ? "做完" : "做对过题" }}</span>
<span class="c-n">{{ explicit ? "交了没对" : "一道没对" }}</span>
<span class="c-n">没交</span>
<span class="c-n">正确率</span>
<span class="c-last">最近一次</span>
<span class="spacer"></span>
</div>
<div class="rows">
<div v-for="row in classes" :key="row.className" class="row">
<b class="c-class">{{ row.label }}</b>
<span class="c-done">
<span class="bar">
<span
class="fill"
:style="{ width: `${row.size ? (row.done / row.size) * 100 : 0}%` }"
></span>
</span>
<span class="num">{{ row.done }}/{{ row.size }}</span>
</span>
<span class="c-n" :style="{ color: tone('warning').color }">{{ row.tried }}</span>
<span class="c-n" :style="{ color: tone('error').color }">{{ row.none }}</span>
<span class="c-n muted">{{ row.rate }}</span>
<span class="c-last muted">{{ row.last ? submissionDayText(row.last) : "—" }}</span>
<span class="spacer"></span>
<n-button
v-if="row.className"
size="small"
text
type="primary"
@click="emit('pick', row.className)"
>
看这个班 →
</n-button>
</div>
<div v-if="!classes.length" class="empty">这段时间没有人交</div>
<p v-if="grid.truncated" class="note warn" :style="{ color: tone('warning').color }">
范围太大,只取了最近的 5000 条提交,更早的班级可能少算或不在表里。缩短时间段就准了。
</p>
<p class="note">没选班级,先按班级列出来;点一个班再看每个人。「做完」是这几道题都做对了。</p>
</div>
</div>
</template>
<style scoped>
.by-class {
flex: 1 1 0;
min-height: 0;
display: flex;
flex-direction: column;
}
.cols {
height: 32px;
flex: none;
box-sizing: border-box;
padding: 0 20px;
display: flex;
align-items: center;
gap: 10px;
font-size: 12px;
color: v-bind("theme.textColor3");
background: v-bind("theme.actionColor");
border-bottom: 1px solid v-bind("theme.dividerColor");
}
.rows {
flex: 1 1 0;
min-height: 0;
overflow-y: auto;
}
.row {
height: 44px;
box-sizing: border-box;
padding: 0 20px;
display: flex;
align-items: center;
gap: 10px;
border-bottom: 1px solid v-bind("theme.dividerColor");
}
.c-class {
width: 140px;
flex: none;
}
.c-done {
width: 240px;
flex: none;
display: flex;
align-items: center;
gap: 10px;
}
.c-n {
width: 90px;
flex: none;
}
.c-last {
width: 110px;
flex: none;
}
.bar {
width: 150px;
height: 6px;
border-radius: 3px;
background: v-bind("theme.dividerColor");
overflow: hidden;
}
.fill {
display: block;
height: 6px;
background: v-bind("theme.successColor");
}
.num {
font-variant-numeric: tabular-nums;
}
.muted {
color: v-bind("theme.textColor3");
}
.spacer {
flex: 1 1 0;
}
.empty {
padding: 40px 16px;
text-align: center;
color: v-bind("theme.textColor3");
}
.note {
margin: 0;
padding: 10px 20px;
font-size: 12px;
color: v-bind("theme.textColor3");
}
/* 手机:这张表列多,横着滑 */
@media (max-width: 767px) {
.by-class {
flex: none;
overflow-x: auto;
}
.cols,
.row {
min-width: 820px;
}
.rows {
flex: none;
overflow: visible;
}
}
</style>
@@ -0,0 +1,817 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { useThemeVars } from "naive-ui"
import type { SubmissionStatistics, SubmissionStatisticsGrid } from "@oj2/contract"
import { SubmissionStatus } from "utils/constants"
import { parseTime } from "utils/functions"
import StatusPill from "oj/submission/components/StatusPill.vue"
import { useTone } from "oj/submission/composables/tone"
import AttemptSquares from "./AttemptSquares.vue"
/**
* 代码统计的主体(设计稿「统计重设计 · 定稿 B」):左边题目,右边学生。
* 选一道题看全班每人在这道题上的每一次;选「全部」看每人几道做完、展开按题看。
*
* 数字口径(做完、没交)跟着统计接口走(stats),这里的方块串和每题做完几人
* 从 grid 里的逐条提交算。
*/
const props = defineProps<{
stats: SubmissionStatistics
grid: SubmissionStatisticsGrid
/**
* 题号是老师填的(true),还是没填、从范围里出现过的题凑出来的(false)。没填时一个月
* 零零散散几十道题谈不上「全做完」,「做完」改成「做对过题」(做对至少一道),和数字行一致
*/
explicit: boolean
}>()
const theme = useThemeVars()
const tone = useTone()
// 暗色下「做对了」那格的底色是浅绿,白字看不清
const isDark = useDark()
type Attempt = SubmissionStatisticsGrid["rows"][number]["submissions"][number]
function isAc(result: number) {
return result === SubmissionStatus.accepted || result === SubmissionStatus.ast_check_failed
}
function isPending(result: number) {
return result === SubmissionStatus.pending || result === SubmissionStatus.judging
}
interface Student {
username: string
name: string
/** 题号 → 这道题上的每一次,从早到晚 */
byProblem: Map<string, Attempt[]>
total: number
accepted: number
/** 判完的条数,正确率的分母(和数字行一个口径:还在判的不算) */
judged: number
last: string | null
}
/** 花名册上的每一个人:交过的从 grid 来,一条没交的从统计接口的 dataUnaccepted 来 */
const students = computed<Student[]>(() => {
const list: Student[] = props.grid.rows.map((row) => {
const byProblem = new Map<string, Attempt[]>()
for (const item of row.submissions) {
const bucket = byProblem.get(item.problemDisplayId) ?? []
bucket.push(item)
byProblem.set(item.problemDisplayId, bucket)
}
return {
username: row.username,
name: row.realName || row.username,
byProblem,
total: row.submissions.length,
accepted: row.submissions.filter((item) => isAc(item.result)).length,
judged: row.submissions.filter((item) => !isPending(item.result)).length,
last: row.submissions.at(-1)?.createTime ?? null,
}
})
const seen = new Set(list.map((item) => item.username))
for (const row of props.stats.dataUnaccepted) {
if (seen.has(row.username)) continue
list.push({
username: row.username,
name: row.realName || row.username,
byProblem: new Map(),
total: 0,
accepted: 0,
judged: 0,
last: null,
})
}
return list
})
const problems = computed(() => props.grid.problems)
const personCount = computed(() => props.stats.personCount || students.value.length)
function solvedOn(student: Student, pid: string) {
return (student.byProblem.get(pid) ?? []).some((item) => isAc(item.result))
}
/**
* 答案对了、但「语法未通过」(AST 规则没按要求写),而且没有一次真正的「答案正确」。
* 这类算做对了(全站口径),但教学上没达标,格子上单独标出来
*/
function astOnlyOn(student: Student, pid: string) {
const items = student.byProblem.get(pid) ?? []
return (
items.some((item) => item.result === SubmissionStatus.ast_check_failed) &&
!items.some((item) => item.result === SubmissionStatus.accepted)
)
}
function doneCount(student: Student) {
return problems.value.filter((problem) => solvedOn(student, problem.problemDisplayId)).length
}
const problemRows = computed(() =>
problems.value.map((problem) => {
const pid = problem.problemDisplayId
const tried = students.value.filter((student) => student.byProblem.has(pid))
const done = tried.filter((student) => solvedOn(student, pid)).length
const attempts = tried.reduce((sum, student) => sum + student.byProblem.get(pid)!.length, 0)
return {
...problem,
done,
tried: tried.length,
avg: tried.length ? (attempts / tried.length).toFixed(1) : "—",
}
}),
)
/** 算「做完」要做对几道:填了题号就要全对,没填就是做对过题 */
const needed = computed(() => (props.explicit ? problems.value.length : 1))
function finished(student: Student) {
return problems.value.length > 0 && doneCount(student) >= needed.value
}
const doneLabel = computed(() => (props.explicit ? "做完" : "做对过题"))
const allDone = computed(() => students.value.filter(finished).length)
// ---------- 选中哪道题、筛哪些人 ----------
/** "" = 全部。只有一道题时直接选中它 */
const focus = ref("")
watch(
problems,
(list) => {
if (list.length === 1) focus.value = list[0]!.problemDisplayId
else if (!list.some((problem) => problem.problemDisplayId === focus.value)) focus.value = ""
},
{ immediate: true },
)
/** 默认只看没做完的:复盘要找的是他们,做完的一长串沉在下面也看不见 */
const onlyUnfinished = ref(true)
const focusRow = computed(() =>
problemRows.value.find((row) => row.problemDisplayId === focus.value),
)
/** 这道题上:没过的在前(交得越多越靠前 —— 卡得最久),一次没交的其次,做完的沉底 */
const problemStudents = computed(() => {
const pid = focus.value
const list = students.value.map((student) => {
const items = student.byProblem.get(pid) ?? []
return { student, items, solved: items.some((item) => isAc(item.result)) }
})
const rank = (row: (typeof list)[number]) => (row.solved ? 2 : row.items.length ? 0 : 1)
return list
.filter((row) => !onlyUnfinished.value || !row.solved)
.sort(
(a, b) =>
rank(a) - rank(b) ||
b.items.length - a.items.length ||
a.student.name.localeCompare(b.student.name, "zh-CN"),
)
})
/** 全部题:没做完的在前(交得多的更前),一道没交的其次,全做完的沉底 */
const allStudents = computed(() => {
// 这段时间没人交:没有题可言,别列一排「0 道」
if (!problems.value.length) return []
const list = students.value.map((student) => ({
student,
done: doneCount(student),
finished: finished(student),
}))
const rank = (row: (typeof list)[number]) => (row.finished ? 2 : row.student.total ? 0 : 1)
return list
.filter((row) => !onlyUnfinished.value || !row.finished)
.sort(
(a, b) =>
rank(a) - rank(b) ||
b.student.total - a.student.total ||
a.student.name.localeCompare(b.student.name, "zh-CN"),
)
})
const unfinishedCount = computed(() =>
focus.value
? students.value.filter((student) => !solvedOn(student, focus.value)).length
: students.value.filter((student) => !finished(student)).length,
)
const expanded = ref("")
watch([focus, onlyUnfinished], () => (expanded.value = ""))
function toggle(username: string) {
expanded.value = expanded.value === username ? "" : username
}
/** 展开一个人:没过的题在前、交得多的更前 */
function problemsOf(student: Student) {
return [...problems.value].sort((a, b) => {
const sa = solvedOn(student, a.problemDisplayId)
const sb = solvedOn(student, b.problemDisplayId)
if (sa !== sb) return sa ? 1 : -1
return (
(student.byProblem.get(b.problemDisplayId)?.length ?? 0) -
(student.byProblem.get(a.problemDisplayId)?.length ?? 0)
)
})
}
function rate(student: Student) {
return student.judged ? `${Math.round((student.accepted / student.judged) * 100)}%` : "—"
}
function dayText(time: string | null) {
return time ? parseTime(time, "M月D日 HH:mm") : "—"
}
function pct(done: number, total: number) {
return total > 0 ? `${Math.min(100, (done / total) * 100)}%` : "0%"
}
</script>
<template>
<div class="code-stats">
<!-- 左:题目 -->
<section class="problems">
<div class="col-head">题目(按你填的顺序)</div>
<div class="problem-list">
<button
v-if="problems.length > 1"
class="problem"
:class="{ on: focus === '' }"
@click="focus = ''"
>
<span class="p-line">
<b>全部 {{ problems.length }} 道</b>
<span class="spacer"></span>
<span class="muted">{{ doneLabel }} {{ allDone }}/{{ personCount }} 人</span>
</span>
<span class="bar"
><span class="fill" :style="{ width: pct(allDone, personCount) }"></span
></span>
</button>
<button
v-for="row in problemRows"
:key="row.problemDisplayId"
class="problem"
:class="{ on: focus === row.problemDisplayId }"
@click="focus = row.problemDisplayId"
>
<span class="p-line">
<span class="muted">{{ row.problemDisplayId }}</span>
<b class="p-title">{{ row.title }}</b>
<span class="spacer"></span>
<span class="muted">做完 {{ row.done }}/{{ personCount }} · 平均 {{ row.avg }} 次</span>
</span>
<span class="bar"
><span class="fill" :style="{ width: pct(row.done, personCount) }"></span
></span>
</button>
<div v-if="!problems.length" class="empty">这段时间没有人交</div>
</div>
</section>
<!-- 右:学生 -->
<section class="students">
<div class="s-head">
<template v-if="focusRow">
<b class="s-title">{{ focusRow.problemDisplayId }} {{ focusRow.title }}</b>
<span class="muted">
做完 {{ focusRow.done }}/{{ personCount }} · 交了没对
{{ focusRow.tried - focusRow.done }} · 没交
{{ Math.max(0, personCount - focusRow.tried) }}
</span>
</template>
<template v-else-if="!problems.length">
<b class="s-title">这段时间没有人交</b>
<span class="muted">换个时间段试试,比如「今天」或「最近 7 天」</span>
</template>
<template v-else>
<b class="s-title">全部 {{ problems.length }} 道</b>
<span class="muted">
{{ doneLabel }} {{ allDone }}/{{ personCount }} · 没做完 {{ unfinishedCount }} ·
一道没交
{{ stats.dataUnaccepted.length }}
</span>
</template>
<span class="spacer"></span>
<button
v-if="problems.length"
class="chip"
:class="{ on: onlyUnfinished }"
@click="onlyUnfinished = true"
>
没做完 <span class="n">{{ unfinishedCount }}</span>
</button>
<button
v-if="problems.length"
class="chip"
:class="{ on: !onlyUnfinished }"
@click="onlyUnfinished = false"
>
全部 <span class="n">{{ students.length }}</span>
</button>
</div>
<!-- 一道题:每人一行,后面是这道题上的每一次 -->
<template v-if="focusRow">
<div class="cols">
<span class="c-name">学生</span>
<span class="c-last">最后一次</span>
<span class="c-n">次数</span>
<span class="c-seq">每一次(从早到晚)</span>
<span class="c-when">最近一次在</span>
</div>
<div class="rows">
<div v-for="row in problemStudents" :key="row.student.username" class="row">
<span class="c-name name" :title="row.student.username">{{ row.student.name }}</span>
<span class="c-last">
<StatusPill v-if="row.items.length" :result="row.items.at(-1)!.result" />
<b v-else class="none" :style="{ color: tone('error').color }">没交</b>
</span>
<span class="c-n muted">{{ row.items.length || "—" }}</span>
<span class="c-seq"><AttemptSquares :items="row.items" /></span>
<span class="c-when muted">{{ dayText(row.items.at(-1)?.createTime ?? null) }}</span>
</div>
<div v-if="!problemStudents.length" class="empty">全都做完了</div>
</div>
</template>
<!-- 全部题:每人一行,一格一道题;点开按题看方块串 -->
<template v-else>
<div v-if="problems.length" class="cols">
<span class="c-caret"></span>
<span class="c-name">学生</span>
<span class="c-done">做完</span>
<span class="c-n">提交</span>
<span class="c-rate">正确率</span>
<span class="c-grid">{{ problems.map((p) => p.problemDisplayId).join(" · ") }}</span>
</div>
<div class="rows">
<template v-for="row in allStudents" :key="row.student.username">
<div
class="row clickable"
:class="{ on: expanded === row.student.username }"
role="button"
tabindex="0"
:aria-expanded="expanded === row.student.username"
@click="toggle(row.student.username)"
@keydown.enter.prevent="toggle(row.student.username)"
@keydown.space.prevent="toggle(row.student.username)"
>
<span class="c-caret">
<Icon
:icon="
expanded === row.student.username ? 'ph:caret-down-bold' : 'ph:caret-right-bold'
"
:width="12"
/>
</span>
<span class="c-name name" :title="row.student.username">{{ row.student.name }}</span>
<span
class="c-done done"
:style="{
color: row.finished
? tone('success').color
: row.student.total
? theme.textColor1
: tone('error').color,
}"
>
{{ explicit ? `${row.done}/${problems.length}` : `${row.done} 道` }}
</span>
<span class="c-n muted">{{ row.student.total }}</span>
<span class="c-rate muted">{{ rate(row.student) }}</span>
<span class="c-grid cells">
<span
v-for="problem in problems"
:key="problem.problemDisplayId"
class="cell"
:title="
problem.problemDisplayId +
' ' +
problem.title +
(astOnlyOn(row.student, problem.problemDisplayId)
? ':答案对了,但没按要求的写法写'
: '')
"
:style="
astOnlyOn(row.student, problem.problemDisplayId)
? { background: tone('warning').background, color: tone('warning').color }
: solvedOn(row.student, problem.problemDisplayId)
? { background: tone('success').solid, color: isDark ? '#18181c' : '#fff' }
: row.student.byProblem.has(problem.problemDisplayId)
? { background: tone('error').background, color: tone('error').color }
: { background: theme.actionColor, color: theme.textColor3 }
"
>
{{
astOnlyOn(row.student, problem.problemDisplayId)
? "语"
: solvedOn(row.student, problem.problemDisplayId)
? "✓"
: (row.student.byProblem.get(problem.problemDisplayId)?.length ?? "·")
}}
</span>
</span>
</div>
<div v-if="expanded === row.student.username" class="detail">
<div
v-for="problem in problemsOf(row.student)"
:key="problem.problemDisplayId"
class="d-line"
>
<span class="d-title"
><span class="muted">{{ problem.problemDisplayId }}</span>
{{ problem.title }}</span
>
<span
class="d-state"
:style="{
color: solvedOn(row.student, problem.problemDisplayId)
? tone('success').color
: tone('error').color,
}"
>
{{ row.student.byProblem.get(problem.problemDisplayId)?.length ?? 0 }} 次 ·
{{
solvedOn(row.student, problem.problemDisplayId)
? "已通过"
: row.student.byProblem.has(problem.problemDisplayId)
? "还没过"
: "没交"
}}
</span>
<AttemptSquares
:items="row.student.byProblem.get(problem.problemDisplayId) ?? []"
/>
</div>
</div>
</template>
<div v-if="problems.length && !allStudents.length" class="empty">全都做完了</div>
</div>
</template>
<div v-if="grid.truncated" class="truncated">
范围太大,只取了最近的 5000 条提交:上面一排数字是全部算的,左边每道题的「做完」、
右边的名单和方块串只按这 5000 条算,更早交的人可能不在里面。缩短时间段就准了。
</div>
</section>
</div>
</template>
<style scoped>
.code-stats {
flex: 1 1 0;
min-height: 0;
display: flex;
}
.problems {
width: 380px;
flex: none;
display: flex;
flex-direction: column;
border-right: 1px solid v-bind("theme.dividerColor");
min-height: 0;
}
.col-head {
height: 36px;
flex: none;
box-sizing: border-box;
padding: 0 16px 0 20px;
display: flex;
align-items: center;
font-size: 12px;
color: v-bind("theme.textColor3");
background: v-bind("theme.actionColor");
border-bottom: 1px solid v-bind("theme.dividerColor");
}
.problem-list {
flex: 1 1 0;
min-height: 0;
overflow-y: auto;
}
.problem {
width: 100%;
height: 50px;
box-sizing: border-box;
padding: 0 16px 0 20px;
display: flex;
flex-direction: column;
justify-content: center;
gap: 6px;
border: 0;
border-bottom: 1px solid v-bind("theme.dividerColor");
background: transparent;
font: inherit;
color: inherit;
text-align: left;
cursor: pointer;
}
.problem:hover {
background: v-bind("theme.hoverColor");
}
.problem.on {
background: v-bind("tone('success').background");
}
.p-line {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
min-width: 0;
}
.p-title {
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.bar {
height: 4px;
border-radius: 2px;
background: v-bind("theme.dividerColor");
overflow: hidden;
}
.fill {
display: block;
height: 4px;
border-radius: 2px;
background: v-bind("theme.successColor");
}
.muted {
color: v-bind("theme.textColor3");
font-size: 12px;
}
.spacer {
flex: 1 1 0;
}
.students {
flex: 1 1 0;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
}
.s-head {
height: 44px;
flex: none;
box-sizing: border-box;
padding: 0 20px;
display: flex;
align-items: center;
gap: 10px;
border-bottom: 1px solid v-bind("theme.dividerColor");
white-space: nowrap;
}
.s-title {
font-size: 15px;
}
.chip {
height: 28px;
padding: 0 12px;
border: 1px solid v-bind("theme.borderColor");
border-radius: 14px;
background: transparent;
font: inherit;
font-size: 13px;
color: v-bind("theme.textColor2");
cursor: pointer;
}
.chip.on {
border-color: v-bind("theme.primaryColor");
background: v-bind("tone('success').background");
color: v-bind("tone('success').color");
font-weight: 600;
}
.chip .n {
opacity: 0.75;
font-weight: 400;
}
.cols {
height: 32px;
flex: none;
box-sizing: border-box;
padding: 0 20px;
display: flex;
align-items: center;
gap: 10px;
font-size: 12px;
color: v-bind("theme.textColor3");
background: v-bind("theme.actionColor");
border-bottom: 1px solid v-bind("theme.dividerColor");
}
.rows {
flex: 1 1 0;
min-height: 0;
overflow-y: auto;
}
.row {
min-height: 38px;
box-sizing: border-box;
padding: 6px 20px;
display: flex;
align-items: center;
gap: 10px;
border-bottom: 1px solid v-bind("theme.dividerColor");
}
.row.clickable {
cursor: pointer;
}
.row.clickable:hover {
background: v-bind("theme.hoverColor");
}
.row.on {
background: v-bind("tone('success').background");
}
.c-caret {
width: 16px;
flex: none;
display: flex;
color: v-bind("theme.textColor3");
}
.c-name {
width: 110px;
flex: none;
}
.c-last {
width: 120px;
flex: none;
}
.c-n {
width: 50px;
flex: none;
}
.c-seq {
flex: 1 1 0;
min-width: 0;
}
.c-when {
width: 100px;
flex: none;
}
.c-done {
width: 60px;
flex: none;
}
.c-rate {
width: 60px;
flex: none;
}
.c-grid {
flex: 1 1 0;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.name {
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.done {
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.none {
font-size: 13px;
}
.cells {
display: flex;
gap: 4px;
flex-wrap: wrap;
white-space: normal;
}
.cell {
width: 30px;
height: 18px;
border-radius: 4px;
font-size: 11px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.detail {
padding: 6px 20px 8px 46px;
border-bottom: 1px solid v-bind("theme.dividerColor");
background: v-bind("theme.actionColor");
}
.d-line {
min-height: 28px;
display: flex;
align-items: center;
gap: 12px;
}
.d-title {
width: 190px;
flex: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.d-state {
width: 100px;
flex: none;
font-size: 12px;
}
.empty {
padding: 40px 16px;
text-align: center;
color: v-bind("theme.textColor3");
}
.truncated {
padding: 8px 20px;
font-size: 12px;
color: v-bind("tone('warning').color");
border-top: 1px solid v-bind("theme.dividerColor");
}
/* 手机:题目在上、学生在下,整页滚动 */
@media (max-width: 767px) {
.code-stats {
flex-direction: column;
flex: none;
}
.problems {
width: 100%;
border-right: 0;
border-bottom: 1px solid v-bind("theme.dividerColor");
}
.problem-list,
.rows {
flex: none;
overflow: visible;
}
.s-head {
height: auto;
flex-wrap: wrap;
padding: 8px 14px;
row-gap: 6px;
white-space: normal;
}
.cols {
display: none;
}
.row {
flex-wrap: wrap;
padding: 8px 14px;
row-gap: 6px;
}
.c-seq,
.c-grid {
flex-basis: 100%;
}
.c-when {
display: none;
}
.detail {
padding-left: 14px;
}
}
</style>
@@ -0,0 +1,496 @@
<script setup lang="ts">
import {
ArcElement,
BarElement,
CategoryScale,
Chart as ChartJS,
Filler,
Legend,
LinearScale,
LineElement,
PointElement,
RadialLinearScale,
Tooltip,
} from "chart.js"
import { WordCloudController, WordElement } from "chartjs-chart-wordcloud"
import { useThemeVars } from "naive-ui"
import { Bar, Doughnut, Radar } from "vue-chartjs"
import type { FlowchartStatistics } from "@oj2/contract"
import { useChartTheme } from "shared/composables/chartTheme"
import { FLOWCHART_CRITERIA_ORDER } from "utils/constants"
import { useTone } from "oj/submission/composables/tone"
import RingChart from "./RingChart.vue"
import StatItem from "./StatItem.vue"
ChartJS.register(
ArcElement,
BarElement,
CategoryScale,
Filler,
Legend,
LinearScale,
LineElement,
PointElement,
RadialLinearScale,
Tooltip,
WordCloudController,
WordElement,
)
/**
* 流程图统计(设计稿「统计重设计 · 流程图」):等级分布、四项评分、各项平均分、
* 高频问题词云、每人最好的一次。图表都是 chart.js,和站里别的图一套主题。
*/
const props = defineProps<{ data: FlowchartStatistics }>()
const theme = useThemeVars()
const tone = useTone()
const { chartKey } = useChartTheme()
const GRADES = ["S", "A", "B", "C"] as const
const gradeColor = computed(() => ({
S: tone("success").solid,
A: tone("info").solid,
B: tone("warning").solid,
C: tone("error").solid,
}))
/** 拿到 A / S 算完成(和题单、课堂条的口径一致) */
const goodCount = computed(() => {
// 查几道题就要每道都拿到 A / S;没填题号时拿到一道就算
const need = Math.max(1, props.data.problemCount)
return props.data.people.filter((row) => row.goodProblems >= need).length
})
const personCount = computed(() => props.data.personCount || props.data.people.length)
const gradeData = computed(() => ({
labels: GRADES.map((grade) => `${grade} 级`),
datasets: [
{
data: GRADES.map((grade) => props.data.gradeDistribution[grade] ?? 0),
backgroundColor: GRADES.map((grade) => gradeColor.value[grade]),
borderWidth: 0,
},
],
}))
const gradeOptions = {
responsive: true,
maintainAspectRatio: false,
cutout: "60%",
plugins: { legend: { position: "right" as const } },
}
/** 四项按固定顺序(jsonb 不保留键序,逻辑正确性要在最前) */
const criteria = computed(() =>
FLOWCHART_CRITERIA_ORDER.filter((key) => props.data.criteriaAverages[key]).map((key) => ({
key,
...props.data.criteriaAverages[key]!,
})),
)
const radarData = computed(() => ({
labels: criteria.value.map((item) => item.key),
datasets: [
{
label: "平均得分率",
data: criteria.value.map((item) => Math.round((item.avg / (item.max || 1)) * 100)),
backgroundColor: "rgba(32, 128, 240, 0.18)",
borderColor: tone("info").solid,
pointBackgroundColor: tone("info").solid,
fill: true,
},
],
}))
const radarOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: { label: (context: { parsed: { r: number } }) => `${context.parsed.r}%` },
},
},
scales: { r: { min: 0, max: 100, ticks: { stepSize: 25, display: false } } },
}
const barData = computed(() => ({
labels: criteria.value.map((item) => item.key),
datasets: [
{
label: "平均分",
data: criteria.value.map((item) => item.avg),
backgroundColor: tone("info").solid,
borderRadius: 4,
},
],
}))
const barOptions = computed(() => ({
indexAxis: "y" as const,
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: (context: { dataIndex: number; parsed: { x: number | null } }) =>
`${context.parsed.x ?? 0} / ${criteria.value[context.dataIndex]?.max ?? 100}`,
},
},
},
scales: { x: { min: 0, max: Math.max(...criteria.value.map((item) => item.max), 10) } },
}))
// ---------- 词云(chart.js 的 wordCloud 插件,canvas 上画,要自己管实例) ----------
const WORD_COLORS = ["#2080f0", "#18a058", "#d03050", "#f0a020", "#7a3e9d", "#13c2c2"]
const cloudCanvas = useTemplateRef<HTMLCanvasElement>("cloudCanvas")
let cloud: ChartJS | null = null
function drawCloud() {
cloud?.destroy()
cloud = null
const words = props.data.wordFrequencies
if (!cloudCanvas.value || !words.length) return
const max = Math.max(...words.map((word) => word.count))
cloud = new ChartJS(cloudCanvas.value, {
type: "wordCloud" as never,
data: {
labels: words.map((word) => word.word),
datasets: [
{
label: "",
data: words.map((word) => 12 + (word.count / max) * 42),
color: words.map((_, i) => WORD_COLORS[i % WORD_COLORS.length]),
rotate: 0,
} as never,
],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: (context: { dataIndex: number }) => {
const word = words[context.dataIndex]
return word ? `${word.word}:${word.count} 次` : ""
},
},
},
},
} as never,
})
}
watch(
() => [props.data.wordFrequencies, chartKey.value],
() => nextTick(drawCloud),
{ immediate: true },
)
onUnmounted(() => cloud?.destroy())
// ---------- 每人最好的一次:没交的在前,其次按分数从低到高 ----------
const people = computed(() => [
...props.data.dataUnaccepted.map((row) => ({
username: row.username,
name: row.realName,
grade: null as string | null,
score: null as number | null,
count: 0,
})),
...props.data.people.map((row) => ({
username: row.username,
name: row.realName,
grade: row.bestGrade,
score: row.bestScore,
count: row.count,
})),
])
function gradeStyle(grade: string | null) {
const map: Record<string, ReturnType<typeof tone>> = {
S: tone("success"),
A: tone("info"),
B: tone("warning"),
C: tone("error"),
}
const t = grade ? map[grade] : null
return t
? { color: t.color, background: t.background }
: { color: theme.value.textColor3, background: theme.value.actionColor }
}
</script>
<template>
<div class="flow-stats">
<div class="summary">
<StatItem label="提交" :value="data.totalCount" unit="张" />
<StatItem label="平均分" :value="data.avgScore" />
<StatItem
label="拿到 A / S"
:value="`${goodCount} / ${personCount}`"
unit="人"
tone="success"
/>
<StatItem
label="一张没交"
:value="data.dataUnaccepted.length"
unit="人"
tone="error"
:hidden="!data.personCount"
/>
<span class="spacer"></span>
<!-- 没选班级就没有花名册,「班级完成度」无从谈起 -->
<span v-if="data.personCount" class="ring-wrap">
<RingChart
:value="goodCount"
:total="personCount"
:color="tone('info').solid"
:labels="['拿到 A / S', '还没有']"
/>
<span class="ring-label">班级<br />完成度</span>
</span>
</div>
<div v-if="!data.totalCount && !data.dataUnaccepted.length" class="empty">
这段时间没有人交流程图
</div>
<div v-else class="body">
<div class="row-cards">
<div class="card grade">
<div class="card-title">等级分布</div>
<div class="chart">
<Doughnut :key="chartKey" :data="gradeData" :options="gradeOptions" />
</div>
</div>
<div v-if="criteria.length" class="card radar">
<div class="card-title">四项评分(平均得分率)</div>
<div class="chart">
<Radar :key="chartKey" :data="radarData" :options="radarOptions" />
</div>
</div>
<div v-if="criteria.length" class="card grow">
<div class="card-title">各项平均分</div>
<div class="chart"><Bar :key="chartKey" :data="barData" :options="barOptions" /></div>
</div>
</div>
<div class="row-cards bottom">
<div class="card grow">
<div class="card-title">常见问题高频词(最近的评语里)</div>
<div class="cloud"><canvas ref="cloudCanvas"></canvas></div>
<div v-if="!data.wordFrequencies.length" class="muted">评语太少,还拼不出词云</div>
</div>
<div class="card people">
<div class="card-title">每人最好的一次(没交的在前)</div>
<div v-if="data.peopleTruncated" class="muted">人太多,只列了成绩靠后的 500 位</div>
<div class="people-list">
<div v-for="row in people" :key="row.username" class="person">
<b class="p-name" :title="row.username">{{ row.name }}</b>
<span class="grade-pill" :style="gradeStyle(row.grade)">
{{ row.grade ? `${row.grade} 级 ${row.score ?? 0} 分` : "没交" }}
</span>
<span class="muted">{{ row.count }} 次</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.flow-stats {
flex: 1 1 0;
min-height: 0;
display: flex;
flex-direction: column;
}
.summary {
height: 72px;
flex: none;
box-sizing: border-box;
padding: 0 20px;
display: flex;
align-items: center;
gap: 36px;
border-bottom: 1px solid v-bind("theme.dividerColor");
background: v-bind("theme.actionColor");
}
.spacer {
flex: 1 1 0;
}
.ring-wrap {
display: flex;
align-items: center;
gap: 8px;
}
.ring-label {
font-size: 12px;
line-height: 1.5;
color: v-bind("theme.textColor3");
}
.body {
flex: 1 1 0;
min-height: 0;
overflow-y: auto;
box-sizing: border-box;
padding: 16px 20px;
display: flex;
flex-direction: column;
gap: 14px;
}
.row-cards {
display: flex;
gap: 14px;
height: 220px;
flex: none;
}
.row-cards.bottom {
height: 300px;
}
.card {
box-sizing: border-box;
border: 1px solid v-bind("theme.dividerColor");
border-radius: 8px;
background: v-bind("theme.cardColor");
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
.card.grade {
width: 340px;
flex: none;
}
.card.radar {
width: 300px;
flex: none;
}
.card.grow {
flex: 1 1 0;
}
.card.people {
width: 420px;
flex: none;
}
.card-title {
font-size: 13px;
font-weight: 600;
color: v-bind("theme.textColor2");
}
.chart,
.cloud {
flex: 1 1 0;
min-height: 0;
position: relative;
}
.people-list {
flex: 1 1 0;
min-height: 0;
overflow-y: auto;
}
.person {
height: 34px;
display: flex;
align-items: center;
gap: 12px;
border-bottom: 1px solid v-bind("theme.dividerColor");
font-size: 13px;
}
.p-name {
width: 80px;
flex: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.grade-pill {
width: 100px;
flex: none;
height: 22px;
border-radius: 11px;
font-size: 12px;
font-weight: 700;
display: inline-flex;
align-items: center;
justify-content: center;
}
.muted {
color: v-bind("theme.textColor3");
font-size: 12px;
}
.empty {
padding: 60px 16px;
text-align: center;
color: v-bind("theme.textColor3");
}
/* 手机:卡片一张一行 */
@media (max-width: 767px) {
.flow-stats {
flex: none;
}
.summary {
height: auto;
flex-wrap: wrap;
gap: 12px 24px;
padding: 10px 14px;
}
.body {
flex: none;
overflow: visible;
padding: 12px 14px;
}
.row-cards,
.row-cards.bottom {
flex-direction: column;
height: auto;
}
.card.grade,
.card.radar,
.card.people {
width: 100%;
}
.card .chart,
.card .cloud {
flex: none;
height: 240px;
}
.card.people .people-list {
flex: none;
max-height: 360px;
}
}
</style>
@@ -0,0 +1,77 @@
<script setup lang="ts">
import { ArcElement, Chart as ChartJS, Tooltip } from "chart.js"
import { Doughnut } from "vue-chartjs"
import { useChartTheme } from "shared/composables/chartTheme"
ChartJS.register(ArcElement, Tooltip)
/**
* 数字行右边的小圆环(提交正确率、班级完成度)。中间写百分比,
* 鼠标停上去看两段各是多少 —— 这是 chart.js 现成的提示。
*/
const props = defineProps<{
value: number
total: number
color: string
/** 两段的名字,提示里用:["正确", "没对"] */
labels: [string, string]
size?: number
}>()
const { chartKey, gridColor } = useChartTheme()
const pct = computed(() => (props.total > 0 ? Math.round((props.value / props.total) * 100) : 0))
const data = computed(() => ({
labels: props.labels,
datasets: [
{
data: [props.value, Math.max(0, props.total - props.value)],
backgroundColor: [props.color, gridColor.value],
borderWidth: 0,
},
],
}))
const options = {
responsive: true,
maintainAspectRatio: false,
cutout: "72%",
animation: false as const,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: (context: { label: string; parsed: number }) =>
`${context.label}:${context.parsed}`,
},
},
},
}
</script>
<template>
<span class="ring" :style="{ width: `${size ?? 48}px`, height: `${size ?? 48}px` }">
<Doughnut :key="chartKey" :data="data" :options="options" />
<span class="center">{{ total > 0 ? `${pct}%` : "—" }}</span>
</span>
</template>
<style scoped>
.ring {
position: relative;
display: inline-block;
flex: none;
}
.center {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
pointer-events: none;
}
</style>
@@ -0,0 +1,54 @@
<script setup lang="ts">
import { useThemeVars } from "naive-ui"
import { useTone, type Tone } from "oj/submission/composables/tone"
/** 数字行的一格:小字标签 + 大号数字 + 单位 */
const props = defineProps<{
label: string
value: string | number
unit?: string
tone?: Tone
/** 算不出来的(没选班级就没有花名册)整格不显示,免得写个 0 让人误会 */
hidden?: boolean
}>()
const theme = useThemeVars()
const toneOf = useTone()
const color = computed(() => (props.tone ? toneOf(props.tone).color : theme.value.textColor1))
</script>
<template>
<div v-if="!hidden" class="stat">
<span class="label">{{ label }}</span>
<span class="value-line">
<b class="value" :style="{ color }">{{ value }}</b>
<span v-if="unit" class="unit">{{ unit }}</span>
</span>
</div>
</template>
<style scoped>
.stat {
display: flex;
flex-direction: column;
gap: 2px;
flex: none;
}
.label,
.unit {
font-size: 12px;
color: v-bind("theme.textColor3");
}
.value-line {
display: flex;
align-items: baseline;
gap: 5px;
}
.value {
font-size: 22px;
font-variant-numeric: tabular-nums;
}
</style>
+447
View File
@@ -0,0 +1,447 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { useThemeVars } from "naive-ui"
import { useRouteQuery } from "@vueuse/router"
import type {
FlowchartStatistics,
SubmissionStatistics,
SubmissionStatisticsGrid,
} from "@oj2/contract"
import {
getFlowchartStatistics,
getSubmissionStatistics,
getSubmissionStatisticsGrid,
} from "oj/api"
import { useConfigStore } from "shared/store/config"
import { fromPickerValue, toPickerValue } from "utils/functions"
import { useTone } from "oj/submission/composables/tone"
import { classLabel } from "oj/submission/utils"
import ByClass from "./components/ByClass.vue"
import CodeStats from "./components/CodeStats.vue"
import RingChart from "./components/RingChart.vue"
import StatItem from "./components/StatItem.vue"
import { DEFAULT_PERIOD, PERIOD_OPTIONS, periodRange, type Period } from "./period"
/**
* 数据统计(设计稿「统计重设计 · 定稿 B」)。从提交列表、题目页的弹框改成独立页面。
*
* 分工:**课堂看板管这节课,这里管回头看**(用户定的)。所以这页不自动刷新、没有
* 投影用的大字名单和请假隐藏 —— 那些在看板上。课堂统计一般看一两节课,时间段默认「两节课」。
*
* 查询条件都在地址栏里:从提交列表、题目页带过来,刷新、转发给别的老师都还是这一份。
*/
const FlowStats = defineAsyncComponent(() => import("./components/FlowStats.vue"))
const router = useRouter()
const configStore = useConfigStore()
const theme = useThemeVars()
const tone = useTone()
const tab = useRouteQuery<string>("tab", "code", { mode: "replace" })
const className = useRouteQuery<string>("className", "", { mode: "replace" })
const username = useRouteQuery<string>("username", "", { mode: "replace" })
const problem = useRouteQuery<string>("problem", "", { mode: "replace" })
const period = useRouteQuery<string>("period", DEFAULT_PERIOD, { mode: "replace" })
const from = useRouteQuery<string>("from", "", { mode: "replace" })
const to = useRouteQuery<string>("to", "", { mode: "replace" })
const periodValue = computed<Period>(() =>
PERIOD_OPTIONS.some((option) => option.value === period.value)
? (period.value as Period)
: DEFAULT_PERIOD,
)
/** 地址栏里的 from / to 是手改过、不是数字的话当没选(不然 toISOString 抛错整页空白) */
const custom = computed<[number, number] | null>(() => {
const a = Number(from.value)
const b = Number(to.value)
return from.value && to.value && Number.isFinite(a) && Number.isFinite(b) ? [a, b] : null
})
/** n-date-picker 按浏览器时区渲染,存取都要平移到东八区(见 utils/functions 的注释) */
const pickerValue = computed<[number, number] | null>(() =>
custom.value ? [toPickerValue(custom.value[0]), toPickerValue(custom.value[1])] : null,
)
function pickDates(value: [number, number] | null) {
if (!value) {
from.value = ""
to.value = ""
return
}
from.value = String(fromPickerValue(value[0]))
to.value = String(fromPickerValue(value[1]))
}
/** 学生框:包含匹配。班级另外单独传(按 class_name 精确匹配,见后端 scopedUsers) */
const studentParam = computed(() => username.value.trim())
const classOptions = computed(() => {
const list = configStore.config?.classList ?? []
const all = className.value && !list.includes(className.value) ? [className.value, ...list] : list
return all.map((item) => ({ label: classLabel(item), value: item }))
})
// ---------- 取数 ----------
const loading = ref(false)
const failed = ref("")
const stats = ref<SubmissionStatistics | null>(null)
const grid = ref<SubmissionStatisticsGrid | null>(null)
const flow = ref<FlowchartStatistics | null>(null)
/**
* 这一份数据是按什么条件查出来的。页面上的标签(按班级汇总还是看人、「做完」还是
* 「做对过题」、时间范围那句话)跟着它走,不跟着输入框 —— 老师边打字边看时,数据还是
* 上一次的,标签先变了就对不上
*/
const shown = ref({ byClass: true, explicit: false, rangeText: "", withinToday: false })
let seq = 0
const ERROR_TEXT: Record<string, string> = {
"problem-not-found": "有题号找不到,检查一下是不是打错了",
"invalid-request": "一次最多查 20 道题",
}
async function load() {
const current = ++seq
const range = periodRange(periodValue.value, custom.value)
const duration = { start: range.start, end: range.end }
const snapshot = {
byClass: !studentParam.value && !className.value,
explicit: !!problem.value.trim(),
rangeText: range.text,
withinToday: range.withinToday,
}
loading.value = true
failed.value = ""
try {
if (tab.value === "flow") {
const res = await getFlowchartStatistics(
duration,
problem.value,
studentParam.value,
className.value,
)
if (current !== seq) return
flow.value = res
} else {
const [s, g] = await Promise.all([
getSubmissionStatistics(duration, problem.value, studentParam.value, className.value),
getSubmissionStatisticsGrid(duration, problem.value, studentParam.value, className.value),
])
if (current !== seq) return
stats.value = s
grid.value = g
}
shown.value = snapshot
} catch (error) {
if (current !== seq) return
// 失败了就别留着上一次的结果配这一次的条件 —— 清掉,说清楚为什么
stats.value = null
grid.value = null
flow.value = null
const code = (error as { error?: string })?.error ?? ""
failed.value = ERROR_TEXT[code] ?? "没拉下来,改一下条件再试"
} finally {
if (current === seq) loading.value = false
}
}
onMounted(load)
watch([tab, className, periodValue, custom], load)
watchDebounced([username, problem], load, { debounce: 600, maxWait: 1500 })
// ---------- 数字行(代码) ----------
const doneCount = computed(() => stats.value?.data.filter((row) => row.done).length ?? 0)
/**
* 没填题号时后端的「做完」是「至少做对一道」—— 一个月里零零散散几十道题,谈不上
* 「全做完」。这时各处都叫「做对过题」(CodeStats、ByClass 同一个口径)
*/
const hasProblems = computed(() => shown.value.explicit)
const personCount = computed(() => stats.value?.personCount ?? 0)
const judged = computed(() =>
stats.value ? stats.value.submissionCount - stats.value.judgingCount : 0,
)
// ---------- 两个入口 ----------
/** 1366 以下(机房 1280 的屏)右上两个入口只留图标 */
const narrow = useMediaQuery("(max-width: 1365px)")
function openList() {
const href = router.resolve({
name: "submissions",
query: {
...(tab.value === "flow" ? { language: "Flowchart" } : {}),
...(className.value ? { className: className.value } : {}),
...(studentParam.value ? { username: studentParam.value } : {}),
...(problem.value ? { problem: problem.value } : {}),
...(shown.value.withinToday ? { today: "1" } : {}),
},
}).href
window.open(href, "_blank")
}
function pickClass(value: string) {
className.value = value
username.value = ""
}
</script>
<template>
<div class="page">
<div class="filters">
<span class="title">数据统计</span>
<n-radio-group v-model:value="tab" size="small">
<n-radio-button value="code">代码</n-radio-button>
<n-radio-button value="flow">流程图</n-radio-button>
</n-radio-group>
<span class="vsep"></span>
<n-select
:value="className || null"
class="w-class"
size="small"
:options="classOptions"
placeholder="全部班级"
clearable
filterable
@update:value="(v: string | null) => (className = v ?? '')"
/>
<n-input v-model:value="username" class="w-user" size="small" clearable placeholder="学生">
<template #prefix><Icon icon="ph:magnifying-glass" /></template>
</n-input>
<n-input
v-model:value="problem"
class="w-problem"
size="small"
clearable
placeholder="题号,几道用空格隔开"
>
<template #prefix><Icon icon="ph:hash" /></template>
</n-input>
<n-select
:value="periodValue"
class="w-period"
size="small"
:options="PERIOD_OPTIONS.map((o) => ({ label: o.label, value: o.value }))"
:consistent-menu-width="false"
@update:value="(v: string) => (period = v)"
/>
<n-date-picker
v-if="periodValue === 'custom'"
:value="pickerValue"
type="daterange"
size="small"
class="w-dates"
clearable
@update:value="pickDates"
/>
<span v-else class="range">{{ shown.rangeText }}</span>
<n-spin v-if="loading" :size="14" />
<n-text v-if="failed" type="error" class="range">{{ failed }}</n-text>
<span v-if="periodValue === 'custom' && !custom" class="range"
>选一下开始和结束的日子,没选先按今天算</span
>
<span class="spacer"></span>
<n-button
size="small"
quaternary
title="这节课去课堂看板"
aria-label="这节课去课堂看板"
@click="router.push('/classroom')"
>
<template #icon><Icon icon="ph:monitor" /></template>
<template v-if="!narrow">这节课去课堂看板</template>
</n-button>
<n-button size="small" title="在提交列表里看" aria-label="在提交列表里看" @click="openList">
<template #icon><Icon icon="ph:arrow-square-out" /></template>
<template v-if="!narrow">在提交列表里看</template>
</n-button>
</div>
<template v-if="tab === 'flow'">
<FlowStats v-if="flow" :data="flow" />
</template>
<template v-else-if="stats && grid">
<div class="summary">
<StatItem label="提交" :value="stats.submissionCount" unit="条" />
<StatItem
label="正确率"
:value="stats.submissionCount ? `${Math.round(stats.correctRate)}%` : '—'"
:unit="stats.judgingCount ? `判题中 ${stats.judgingCount}` : ''"
/>
<template v-if="personCount">
<StatItem
:label="hasProblems ? '做完' : '做对过题'"
:value="`${doneCount} / ${personCount}`"
unit="人"
tone="success"
/>
<StatItem
v-if="hasProblems"
label="完成度"
:value="`${personCount ? Math.round((doneCount / personCount) * 100) : 0}%`"
/>
<StatItem label="一道没交" :value="stats.dataUnaccepted.length" unit="人" tone="error" />
</template>
<StatItem
:label="hasProblems ? '交了没全对' : '一道没做对'"
:value="stats.dataAttempted.length"
unit="人"
tone="warning"
/>
<span class="spacer"></span>
<span class="ring-wrap">
<RingChart
:value="stats.acceptedCount"
:total="judged"
:color="tone('success').solid"
:labels="['正确', '没对']"
/>
<span class="ring-label">提交<br />正确率</span>
</span>
<span v-if="personCount && hasProblems" class="ring-wrap">
<RingChart
:value="doneCount"
:total="personCount"
:color="tone('info').solid"
:labels="['做完', '没做完']"
/>
<span class="ring-label">班级<br />完成度</span>
</span>
</div>
<ByClass v-if="shown.byClass" :grid="grid" :explicit="shown.explicit" @pick="pickClass" />
<CodeStats v-else :stats="stats" :grid="grid" :explicit="shown.explicit" />
</template>
</div>
</template>
<style scoped>
/* 铺满顶栏以下,和提交列表同一个做法(负边距吃掉外层 16px) */
.page {
margin: -16px;
height: calc(100vh - 56px);
display: flex;
flex-direction: column;
background: v-bind("theme.cardColor");
}
.filters {
height: 52px;
flex: none;
box-sizing: border-box;
padding: 0 20px;
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
border-bottom: 1px solid v-bind("theme.dividerColor");
}
.title {
font-size: 17px;
font-weight: 700;
margin-right: 6px;
}
.vsep {
width: 1px;
height: 20px;
margin: 0 4px;
background: v-bind("theme.dividerColor");
}
.w-class {
width: 132px;
}
.w-user {
width: 110px;
}
.w-problem {
width: 220px;
}
.w-period {
width: 150px;
}
.w-dates {
width: 240px;
}
@media (max-width: 1365px) {
.w-problem {
width: 190px;
}
}
.range {
font-size: 12px;
color: v-bind("theme.textColor3");
}
.spacer {
flex: 1 1 0;
}
.summary {
height: 72px;
flex: none;
box-sizing: border-box;
padding: 0 20px;
display: flex;
align-items: center;
gap: 36px;
border-bottom: 1px solid v-bind("theme.dividerColor");
background: v-bind("theme.actionColor");
}
.ring-wrap {
display: flex;
align-items: center;
gap: 8px;
}
.ring-label {
font-size: 12px;
line-height: 1.5;
color: v-bind("theme.textColor3");
}
/* 手机:老师很少在手机上看统计,只保证不坏 —— 筛选和数字行折行,整页自然滚动 */
@media (max-width: 767px) {
.page {
height: auto;
min-height: calc(100vh - 56px);
}
.filters,
.summary {
height: auto;
flex-wrap: wrap;
padding: 10px 14px;
row-gap: 10px;
white-space: normal;
}
.summary {
gap: 12px 24px;
}
.vsep,
.spacer {
display: none;
}
.w-class,
.w-user,
.w-period {
width: calc(50% - 4px);
}
.w-problem,
.w-dates {
width: 100%;
}
}
</style>
+105
View File
@@ -0,0 +1,105 @@
import { TIME_ZONE_OFFSET_MINUTES } from "@oj2/contract"
import { parseTime, zonedParts } from "utils/functions"
const OFFSET_MS = TIME_ZONE_OFFSET_MINUTES * 60_000
const HOUR = 3_600_000
const DAY = 86_400_000
/**
* 统计页的时间段。课堂统计一般看一两节课(用户原话),所以这两档在最前面、
* 默认「两节课」;后面几档是回头看。原来弹框里的 10 / 20 / 30 分钟是上课盯人用的,
* 那件事现在归课堂看板。
*/
export const PERIOD_OPTIONS = [
{ label: "这节课(1 小时内)", value: "1h" },
{ label: "两节课(2 小时内)", value: "2h" },
{ label: "今天", value: "today" },
{ label: "最近 7 天", value: "7d" },
{ label: "最近 30 天", value: "30d" },
{ label: "这学期", value: "term" },
{ label: "全部", value: "all" },
{ label: "自己选日子", value: "custom" },
] as const
export type Period = (typeof PERIOD_OPTIONS)[number]["value"]
export const DEFAULT_PERIOD: Period = "2h"
/** 东八区某天 0 点对应的 UTC 时刻 */
function dayStartOf(year: number, month: number, day: number) {
return Date.UTC(year, month - 1, day) - OFFSET_MS
}
/** 东八区「现在」这一天的 0 点 */
function todayStart(now: number) {
const p = zonedParts(new Date(now))!
return dayStartOf(p.year, p.month, p.day)
}
/**
* 这学期从哪天起:9 月到次年 1 月算秋季学期(9 月 1 日起),2 到 8 月算春季(2 月 1 日起)。
* 春季不从 3 月算:2 月下旬就开学了,按 3 月 1 日算的话开学头一两周「这学期」会把整个
* 上学期和寒假都带进来
*/
function termStart(now: number) {
const p = zonedParts(new Date(now))!
if (p.month >= 9) return dayStartOf(p.year, 9, 1)
if (p.month >= 2) return dayStartOf(p.year, 2, 1)
return dayStartOf(p.year - 1, 9, 1)
}
/**
* 时间段换成接口要的 start / end(ISO),外加一句给人看的范围。
* `custom` 用的是 [开始日 0 点, 结束日 24 点),两个都是 date picker 取回来的 UTC 时刻。
*/
export function periodRange(period: Period, custom: [number, number] | null, now = Date.now()) {
let start: number | null
let end = now
switch (period) {
case "1h":
start = now - HOUR
break
case "2h":
start = now - 2 * HOUR
break
case "today":
start = todayStart(now)
break
case "7d":
start = todayStart(now) - 6 * DAY
break
case "30d":
start = todayStart(now) - 29 * DAY
break
case "term":
start = termStart(now)
break
case "custom":
if (custom) {
start = custom[0]
end = custom[1] + DAY
} else {
start = todayStart(now)
}
break
default:
start = null
}
const text = describe(start, end, period)
return {
start: start === null ? undefined : new Date(start).toISOString(),
end: new Date(end).toISOString(),
text,
/** 范围落在今天以内:提交列表那边能用「今天」接上 */
withinToday: start !== null && start >= todayStart(now),
}
}
function describe(start: number | null, end: number, period: Period) {
if (start === null) return ""
if (period === "1h" || period === "2h") {
return `${parseTime(new Date(start), "HH:mm")}–${parseTime(new Date(end), "HH:mm")}`
}
const from = parseTime(new Date(start), "M月D日")
const to = parseTime(new Date(end - 1), "M月D日")
return from === to ? from : `${from}–${to}`
}
@@ -49,29 +49,41 @@ const evaluating = computed(
props.row.status === FlowchartSubmissionStatus.PROCESSING, props.row.status === FlowchartSubmissionStatus.PROCESSING,
) )
/**
* 按 id 缓存,重新评分一开始(状态回到排队 / 评分中)就把这一条扔掉。原来按「id + 状态」
* 缓存,评完 → 排队 → 评分中 → 评完 转一圈回来正好命中重评之前的旧评语
*/
let loadSeq = 0
async function load() { async function load() {
const row = props.row const row = props.row
const seq = ++loadSeq
failed.value = false failed.value = false
bigOpen.value = false bigOpen.value = false
if (evaluating.value) cache.delete(row.id)
if (!row.showLink) { if (!row.showLink) {
detail.value = null detail.value = null
return return
} }
const key = `${row.id}:${row.status}` let res = cache.get(row.id)
let res = cache.get(key) if (!res || res.status !== row.status) {
if (!res) {
loading.value = true loading.value = true
try { try {
res = await getFlowchartSubmission(row.id) res = await getFlowchartSubmission(row.id)
if (!evaluating.value) cache.set(key, res) if (seq !== loadSeq) return
if (!evaluating.value && res.status === props.row.status) cache.set(row.id, res)
} catch { } catch {
if (props.row.id === row.id) failed.value = true if (seq !== loadSeq) return
failed.value = true
// 别让上一条的图留在那儿,旁边却写着「没拉下来」
detail.value = null
if (graph.value) graph.value.innerHTML = ""
return return
} finally { } finally {
if (props.row.id === row.id) loading.value = false if (seq === loadSeq) loading.value = false
} }
} }
if (props.row.id !== row.id) return if (seq !== loadSeq) return
detail.value = res detail.value = res
await nextTick() await nextTick()
await renderFlowchart(graph.value, res.mermaidCode) await renderFlowchart(graph.value, res.mermaidCode)
@@ -131,8 +143,14 @@ const canRetry = computed(
<section class="pane" :class="{ narrow }"> <section class="pane" :class="{ narrow }">
<div class="head"> <div class="head">
<div class="who"> <div class="who">
<UserName :username="row.username" /> <a
<button v-if="teacher" class="filter-pill" @click="emit('filterUser', row.username)"> class="user-link"
:href="`/user?name=${encodeURIComponent(row.username)}`"
target="_blank"
>
<UserName :username="row.username" />
</a>
<button class="filter-pill" @click="emit('filterUser', row.username)">
<Icon icon="ph:funnel-simple-bold" :width="11" />只看他 <Icon icon="ph:funnel-simple-bold" :width="11" />只看他
</button> </button>
</div> </div>
@@ -141,11 +159,7 @@ const canRetry = computed(
<span class="problem-id">{{ row.problemDisplayId }}</span> <span class="problem-id">{{ row.problemDisplayId }}</span>
{{ row.problemTitle }} {{ row.problemTitle }}
</a> </a>
<button <button class="filter-pill" @click="emit('filterProblem', row.problemDisplayId)">
v-if="teacher"
class="filter-pill"
@click="emit('filterProblem', row.problemDisplayId)"
>
<Icon icon="ph:funnel-simple-bold" :width="11" />只看这题 <Icon icon="ph:funnel-simple-bold" :width="11" />只看这题
</button> </button>
<div class="spacer"></div> <div class="spacer"></div>
@@ -171,13 +185,22 @@ const canRetry = computed(
<div v-if="!row.showLink" class="locked"> <div v-if="!row.showLink" class="locked">
<Icon icon="ph:lock-simple" :width="30" class="lock-icon" /> <Icon icon="ph:lock-simple" :width="30" class="lock-icon" />
<span class="locked-title">别人画的流程图看不到</span> <!-- 列表项里没有 userId,分不出是别人的还是自己被题单挡住的,文案两种都说得通 -->
<span class="locked-title">这张流程图看不到</span>
<span class="muted">别人画的看不到;自己的在题单里做完这道题就能看到。</span>
</div> </div>
<div v-else class="body"> <div v-else class="body">
<div class="graph-box"> <div class="graph-box">
<n-alert v-if="renderError" type="error" title="流程图画不出来"> <!-- mermaid 的报错是英文,学生看自己的图也会碰到:先说中文,原文折起来 -->
{{ renderError }} <div v-if="renderError" class="render-error">
</n-alert> <b>这张流程图画不出来</b>
<span class="muted">可能是画的时候有框没连上,或者文字里有特殊符号。</span>
<n-collapse>
<n-collapse-item title="原始报错" name="raw">
<pre class="raw">{{ renderError }}</pre>
</n-collapse-item>
</n-collapse>
</div>
<div v-show="!renderError" ref="graph" class="graph"></div> <div v-show="!renderError" ref="graph" class="graph"></div>
<n-spin v-if="loading && !shown" size="small" /> <n-spin v-if="loading && !shown" size="small" />
<span v-if="failed" class="muted" <span v-if="failed" class="muted"
@@ -220,7 +243,7 @@ const canRetry = computed(
</div> </div>
<div v-if="!narrow" class="foot"> <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> <span>换上一张 / 下一张</span>
<div class="spacer"></div> <div class="spacer"></div>
<span v-if="position">本页第 {{ position.index }} / {{ position.count }} 条</span> <span v-if="position">本页第 {{ position.index }} / {{ position.count }} 条</span>
@@ -277,6 +300,29 @@ const canRetry = computed(
height: 320px; height: 320px;
} }
.user-link {
display: flex;
min-width: 0;
text-decoration: none;
}
.render-error {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px;
align-self: stretch;
}
.raw {
margin: 0;
max-height: 140px;
overflow: auto;
font-size: 12px;
white-space: pre-wrap;
word-break: break-all;
}
.who { .who {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -30,7 +30,7 @@ import UserName from "./UserName.vue"
*/ */
const props = defineProps<{ const props = defineProps<{
row: SubmissionListItem row: SubmissionListItem
/** 老师那套:「只看他」「只看这题」、重新判题、逐个测试点 */ /** 老师那套:重新判题、逐个测试点、复制两项收进「⋯」 */
teacher: boolean teacher: boolean
/** 在比赛的提交列表里(不能重判,题目链接走比赛) */ /** 在比赛的提交列表里(不能重判,题目链接走比赛) */
contest: boolean contest: boolean
@@ -55,8 +55,9 @@ const tone = useTone()
const { copyToCat, copyToProblem } = useCopySubmission() const { copyToCat, copyToProblem } = useCopySubmission()
/** /**
* 详情按「id + 结果」缓存:同一条判完之前结果会变,变了要重拉;判完的代码不会再变, * 详情按提交 id 缓存:判完的代码和结果不会再变,老师在几行之间来回按方向键时不用每次都等网络。
* 老师在几行之间来回按方向键时不用每次都等网络。 * 还在判的不进缓存;一条被重判(结果回到「等待评分」)就把它的缓存扔掉 —— 重判后结果
* 可能没变(答案错误还是答案错误),但测试点和报错是新的
*/ */
const cache = new Map<string, Submission>() const cache = new Map<string, Submission>()
const detail = ref<Submission | null>(null) const detail = ref<Submission | null>(null)
@@ -68,30 +69,34 @@ const judging = computed(
props.row.result === SubmissionStatus.pending || props.row.result === SubmissionStatus.judging, props.row.result === SubmissionStatus.pending || props.row.result === SubmissionStatus.judging,
) )
// 每次加载一个序号:同一条先按「判题中」拉了一次、判完又拉一次时,晚回来的旧请求不能盖掉新的
let loadSeq = 0
async function load() { async function load() {
const row = props.row const row = props.row
const seq = ++loadSeq
failed.value = false failed.value = false
if (judging.value) cache.delete(row.id)
if (!row.showLink) { if (!row.showLink) {
detail.value = null detail.value = null
return return
} }
const key = `${row.id}:${row.result}` const hit = cache.get(row.id)
const hit = cache.get(key) if (hit && hit.result === row.result) {
if (hit) {
detail.value = hit detail.value = hit
return return
} }
// 换行时先别清空:接口一般几十毫秒就回来,清了会闪一下白
loading.value = true loading.value = true
try { try {
const res = await getSubmission(row.id) const res = await getSubmission(row.id)
if (props.row.id !== row.id) return if (seq !== loadSeq) return
if (!judging.value) cache.set(key, res) // 列表那边还在判、详情这边已经判完了(或反过来)都不缓存,等两边对上
if (!judging.value && res.result === props.row.result) cache.set(row.id, res)
detail.value = res detail.value = res
} catch { } catch {
if (props.row.id === row.id) failed.value = true if (seq === loadSeq) failed.value = true
} finally { } finally {
if (props.row.id === row.id) loading.value = false if (seq === loadSeq) loading.value = false
} }
} }
@@ -222,9 +227,15 @@ function openStandalone() {
<section class="pane" :class="{ narrow }"> <section class="pane" :class="{ narrow }">
<div class="head"> <div class="head">
<div class="who"> <div class="who">
<UserName :username="row.username" :muted="!!locked && locked !== 'problemset'" /> <!-- 点名字开个人主页(原来列表里的用户名就是这个链接) -->
<a
class="user-link"
:href="`/user?name=${encodeURIComponent(row.username)}`"
target="_blank"
>
<UserName :username="row.username" :muted="!!locked && locked !== 'problemset'" />
</a>
<button <button
v-if="teacher"
class="filter-pill" class="filter-pill"
title="只看这个人的提交" title="只看这个人的提交"
@click="emit('filterUser', row.username)" @click="emit('filterUser', row.username)"
@@ -238,7 +249,6 @@ function openStandalone() {
{{ row.problemTitle }} {{ row.problemTitle }}
</a> </a>
<button <button
v-if="teacher"
class="filter-pill" class="filter-pill"
title="只看这道题的提交" title="只看这道题的提交"
@click="emit('filterProblem', row.problemDisplayId)" @click="emit('filterProblem', row.problemDisplayId)"
@@ -255,12 +265,13 @@ function openStandalone() {
题单 {{ row.problemSet.title }} 题单 {{ row.problemSet.title }}
</a> </a>
<div class="spacer"></div> <div class="spacer"></div>
<!-- 重判不用等代码拉下来:代码拉不下来的那条,恰恰可能要重判 -->
<n-button v-if="canRejudge" size="small" @click="emit('rejudge', row.id)">
<template #icon><Icon icon="ph:arrow-clockwise" /></template>
重新判题
</n-button>
<template v-if="shown"> <template v-if="shown">
<template v-if="teacher"> <template v-if="teacher">
<n-button v-if="canRejudge" size="small" @click="emit('rejudge', row.id)">
<template #icon><Icon icon="ph:arrow-clockwise" /></template>
重新判题
</n-button>
<n-button <n-button
size="small" size="small"
quaternary quaternary
@@ -380,7 +391,7 @@ function openStandalone() {
</template> </template>
<div v-if="!narrow" class="foot"> <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>
{{ {{
teacher teacher
@@ -429,6 +440,16 @@ function openStandalone() {
flex-basis: 100%; flex-basis: 100%;
} }
.user-link {
display: flex;
min-width: 0;
text-decoration: none;
}
.user-link:hover :deep(.name) {
color: v-bind("theme.primaryColor");
}
.who { .who {
display: flex; display: flex;
align-items: center; align-items: center;
-1
View File
@@ -16,7 +16,6 @@ import { useCopySubmission } from "./composables/copySubmission"
const props = defineProps<{ const props = defineProps<{
submissionID: string submissionID: string
problemID?: string
submission?: Submission submission?: Submission
hideList?: boolean hideList?: boolean
}>() }>()
+178 -60
View File
@@ -40,14 +40,9 @@ import { classLabel, submissionClockText, submissionDayText } from "./utils"
* 完成情况在课堂看板和「数据统计」里,这页不重复造一张表。 * 完成情况在课堂看板和「数据统计」里,这页不重复造一张表。
*/ */
// 右栏和两个统计弹框都不在关键路径上:右栏要等选中一条才有内容,弹框默认关着 // 右栏和今日统计弹框都不在关键路径上:右栏要等选中一条才有内容,弹框默认关着
// (光 chart.js 就 197KB)
const SubmissionPane = defineAsyncComponent(() => import("./components/SubmissionPane.vue")) const SubmissionPane = defineAsyncComponent(() => import("./components/SubmissionPane.vue"))
const FlowchartPane = defineAsyncComponent(() => import("./components/FlowchartPane.vue")) const FlowchartPane = defineAsyncComponent(() => import("./components/FlowchartPane.vue"))
const StatisticsPanel = defineAsyncComponent(() => import("shared/components/StatisticsPanel.vue"))
const FlowchartStatisticsPanel = defineAsyncComponent(
() => import("shared/components/FlowchartStatisticsPanel.vue"),
)
const TodayStatistics = defineAsyncComponent(() => import("./components/TodayStatistics.vue")) const TodayStatistics = defineAsyncComponent(() => import("./components/TodayStatistics.vue"))
interface SubmissionQuery { interface SubmissionQuery {
@@ -101,6 +96,8 @@ const submissions = ref<SubmissionListItem[]>([])
const flowcharts = ref<FlowchartSubmissionListItem[]>([]) const flowcharts = ref<FlowchartSubmissionListItem[]>([])
const total = ref(0) const total = ref(0)
const todayCount = ref(0) const todayCount = ref(0)
/** 题号框里查不到的题号(打错了)。一次填好几道时打错的那道会被忽略,得告诉老师 */
const unknownProblems = ref<string[]>([])
const loading = ref(false) const loading = ref(false)
const loaded = ref(false) const loaded = ref(false)
@@ -132,6 +129,9 @@ function fetchPage() {
limit: query.limit, limit: query.limit,
today: query.today, today: query.today,
grade: query.result, grade: query.result,
...(exactFor.value && query.username === exactFor.value
? { exactUsername: "1" as const }
: {}),
}) })
} }
return getSubmissions({ return getSubmissions({
@@ -164,9 +164,16 @@ async function listSubmissions() {
if (mode) flowcharts.value = res.results as FlowchartSubmissionListItem[] if (mode) flowcharts.value = res.results as FlowchartSubmissionListItem[]
else submissions.value = res.results as SubmissionListItem[] else submissions.value = res.results as SubmissionListItem[]
total.value = res.total total.value = res.total
unknownProblems.value = res.unknownProblems
newCount.value = 0 newCount.value = 0
loaded.value = true loaded.value = true
// 翻了页、换了条件就回到列表顶上(往回翻到上一页末尾的,settleSelection 会滚到那一条)
const scroller = document.querySelector(".rows")
if (scroller) scroller.scrollTop = 0
settleSelection() settleSelection()
} catch {
// 失败了别把「落地后停在哪一头」留给下一次不相干的加载
if (seq === requestSeq) pendingJump = null
} finally { } finally {
if (seq === requestSeq) loading.value = false if (seq === requestSeq) loading.value = false
} }
@@ -205,8 +212,39 @@ watch(
if (route.name === "submissions") getTodayCount() if (route.name === "submissions") getTodayCount()
}, },
) )
// 登录态一变,能看哪些代码就变了(showLink 是后端逐行算的) // 登录态一变,能看哪些代码就变了(showLink 是后端逐行算的)。掉了登录还停在流程图上的话
watch(() => userStore.isAuthed, listSubmissions) // 退回代码:流程图列表要登录才能看,而没登录时那个切换按钮不显示,会卡在一片报错里出不去
watch(
() => userStore.isAuthed,
(authed) => {
if (!authed && query.language === "Flowchart") query.language = ""
else listSubmissions()
},
)
/**
* 地址栏整个换掉时(顶栏「提交」「我的提交」、浏览器后退),地址里没写的筛选要回到默认值。
* usePagination 只同步地址里**有**的键 —— 在 `?myself=1` 上点顶栏「提交」,地址已经变成
* `/submission`,页面却还停在「我的」
*/
const QUERY_DEFAULTS: Record<string, string> = {
username: "",
className: "",
result: "",
myself: "0",
problem: "",
language: "",
today: "0",
}
watch(
() => route.query,
(next) => {
const writable = query as unknown as Record<string, string>
for (const [key, value] of Object.entries(QUERY_DEFAULTS)) {
if (next[key] === undefined && writable[key] !== value) writable[key] = value
}
},
)
// ==================== 选中与翻阅 ==================== // ==================== 选中与翻阅 ====================
@@ -258,6 +296,8 @@ const maxPage = computed(() => Math.max(1, Math.ceil(total.value / query.limit))
function move(step: 1 | -1) { function move(step: 1 | -1) {
// 这一页一条能看的都没有(学生看全站时常见):不翻页,不然按一下翻一页停不下来 // 这一页一条能看的都没有(学生看全站时常见):不翻页,不然按一下翻一页停不下来
if (!navigable.value.length) return if (!navigable.value.length) return
// 上一次跨页还没回来:按住 ↓ 或者在页尾连按两下,不然会整页跳过
if (loading.value || pendingJump) return
const all = rows.value const all = rows.value
const start = all.findIndex((row) => row.id === selectedId.value) const start = all.findIndex((row) => row.id === selectedId.value)
for (let i = start + step; i >= 0 && i < all.length; i += step) { for (let i = start + step; i >= 0 && i < all.length; i += step) {
@@ -272,25 +312,30 @@ function move(step: 1 | -1) {
query.page = page query.page = page
} }
const [statisticPanel, toggleStatisticPanel] = useToggle(false)
const [todayPanel, toggleTodayPanel] = useToggle(false) const [todayPanel, toggleTodayPanel] = useToggle(false)
onKeyStroke(["ArrowUp", "ArrowDown"], (e: KeyboardEvent) => { // ← → 和 ↑ ↓ 一样(9 月加方向键时就是四个键都能翻,老师已经用惯了)
if (!isDesktop.value || statisticPanel.value || todayPanel.value) return onKeyStroke(["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"], (e: KeyboardEvent) => {
// 焦点在输入框、下拉框里时不抢方向键 if (!isDesktop.value || todayPanel.value) return
// 别的控件已经处理了这次按键(下拉框自己会开菜单、换选项)
if (e.defaultPrevented) return
const target = e.target as HTMLElement | null const target = e.target as HTMLElement | null
if ( if (target) {
target && // 「代码 / 流程图」「全部 / 我的」是单选框:点过之后焦点停在上面,浏览器默认会拿方向键
(target.tagName === "INPUT" || // 切换选项 —— 老师想看下一条,结果模式被切了。单选框照常换条,下面的 preventDefault 挡掉切换
target.tagName === "TEXTAREA" || const radio = target.tagName === "INPUT" && (target as HTMLInputElement).type === "radio"
// 焦点在输入框、下拉框、弹框里时不抢方向键
if (
(!radio && (target.tagName === "INPUT" || target.tagName === "TEXTAREA")) ||
target.isContentEditable || target.isContentEditable ||
target.closest(".n-base-select-menu, .n-dropdown-menu, .n-modal")) target.closest(".n-base-selection, .n-base-select-menu, .n-dropdown-menu, .n-modal")
) { ) {
return return
}
} }
if (!rows.value.length) return if (!rows.value.length) return
e.preventDefault() e.preventDefault()
move(e.key === "ArrowUp" ? -1 : 1) move(e.key === "ArrowUp" || e.key === "ArrowLeft" ? -1 : 1)
}) })
// 手机上没有右栏:点一行从右边滑出整屏的详情 // 手机上没有右栏:点一行从右边滑出整屏的详情
@@ -504,6 +549,24 @@ function filterUser(username: string) {
query.username = username query.username = username
} }
/**
* 「数据统计」:新标签打开统计页,带上现在的班级 / 学生 / 题号 / 代码或流程图
* (原来是这一页上的弹框)。「今天」带过去就是统计页的「今天」
*/
function openStatistics() {
const href = router.resolve({
name: "statistics",
query: {
...(flowMode.value ? { tab: "flow" } : {}),
...(query.className ? { className: query.className } : {}),
...(query.username ? { username: query.username } : {}),
...(query.problem ? { problem: query.problem } : {}),
...(query.today === "1" ? { period: "today" } : {}),
},
}).href
window.open(href, "_blank")
}
function filterProblem(displayId: string) { function filterProblem(displayId: string) {
query.problem = displayId query.problem = displayId
} }
@@ -600,6 +663,16 @@ function dayBreak(index: number) {
<span v-if="userStore.isAuthed" class="vsep"></span> <span v-if="userStore.isAuthed" class="vsep"></span>
<!-- 「我的」时这节课 / 班级 / 学生都用不上:后端只看自己时本来就不认它们 --> <!-- 「我的」时这节课 / 班级 / 学生都用不上:后端只看自己时本来就不认它们 -->
<!-- 学生界面没有班级下拉:地址里带着班级(比如从统计页、看板跳过来)就摆一个能点掉的标签,
不然一直被一个看不见的条件筛着 -->
<button
v-if="!teacher && query.className"
class="chip on"
title="点一下去掉班级筛选"
@click="query.className = ''"
>
{{ classLabel(query.className) }}<Icon icon="ph:x-bold" :width="12" />
</button>
<template v-if="teacher && !inContest && !mine"> <template v-if="teacher && !inContest && !mine">
<button <button
class="lesson" class="lesson"
@@ -654,6 +727,7 @@ function dayBreak(index: number) {
size="small" size="small"
clearable clearable
placeholder="题号" placeholder="题号"
:status="unknownProblems.length ? 'warning' : undefined"
> >
<template #prefix><Icon icon="ph:hash" /></template> <template #prefix><Icon icon="ph:hash" /></template>
</n-input> </n-input>
@@ -692,11 +766,7 @@ function dayBreak(index: number) {
<template v-if="!(teacher && narrowBar)">今日统计</template> <template v-if="!(teacher && narrowBar)">今日统计</template>
<span v-if="todayCount" class="count">{{ todayCount }}</span> <span v-if="todayCount" class="count">{{ todayCount }}</span>
</n-button> </n-button>
<n-button <n-button v-if="teacher && route.name === 'submissions'" size="small" @click="openStatistics">
v-if="teacher && route.name === 'submissions'"
size="small"
@click="toggleStatisticPanel(true)"
>
<template #icon><Icon icon="ph:chart-pie-slice" /></template> <template #icon><Icon icon="ph:chart-pie-slice" /></template>
数据统计 数据统计
</n-button> </n-button>
@@ -732,6 +802,12 @@ function dayBreak(index: number) {
size="small" size="small"
:options="flowMode ? gradeOptions : resultOptions" :options="flowMode ? gradeOptions : resultOptions"
/> />
<n-select
v-if="!inContest && !flowMode"
v-model:value="query.language"
size="small"
:options="languageOptions"
/>
<n-flex align="center"> <n-flex align="center">
<button v-if="!inContest" class="chip" :class="{ on: today }" @click="today = !today"> <button v-if="!inContest" class="chip" :class="{ on: today }" @click="today = !today">
<Icon v-if="today" icon="ph:check-bold" :width="12" />今天 <Icon v-if="today" icon="ph:check-bold" :width="12" />今天
@@ -743,6 +819,10 @@ function dayBreak(index: number) {
<div class="split"> <div class="split">
<!-- 左栏:列表 --> <!-- 左栏:列表 -->
<section class="list"> <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"> <button v-if="newCount" class="new-bar" @click="showNew">
<Icon icon="ph:arrow-up-bold" :width="13" /> <Icon icon="ph:arrow-up-bold" :width="13" />
有 {{ newCount }}{{ newCountCapped ? "+" : "" }} 条新提交,点这里显示 有 {{ newCount }}{{ newCountCapped ? "+" : "" }} 条新提交,点这里显示
@@ -753,18 +833,39 @@ function dayBreak(index: number) {
<div v-if="dayBreak(index)" class="day">{{ dayBreak(index) }}</div> <div v-if="dayBreak(index)" class="day">{{ dayBreak(index) }}</div>
<div <div
class="row" class="row"
:class="{ on: row.id === selectedId && !isMobile }" :class="{ on: row.id === selectedId && !isMobile, mobile: isMobile }"
:data-row-id="row.id" :data-row-id="row.id"
@click="rowClicked(row)" @click="rowClicked(row)"
> >
<span class="c-time">{{ submissionClockText(row.createTime) }}</span> <template v-if="isDesktop">
<span class="c-user"><UserName :username="row.username" /></span> <span class="c-time">{{ submissionClockText(row.createTime) }}</span>
<span class="c-problem"> <span class="c-user"><UserName :username="row.username" /></span>
<span class="pid">{{ row.problemDisplayId }}</span> {{ row.problemTitle }} <span class="c-problem">
</span> <span class="pid">{{ row.problemDisplayId }}</span> {{ row.problemTitle }}
<span class="c-state"> </span>
<FlowchartState :status="row.status" :grade="row.aiGrade" :score="row.aiScore" /> <span class="c-state">
</span> <FlowchartState
:status="row.status"
:grade="row.aiGrade"
:score="row.aiScore"
/>
</span>
</template>
<template v-else>
<div class="m-line">
<UserName :username="row.username" />
<div class="spacer"></div>
<FlowchartState
:status="row.status"
:grade="row.aiGrade"
:score="row.aiScore"
/>
</div>
<div class="m-line sub">
<span>{{ submissionClockText(row.createTime) }}</span>
<span class="title">{{ row.problemDisplayId }} {{ row.problemTitle }}</span>
</div>
</template>
</div> </div>
</template> </template>
</template> </template>
@@ -806,12 +907,14 @@ function dayBreak(index: number) {
</span> </span>
<span class="c-state"><StatusPill :result="row.result" /></span> <span class="c-state"><StatusPill :result="row.result" /></span>
<span v-if="!teacher" class="c-lock"> <span v-if="!teacher" class="c-lock">
<Icon <!-- title 放在外层 span:挂在 svg 上 Chrome 不出提示 -->
<span
v-if="!row.showLink" v-if="!row.showLink"
icon="ph:lock-simple" class="lock-hint"
:width="13"
:title="othersRow(row) ? '别人的代码看不到' : '在题单里做完才能看'" :title="othersRow(row) ? '别人的代码看不到' : '在题单里做完才能看'"
/> >
<Icon icon="ph:lock-simple" :width="13" />
</span>
</span> </span>
</template> </template>
<template v-else> <template v-else>
@@ -896,19 +999,25 @@ function dayBreak(index: number) {
<FlowchartPane <FlowchartPane
v-if="flowMode" v-if="flowMode"
:row="selectedRow as FlowchartSubmissionListItem" :row="selectedRow as FlowchartSubmissionListItem"
:teacher="false" :teacher="teacher"
:position="null" :position="null"
narrow narrow
@filter-user="(name: string) => ((mobilePane = false), filterUser(name))"
@filter-problem="(id: string) => ((mobilePane = false), filterProblem(id))"
@open-problem="openProblem" @open-problem="openProblem"
@retry="retryFlowchart"
/> />
<SubmissionPane <SubmissionPane
v-else v-else
:row="selectedRow as SubmissionListItem" :row="selectedRow as SubmissionListItem"
:teacher="false" :teacher="teacher"
:contest="inContest" :contest="inContest"
:position="null" :position="null"
narrow narrow
@filter-user="(name: string) => ((mobilePane = false), filterUser(name))"
@filter-problem="(id: string) => ((mobilePane = false), filterProblem(id))"
@open-problem="openProblem" @open-problem="openProblem"
@rejudge="rejudge"
/> />
</div> </div>
<!-- 手机没有方向键:上一条 / 下一条换成按钮,走的是同一个 move() --> <!-- 手机没有方向键:上一条 / 下一条换成按钮,走的是同一个 move() -->
@@ -923,31 +1032,17 @@ function dayBreak(index: number) {
<span class="mobile-pos">{{ <span class="mobile-pos">{{
position ? `${position.index} / ${position.count}` : "" position ? `${position.index} / ${position.count}` : ""
}}</span> }}</span>
<n-button @click="move(1)">下一条</n-button> <n-button
:disabled="!position || (position.index >= position.count && query.page >= maxPage)"
@click="move(1)"
>
下一条
</n-button>
</n-flex> </n-flex>
</template> </template>
</n-drawer-content> </n-drawer-content>
</n-drawer> </n-drawer>
<n-modal
v-if="teacher"
v-model:show="statisticPanel"
preset="card"
:style="{ maxWidth: isDesktop && '800px', maxHeight: '80vh' }"
:content-style="{ overflow: 'auto' }"
:title="flowMode ? '流程图提交的统计' : '提交记录的统计'"
>
<FlowchartStatisticsPanel
v-if="flowMode"
:problem="query.problem"
:username="query.className ? 'ks' + query.className : query.username"
/>
<StatisticsPanel
v-else
:problem="query.problem"
:username="query.className ? 'ks' + query.className : query.username"
/>
</n-modal>
<n-modal <n-modal
v-model:show="todayPanel" v-model:show="todayPanel"
preset="card" preset="card"
@@ -1138,6 +1233,20 @@ function dayBreak(index: number) {
cursor: pointer; 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 { .rows {
flex: 1 1 0; flex: 1 1 0;
min-height: 0; min-height: 0;
@@ -1167,6 +1276,11 @@ function dayBreak(index: number) {
font-size: 14px; font-size: 14px;
} }
/* 往上翻时选中那一行别被吸顶的日期行挡住 */
.row {
scroll-margin-top: 28px;
}
.row:hover { .row:hover {
background: v-bind("theme.hoverColor"); background: v-bind("theme.hoverColor");
} }
@@ -1281,6 +1395,10 @@ function dayBreak(index: number) {
justify-content: flex-end; justify-content: flex-end;
} }
.lock-hint {
display: flex;
}
.c-lock { .c-lock {
width: 14px; width: 14px;
flex: none; flex: none;
+6
View File
@@ -75,6 +75,12 @@ export const ojs: RouteRecordRaw = {
component: () => import("oj/classroom/board.vue"), component: () => import("oj/classroom/board.vue"),
meta: { requiresAuth: true, requiresTeacherAdmin: true }, meta: { requiresAuth: true, requiresTeacherAdmin: true },
}, },
{
path: "statistics",
name: "statistics",
component: () => import("oj/statistics/index.vue"),
meta: { requiresAuth: true, requiresTeacherAdmin: true },
},
{ {
path: "class", path: "class",
name: "class", name: "class",
@@ -1,513 +0,0 @@
<template>
<n-flex align="center">
<n-input
placeholder="用户(可选)"
v-model:value="query.username"
style="width: 150px"
clearable
/>
<n-input
placeholder="题号(可选)"
v-model:value="query.problem"
style="width: 120px"
clearable
/>
<n-select style="width: 120px" v-model:value="query.duration" :options="durationOptions" />
<n-button type="primary" :loading="loading" @click="handleStatistics">统计</n-button>
<n-text v-if="failed" type="error">统计没拉下来,稍后会自动再试</n-text>
</n-flex>
<n-empty v-if="!hasResult" description="暂无数据" style="margin: 40px 0" />
<template v-if="hasResult">
<n-divider style="margin: 16px 0" />
<n-flex justify="space-around">
<div class="stat-item">
<n-text>总提交</n-text>
<n-gradient-text type="info" font-size="28">
{{ data.totalCount }}
</n-gradient-text>
</div>
<div class="stat-item">
<n-text>平均分</n-text>
<n-gradient-text type="primary" font-size="28">
{{ data.avgScore }}
</n-gradient-text>
</div>
<template v-if="data.personCount > 0">
<div class="stat-item">
<n-text>完成人数</n-text>
<n-gradient-text type="error" font-size="28">
{{ data.completedCount }}
</n-gradient-text>
</div>
<div class="stat-item">
<n-text>班级人数</n-text>
<!-- 减掉请假隐藏的,和下面完成度的分母、代码统计面板是同一个数 -->
<n-gradient-text type="warning" font-size="28">
{{ adjustedPersonCount }}
</n-gradient-text>
</div>
<div class="stat-item">
<n-text>完成度</n-text>
<n-gradient-text type="success" font-size="28">
{{ completionRate }}
</n-gradient-text>
</div>
</template>
</n-flex>
<n-divider style="margin: 16px 0" />
<n-tabs animated type="line">
<n-tab-pane name="charts" tab="数据图表">
<n-grid :cols="2" :x-gap="20" :y-gap="20" style="margin-top: 12px">
<!-- 1. Grade pie chart -->
<n-gi v-if="data.totalCount > 0">
<n-card title="等级分布">
<div class="chart-container">
<Doughnut :data="gradeChartData" :options="doughnutOptions" />
</div>
</n-card>
</n-gi>
<!-- 3. Completion doughnut -->
<n-gi v-if="data.personCount > 0">
<n-card title="班级完成度">
<div class="chart-container">
<Doughnut :data="completionChartData" :options="doughnutOptions" />
</div>
</n-card>
</n-gi>
<!-- 2. Radar chart -->
<n-gi v-if="hasRadarData">
<n-card title="四维评分雷达图">
<div class="chart-container">
<Radar :data="radarChartData" :options="radarOptions" />
</div>
</n-card>
</n-gi>
<!-- 4. Criteria bar chart (only when class exists, pairs with radar) -->
<n-gi v-if="data.personCount > 0 && hasRadarData">
<n-card title="各维度平均得分">
<div class="chart-container">
<Bar :data="criteriaBarChartData" :options="barOptions" />
</div>
</n-card>
</n-gi>
<!-- 4. Word cloud -->
<n-gi :span="2" v-if="data.wordFrequencies.length > 0">
<n-card title="常见问题高频词">
<div class="wordcloud-container">
<canvas ref="wordcloudCanvas"></canvas>
</div>
</n-card>
</n-gi>
</n-grid>
</n-tab-pane>
<!-- 有花名册就一直在(和代码统计面板一样):原来人一做完这个页签就整个消失,
老师找不到「全都完成了」那句 -->
<n-tab-pane
v-if="data.personCount > 0 || data.dataUnaccepted.length > 0"
name="unaccepted"
:tab="`未完成(${visibleUnaccepted.length})`"
>
<n-flex align="center" style="margin: 12px 0">
<n-switch v-model:value="hideMode" size="large">
<template #checked>请假隐藏中</template>
<template #unchecked>请假隐藏</template>
</n-switch>
<n-button v-if="hiddenCount > 0" size="small" type="info" @click="showAll">
恢复 {{ hiddenCount }} 位
</n-button>
</n-flex>
<n-flex size="large" align="center">
<n-text v-if="visibleUnaccepted.length === 0 && hiddenCount > 0" depth="3">
没完成的 {{ hiddenCount }} 位都隐藏了
</n-text>
<n-gradient-text v-else-if="visibleUnaccepted.length === 0" font-size="24" type="success">
全都完成了
</n-gradient-text>
<template v-for="item in visibleUnaccepted" :key="item.username">
<n-tag
v-if="hideMode"
closable
size="large"
style="font-size: 20px"
@close="hideStudent(item.username)"
>
{{ item.realName }}
</n-tag>
<span v-else style="font-size: 24px">{{ item.realName }}</span>
</template>
</n-flex>
</n-tab-pane>
</n-tabs>
</template>
</template>
<script setup lang="ts">
import { formatISO, sub, type Duration } from "date-fns"
import type { FlowchartStatistics } from "@oj2/contract"
import { getFlowchartStatistics } from "oj/api"
import { PANEL_DURATION_OPTIONS, FLOWCHART_CRITERIA_ORDER, STORAGE_KEY } from "utils/constants"
import { durationFromValue } from "utils/functions"
import { useHiddenStudents } from "../composables/hiddenStudents"
import { useStatisticsQuery } from "../composables/statisticsQuery"
import { Doughnut, Radar, Bar } from "vue-chartjs"
import {
Chart as ChartJS,
ArcElement,
Title,
Tooltip,
Legend,
RadialLinearScale,
PointElement,
LineElement,
Filler,
LinearScale,
BarElement,
CategoryScale,
} from "chart.js"
import { WordCloudController, WordElement } from "chartjs-chart-wordcloud"
ChartJS.register(
ArcElement,
Title,
Tooltip,
Legend,
RadialLinearScale,
PointElement,
LineElement,
Filler,
LinearScale,
BarElement,
CategoryScale,
WordCloudController,
WordElement,
)
interface Props {
problem: string
username: string
}
const props = defineProps<Props>()
const durationOptions: SelectOption[] = [...PANEL_DURATION_OPTIONS]
// 班级、题号、时间段记一小时,理由见 useStatisticsQuery
const { query, save: saveQuery } = useStatisticsQuery(STORAGE_KEY.FLOWCHART_STATISTICS_QUERY, props)
const data = reactive<FlowchartStatistics>({
totalCount: 0,
avgScore: 0,
gradeDistribution: {},
criteriaAverages: {},
personCount: 0,
completedCount: 0,
wordFrequencies: [],
dataUnaccepted: [],
})
/**
* 「查出东西了吗」。和 StatisticsPanel 同一个道理:一节课刚开始时没有任何提交,
* 但花名册整份都在 dataUnaccepted 里,那会儿正是老师要看名单的时候。
*/
const hasResult = computed(() => data.totalCount > 0 || data.dataUnaccepted.length > 0)
const wordcloudCanvas = useTemplateRef<HTMLCanvasElement>("wordcloudCanvas")
let wordcloudChart: ChartJS | null = null
const { hideMode, hideStudent, showAll, isHidden, notHidden } = useHiddenStudents(
"oj_hidden_students_flowchart",
)
const visibleUnaccepted = computed(() => data.dataUnaccepted.filter(notHidden))
const hiddenCount = computed(
() => data.dataUnaccepted.filter((item) => isHidden(item.username)).length,
)
const adjustedPersonCount = computed(() => Math.max(0, data.personCount - hiddenCount.value))
const completionRate = computed(() => {
if (adjustedPersonCount.value <= 0) return "0%"
const rate = Math.min(100, (data.completedCount / adjustedPersonCount.value) * 100)
return `${Math.round(rate * 100) / 100}%`
})
const GRADE_COLORS: Record<string, { bg: string; border: string }> = {
S: { bg: "rgba(24, 160, 88, 0.6)", border: "rgba(24, 160, 88, 1)" },
A: { bg: "rgba(32, 128, 240, 0.6)", border: "rgba(32, 128, 240, 1)" },
B: { bg: "rgba(240, 160, 32, 0.6)", border: "rgba(240, 160, 32, 1)" },
C: { bg: "rgba(208, 48, 80, 0.6)", border: "rgba(208, 48, 80, 1)" },
}
const gradeChartData = computed(() => {
const grades = ["S", "A", "B", "C"]
const counts = grades.map((g) => data.gradeDistribution[g] || 0)
const labels = grades.map((g) => `${g}级 (${data.gradeDistribution[g] || 0})`)
return {
labels,
datasets: [
{
data: counts,
backgroundColor: grades.map((g) => GRADE_COLORS[g].bg),
borderColor: grades.map((g) => GRADE_COLORS[g].border),
borderWidth: 2,
},
],
}
})
const completionChartData = computed(() => {
const uncompleted = Math.max(0, adjustedPersonCount.value - data.completedCount)
return {
labels: ["已完成", "未完成"],
datasets: [
{
data: [data.completedCount, uncompleted],
backgroundColor: ["rgba(106, 176, 76, 0.6)", "rgba(255, 159, 64, 0.6)"],
borderColor: ["rgba(106, 176, 76, 1)", "rgba(255, 159, 64, 1)"],
borderWidth: 2,
},
],
}
})
const doughnutOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: "bottom" as const },
tooltip: {
callbacks: {
label(context: any) {
const label = context.label || ""
const value = context.parsed || 0
const total = context.dataset.data.reduce((a: number, b: number) => a + b, 0)
const pct = ((value / total) * 100).toFixed(1)
return `${label}: ${value} (${pct}%)`
},
},
},
},
}
// 与评分明细共用同一份顺序,见 utils/constants
const CRITERIA_ORDER = FLOWCHART_CRITERIA_ORDER
const hasRadarData = computed(() => CRITERIA_ORDER.some((k) => k in data.criteriaAverages))
const radarChartData = computed(() => {
const labels = CRITERIA_ORDER
const values = CRITERIA_ORDER.map((k) => {
const item = data.criteriaAverages[k]
if (!item) return 0
return Math.round((item.avg / item.max) * 100)
})
return {
labels,
datasets: [
{
label: "平均得分率 (%)",
data: values,
backgroundColor: "rgba(32, 128, 240, 0.2)",
borderColor: "rgba(32, 128, 240, 1)",
borderWidth: 2,
pointBackgroundColor: "rgba(32, 128, 240, 1)",
},
],
}
})
const radarOptions = {
responsive: true,
maintainAspectRatio: false,
scales: {
r: {
beginAtZero: true,
max: 100,
ticks: { stepSize: 20 },
},
},
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label(context: any) {
const key = CRITERIA_ORDER[context.dataIndex]
const item = data.criteriaAverages[key]
if (!item) return ""
return `${key}: ${item.avg}/${item.max} (${context.parsed.r}%)`
},
},
},
},
}
const criteriaBarChartData = computed(() => {
const labels = CRITERIA_ORDER.filter((k) => k in data.criteriaAverages)
return {
labels,
datasets: [
{
label: "平均得分",
data: labels.map((k) => data.criteriaAverages[k]?.avg ?? 0),
backgroundColor: labels.map((_, i) => GRADE_COLORS[["S", "A", "B", "C"][i]].bg),
borderColor: labels.map((_, i) => GRADE_COLORS[["S", "A", "B", "C"][i]].border),
borderWidth: 2,
},
],
}
})
const barOptions = {
responsive: true,
aspectRatio: 1,
scales: {
y: { beginAtZero: true },
},
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label(context: any) {
const key = context.label
const item = data.criteriaAverages[key]
if (!item) return ""
return `${item.avg} / ${item.max}`
},
},
},
},
}
const WORD_COLORS = [
"#2080f0",
"#18a058",
"#f0a020",
"#d03050",
"#722ed1",
"#13c2c2",
"#1890ff",
"#52c41a",
"#faad14",
"#f5222d",
]
function renderWordCloud() {
if (!wordcloudCanvas.value || data.wordFrequencies.length === 0) return
if (wordcloudChart) {
wordcloudChart.destroy()
wordcloudChart = null
}
const words = data.wordFrequencies
const maxCount = Math.max(...words.map((w) => w.count))
wordcloudChart = new ChartJS(wordcloudCanvas.value, {
type: "wordCloud" as any,
data: {
labels: words.map((w) => w.word),
datasets: [
{
label: "",
data: words.map((w) => 10 + (w.count / maxCount) * 50),
color: words.map((_, i) => WORD_COLORS[i % WORD_COLORS.length]),
rotate: 0,
} as any,
],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label(context: any) {
const word = words[context.dataIndex]
return word ? `${word.word}: ${word.count}次` : ""
},
},
},
},
},
})
}
const subOptions = computed<Duration>(
() => durationFromValue(query.duration) ?? durationFromValue(PANEL_DURATION_OPTIONS[0].value)!,
)
const loading = ref(false)
/** 上一次统计失败了。不弹 toast:会自动刷新,断网时会一直弹 */
const failed = ref(false)
async function handleStatistics() {
// 自动刷新和手点可能撞上,上一次没回来就跳过这一次
if (loading.value) return
loading.value = true
const current = Date.now()
const end = formatISO(current)
const duration =
query.duration === "all" ? { end } : { start: formatISO(sub(current, subOptions.value)), end }
try {
const res = await getFlowchartStatistics(duration, query.problem, query.username)
Object.assign(data, res)
saveQuery()
failed.value = false
} catch (error) {
failed.value = true
console.error("获取流程图统计失败:", error)
return
} finally {
loading.value = false
}
await nextTick()
renderWordCloud()
}
/**
* 和代码统计面板一样:打开就查、开着就刷(老师是投在屏幕上盯着看的)。
* 流程图统计要给词云分词,比代码统计重,所以 30 秒一次,而且只刷一天以内的时间段 ——
* 查一周、一学年的是回头看,不是盯课堂,不必反复重算
*/
onMounted(handleStatistics)
const visibility = useDocumentVisibility()
useIntervalFn(() => {
if (visibility.value !== "visible") return
if (query.duration === "all") return
const d = subOptions.value
if (d.weeks || d.months || d.years || (d.days ?? 0) > 1) return
handleStatistics()
}, 30_000)
onUnmounted(() => {
if (wordcloudChart) {
wordcloudChart.destroy()
}
})
</script>
<style scoped>
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.chart-container {
height: 280px;
position: relative;
}
.wordcloud-container {
height: 300px;
position: relative;
}
</style>
@@ -1,813 +0,0 @@
<template>
<n-flex align="center">
<n-select
placeholder="班级或用户(可选)"
v-model:value="query.username"
:options="classOptions"
style="width: 190px"
filterable
tag
clearable
/>
<n-input
placeholder="题号(可选,逗号分隔)"
v-model:value="query.problem"
style="width: 200px"
clearable
/>
<n-select style="width: 120px" v-model:value="query.duration" :options="options" />
<n-button type="primary" :loading="loading" @click="handleStatistics"> 统计 </n-button>
<n-button v-if="route.name !== 'submissions'" @click="goSubmissions"> 前往提交列表 </n-button>
<n-text v-if="failed" type="error">统计没拉下来,15 秒后会自动再试</n-text>
</n-flex>
<n-empty v-if="!hasResult" description="暂无数据" style="margin: 40px 0" />
<template v-if="hasResult">
<n-divider style="margin: 16px 0" />
<n-flex justify="space-around">
<div class="stat-item">
<n-text>总提交</n-text>
<n-gradient-text type="info" font-size="28">{{ count.total }}</n-gradient-text>
</div>
<div class="stat-item">
<n-text>正确提交</n-text>
<n-gradient-text type="primary" font-size="28">{{ count.accepted }}</n-gradient-text>
</div>
<div class="stat-item" v-if="count.judging > 0">
<n-text>判题中</n-text>
<n-gradient-text type="info" font-size="28">{{ count.judging }}</n-gradient-text>
</div>
<div class="stat-item">
<n-text>正确率</n-text>
<n-gradient-text type="warning" font-size="28">{{ count.rate }}%</n-gradient-text>
</div>
<template v-if="personCount > 0">
<div class="stat-item">
<n-text>完成人数</n-text>
<n-gradient-text type="error" font-size="28">{{ doneList.length }}</n-gradient-text>
</div>
<div class="stat-item">
<n-text>班级人数</n-text>
<n-gradient-text type="warning" font-size="28">{{ adjustedPersonCount }}</n-gradient-text>
</div>
<div class="stat-item">
<n-text>完成度</n-text>
<n-gradient-text type="success" font-size="28">{{ adjustedPersonRate }}</n-gradient-text>
</div>
</template>
</n-flex>
<n-divider style="margin: 16px 0" />
<n-tabs animated type="line">
<n-tab-pane name="charts" tab="数据图表">
<n-grid :cols="2" :x-gap="20" :y-gap="20" style="margin-top: 12px">
<n-gi v-if="count.total > 0">
<n-card title="提交正确率">
<Doughnut :data="pieChartData" :options="pieChartOptions" />
</n-card>
</n-gi>
<n-gi v-if="personCount > 0">
<n-card title="班级完成度">
<Doughnut :data="completionChartData" :options="completionChartOptions" />
</n-card>
</n-gi>
</n-grid>
</n-tab-pane>
<!--
窗口里交过东西的**所有人**,一行一个,展开是他这段时间的全部提交(对的错的
都在)。没做完的人也在表里 —— 一次没对的学生正是老师最想点开看的那个。
隔壁「未完成」是点名用的名单,只有名字,两栏用途不同。
-->
<n-tab-pane name="submissions" :tab="`提交记录(${list.length})`">
<n-data-table
v-if="list.length"
striped
:columns="columns"
:data="list"
:row-key="rowKey"
:expanded-row-keys="expandedRowKeys"
@update:expanded-row-keys="updateExpandedRowKeys"
:row-props="rowProps"
style="margin-top: 12px"
/>
<n-empty v-else description="还没有人提交" style="margin: 24px 0" />
</n-tab-pane>
<n-tab-pane name="unaccepted" :tab="`未完成(${unfinishedTotal})`">
<n-flex align="center" style="margin: 12px 0">
<n-switch v-model:value="hideMode" size="large">
<template #checked>请假隐藏中</template>
<template #unchecked>请假隐藏</template>
</n-switch>
<n-button v-if="hiddenCount > 0" size="small" type="info" @click="showAll">
恢复 {{ hiddenCount }} 位
</n-button>
</n-flex>
<!-- 名单上的人全被「请假隐藏」了不等于全做完了,别让老师误以为全班过了 -->
<n-text v-if="unfinishedGroups.length === 0 && hiddenCount > 0" depth="3">
没完成的 {{ hiddenCount }} 位都隐藏了
</n-text>
<n-gradient-text v-else-if="unfinishedGroups.length === 0" font-size="24" type="success">
全都完成了
</n-gradient-text>
<template v-for="group in unfinishedGroups" :key="group.title">
<n-text depth="3" class="group-title">
{{ group.title }}({{ group.items.length }})
</n-text>
<n-flex size="large" align="center">
<template v-for="item in group.items" :key="item.username">
<n-popover
trigger="click"
placement="bottom"
:disabled="!item.failure"
style="max-width: 460px"
>
<template #trigger>
<n-tag
v-if="hideMode"
closable
size="large"
style="font-size: 20px"
@close="hideStudent(item.username)"
>
{{ item.label }}
</n-tag>
<span v-else :class="{ name: true, 'name-clickable': !!item.failure }">
{{ item.label }}
</span>
</template>
<n-flex vertical size="small">
<n-text depth="3">
最近一次:{{ item.failure?.problemDisplayId }} ·
{{ statusName(item.failure?.result) }}
</n-text>
<pre v-if="item.failure?.error" class="failure-error">{{
item.failure?.error
}}</pre>
<n-button size="small" tertiary @click="openFailure(item.failure?.id)">
看代码
</n-button>
</n-flex>
</n-popover>
</template>
</n-flex>
</template>
</n-tab-pane>
</n-tabs>
</template>
</template>
<script setup lang="ts">
import { h } from "vue"
import { formatISO, sub, type Duration } from "date-fns"
import { getSubmission, getSubmissionStatistics, getSubmissionStatisticsItems } from "oj/api"
import { LANGUAGE_FORMAT_VALUE, PANEL_DURATION_OPTIONS, STORAGE_KEY } from "utils/constants"
import { useConfigStore } from "../store/config"
import { useHiddenStudents } from "../composables/hiddenStudents"
import { useStatisticsQuery } from "../composables/statisticsQuery"
import { Doughnut } from "vue-chartjs"
import { Chart as ChartJS, ArcElement, Title, Tooltip, Legend } from "chart.js"
import { NCode, NFlex, NPopover, NTag, NText, type DataTableRowKey } from "naive-ui"
import { JUDGE_STATUS, SubmissionStatus } from "utils/constants"
import { durationFromValue, parseTime } from "utils/functions"
import type {
AttemptedStudent,
SubmissionStatisticsItems,
SubmissionStatisticsUser,
UnacceptedStudent,
} from "@oj2/contract"
// 注册 Chart.js 组件
ChartJS.register(ArcElement, Title, Tooltip, Legend)
interface Props {
problem: string
username: string
}
const props = defineProps<Props>()
const options: SelectOption[] = [...PANEL_DURATION_OPTIONS]
/**
* 轨迹方块的颜色。取值就是 JUDGE_STATUS 里那个 type,色号沿用 Naive UI 的语义色
* (项目里 ExerciseMatch.vue 等处也是直接写这几个值)。
*/
const ATTEMPT_COLORS: Record<string, string> = {
success: "#18a058",
error: "#d03050",
warning: "#f0a020",
info: "#2080f0",
default: "#909399",
}
/**
* 把一个学生的提交按题目聚成组,给展开行画「状态轨迹」用。
*
* 原来展开行是一排提交编号按钮 —— 12 位十六进制本身没有信息量,一节课里学生在好几道
* 题之间来回跳,那一排看不出他到底卡在哪。现在一道题一行:题号、标题、交了几次、
* 过没过,后面跟一排按时间**从早到晚**的小方块,颜色就是判题状态。
* 「三红一绿」和「七红到底」一眼分得开。
*
* 排序按老师的用法来:**没过的排前面**,其中交得越多越靠前 —— 卡得最久的那道顶到眼前;
* 已通过的沉底,它们只是「做完了」,不需要再看。
*/
function groupByProblem(list: SubmissionStatisticsItems["items"]) {
const groups = new Map<
string,
{
problemDisplayId: string
problemTitle: string
items: SubmissionStatisticsItems["items"]
}
>()
for (const item of list) {
const group = groups.get(item.problemDisplayId)
if (group) group.items.push(item)
else
groups.set(item.problemDisplayId, {
problemDisplayId: item.problemDisplayId,
problemTitle: item.problemTitle,
items: [item],
})
}
return [...groups.values()]
.map((group) => ({
...group,
// 后端按时间倒序给,轨迹要从早到晚读,所以翻过来
items: [...group.items].reverse(),
// 「语法未过」(ast_check_failed) 也算做出来了 —— 答案对了,只是没按要求写;
// 和统计表格上「已解决」那一列的口径保持一致
solved: group.items.some(
(item) =>
item.result === SubmissionStatus.accepted ||
item.result === SubmissionStatus.ast_check_failed,
),
}))
.sort((a, b) => Number(a.solved) - Number(b.solved) || b.items.length - a.items.length)
}
/**
* 悬停方块时看到的代码,按提交编号缓存。
*
* 原来悬停只有「状态 · 时间 · 编号」,想看学生写了什么非得点开新页面,老师在一排
* 红方块之间来回对比时要开一堆标签页。代码**悬停时才拉**、不随明细一起下发:
* 明细一次最多 200 条、展开着每 15 秒还要重拉,而真正会被悬停的只是其中几个。
* 代码交了就不会再变,所以缓存不随重新统计清空(和 items 不一样)。
*/
type CodeState =
| { status: "loading" }
| { status: "error" }
| { status: "done"; code: string; language: string }
const codes = reactive<Record<string, CodeState>>({})
async function loadCode(id: string) {
const cached = codes[id]
if (cached && cached.status !== "error") return
codes[id] = { status: "loading" }
try {
const submission = await getSubmission(id)
codes[id] = {
status: "done",
code: submission.code,
language: LANGUAGE_FORMAT_VALUE[submission.language],
}
} catch {
codes[id] = { status: "error" }
}
}
function renderCode(id: string) {
const cached = codes[id]
if (!cached || cached.status === "loading") return h(NText, { depth: 3 }, () => "代码加载中…")
if (cached.status === "error")
return h(NText, { type: "error" }, () => "代码没拉下来,点方块打开看")
return h(
"div",
// 太长就在浮层里滚:鼠标移进浮层它不会消失,可以接着往下翻
{ style: "max-height: 360px; overflow: auto" },
h(NCode, { code: cached.code, language: cached.language, showLineNumbers: true }),
)
}
function openSubmission(id: string) {
window.open(`/submission/${id}`, "_blank", "noopener")
}
const columns: DataTableColumn<SubmissionStatisticsUser>[] = [
{
type: "expand",
renderExpand: (row) => {
const loaded = items[row.username]
if (!loaded) return h(NText, { depth: 3 }, () => "加载中…")
return h(NFlex, { vertical: true, size: "medium" }, () => [
...groupByProblem(loaded.items).map((group) =>
// 题头一栏、轨迹一栏,**外层不换行**:一道题交了几十发时方块要在自己那一栏里
// 折行,折下来的一行才会和上一行对齐
h(NFlex, { size: "small", align: "flex-start", wrap: false }, () => [
h(
NFlex,
{
size: 4,
align: "center",
wrap: false,
style: "width: 200px; flex: none",
},
() => [
h(NTag, { size: "small", bordered: false }, () => group.problemDisplayId),
h(
NText,
{
depth: 2,
style: "overflow: hidden; text-overflow: ellipsis; white-space: nowrap",
},
() => group.problemTitle,
),
],
),
h(
NText,
{
depth: 3,
style: "width: 104px; flex: none",
},
() => `${group.items.length} 次 · ${group.solved ? "已通过" : "未通过"}`,
),
h(NFlex, { size: 4, wrap: true, style: "flex: 1; min-width: 0" }, () =>
group.items.map((item) =>
h(
NPopover,
{
delay: 200,
// 代码比一行提示宽得多;限宽让长行在浮层里横向滚,不把浮层撑出屏幕
style: "max-width: 560px",
onUpdateShow: (show: boolean) => {
if (show) loadCode(item.id)
},
},
{
trigger: () =>
h("button", {
// 内联样式而不是 class:这些方块是 h() 出来、挂在 NDataTable 的
// 展开槽里渲染的,<style scoped> 能不能盖到它并不确定
style: {
width: "14px",
height: "14px",
padding: "0",
border: "none",
borderRadius: "3px",
cursor: "pointer",
background: ATTEMPT_COLORS[JUDGE_STATUS[item.result]?.type ?? "default"],
},
onClick: (event: MouseEvent) => {
event.stopPropagation()
openSubmission(item.id)
},
}),
default: () =>
h(NFlex, { vertical: true, size: "small" }, () => [
h(
NText,
{ depth: 3 },
() =>
`${JUDGE_STATUS[item.result]?.name ?? item.result} · ` +
`${parseTime(item.createTime, "MM-DD HH:mm:ss")} · ` +
`${item.id.toString().slice(0, 12)}`,
),
renderCode(item.id),
]),
},
),
),
),
]),
),
loaded.truncated
? h(
NText,
{ depth: 3 },
() => `只显示最近 ${loaded.items.length} 条,上面「提交数」才是总数`,
)
: null,
])
},
},
{ title: "用户", key: "username" },
// 做完没做完在同一张表里,用标签区分 —— 少了它这张表看不出谁还卡着
{
title: "完成",
key: "done",
render: (row) =>
h(
NTag,
{
size: "small",
type: row.done ? "success" : "default",
bordered: false,
},
() => (row.done ? "已完成" : "未完成"),
),
},
{
title: "提交数",
key: "submissionCount",
render: (row) =>
row.judgingCount > 0
? `${row.submissionCount}(${row.judgingCount} 条判题中)`
: `${row.submissionCount}`,
},
// 题数,不是通过的提交条数 —— 同一道题重复 AC 只算一道。
// 「语法未过」是答案对了但没按要求写、而且最后也没改对的,算在已解决里但教学上没达标
{
title: "已解决",
key: "solvedCount",
render: (row) =>
row.astOnlyCount > 0
? `${row.solvedCount}(${row.astOnlyCount} 题语法未过)`
: `${row.solvedCount}`,
},
// 新后端返回的是数值,百分号在这里补 —— 旧后端直接返回 "85.5%" 字符串
{
title: "正确率",
key: "correctRate",
render: (row) => `${row.correctRate}%`,
},
]
const configStore = useConfigStore()
/**
* 班级下拉。值就是用户名前缀(`ks231`),后端那边本来就是 ilike 模糊匹配,
* 所以选班级和手打前缀是同一件事。`tag` 留着让老师仍然能直接打某个学生的名字。
*/
const classOptions = computed<SelectOption[]>(
() =>
configStore.config?.classList.map((item) => ({
label: `${item.slice(0, 2)}计算机${item.slice(2)}班`,
value: `ks${item}`,
})) ?? [],
)
// 班级、题号、时间段记一小时,理由见 useStatisticsQuery
const { query, save: saveQuery } = useStatisticsQuery(STORAGE_KEY.STATISTICS_QUERY, props)
const count = reactive({
total: 0,
accepted: 0,
// 还在判题队列里的条数。total 含它,rate 的分母不含 —— 全班同时交卷的那几秒,
// 分母涨了分子没涨,正确率会凭空掉一截
judging: 0,
rate: 0,
})
// 花名册人数。后端只下发这一个分母,完成度在前端算 —— 「请假隐藏」要从分母里
// 减人,那是浏览器本地状态,后端算不出来
const personCount = ref(0)
const route = useRoute()
const router = useRouter()
// 交过东西的所有人(做没做完看 done)
const list = ref<SubmissionStatisticsUser[]>([])
// 「完成人数」「完成度」算的是做完了的那些,不是整张表的行数
const doneList = computed(() => list.value.filter((row) => row.done))
const listUnaccepted = ref<UnacceptedStudent[]>([])
// 交了但一次没对的。和上面那一栏合起来才是「未完成」的全部
const listAttempted = ref<AttemptedStudent[]>([])
const expandedRowKeys = ref<DataTableRowKey[]>([])
/**
* 展开行的明细,按人缓存。**每次重新统计都清空** —— 时间窗滚过之后旧明细就不对了;
* 清完如果还有展开着的行,顺手把那一行重拉一遍,让它跟着自动刷新一起活着。
*/
const items = reactive<Record<string, SubmissionStatisticsItems>>({})
// 点一行会同时走 rowProps 的 onClick 和表格的 update:expanded-row-keys,
// 两边都想拉一次;去重放在这里,调用方不用各自判
const itemsLoading = new Set<string>()
async function loadItems(username: string) {
if (items[username] || itemsLoading.has(username)) return
itemsLoading.add(username)
const current = Date.now()
const duration =
query.duration === "all"
? { end: formatISO(current) }
: {
start: formatISO(sub(current, subOptions.value)),
end: formatISO(current),
}
try {
items[username] = await getSubmissionStatisticsItems(duration, username, query.problem)
} catch {
// 拉不到就当空的:展开行显示不出东西,但不该把整个面板带崩
items[username] = { items: [], truncated: false }
} finally {
itemsLoading.delete(username)
}
}
/**
* 「查出东西了吗」。**不能只看提交数** —— 一节课刚开始时一条提交都没有,但后端
* 已经把整份花名册当作「未完成」返回了,而那正是老师最想看名单的时刻。
* 原来整个面板 v-if 在 count.total > 0 上,那会儿只显示「暂无数据」。
*/
const hasResult = computed(() => count.total > 0 || listUnaccepted.value.length > 0)
const { hideMode, hideStudent, showAll, notHidden } = useHiddenStudents("oj_hidden_students")
const visibleUnaccepted = computed(() => listUnaccepted.value.filter(notHidden))
const visibleAttempted = computed(() => listAttempted.value.filter(notHidden))
// 请假的人两栏都要藏 —— 他们同样占着班级人数这个分母
const hiddenCount = computed(
() =>
listUnaccepted.value.length +
listAttempted.value.length -
visibleUnaccepted.value.length -
visibleAttempted.value.length,
)
const unfinishedTotal = computed(
() => visibleUnaccepted.value.length + visibleAttempted.value.length,
)
/**
* 这次查的是几道题。**取的是「上次点统计时」的值**,不是输入框的当前内容 ——
* 老师改到一半的题号不该把已经查出来的结果重新解释一遍。
*/
const queriedProblemCount = ref(0)
/**
* 未完成的两组。合成一栏而不是各占一个 tab:点名时来回切 tab 很别扭;
* 但也不能混成一个名单 —— 「卡住了」和「还没动手」在课堂上要做的事不一样。
* 次数缀在名字后面,教师一眼看得出谁卡得最久(后端按提交数倒序给)。
*/
type UnfinishedItem = {
username: string
label: string
/** 只有「交了没对」那一组有,点名字弹出来看错在哪 */
failure: AttemptedStudent["lastFailure"]
}
const unfinishedGroups = computed<{ title: string; items: UnfinishedItem[] }[]>(() =>
[
{
title: "还没交",
items: visibleUnaccepted.value.map((item) => ({
username: item.username,
label: item.realName,
failure: null,
})),
},
{
// 查多道题时这一栏混着「一道没对」和「差一道」两种人,标题得说清是「没全对」
title: queriedProblemCount.value > 1 ? "交了没全对" : "交了没对",
items: visibleAttempted.value.map((item) => ({
username: item.username,
label: attemptedLabel(item),
failure: item.lastFailure,
})),
},
].filter((group) => group.items.length > 0),
)
type FailureResult = NonNullable<AttemptedStudent["lastFailure"]>["result"]
// 查多道题时把「做出几道」缀上:差一道和一道没做出来,老师要先管的不是同一个人
function attemptedLabel(item: AttemptedStudent) {
const total = queriedProblemCount.value
const progress = total > 1 ? ` ${item.solvedCount}/${total}题` : ""
return `${item.realName}${progress} ${item.submissionCount}次`
}
function statusName(result?: FailureResult) {
return result === undefined ? "" : (JUDGE_STATUS[result]?.name ?? "未知状态")
}
function openFailure(id?: string) {
if (id) openSubmission(id)
}
const adjustedPersonCount = computed(() => personCount.value - hiddenCount.value)
const adjustedPersonRate = computed(() => {
if (adjustedPersonCount.value <= 0) return "0%"
const rate = Math.min(100, (doneList.value.length / adjustedPersonCount.value) * 100)
return `${Math.round(rate * 100) / 100}%`
})
onMounted(() => {
// 过期清理在 useHiddenStudents 里,这里只管「打开就查一次」——
// 老师是投在屏幕上盯着看的,不该还要先点一下按钮
handleStatistics()
})
/**
* 课堂上「还剩几个没交」每十几秒就在变,所以面板自己刷。
* 时间窗是相对当下算的,每次刷都是新的窗口,不是同一份快照重放。
* 页面切到后台就停 —— 那会儿没人在看,白跑而已。
*/
const visibility = useDocumentVisibility()
useIntervalFn(() => {
if (visibility.value === "visible") handleStatistics()
}, 15000)
// 饼图数据 - 提交正确率分布
const pieChartData = computed(() => {
// 判题中的既不算对也不算错,和正确率同一个口径
const wrongCount = count.total - count.accepted - count.judging
return {
labels: ["正确提交", "错误提交"],
datasets: [
{
label: "提交数",
data: [count.accepted, wrongCount],
backgroundColor: ["rgba(75, 192, 192, 0.6)", "rgba(255, 99, 132, 0.6)"],
borderColor: ["rgba(75, 192, 192, 1)", "rgba(255, 99, 132, 1)"],
borderWidth: 2,
},
],
}
})
const pieChartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: "bottom" as const,
},
tooltip: {
callbacks: {
label: function (context: any) {
const label = context.label || ""
const value = context.parsed || 0
const total = context.dataset.data.reduce((a: number, b: number) => a + b, 0)
const percentage = ((value / total) * 100).toFixed(1)
return `${label}: ${value} (${percentage}%)`
},
},
},
},
}
// 环形图数据 - 班级完成度。「已完成」和上面数字行的「完成人数」是同一个数(做完的),
// 原来这里用的是 list.length —— 那是所有交过的人,交了没对的也被画成了已完成
const completionChartData = computed(() => {
const completedCount = doneList.value.length
const uncompletedCount = Math.max(0, adjustedPersonCount.value - completedCount)
return {
labels: ["已完成", "未完成"],
datasets: [
{
label: "人数",
data: [completedCount, uncompletedCount],
backgroundColor: ["rgba(106, 176, 76, 0.6)", "rgba(255, 159, 64, 0.6)"],
borderColor: ["rgba(106, 176, 76, 1)", "rgba(255, 159, 64, 1)"],
borderWidth: 2,
},
],
}
})
const completionChartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: "bottom" as const,
},
tooltip: {
callbacks: {
label: function (context: any) {
const label = context.label || ""
const value = context.parsed || 0
const total = context.dataset.data.reduce((a: number, b: number) => a + b, 0)
const percentage = ((value / total) * 100).toFixed(1)
return `${label}: ${value} (${percentage}%)`
},
},
},
},
}
const subOptions = computed<Duration>(
// 认不出来(含 all)就退回列表第一档,和原来 `?? options[0]` 一致;
// all 实际不会走到这里,handleStatistics 先分支掉了
() => durationFromValue(query.duration) ?? durationFromValue(PANEL_DURATION_OPTIONS[0].value)!,
)
function goSubmissions() {
router.push({
name: "submissions",
query: {
username: query.username,
problem: query.problem,
},
})
}
const loading = ref(false)
/**
* 上一次统计失败了。原来没 catch,请求一失败面板上什么都不说,老师看着的还是旧数字。
* 不弹 toast:每 15 秒自动刷新一次,断网时会一直弹
*/
const failed = ref(false)
async function handleStatistics() {
// 自动刷新和手点可能撞上,上一次没回来就跳过这一次
if (loading.value) return
loading.value = true
try {
await fetchStatistics()
failed.value = false
} catch {
failed.value = true
} finally {
loading.value = false
}
}
async function fetchStatistics() {
const current = Date.now()
const end = formatISO(current)
const duration =
query.duration === "all" ? { end } : { start: formatISO(sub(current, subOptions.value)), end }
const problems = query.problem
.split(/[,,;;\s]+/)
.map((item) => item.trim())
.filter(Boolean)
const res = await getSubmissionStatistics(duration, query.problem, query.username)
saveQuery()
queriedProblemCount.value = new Set(problems.map((p) => p.toLowerCase())).size
count.total = res.submissionCount
count.accepted = res.acceptedCount
count.judging = res.judgingCount
count.rate = res.correctRate
// 这里的 res.data 是载荷**自己**的 data 字段(每个学生一行),
// 不是原来那层信封 —— 契约 submissionStatisticsSchema 就是这么定的
list.value = res.data
listUnaccepted.value = res.dataUnaccepted
listAttempted.value = res.dataAttempted
personCount.value = res.personCount
const expanded = expandedRowKeys.value[0]
for (const key of Object.keys(items)) delete items[key]
if (typeof expanded === "string") loadItems(expanded)
}
function rowKey(row: SubmissionStatisticsUser): DataTableRowKey {
return row.username
}
function updateExpandedRowKeys(keys: DataTableRowKey[]) {
expandedRowKeys.value = keys.slice(-1)
const opened = expandedRowKeys.value[0]
if (typeof opened === "string") loadItems(opened)
}
function rowProps(row: SubmissionStatisticsUser) {
return {
style: "cursor: pointer;",
onClick: () => {
const key = rowKey(row)
const isExpanded = expandedRowKeys.value.includes(key)
expandedRowKeys.value = isExpanded ? [] : [key]
if (!isExpanded) loadItems(row.username)
},
}
}
</script>
<style scoped>
.name {
font-size: 24px;
}
.name-clickable {
cursor: pointer;
text-decoration: underline dotted;
text-underline-offset: 4px;
}
.failure-error {
margin: 0;
max-height: 160px;
overflow: auto;
font-size: 12px;
white-space: pre-wrap;
word-break: break-all;
}
.group-title {
display: block;
margin: 16px 0 8px;
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
</style>
@@ -1,21 +1,19 @@
import { onMounted, ref } from "vue" import { onMounted, ref } from "vue"
/** /**
* 统计面板的「暂时隐藏某个学生」。 * 课堂看板的「请假隐藏」:暂时把某个学生从「今天还没交过」里藏起来。
* *
* 老师是把面板投在屏幕上盯着看的,未完成名单里总有那么几个是请假/转班/学号错了的, * 老师是把看板投在屏幕上盯着看的,没交名单里总有那么几个是请假/转班/学号错了的,
* 一直挂在上面会盖住真正需要盯的人。隐藏是**带过期时间**的(两小时,够一节课), * 一直挂在上面会盖住真正需要盯的人。隐藏是**带过期时间**的(两小时,够一节课),
* 不是永久删除 —— 下节课自动回来,免得有人被无声地漏掉。 * 不是永久删除 —— 下节课自动回来,免得有人被无声地漏掉。
* *
* 存在 localStorage 而不是后端:这是「这台电脑上这位老师这节课不想看谁」, * 存在 localStorage 而不是后端:这是「这台电脑上这位老师这节课不想看谁」,
* 换个人、换台机器都不该继承。 * 换个人、换台机器都不该继承。
* *
* 原来这套(loadHidden / saveHidden / hideStudent / showAll / 过期清理)在 * 原来在提交统计、流程图统计两个弹框里;统计改成回头看的页面之后(2026-09),
* StatisticsPanel.vue 和 FlowchartStatisticsPanel.vue 里各写了一遍,除了存储键 * 课上盯人的事归看板,这个也挪了过来。
* 和一个参数名逐字相同;判断「有没有被隐藏」两边还各自内联了三处。
* *
* @param storageKey localStorage 的键。**两个面板各用各的** —— 提交统计里隐掉的人 * @param storageKey localStorage 的键
* 不该连带在流程图统计里也消失,那是两件事。
*/ */
export function useHiddenStudents(storageKey: string) { export function useHiddenStudents(storageKey: string) {
/** 隐藏时长:两小时,一节课的量级 */ /** 隐藏时长:两小时,一节课的量级 */
@@ -30,7 +28,7 @@ export function useHiddenStudents(storageKey: string) {
} }
const hiddenStudents = ref<Record<string, number>>(load()) const hiddenStudents = ref<Record<string, number>>(load())
/** 面板上的「隐藏模式」开关:打开后每行才出现那个隐藏按钮 */ /** 「请假隐藏」开关:打开后名字旁边才出现那个 × */
const hideMode = ref(false) const hideMode = ref(false)
function save(data: Record<string, number>) { function save(data: Record<string, number>) {
@@ -45,9 +43,13 @@ export function useHiddenStudents(storageKey: string) {
save(hiddenStudents.value) save(hiddenStudents.value)
} }
function showAll() { /** 恢复这几个人。只恢复看板上这个班的,别把别的班藏着的人一起放出来 */
hiddenStudents.value = {} function unhide(usernames: string[]) {
save({}) const drop = new Set(usernames)
hiddenStudents.value = Object.fromEntries(
Object.entries(hiddenStudents.value).filter(([name]) => !drop.has(name)),
)
save(hiddenStudents.value)
} }
function isHidden(username: string) { function isHidden(username: string) {
@@ -55,11 +57,6 @@ export function useHiddenStudents(storageKey: string) {
return !!expiresAt && expiresAt > Date.now() return !!expiresAt && expiresAt > Date.now()
} }
/** 给 filter 用:`list.filter(notHidden)` */
function notHidden(item: { username: string }) {
return !isHidden(item.username)
}
onMounted(() => { onMounted(() => {
// 把已经到期的清掉再落一次盘,否则这张表只增不减 // 把已经到期的清掉再落一次盘,否则这张表只增不减
const now = Date.now() const now = Date.now()
@@ -70,7 +67,7 @@ export function useHiddenStudents(storageKey: string) {
save(cleaned) save(cleaned)
}) })
// 不导出 hiddenStudents 本身:两个面板要的都是「这个人该不该显示」, // 不导出 hiddenStudents 本身:用的地方要的是「这个人该不该显示」,
// 把那张表递出去只会让判断逻辑又散回各自的组件里 // 把那张表递出去只会让判断逻辑又散回组件里
return { hideMode, hideStudent, showAll, isHidden, notHidden } return { hideMode, hideStudent, unhide, isHidden }
} }
@@ -1,65 +0,0 @@
import { reactive } from "vue"
import { PANEL_DURATION_OPTIONS } from "utils/constants"
import storage from "utils/storage"
/**
* 统计面板的查询条件(班级/用户、题号、时间段),**记一小时**:一小时内再打开,
* 框里是上次查的那一组。
*
* 老师一节课里会反复开关这个弹窗(看一眼、关掉、走下去辅导、回来再开),每次都从空的
* 选班级、敲题号很烦。但也不能一直记着 —— 曾经把上次的班级永久写进 localStorage,
* 下课换了班、或者换个人坐这台机器,上一个人查的班悄悄留在框里,老师没注意就按了
* 「统计」,看到的整个是别人的班。一小时是折中:一节课够用,隔一节课就自己清掉。
* 注意**连堂**时下一节仍在一小时内,框里会是上一个班,这一点是接受了的。
*
* 一小时从「最后一次查询」算起(调用方在每次查完后调 `save`,自动刷新也算),
* 开着面板盯一整节课不会中途过期。
*
* 调用方传进来的初值优先:题目页固定是这道题、提交列表带着自己的筛选条件,
* 那是比缓存更明确的上下文;为空的那几项才用缓存补上。
* 登录框那份 LOGIN_CLASS 是另一回事:那记的是这台机器的身份,不是一次临时查询。
*
* @param storageKey localStorage 的键。**两个面板各用各的** —— 流程图题和代码题的
* 题号不是一批,互相带过去只会查出一片空。
*/
export function useStatisticsQuery(
storageKey: string,
initial: { username: string; problem: string },
) {
const TTL = 60 * 60 * 1000
type Cached = { username: string; problem: string; duration: string; savedAt: number }
function load(): Partial<Cached> {
try {
const cached: Cached | null = storage.get(storageKey)
if (!cached || Date.now() - cached.savedAt > TTL) return {}
return cached
} catch {
return {}
}
}
const cached = load()
const query = reactive({
username: initial.username || cached.username || "",
problem: initial.problem || cached.problem || "",
// 选项表改过之后旧缓存里的值可能已经不在里面了,认不出来就回到第一档
duration: PANEL_DURATION_OPTIONS.some((option) => option.value === cached.duration)
? cached.duration!
: PANEL_DURATION_OPTIONS[0].value,
})
function save() {
storage.set(storageKey, {
// 选择框点了清除是 null,不是空串
username: query.username ?? "",
problem: query.problem ?? "",
duration: query.duration,
savedAt: Date.now(),
} satisfies Cached)
}
return { query, save }
}
-19
View File
@@ -156,8 +156,6 @@ export const STORAGE_KEY = {
ADMIN_SIDER_COLLAPSED: "adminSiderCollapsed", ADMIN_SIDER_COLLAPSED: "adminSiderCollapsed",
DEMO_MODE: "demoMode", DEMO_MODE: "demoMode",
LOGIN_CLASS: "loginClass", LOGIN_CLASS: "loginClass",
STATISTICS_QUERY: "statisticsQuery",
FLOWCHART_STATISTICS_QUERY: "flowchartStatisticsQuery",
} }
export const DIFFICULTY = { export const DIFFICULTY = {
@@ -328,23 +326,6 @@ export const DURATION_OPTIONS = [
{ label: "一年内", value: "years:1" }, { label: "一年内", value: "years:1" },
] as const ] as const
/**
* 两个统计面板(提交统计、流程图统计)的时段下拉。
*
* 比通用的那份多了头尾:前面三档分钟级是给**上课当场**用的 —— 老师布置完一道题,
* 想看的就是「刚才这十分钟谁交了」;末尾的 `all` 不是一个时长,`query.duration === "all"`
* 会走各自的分支不带时间条件,所以它永远不会进 durationFromValue()。
*
* 原来这份列表在两个面板里逐字抄了两遍。
*/
export const PANEL_DURATION_OPTIONS = [
{ label: "10分钟内", value: "minutes:10" },
{ label: "20分钟内", value: "minutes:20" },
{ label: "30分钟内", value: "minutes:30" },
...DURATION_OPTIONS,
{ label: "全部时段", value: "all" },
] as const
/** /**
* 榜单和班级对比用的长时段,一周起步。 * 榜单和班级对比用的长时段,一周起步。
* *
+23 -1
View File
@@ -58,7 +58,10 @@ export const flowchartListItemSchema = z.object({
showLink: z.boolean(), 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({ export const createFlowchartResponseSchema = z.object({
submissionId: z.string(), submissionId: z.string(),
status: z.literal("pending"), status: z.literal("pending"),
@@ -106,6 +109,25 @@ export const flowchartStatisticsSchema = z.object({
wordFrequencies: z.array(z.object({ word: z.string(), count: z.number().int() })), wordFrequencies: z.array(z.object({ word: z.string(), count: z.number().int() })),
// 与提交统计共用「未完成学生」的形状,见 submission.ts 的 unacceptedStudentSchema // 与提交统计共用「未完成学生」的形状,见 submission.ts 的 unacceptedStudentSchema
dataUnaccepted: z.array(z.object({ username: z.string(), realName: z.string() })), dataUnaccepted: z.array(z.object({ username: z.string(), realName: z.string() })),
/**
* 每人**最好的一次**(分数最高的那张),统计页的「每人最好的一次」表用。
* 只含普通学生;按最好成绩从低到高排,没拿到 A 的排前面 —— 老师要先看的是他们
*/
people: z.array(
z.object({
username: z.string(),
realName: z.string(),
bestScore: z.number().nullable(),
bestGrade: z.string().nullable(),
count: z.number().int(),
/** 拿到 A / S 的题数(查几道题时要每道都拿到才算完成) */
goodProblems: z.number().int(),
}),
),
/** people 超过上限被截了(截掉的是成绩最好的那些) */
peopleTruncated: z.boolean(),
/** 查了几道题(没填题号为 0,此时拿到一道 A / S 就算完成) */
problemCount: z.number().int(),
}) })
export const flowchartUpdateSchema = z.object({ export const flowchartUpdateSchema = z.object({
+26 -42
View File
@@ -287,7 +287,13 @@ export const submissionListItemSchema = z.object({
caseSummary: caseSummarySchema, caseSummary: caseSummarySchema,
}) })
export const submissionListSchema = paginatedSchema(submissionListItemSchema) export const submissionListSchema = paginatedSchema(submissionListItemSchema).extend({
/**
* 题号框里填了、但查不到的题号(打错了,或不在这个范围里 —— 比赛列表只认本场的题)。
* 一次填好几道时,打错的那道原来被悄悄忽略,老师看到的列表少一道自己却不知道
*/
unknownProblems: z.array(z.string()),
})
/** /**
* **一条都没交**的学生。`realName` 是从用户名里剥掉 `ks<班级号>` 前缀后剩下的那一段, * **一条都没交**的学生。`realName` 是从用户名里剥掉 `ks<班级号>` 前缀后剩下的那一段,
@@ -314,20 +320,6 @@ export const attemptedStudentSchema = unacceptedStudentSchema.extend({
* 差几道决定了老师先管谁 —— 所以名字后面要缀 `2/3`。 * 差几道决定了老师先管谁 —— 所以名字后面要缀 `2/3`。
*/ */
solvedCount: z.number().int(), solvedCount: z.number().int(),
/**
* 最近一条提交错在哪。教师点名字就能看到「是编译错了还是答案错了」,
* 不必再切去提交列表翻这个人。`error` 是判题机写进 statistic_info 的 err_info,
* 已截断;没有错误文本(比如答案错误那种)时为 null。
*/
lastFailure: z
.object({
id: z.string(),
/** 题目的展示编号,用来告诉老师错在哪道题 */
problemDisplayId: z.string(),
result: judgeStatusSchema,
error: z.string().nullable(),
})
.nullable(),
}) })
export const submissionStatisticsUserSchema = z.object({ export const submissionStatisticsUserSchema = z.object({
@@ -341,11 +333,6 @@ export const submissionStatisticsUserSchema = z.object({
* 不指定题号查「这节课全班」时,条数和题数能差出好几倍。 * 不指定题号查「这节课全班」时,条数和题数能差出好几倍。
*/ */
solvedCount: z.number().int(), solvedCount: z.number().int(),
/**
* 「答案对了但语法没按要求写」且**最后也没改对**的题数。这些题算在 solvedCount 里
* (AST_CHECK_FAILED 全站都算通过),单列出来只是让教师看得见教学上没达标的那几个。
*/
astOnlyCount: z.number().int(),
/** 这个人还在判题队列里的条数。`submissionCount` 含它,`correctRate` 的分母不含 */ /** 这个人还在判题队列里的条数。`submissionCount` 含它,`correctRate` 的分母不含 */
judgingCount: z.number().int(), judgingCount: z.number().int(),
// 百分比数值,不带 %。旧后端返回 "85.5%" 字符串,展示格式化交给前端。 // 百分比数值,不带 %。旧后端返回 "85.5%" 字符串,展示格式化交给前端。
@@ -359,30 +346,27 @@ export const submissionStatisticsUserSchema = z.object({
done: z.boolean(), done: z.boolean(),
}) })
/** export const submissionStatisticsGridSchema = z.object({
* 展开行的明细,**按需拉**(GET /submissions/statistics/items)。 /** 传了题号就按传的顺序;没传就是范围内出现过的题,按第一次有人交的时间排 */
* problems: z.array(z.object({ problemDisplayId: z.string(), title: z.string() })),
* 原来是随统计一起给每个人各带一份,可表格一次只展开一行 —— 生产快照上那是 rows: z.array(
* 4.9 万行没人看的数据。`truncated` 为真时前端要说明「只显示最近 N 条」,
* 免得老师以为这人就交了这么多。
*/
export const submissionStatisticsItemsSchema = z.object({
/**
* 展开某个学生时列出他这段时间的提交。**带上题目**:一节课里学生往往在好几道题
* 之间来回跳,一串只有编号的按钮看不出他卡在哪一道 —— 前端按题目分组展示。
*
* 字段名沿用 submissionListItemSchema 的口径:`problemDisplayId` 是展示用题号(problem._id),
* `problemTitle` 是标题。
*/
items: z.array(
z.object({ z.object({
id: z.string(), username: z.string(),
result: judgeStatusSchema, /** 剥掉 `ks<班级号>` 前缀后的那段,同 unacceptedStudentSchema.realName */
createTime: z.string(), realName: z.string(),
problemDisplayId: z.string(), className: z.string().nullable(),
problemTitle: z.string(), /** 从早到晚 */
submissions: z.array(
z.object({
id: z.string(),
problemDisplayId: z.string(),
result: judgeStatusSchema,
createTime: z.string(),
}),
),
}), }),
), ),
classSizes: z.record(z.string(), z.number().int()),
truncated: z.boolean(), truncated: z.boolean(),
}) })
@@ -527,7 +511,7 @@ export type SubmissionUpdate = z.infer<typeof submissionUpdateSchema>
export type SubmissionStatistics = z.infer<typeof submissionStatisticsSchema> export type SubmissionStatistics = z.infer<typeof submissionStatisticsSchema>
export type TodaySubmissionStatistics = z.infer<typeof todaySubmissionStatisticsSchema> export type TodaySubmissionStatistics = z.infer<typeof todaySubmissionStatisticsSchema>
export type SubmissionStatisticsUser = z.infer<typeof submissionStatisticsUserSchema> export type SubmissionStatisticsUser = z.infer<typeof submissionStatisticsUserSchema>
export type SubmissionStatisticsItems = z.infer<typeof submissionStatisticsItemsSchema> export type SubmissionStatisticsGrid = z.infer<typeof submissionStatisticsGridSchema>
export type UnacceptedStudent = z.infer<typeof unacceptedStudentSchema> export type UnacceptedStudent = z.infer<typeof unacceptedStudentSchema>
export type AttemptedStudent = z.infer<typeof attemptedStudentSchema> export type AttemptedStudent = z.infer<typeof attemptedStudentSchema>