Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52bf2d6111 | ||
|
|
0bcc981e6f | ||
|
|
26c8d65e89 | ||
|
|
ed55ee5af6 | ||
|
|
f09da88ff7 |
No files matched your search
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
@@ -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)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
}>()
|
}>()
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 榜单和班级对比用的长时段,一周起步。
|
* 榜单和班级对比用的长时段,一周起步。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user