Compare commits
2
Commits
e98ee610a1
...
e3be3cd62c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3be3cd62c | ||
|
|
921990285c |
No files matched your search
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
type ProblemLanguage,
|
type ProblemLanguage,
|
||||||
|
type SubmissionLessons,
|
||||||
type SubmissionStatistics,
|
type SubmissionStatistics,
|
||||||
type SubmissionStatisticsGrid,
|
type SubmissionStatisticsGrid,
|
||||||
type TodaySubmissionStatistics,
|
type TodaySubmissionStatistics,
|
||||||
@@ -21,15 +22,8 @@ import { failure, success } from "../http"
|
|||||||
import { JudgeStatus, UNJUDGED_RESULTS, type JudgeStatusValue } from "../judge/status"
|
import { JudgeStatus, UNJUDGED_RESULTS, type JudgeStatusValue } 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 { dayStart } from "../time"
|
import { dayStart, localTime } from "../time"
|
||||||
import {
|
import { isAdminRole, parseDisplayIds, rounded, scopedUsers, stripClassPrefix } from "./helpers"
|
||||||
isAdminRole,
|
|
||||||
isTeacherOrAbove,
|
|
||||||
parseDisplayIds,
|
|
||||||
rounded,
|
|
||||||
scopedUsers,
|
|
||||||
stripClassPrefix,
|
|
||||||
} from "./helpers"
|
|
||||||
|
|
||||||
export const submissionStatisticsRoutes = new Hono<ContestEnv>()
|
export const submissionStatisticsRoutes = new Hono<ContestEnv>()
|
||||||
|
|
||||||
@@ -40,27 +34,19 @@ function judgedRate(accepted: number, judged: number) {
|
|||||||
return judged > 0 ? rounded((accepted / judged) * 100) : 0
|
return judged > 0 ? rounded((accepted / judged) * 100) : 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 同班这么多人做了同一道题,才算这个班今天上了课(和首页「班里在做」同一个门槛) */
|
|
||||||
const TODAY_LESSON_MIN_USERS = 5
|
|
||||||
/** 最后一条提交在这么多分钟内,算「正在上课」 */
|
|
||||||
const TODAY_LIVE_MINUTES = 15
|
|
||||||
|
|
||||||
type TodayRow = {
|
type TodayRow = {
|
||||||
class_name: string | null
|
|
||||||
problem_id: number
|
problem_id: number
|
||||||
display_id: string
|
display_id: string
|
||||||
title: string
|
title: string
|
||||||
visible: boolean
|
visible: boolean
|
||||||
problemset_id: number | null
|
|
||||||
user_id: number
|
user_id: number
|
||||||
result: JudgeStatusValue
|
result: JudgeStatusValue
|
||||||
language: ProblemLanguage
|
language: ProblemLanguage
|
||||||
create_time: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 「今日统计」(提交列表顶上那颗标签点开的)。一般是老师看:今天哪几个班上了课、几点到几点、
|
* 「今日统计」(提交列表顶上那颗标签点开的),只剩学生版 —— 老师那一版并进了统计的
|
||||||
* 错得最多的是哪几道;学生版从简。口径和那颗标签一致:东八区今天 + 非比赛提交。
|
* 「一行一节课」(下面的 /submissions/statistics/lessons)。口径和那颗标签一致:东八区今天 + 非比赛提交。
|
||||||
*
|
*
|
||||||
* 今天的提交一天也就几百到一千来条,整批拉回来在进程里分组,比写五六条各自 group by 的
|
* 今天的提交一天也就几百到一千来条,整批拉回来在进程里分组,比写五六条各自 group by 的
|
||||||
* SQL 好读,也只扫一遍 `create_time` 索引。
|
* SQL 好读,也只扫一遍 `create_time` 索引。
|
||||||
@@ -74,15 +60,13 @@ submissionStatisticsRoutes.get("/submissions/today-statistics", optionalAuth, as
|
|||||||
*/
|
*/
|
||||||
const showProblems =
|
const showProblems =
|
||||||
(await getBooleanOption("submission_list_show_all", true)) || isAdminRole(user)
|
(await getBooleanOption("submission_list_show_all", true)) || isAdminRole(user)
|
||||||
const teacher = isTeacherOrAbove(user)
|
|
||||||
const since = dayStart()
|
const since = dayStart()
|
||||||
const [rows, [flow]] = await Promise.all([
|
const [rows, [flow]] = await Promise.all([
|
||||||
db.execute<TodayRow>(sql`
|
db.execute<TodayRow>(sql`
|
||||||
select u.class_name, s.problem_id, p._id as display_id, p.title, p.visible,
|
select s.problem_id, p._id as display_id, p.title, p.visible,
|
||||||
s.problemset_id, s.user_id, s.result, s.language, s.create_time
|
s.user_id, s.result, s.language
|
||||||
from ${schema.submission} s
|
from ${schema.submission} s
|
||||||
join ${schema.problem} p on p.id = s.problem_id
|
join ${schema.problem} p on p.id = s.problem_id
|
||||||
left join ${schema.user} u on u.id = s.user_id
|
|
||||||
where s.contest_id is null and s.create_time >= ${since}
|
where s.contest_id is null and s.create_time >= ${since}
|
||||||
`),
|
`),
|
||||||
db
|
db
|
||||||
@@ -114,7 +98,7 @@ submissionStatisticsRoutes.get("/submissions/today-statistics", optionalAuth, as
|
|||||||
const byProblem = groupBy(rows, (row) => row.problem_id)
|
const byProblem = groupBy(rows, (row) => row.problem_id)
|
||||||
const mineRows = user ? rows.filter((row) => row.user_id === user.id) : []
|
const mineRows = user ? rows.filter((row) => row.user_id === user.id) : []
|
||||||
|
|
||||||
// ---------- 学生:大家都在做 ----------
|
// 大家都在做
|
||||||
const problems = showProblems
|
const problems = showProblems
|
||||||
? [...byProblem.values()]
|
? [...byProblem.values()]
|
||||||
.filter((list) => list[0]!.visible)
|
.filter((list) => list[0]!.visible)
|
||||||
@@ -138,80 +122,6 @@ submissionStatisticsRoutes.get("/submissions/today-statistics", optionalAuth, as
|
|||||||
.slice(0, 8)
|
.slice(0, 8)
|
||||||
: []
|
: []
|
||||||
|
|
||||||
// ---------- 老师:哪几个班上了课、错得最多的题 ----------
|
|
||||||
let classes: TodaySubmissionStatistics["classes"] = null
|
|
||||||
let scattered: TodaySubmissionStatistics["scattered"] = null
|
|
||||||
let hardProblems: TodaySubmissionStatistics["hardProblems"] = null
|
|
||||||
if (teacher) {
|
|
||||||
const byClass = groupBy(rows, (row) => row.class_name)
|
|
||||||
const lessonClasses: { className: string; list: TodayRow[]; problemCount: number }[] = []
|
|
||||||
scattered = []
|
|
||||||
for (const [className, list] of byClass) {
|
|
||||||
const problemCount = className
|
|
||||||
? [...groupBy(list, (row) => row.problem_id).values()].filter(
|
|
||||||
(group) => distinctUsers(group) >= TODAY_LESSON_MIN_USERS,
|
|
||||||
).length
|
|
||||||
: 0
|
|
||||||
if (className && problemCount) lessonClasses.push({ className, list, problemCount })
|
|
||||||
else scattered.push({ className, userCount: distinctUsers(list), total: list.length })
|
|
||||||
}
|
|
||||||
scattered.sort((a, b) =>
|
|
||||||
a.className === null ? 1 : b.className === null ? -1 : b.total - a.total,
|
|
||||||
)
|
|
||||||
const sizes = lessonClasses.length
|
|
||||||
? await db
|
|
||||||
.select({ className: schema.user.className, value: count() })
|
|
||||||
.from(schema.user)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
inArray(
|
|
||||||
schema.user.className,
|
|
||||||
lessonClasses.map((item) => item.className),
|
|
||||||
),
|
|
||||||
eq(schema.user.adminType, "Regular User"),
|
|
||||||
eq(schema.user.isDisabled, false),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.groupBy(schema.user.className)
|
|
||||||
: []
|
|
||||||
const liveSince = new Date(Date.now() - TODAY_LIVE_MINUTES * 60_000).toISOString()
|
|
||||||
classes = lessonClasses
|
|
||||||
.map(({ className, list, problemCount }) => {
|
|
||||||
const times = list.map((row) => row.create_time).sort()
|
|
||||||
return {
|
|
||||||
className,
|
|
||||||
start: times[0]!,
|
|
||||||
end: times.at(-1)!,
|
|
||||||
userCount: distinctUsers(list),
|
|
||||||
classSize: sizes.find((row) => row.className === className)?.value ?? 0,
|
|
||||||
problemCount,
|
|
||||||
fromProblemSet: list.filter((row) => row.problemset_id !== null).length * 2 > list.length,
|
|
||||||
total: list.length,
|
|
||||||
correctRate: rate(list),
|
|
||||||
live: times.at(-1)! >= liveSince,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.sort((a, b) => a.start.localeCompare(b.start))
|
|
||||||
const failedOf = (list: TodayRow[]) => list.filter((row) => !isAccepted(row) && !isJudging(row))
|
|
||||||
hardProblems = [...byProblem.values()]
|
|
||||||
.filter((list) => failedOf(list).length > 0)
|
|
||||||
.sort((a, b) => failedOf(b).length - failedOf(a).length)
|
|
||||||
.slice(0, 6)
|
|
||||||
.map((list) => ({
|
|
||||||
problemDisplayId: list[0]!.display_id,
|
|
||||||
problemTitle: list[0]!.title,
|
|
||||||
className: countBy(list, (row) => row.class_name)[0]?.value ?? null,
|
|
||||||
total: list.length,
|
|
||||||
accepted: list.filter(isAccepted).length,
|
|
||||||
failures: countBy(failedOf(list), (row) => row.result).map(({ value, count }) => ({
|
|
||||||
result: value,
|
|
||||||
count,
|
|
||||||
})),
|
|
||||||
userCount: distinctUsers(list),
|
|
||||||
acceptedUsers: acceptedUsers(list),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
return success(c, {
|
return success(c, {
|
||||||
asOf: new Date().toISOString(),
|
asOf: new Date().toISOString(),
|
||||||
total: rows.length,
|
total: rows.length,
|
||||||
@@ -235,9 +145,6 @@ submissionStatisticsRoutes.get("/submissions/today-statistics", optionalAuth, as
|
|||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
problems,
|
problems,
|
||||||
classes,
|
|
||||||
scattered,
|
|
||||||
hardProblems,
|
|
||||||
} satisfies TodaySubmissionStatistics)
|
} satisfies TodaySubmissionStatistics)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -486,7 +393,7 @@ submissionStatisticsRoutes.get("/submissions/statistics", requireTeacher, async
|
|||||||
*/
|
*/
|
||||||
const solvedFilter = sql`count(distinct ${schema.submission.problemId}) filter (where ${inArray(schema.submission.result, ACCEPTED_RESULTS)})`
|
const solvedFilter = sql`count(distinct ${schema.submission.problemId}) filter (where ${inArray(schema.submission.result, ACCEPTED_RESULTS)})`
|
||||||
|
|
||||||
const [[totals], perUser] = await Promise.all([
|
const [[totals], perUser, results] = await Promise.all([
|
||||||
db
|
db
|
||||||
.select({
|
.select({
|
||||||
total: count(),
|
total: count(),
|
||||||
@@ -529,6 +436,13 @@ submissionStatisticsRoutes.get("/submissions/statistics", requireTeacher, async
|
|||||||
schema.user.adminType,
|
schema.user.adminType,
|
||||||
)
|
)
|
||||||
.orderBy(desc(count())),
|
.orderBy(desc(count())),
|
||||||
|
// 数字行里那根结果条(原来今日统计底部那根,并进来之后哪个时间段都有)
|
||||||
|
db
|
||||||
|
.select({ result: schema.submission.result, count: count() })
|
||||||
|
.from(schema.submission)
|
||||||
|
.where(where)
|
||||||
|
.groupBy(schema.submission.result)
|
||||||
|
.orderBy(desc(count())),
|
||||||
])
|
])
|
||||||
|
|
||||||
const submissionCount = totals?.total ?? 0
|
const submissionCount = totals?.total ?? 0
|
||||||
@@ -624,6 +538,7 @@ submissionStatisticsRoutes.get("/submissions/statistics", requireTeacher, async
|
|||||||
data,
|
data,
|
||||||
dataUnaccepted,
|
dataUnaccepted,
|
||||||
dataAttempted,
|
dataAttempted,
|
||||||
|
results,
|
||||||
} satisfies SubmissionStatistics)
|
} satisfies SubmissionStatistics)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -758,3 +673,295 @@ submissionStatisticsRoutes.get("/submissions/statistics/grid", requireTeacher, a
|
|||||||
truncated,
|
truncated,
|
||||||
} satisfies SubmissionStatisticsGrid)
|
} satisfies SubmissionStatisticsGrid)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** 相邻两条提交隔这么久,就当下课了、后面的是另一段 */
|
||||||
|
const LESSON_GAP_MINUTES = 30
|
||||||
|
/** 一段里有一道题这么多人做过,才算一节课(和原来今日统计、首页「班里在做」同一个门槛) */
|
||||||
|
const LESSON_MIN_USERS = 5
|
||||||
|
/** 最后一条在这么多分钟内,算「还在上课」 */
|
||||||
|
const LESSON_LIVE_MINUTES = 15
|
||||||
|
/** 一次列多少节课。「一节课」「今天」一般一两行,「这学期」「全部」有几百节,先给最近的这些 */
|
||||||
|
const LESSONS_DEFAULT = 50
|
||||||
|
const LESSONS_MAX = 500
|
||||||
|
|
||||||
|
type LessonAggRow = {
|
||||||
|
level: number
|
||||||
|
class_name: string | null
|
||||||
|
day: string | null
|
||||||
|
sid: number | null
|
||||||
|
problem_id: number | null
|
||||||
|
result: JudgeStatusValue | null
|
||||||
|
lesson: boolean | null
|
||||||
|
users: number
|
||||||
|
total: number
|
||||||
|
accepted: number
|
||||||
|
judging: number
|
||||||
|
from_ps: number
|
||||||
|
first: string
|
||||||
|
last: string
|
||||||
|
accepted_users: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计「一行一节课」的总览(口径见契约 submissionLessonsSchema)。
|
||||||
|
*
|
||||||
|
* 切课、零散提交、错得最多的题全在一条 SQL 里:先按「班 + 东八区哪一天」隔 30 分钟切段,
|
||||||
|
* 再标出哪几段算课,最后用 grouping sets 一次聚合出下面五种粒度 —— 不走方块串那个明细
|
||||||
|
* 接口在前端切,是因为那边最多给 5000 条,「这学期」就不止这个数,截断之后课会少算。
|
||||||
|
*/
|
||||||
|
submissionStatisticsRoutes.get("/submissions/statistics/lessons", requireTeacher, async (c) => {
|
||||||
|
const range = statisticsRange(c)
|
||||||
|
if (!range) return failure(c, 400, "invalid-request", "end is required")
|
||||||
|
const limit = Math.min(
|
||||||
|
LESSONS_MAX,
|
||||||
|
Math.max(1, Number.parseInt(c.req.query("limit") ?? "", 10) || LESSONS_DEFAULT),
|
||||||
|
)
|
||||||
|
const accepted = sql.join(
|
||||||
|
ACCEPTED_RESULTS.map((value) => sql`${value}`),
|
||||||
|
sql`, `,
|
||||||
|
)
|
||||||
|
const judging = sql.join(
|
||||||
|
UNJUDGED_RESULTS.map((value) => sql`${value}`),
|
||||||
|
sql`, `,
|
||||||
|
)
|
||||||
|
|
||||||
|
// grouping() 的位:class_name 32、day 16、sid 8、problem_id 4、result 2、lesson 1。
|
||||||
|
// 没参与分组的那几列记 1,所以每种粒度对应一个固定的 level
|
||||||
|
const rows = await db.execute<LessonAggRow>(sql`
|
||||||
|
with s as (
|
||||||
|
select u.class_name, s.user_id, s.problem_id, s.result, s.problemset_id, s.create_time,
|
||||||
|
to_char(${localTime(sql`s.create_time`)}, 'YYYY-MM-DD') as day
|
||||||
|
from ${schema.submission} s
|
||||||
|
join ${schema.user} u on u.id = s.user_id
|
||||||
|
where s.contest_id is null
|
||||||
|
and s.create_time <= ${range.end}
|
||||||
|
${range.start ? sql`and s.create_time >= ${range.start}` : sql``}
|
||||||
|
-- 只算普通学生:老师试题的提交不该把一段拼成「一节课」
|
||||||
|
and u.admin_type = 'Regular User' and u.is_disabled = false
|
||||||
|
),
|
||||||
|
g as (
|
||||||
|
select *,
|
||||||
|
case when create_time - lag(create_time) over w > ${`${LESSON_GAP_MINUTES} minutes`}::interval
|
||||||
|
then 1 else 0 end as brk
|
||||||
|
from s
|
||||||
|
window w as (partition by class_name, day order by create_time)
|
||||||
|
),
|
||||||
|
seg as (
|
||||||
|
select *,
|
||||||
|
sum(brk) over (partition by class_name, day order by create_time rows unbounded preceding) as sid
|
||||||
|
from g
|
||||||
|
),
|
||||||
|
lesson_seg as (
|
||||||
|
select class_name, day, sid
|
||||||
|
from (
|
||||||
|
select class_name, day, sid, problem_id, count(distinct user_id) as n
|
||||||
|
from seg where class_name is not null
|
||||||
|
group by class_name, day, sid, problem_id
|
||||||
|
) p
|
||||||
|
group by class_name, day, sid
|
||||||
|
having max(n) >= ${LESSON_MIN_USERS}
|
||||||
|
),
|
||||||
|
marked as (
|
||||||
|
select seg.*, (l.sid is not null) as lesson
|
||||||
|
from seg
|
||||||
|
left join lesson_seg l
|
||||||
|
on l.class_name = seg.class_name and l.day = seg.day and l.sid = seg.sid
|
||||||
|
)
|
||||||
|
select grouping(class_name, day, sid, problem_id, result, lesson) as level,
|
||||||
|
class_name, day, sid::int as sid, problem_id, result, lesson,
|
||||||
|
count(distinct user_id)::int as users,
|
||||||
|
count(*)::int as total,
|
||||||
|
(count(*) filter (where result in (${accepted})))::int as accepted,
|
||||||
|
(count(*) filter (where result in (${judging})))::int as judging,
|
||||||
|
(count(*) filter (where problemset_id is not null))::int as from_ps,
|
||||||
|
min(create_time) as first,
|
||||||
|
max(create_time) as last,
|
||||||
|
(count(distinct user_id) filter (where result in (${accepted})))::int as accepted_users
|
||||||
|
from marked
|
||||||
|
group by grouping sets (
|
||||||
|
(class_name, day, sid, problem_id),
|
||||||
|
(class_name, day, sid, lesson),
|
||||||
|
(problem_id, class_name),
|
||||||
|
(problem_id, result),
|
||||||
|
(lesson, class_name, day)
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
|
||||||
|
const LEVEL = { segProblem: 3, segment: 6, problemClass: 27, problemResult: 57, dayClass: 14 }
|
||||||
|
const segKey = (row: LessonAggRow) => `${row.class_name}|${row.day}|${row.sid}`
|
||||||
|
const failedOf = (row: { total: number; accepted: number; judging: number }) =>
|
||||||
|
row.total - row.accepted - row.judging
|
||||||
|
|
||||||
|
const problemsOfSeg = new Map<string, LessonAggRow[]>()
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.level !== LEVEL.segProblem) continue
|
||||||
|
const key = segKey(row)
|
||||||
|
problemsOfSeg.set(key, [...(problemsOfSeg.get(key) ?? []), row])
|
||||||
|
}
|
||||||
|
const allLessons = rows
|
||||||
|
.filter((row) => row.level === LEVEL.segment && row.lesson)
|
||||||
|
.sort((a, b) => b.first.localeCompare(a.first))
|
||||||
|
const picked = allLessons.slice(0, limit)
|
||||||
|
const hasMore = allLessons.length > limit
|
||||||
|
|
||||||
|
// 题号、标题一次查齐:课的题、每节课错得最多的那道、整段时间错得最多的几道
|
||||||
|
const hardCandidates = new Map<number, { rows: LessonAggRow[]; failures: LessonAggRow[] }>()
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.problem_id === null) continue
|
||||||
|
if (row.level === LEVEL.problemClass || row.level === LEVEL.problemResult) {
|
||||||
|
const entry = hardCandidates.get(row.problem_id) ?? { rows: [], failures: [] }
|
||||||
|
if (row.level === LEVEL.problemClass) entry.rows.push(row)
|
||||||
|
else entry.failures.push(row)
|
||||||
|
hardCandidates.set(row.problem_id, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const hardRanked = [...hardCandidates.entries()]
|
||||||
|
.map(([problemId, entry]) => {
|
||||||
|
const sum = (key: "total" | "accepted" | "judging" | "users" | "accepted_users") =>
|
||||||
|
entry.rows.reduce((acc, row) => acc + row[key], 0)
|
||||||
|
return {
|
||||||
|
problemId,
|
||||||
|
className: [...entry.rows].sort((a, b) => b.total - a.total)[0]?.class_name ?? null,
|
||||||
|
total: sum("total"),
|
||||||
|
accepted: sum("accepted"),
|
||||||
|
judging: sum("judging"),
|
||||||
|
userCount: sum("users"),
|
||||||
|
acceptedUsers: sum("accepted_users"),
|
||||||
|
failures: entry.failures
|
||||||
|
.filter(
|
||||||
|
(row) =>
|
||||||
|
row.result !== null &&
|
||||||
|
!ACCEPTED_RESULTS.includes(row.result) &&
|
||||||
|
!UNJUDGED_RESULTS.includes(row.result),
|
||||||
|
)
|
||||||
|
.sort((a, b) => b.total - a.total)
|
||||||
|
.map((row) => ({ result: row.result!, count: row.total })),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((row) => failedOf(row) > 0)
|
||||||
|
.sort((a, b) => failedOf(b) - failedOf(a))
|
||||||
|
.slice(0, 6)
|
||||||
|
|
||||||
|
const lessonProblemRows = picked.map((lesson) =>
|
||||||
|
(problemsOfSeg.get(segKey(lesson)) ?? [])
|
||||||
|
.filter((row) => row.users >= LESSON_MIN_USERS)
|
||||||
|
.sort((a, b) => b.users - a.users),
|
||||||
|
)
|
||||||
|
const problemIds = [
|
||||||
|
...new Set([
|
||||||
|
...lessonProblemRows.flat().map((row) => row.problem_id!),
|
||||||
|
...hardRanked.map((row) => row.problemId),
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
const problems = problemIds.length
|
||||||
|
? await db
|
||||||
|
.select({
|
||||||
|
id: schema.problem.id,
|
||||||
|
displayId: schema.problem.displayId,
|
||||||
|
title: schema.problem.title,
|
||||||
|
visible: schema.problem.visible,
|
||||||
|
})
|
||||||
|
.from(schema.problem)
|
||||||
|
.where(inArray(schema.problem.id, problemIds))
|
||||||
|
: []
|
||||||
|
const problemById = new Map(problems.map((row) => [row.id, row]))
|
||||||
|
|
||||||
|
const lessonClasses = [...new Set(picked.map((row) => row.class_name!))]
|
||||||
|
const sizes = lessonClasses.length
|
||||||
|
? await db
|
||||||
|
.select({ className: schema.user.className, value: count() })
|
||||||
|
.from(schema.user)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(schema.user.className, lessonClasses),
|
||||||
|
eq(schema.user.adminType, "Regular User"),
|
||||||
|
eq(schema.user.isDisabled, false),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.groupBy(schema.user.className)
|
||||||
|
: []
|
||||||
|
|
||||||
|
const sum = (list: LessonAggRow[], key: "total" | "accepted" | "judging") =>
|
||||||
|
list.reduce((acc, row) => acc + row[key], 0)
|
||||||
|
const liveSince = new Date(Date.now() - LESSON_LIVE_MINUTES * 60_000).toISOString()
|
||||||
|
const lessons = picked.map((lesson, index) => {
|
||||||
|
const own = lessonProblemRows[index]!
|
||||||
|
const hardest = [...own].sort((a, b) => failedOf(b) - failedOf(a))[0]
|
||||||
|
const hardestProblem = hardest ? problemById.get(hardest.problem_id!) : undefined
|
||||||
|
return {
|
||||||
|
className: lesson.class_name!,
|
||||||
|
day: lesson.day!,
|
||||||
|
start: lesson.first,
|
||||||
|
end: lesson.last,
|
||||||
|
userCount: lesson.users,
|
||||||
|
classSize: sizes.find((row) => row.className === lesson.class_name)?.value ?? 0,
|
||||||
|
// 点进去就拿这几道去筛:统计接口只认公开题,一次最多 20 道
|
||||||
|
problems: own
|
||||||
|
.map((row) => problemById.get(row.problem_id!))
|
||||||
|
.filter((row) => row?.visible)
|
||||||
|
.map((row) => row!.displayId)
|
||||||
|
.slice(0, STATISTICS_MAX_PROBLEMS),
|
||||||
|
fromProblemSet: lesson.from_ps * 2 > lesson.total,
|
||||||
|
// 次数和正确率只算这节课的题:点进去筛的就是这几道,两边的数才对得上
|
||||||
|
// (一段里零星一两个人顺手交的别的题不算)
|
||||||
|
total: sum(own, "total"),
|
||||||
|
correctRate: judgedRate(sum(own, "accepted"), sum(own, "total") - sum(own, "judging")),
|
||||||
|
live: lesson.last >= liveSince,
|
||||||
|
hardest:
|
||||||
|
hardest && hardestProblem && failedOf(hardest) > 0
|
||||||
|
? {
|
||||||
|
problemDisplayId: hardestProblem.displayId,
|
||||||
|
problemTitle: hardestProblem.title,
|
||||||
|
failed: failedOf(hardest),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 零散提交只给列出来的这些课覆盖到的日子(「这学期」只列了最近 50 节时,更早的零散不给)
|
||||||
|
const earliestDay = hasMore ? (picked.at(-1)?.day ?? null) : null
|
||||||
|
const scattered = rows
|
||||||
|
.filter(
|
||||||
|
(row) =>
|
||||||
|
row.level === LEVEL.dayClass &&
|
||||||
|
row.lesson === false &&
|
||||||
|
(earliestDay === null || row.day! >= earliestDay),
|
||||||
|
)
|
||||||
|
.sort((a, b) =>
|
||||||
|
a.day !== b.day
|
||||||
|
? b.day!.localeCompare(a.day!)
|
||||||
|
: a.class_name === null
|
||||||
|
? 1
|
||||||
|
: b.class_name === null
|
||||||
|
? -1
|
||||||
|
: b.total - a.total,
|
||||||
|
)
|
||||||
|
.map((row) => ({
|
||||||
|
day: row.day!,
|
||||||
|
className: row.class_name,
|
||||||
|
userCount: row.users,
|
||||||
|
total: row.total,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return success(c, {
|
||||||
|
lessons,
|
||||||
|
scattered,
|
||||||
|
hardProblems: hardRanked.flatMap((row) => {
|
||||||
|
const problem = problemById.get(row.problemId)
|
||||||
|
if (!problem) return []
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
problemDisplayId: problem.displayId,
|
||||||
|
problemTitle: problem.title,
|
||||||
|
className: row.className,
|
||||||
|
total: row.total,
|
||||||
|
accepted: row.accepted,
|
||||||
|
failures: row.failures,
|
||||||
|
userCount: row.userCount,
|
||||||
|
acceptedUsers: row.acceptedUsers,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
hasMore,
|
||||||
|
} satisfies SubmissionLessons)
|
||||||
|
})
|
||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
type UserBadge,
|
type UserBadge,
|
||||||
type FlowchartStatistics,
|
type FlowchartStatistics,
|
||||||
type SubmissionStatistics,
|
type SubmissionStatistics,
|
||||||
|
type SubmissionLessons,
|
||||||
type SubmissionStatisticsGrid,
|
type SubmissionStatisticsGrid,
|
||||||
type TodaySubmissionStatistics,
|
type TodaySubmissionStatistics,
|
||||||
} from "@oj2/contract"
|
} from "@oj2/contract"
|
||||||
@@ -189,6 +190,13 @@ export function getSubmissionStatistics(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 统计「一行一节课」的总览(没选班、没填学生和题号时)。limit 是列几节课,「再往前看」加它 */
|
||||||
|
export function getSubmissionLessons(duration: { start?: string; end: string }, limit?: number) {
|
||||||
|
return api.get<SubmissionLessons>("submissions/statistics/lessons", {
|
||||||
|
params: { ...duration, limit },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/** 统计页的方块串:范围内每个学生的每一次提交(口径见契约 submissionStatisticsGridSchema) */
|
/** 统计页的方块串:范围内每个学生的每一次提交(口径见契约 submissionStatisticsGridSchema) */
|
||||||
export function getSubmissionStatisticsGrid(
|
export function getSubmissionStatisticsGrid(
|
||||||
duration: { start?: string; end: string },
|
duration: { start?: string; end: string },
|
||||||
|
|||||||
@@ -269,7 +269,7 @@ function submissionsHref(
|
|||||||
>
|
>
|
||||||
</n-text>
|
</n-text>
|
||||||
<n-button :loading="loading" @click="load">刷新</n-button>
|
<n-button :loading="loading" @click="load">刷新</n-button>
|
||||||
<!-- 看板只管今天;以前几节课的、任意时间段的去数据统计(带上这个班) -->
|
<!-- 看板只管今天;以前几节课的、任意时间段的去统计(带上这个班) -->
|
||||||
<n-button
|
<n-button
|
||||||
v-if="!projector"
|
v-if="!projector"
|
||||||
@click="router.push({ name: 'statistics', query: className ? { className } : {} })"
|
@click="router.push({ name: 'statistics', query: className ? { className } : {} })"
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { useProblemPageContext } from "../composables/problemPageContext"
|
|||||||
/**
|
/**
|
||||||
* 「统计」页签(设计稿「题目页统计重设计」学生 A)。讲的是**这道题**:难不难 → 大家几次
|
* 「统计」页签(设计稿「题目页统计重设计」学生 A)。讲的是**这道题**:难不难 → 大家几次
|
||||||
* 做对 → 常错在哪,全部按人算。学生多一行「你们班」、自己卡住了再多一张卡;老师多一张
|
* 做对 → 常错在哪,全部按人算。学生多一行「你们班」、自己卡住了再多一张卡;老师多一张
|
||||||
* 各班的表(只到班级这一层,不选班、不列人 —— 按人看是提交页「数据统计」的活)。
|
* 各班的表(只到班级这一层,不选班、不列人 —— 按人看是提交页「统计」的活)。
|
||||||
* 原来的描述表、击败用户、饼图、历年 AC 率都去掉了。
|
* 原来的描述表、击败用户、饼图、历年 AC 率都去掉了。
|
||||||
*/
|
*/
|
||||||
const emit = defineEmits<{ openSubmissions: [] }>()
|
const emit = defineEmits<{ openSubmissions: [] }>()
|
||||||
@@ -139,7 +139,7 @@ const hiddenClasses = computed(
|
|||||||
() => (stats.value?.classes?.length ?? 0) - shownClasses.value.length,
|
() => (stats.value?.classes?.length ?? 0) - shownClasses.value.length,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** 点一个班、或者表下面那行:去提交页的「数据统计」按人看(新标签,题目页不动) */
|
/** 点一个班、或者表下面那行:去「统计」按人看(新标签,题目页不动) */
|
||||||
function openStatistics(className?: string) {
|
function openStatistics(className?: string) {
|
||||||
const href = router.resolve({
|
const href = router.resolve({
|
||||||
name: "statistics",
|
name: "statistics",
|
||||||
@@ -273,7 +273,7 @@ function segmentWidth(value: number, total: number) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 老师:各班一行,和数据统计「按班级汇总」同一份数;按人看去那边 -->
|
<!-- 老师:各班一行,和统计「按班级汇总」同一份数;按人看去那边 -->
|
||||||
<div v-if="stats.classes?.length" class="section">
|
<div v-if="stats.classes?.length" class="section">
|
||||||
<b>各班做得怎样</b>
|
<b>各班做得怎样</b>
|
||||||
<table class="classes">
|
<table class="classes">
|
||||||
@@ -292,7 +292,7 @@ function segmentWidth(value: number, total: number) {
|
|||||||
v-for="item in shownClasses"
|
v-for="item in shownClasses"
|
||||||
:key="item.className ?? ''"
|
:key="item.className ?? ''"
|
||||||
:class="{ pickable: item.className }"
|
:class="{ pickable: item.className }"
|
||||||
:title="item.className ? '去数据统计看这个班的每个人' : undefined"
|
:title="item.className ? '新标签看这个班的每个人' : undefined"
|
||||||
@click="item.className && openStatistics(item.className)"
|
@click="item.className && openStatistics(item.className)"
|
||||||
>
|
>
|
||||||
<td>
|
<td>
|
||||||
@@ -332,7 +332,7 @@ function segmentWidth(value: number, total: number) {
|
|||||||
</a>
|
</a>
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
<a href="#" class="link small" @click.prevent="openStatistics()">
|
<a href="#" class="link small" @click.prevent="openStatistics()">
|
||||||
按人看谁没做对,去数据统计 <Icon icon="ph:arrow-square-out" :width="13" />
|
按人看谁没做对 <Icon icon="ph:arrow-square-out" :width="13" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,561 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Icon } from "@iconify/vue"
|
||||||
|
import { useThemeVars } from "naive-ui"
|
||||||
|
import type { SubmissionLessons } from "@oj2/contract"
|
||||||
|
import { parseTime, zonedParts } from "utils/functions"
|
||||||
|
import { useTone } from "oj/submission/composables/tone"
|
||||||
|
import { classLabel } from "oj/submission/utils"
|
||||||
|
import { resultName, useResultColor } from "../results"
|
||||||
|
|
||||||
|
type Lesson = SubmissionLessons["lessons"][number]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计「一行一节课」的总览(设计稿「统计合二为一」B)。没选班、没填学生和题号时用它:
|
||||||
|
* 原来「今日统计」老师那一版(今天哪几个班上了课、几点到几点、错得最多的题)并进来,
|
||||||
|
* 换成任意时间段。按天分组,从近到远 —— 这节课在最上面;点一行进那节课(班、题、时段都带上)。
|
||||||
|
* 课怎么切的在后端(契约 submissionLessonsSchema)。
|
||||||
|
*/
|
||||||
|
const props = defineProps<{
|
||||||
|
data: SubmissionLessons
|
||||||
|
/** 时间段落在今天以内:每行多一根 7–20 点的横条(原来今日统计的时间轴) */
|
||||||
|
timeline: boolean
|
||||||
|
/** 「再往前看」在加载 */
|
||||||
|
loadingMore: boolean
|
||||||
|
}>()
|
||||||
|
const emit = defineEmits<{
|
||||||
|
pick: [lesson: Lesson]
|
||||||
|
/** 点「错得最多的题」里的一道:只看这道(按班级汇总) */
|
||||||
|
problem: [displayId: string]
|
||||||
|
more: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const theme = useThemeVars()
|
||||||
|
const tone = useTone()
|
||||||
|
const resultColor = useResultColor()
|
||||||
|
|
||||||
|
const WEEKDAYS = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"]
|
||||||
|
|
||||||
|
function dayText(day: string) {
|
||||||
|
const [y, m, d] = day.split("-").map(Number) as [number, number, number]
|
||||||
|
const weekday = WEEKDAYS[new Date(Date.UTC(y, m - 1, d)).getUTCDay()]
|
||||||
|
const today = parseTime(new Date(), "YYYY-MM-DD")
|
||||||
|
const yesterday = parseTime(new Date(Date.now() - 86_400_000), "YYYY-MM-DD")
|
||||||
|
const date = `${m}月${d}日 ${weekday}`
|
||||||
|
if (day === today) return `今天 · ${date}`
|
||||||
|
if (day === yesterday) return `昨天 · ${date}`
|
||||||
|
return y === Number(today.slice(0, 4)) ? date : `${y}年${date}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按天分组,天和天里的课都是从近到远,零散提交挂在对应那天下面 */
|
||||||
|
const days = computed(() => {
|
||||||
|
const map = new Map<string, { lessons: Lesson[]; scattered: string }>()
|
||||||
|
for (const lesson of props.data.lessons) {
|
||||||
|
const entry = map.get(lesson.day) ?? { lessons: [], scattered: "" }
|
||||||
|
entry.lessons.push(lesson)
|
||||||
|
map.set(lesson.day, entry)
|
||||||
|
}
|
||||||
|
const scatteredByDay = new Map<string, string[]>()
|
||||||
|
for (const item of props.data.scattered) {
|
||||||
|
const name = item.className ? classLabel(item.className) : "没填班级的"
|
||||||
|
const text = `${name} ${item.userCount} 人 ${item.total} 次`
|
||||||
|
scatteredByDay.set(item.day, [...(scatteredByDay.get(item.day) ?? []), text])
|
||||||
|
if (!map.has(item.day)) map.set(item.day, { lessons: [], scattered: "" })
|
||||||
|
}
|
||||||
|
return [...map.entries()]
|
||||||
|
.sort((a, b) => b[0].localeCompare(a[0]))
|
||||||
|
.map(([day, entry]) => ({
|
||||||
|
day,
|
||||||
|
label: dayText(day),
|
||||||
|
// 最近的一节在最上面(用户说的):打开就看到这节课,往下是更早的
|
||||||
|
lessons: [...entry.lessons].sort((a, b) => b.start.localeCompare(a.start)),
|
||||||
|
scattered: (scatteredByDay.get(day) ?? []).join(" · "),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
const lessonCount = computed(() => props.data.lessons.length)
|
||||||
|
const liveLessons = computed(() => props.data.lessons.filter((lesson) => lesson.live))
|
||||||
|
|
||||||
|
function clock(lesson: Lesson) {
|
||||||
|
return `${parseTime(lesson.start, "HH:mm")}–${parseTime(lesson.end, "HH:mm")}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 23 人来了、花名册却只有 22(有个号不是学生角色)时,不写「23 / 22」 */
|
||||||
|
function attendance(lesson: Lesson) {
|
||||||
|
return lesson.classSize >= lesson.userCount
|
||||||
|
? `${lesson.userCount} / ${lesson.classSize} 人`
|
||||||
|
: `${lesson.userCount} 人`
|
||||||
|
}
|
||||||
|
|
||||||
|
function problemText(lesson: Lesson) {
|
||||||
|
return `${lesson.fromProblemSet ? "题单 " : ""}${lesson.problems.length} 道`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 时间轴(只在今天以内) ----------
|
||||||
|
|
||||||
|
function hourOf(value: string) {
|
||||||
|
const p = zonedParts(value)!
|
||||||
|
return p.hour + p.minute / 60
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 横轴默认 7 点到 20 点(机房上课的时段),有更早更晚的课就往外扩 */
|
||||||
|
const axis = computed(() => {
|
||||||
|
const list = props.data.lessons
|
||||||
|
const start = Math.min(7, ...list.map((item) => Math.floor(hourOf(item.start))))
|
||||||
|
const end = Math.max(20, ...list.map((item) => Math.ceil(hourOf(item.end))))
|
||||||
|
const ticks = []
|
||||||
|
for (let hour = start + (start % 2); hour <= end; hour += 2) ticks.push(hour)
|
||||||
|
return { start, end, ticks }
|
||||||
|
})
|
||||||
|
|
||||||
|
function axisLeft(hour: number) {
|
||||||
|
const { start, end } = axis.value
|
||||||
|
return `${((hour - start) / (end - start)) * 100}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
function barStyle(lesson: Lesson) {
|
||||||
|
const from = hourOf(lesson.start)
|
||||||
|
const to = hourOf(lesson.end)
|
||||||
|
const { start, end } = axis.value
|
||||||
|
return {
|
||||||
|
left: axisLeft(from),
|
||||||
|
width: `max(6px, ${((to - from) / (end - start)) * 100}%)`,
|
||||||
|
background: lesson.live ? theme.value.successColor : "#3f5f87",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nowHour = computed(() => {
|
||||||
|
const hour = hourOf(new Date().toISOString())
|
||||||
|
return hour >= axis.value.start && hour <= axis.value.end ? hour : null
|
||||||
|
})
|
||||||
|
|
||||||
|
function percent(value: number, total: number) {
|
||||||
|
return total ? `${(value / total) * 100}%` : "0"
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="lessons">
|
||||||
|
<div
|
||||||
|
v-if="liveLessons.length"
|
||||||
|
class="live"
|
||||||
|
:style="{ background: tone('success').background, color: tone('success').color }"
|
||||||
|
>
|
||||||
|
<span>{{ liveLessons.map((item) => classLabel(item.className)).join("、") }}正在上课</span>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<a href="#" class="live-link" @click.prevent="router.push('/classroom')">
|
||||||
|
<Icon icon="ph:monitor" :width="14" />去课堂看板
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!lessonCount && !data.scattered.length" class="empty">这段时间没有人交</div>
|
||||||
|
<div v-else-if="!lessonCount" class="note">
|
||||||
|
这段时间没有哪个班一起上课(同一个班 5 人以上做同一道题才算一节课),只有零散提交
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-for="group in days" :key="group.day">
|
||||||
|
<div class="day">{{ group.label }}</div>
|
||||||
|
<a
|
||||||
|
v-for="lesson in group.lessons"
|
||||||
|
:key="lesson.className + lesson.start"
|
||||||
|
href="#"
|
||||||
|
class="row"
|
||||||
|
@click.prevent="emit('pick', lesson)"
|
||||||
|
>
|
||||||
|
<span class="c-time">{{ clock(lesson) }}</span>
|
||||||
|
<span v-if="timeline" class="c-axis">
|
||||||
|
<span
|
||||||
|
v-for="tick in axis.ticks"
|
||||||
|
:key="tick"
|
||||||
|
class="grid"
|
||||||
|
:style="{ left: axisLeft(tick) }"
|
||||||
|
></span>
|
||||||
|
<span class="bar" :style="barStyle(lesson)"></span>
|
||||||
|
<span v-if="nowHour !== null" class="now" :style="{ left: axisLeft(nowHour) }"></span>
|
||||||
|
</span>
|
||||||
|
<b class="c-class">{{ classLabel(lesson.className) }}</b>
|
||||||
|
<span class="c-came">{{ attendance(lesson) }}</span>
|
||||||
|
<span class="c-probs">{{ problemText(lesson) }}</span>
|
||||||
|
<span class="c-rate">
|
||||||
|
{{ lesson.total }} 次 ·
|
||||||
|
{{ lesson.live ? "上课中" : `正确率 ${Math.round(lesson.correctRate)}%` }}
|
||||||
|
</span>
|
||||||
|
<span class="c-hard">
|
||||||
|
<template v-if="lesson.hardest">
|
||||||
|
<n-text depth="3">错得最多</n-text>
|
||||||
|
<n-text depth="3" class="pid">{{ lesson.hardest.problemDisplayId }}</n-text>
|
||||||
|
{{ lesson.hardest.problemTitle }}
|
||||||
|
<span :style="{ color: tone('warning').color }"
|
||||||
|
>· 没过 {{ lesson.hardest.failed }} 次</span
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
<span class="c-go">看这节课<Icon icon="ph:caret-right-bold" :width="12" /></span>
|
||||||
|
</a>
|
||||||
|
<div v-if="group.scattered" class="scattered">
|
||||||
|
{{ group.lessons.length ? "另有零散提交:" : "零散提交:" }}{{ group.scattered }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-if="timeline && lessonCount" class="axis-row">
|
||||||
|
<span class="c-time"></span>
|
||||||
|
<span class="c-axis axis">
|
||||||
|
<!-- 挨着「现在」的刻度不写,不然两个字叠在一起 -->
|
||||||
|
<template v-for="tick in axis.ticks" :key="tick">
|
||||||
|
<span
|
||||||
|
v-if="nowHour === null || Math.abs(tick - nowHour) > 0.8"
|
||||||
|
:style="{ left: axisLeft(tick) }"
|
||||||
|
>
|
||||||
|
{{ tick }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<span v-if="nowHour !== null" class="now-label" :style="{ left: axisLeft(nowHour) }">
|
||||||
|
现在
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="data.hasMore" class="more">
|
||||||
|
<n-button size="small" :loading="loadingMore" @click="emit('more')">
|
||||||
|
更早的课(现在列了最近 {{ lessonCount }} 节)
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="data.hardProblems.length">
|
||||||
|
<div class="section-title">
|
||||||
|
<b>错得最多的题</b>
|
||||||
|
<n-text depth="3" class="small">按没过的次数 · 点题目看各班做得怎样</n-text>
|
||||||
|
</div>
|
||||||
|
<table class="hard">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>题目</th>
|
||||||
|
<th>哪个班</th>
|
||||||
|
<th>提交</th>
|
||||||
|
<th>没过的原因</th>
|
||||||
|
<th class="right">做对</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="item in data.hardProblems" :key="item.problemDisplayId">
|
||||||
|
<td class="hard-title">
|
||||||
|
<a href="#" @click.prevent="emit('problem', item.problemDisplayId)">
|
||||||
|
<n-text depth="3">{{ item.problemDisplayId }}</n-text> {{ item.problemTitle }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<n-text depth="2">{{ item.className ? classLabel(item.className) : "—" }}</n-text>
|
||||||
|
</td>
|
||||||
|
<td>{{ item.total }} 次</td>
|
||||||
|
<td>
|
||||||
|
<span class="mini">
|
||||||
|
<span
|
||||||
|
:style="{ width: percent(item.accepted, item.total), background: resultColor(0) }"
|
||||||
|
></span>
|
||||||
|
<span
|
||||||
|
v-for="fail in item.failures"
|
||||||
|
:key="fail.result"
|
||||||
|
:style="{
|
||||||
|
width: percent(fail.count, item.total),
|
||||||
|
background: resultColor(fail.result),
|
||||||
|
}"
|
||||||
|
></span>
|
||||||
|
</span>
|
||||||
|
<n-text v-if="item.failures[0]" depth="2" class="small">
|
||||||
|
{{ resultName(item.failures[0].result) }} {{ item.failures[0].count }}
|
||||||
|
</n-text>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
class="right"
|
||||||
|
:style="{
|
||||||
|
color: item.acceptedUsers < item.userCount ? tone('error').color : undefined,
|
||||||
|
fontWeight: item.acceptedUsers < item.userCount ? 600 : undefined,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ item.acceptedUsers }} / {{ item.userCount }} 人
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.lessons {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin: 12px 20px 4px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-link {
|
||||||
|
color: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
flex: 1 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 48px 0;
|
||||||
|
text-align: center;
|
||||||
|
color: v-bind("theme.textColor3");
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
padding: 16px 20px 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: v-bind("theme.textColor3");
|
||||||
|
}
|
||||||
|
|
||||||
|
.day {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
height: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: v-bind("theme.textColor3");
|
||||||
|
background: v-bind("theme.actionColor");
|
||||||
|
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
height: 44px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 20px;
|
||||||
|
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||||
|
color: v-bind("theme.textColor1");
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:hover {
|
||||||
|
background: v-bind("theme.hoverColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-time {
|
||||||
|
width: 110px;
|
||||||
|
flex: none;
|
||||||
|
color: v-bind("theme.textColor2");
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-axis {
|
||||||
|
position: relative;
|
||||||
|
width: 200px;
|
||||||
|
height: 28px;
|
||||||
|
flex: none;
|
||||||
|
margin-right: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-class {
|
||||||
|
width: 120px;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-came,
|
||||||
|
.c-probs {
|
||||||
|
width: 96px;
|
||||||
|
flex: none;
|
||||||
|
color: v-bind("theme.textColor2");
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-rate {
|
||||||
|
width: 170px;
|
||||||
|
flex: none;
|
||||||
|
color: v-bind("theme.textColor2");
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-hard {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pid {
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-go {
|
||||||
|
flex: none;
|
||||||
|
margin-left: 12px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: v-bind("theme.primaryColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
bottom: 4px;
|
||||||
|
width: 1px;
|
||||||
|
background: v-bind("theme.dividerColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
height: 12px;
|
||||||
|
margin-top: -6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.now {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 2px;
|
||||||
|
background: v-bind("theme.successColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
.axis-row {
|
||||||
|
display: flex;
|
||||||
|
padding: 0 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.axis-row .c-axis {
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.axis span {
|
||||||
|
position: absolute;
|
||||||
|
top: 3px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 11px;
|
||||||
|
color: v-bind("theme.textColor3");
|
||||||
|
}
|
||||||
|
|
||||||
|
.axis .now-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: v-bind("theme.successColor");
|
||||||
|
background: v-bind("theme.cardColor");
|
||||||
|
padding: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scattered {
|
||||||
|
padding: 7px 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: v-bind("theme.textColor3");
|
||||||
|
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
.more {
|
||||||
|
padding: 12px 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 18px 20px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.small {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hard {
|
||||||
|
width: calc(100% - 40px);
|
||||||
|
margin: 0 20px;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hard th {
|
||||||
|
text-align: left;
|
||||||
|
font-weight: normal;
|
||||||
|
color: v-bind("theme.textColor3");
|
||||||
|
padding: 4px 8px 6px 0;
|
||||||
|
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
.hard td {
|
||||||
|
padding: 8px 8px 8px 0;
|
||||||
|
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hard .right {
|
||||||
|
text-align: right;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hard-title a {
|
||||||
|
color: v-bind("theme.textColor1");
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hard-title a:hover {
|
||||||
|
color: v-bind("theme.primaryColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini {
|
||||||
|
display: inline-flex;
|
||||||
|
width: 70px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
gap: 1px;
|
||||||
|
margin-right: 8px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 1366 以下(机房 1280):横条窄一点,给「错得最多」留地方 */
|
||||||
|
@media (max-width: 1365px) {
|
||||||
|
.c-axis {
|
||||||
|
width: 150px;
|
||||||
|
margin-right: 14px;
|
||||||
|
}
|
||||||
|
.c-rate {
|
||||||
|
width: 150px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 手机:老师很少在手机上看统计,只保证不坏 —— 一行折成两行 */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.row {
|
||||||
|
height: auto;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
row-gap: 4px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
.c-axis,
|
||||||
|
.axis-row {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.c-hard {
|
||||||
|
flex-basis: 100%;
|
||||||
|
}
|
||||||
|
.hard {
|
||||||
|
display: block;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -3,11 +3,13 @@ import { Icon } from "@iconify/vue"
|
|||||||
import { useThemeVars } from "naive-ui"
|
import { useThemeVars } from "naive-ui"
|
||||||
import type {
|
import type {
|
||||||
FlowchartStatistics,
|
FlowchartStatistics,
|
||||||
|
SubmissionLessons,
|
||||||
SubmissionStatistics,
|
SubmissionStatistics,
|
||||||
SubmissionStatisticsGrid,
|
SubmissionStatisticsGrid,
|
||||||
} from "@oj2/contract"
|
} from "@oj2/contract"
|
||||||
import {
|
import {
|
||||||
getFlowchartStatistics,
|
getFlowchartStatistics,
|
||||||
|
getSubmissionLessons,
|
||||||
getSubmissionStatistics,
|
getSubmissionStatistics,
|
||||||
getSubmissionStatisticsGrid,
|
getSubmissionStatisticsGrid,
|
||||||
} from "oj/api"
|
} from "oj/api"
|
||||||
@@ -16,15 +18,24 @@ import { fromPickerValue, toPickerValue } from "utils/functions"
|
|||||||
import { useTone } from "oj/submission/composables/tone"
|
import { useTone } from "oj/submission/composables/tone"
|
||||||
import { classLabel } from "oj/submission/utils"
|
import { classLabel } from "oj/submission/utils"
|
||||||
import { DEFAULT_PERIOD, PERIOD_OPTIONS, periodRange, type Period } from "../period"
|
import { DEFAULT_PERIOD, PERIOD_OPTIONS, periodRange, type Period } from "../period"
|
||||||
|
import { resultName, useResultColor } from "../results"
|
||||||
import ByClass from "./ByClass.vue"
|
import ByClass from "./ByClass.vue"
|
||||||
import CodeStats from "./CodeStats.vue"
|
import CodeStats from "./CodeStats.vue"
|
||||||
|
import Lessons from "./Lessons.vue"
|
||||||
import RingChart from "./RingChart.vue"
|
import RingChart from "./RingChart.vue"
|
||||||
import StatItem from "./StatItem.vue"
|
import StatItem from "./StatItem.vue"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 数据统计的主体(设计稿「统计重设计 · 定稿 B」)。两处用它:提交列表里的弹框(老师
|
* 统计的主体(设计稿「统计重设计 · 定稿 B」+「统计合二为一」B)。两处用它:提交列表里的弹框
|
||||||
* 平时从这里看),和 /statistics 页面(课堂看板「回头看」、题目页、今日统计之类从别处
|
* (老师平时从这里看),和 /statistics 页面(课堂看板「回头看」、题目页之类从别处进来的,
|
||||||
* 进来的,条件放在地址栏里能刷新、能转发)。
|
* 条件放在地址栏里能刷新、能转发)。
|
||||||
|
*
|
||||||
|
* 代码页签按条件分三种看法:
|
||||||
|
* - 什么都没填:**一行一节课**(Lessons)。原来「今日统计」老师那一版并进来了,选「今天」
|
||||||
|
* 就是它,选别的时间段也一样有
|
||||||
|
* - 只填了题号:**按班级汇总**(ByClass)—— 题目页统计页签点进来就是这种,口径和那边的各班表一致
|
||||||
|
* - 选了班或填了学生:左边题目、右边学生(CodeStats)
|
||||||
|
* 从前两种点进去时左上角有「← 全部」回来。
|
||||||
*
|
*
|
||||||
* 分工:**课堂看板管这节课,这里管回头看**(用户定的)。所以不自动刷新、没有
|
* 分工:**课堂看板管这节课,这里管回头看**(用户定的)。所以不自动刷新、没有
|
||||||
* 投影用的大字名单和请假隐藏 —— 那些在看板上。课堂统计一般看一两节课,时间段默认「一节课」。
|
* 投影用的大字名单和请假隐藏 —— 那些在看板上。课堂统计一般看一两节课,时间段默认「一节课」。
|
||||||
@@ -44,10 +55,10 @@ const emit = defineEmits<{
|
|||||||
list: [query: Record<string, string>]
|
list: [query: Record<string, string>]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const configStore = useConfigStore()
|
const configStore = useConfigStore()
|
||||||
const theme = useThemeVars()
|
const theme = useThemeVars()
|
||||||
const tone = useTone()
|
const tone = useTone()
|
||||||
|
const resultColor = useResultColor()
|
||||||
|
|
||||||
const tab = defineModel<string>("tab", { default: "code" })
|
const tab = defineModel<string>("tab", { default: "code" })
|
||||||
const className = defineModel<string>("className", { default: "" })
|
const className = defineModel<string>("className", { default: "" })
|
||||||
@@ -57,12 +68,6 @@ const period = defineModel<string>("period", { default: DEFAULT_PERIOD })
|
|||||||
const from = defineModel<string>("from", { default: "" })
|
const from = defineModel<string>("from", { default: "" })
|
||||||
const to = defineModel<string>("to", { default: "" })
|
const to = defineModel<string>("to", { default: "" })
|
||||||
|
|
||||||
const periodValue = computed<Period>(() =>
|
|
||||||
PERIOD_OPTIONS.some((option) => option.value === period.value)
|
|
||||||
? (period.value as Period)
|
|
||||||
: DEFAULT_PERIOD,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** 地址栏里的 from / to 是手改过、不是数字的话当没选(不然 toISOString 抛错整页空白) */
|
/** 地址栏里的 from / to 是手改过、不是数字的话当没选(不然 toISOString 抛错整页空白) */
|
||||||
const custom = computed<[number, number] | null>(() => {
|
const custom = computed<[number, number] | null>(() => {
|
||||||
const a = Number(from.value)
|
const a = Number(from.value)
|
||||||
@@ -70,6 +75,20 @@ const custom = computed<[number, number] | null>(() => {
|
|||||||
return from.value && to.value && Number.isFinite(a) && Number.isFinite(b) ? [a, b] : null
|
return from.value && to.value && Number.isFinite(a) && Number.isFinite(b) ? [a, b] : null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const periodValue = computed<Period>(() => {
|
||||||
|
// 「那节课」要带着精确的起止时刻,地址栏里缺了就退回默认
|
||||||
|
if (period.value === "lesson") return custom.value ? "lesson" : DEFAULT_PERIOD
|
||||||
|
return PERIOD_OPTIONS.some((option) => option.value === period.value)
|
||||||
|
? (period.value as Period)
|
||||||
|
: DEFAULT_PERIOD
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 从「一行一节课」点进某一节课时,下拉里临时多一项「那节课」 */
|
||||||
|
const periodOptions = computed(() => [
|
||||||
|
...(periodValue.value === "lesson" ? [{ label: "那节课", value: "lesson" }] : []),
|
||||||
|
...PERIOD_OPTIONS.map((o) => ({ label: o.label, value: o.value as string })),
|
||||||
|
])
|
||||||
|
|
||||||
/** n-date-picker 按浏览器时区渲染,存取都要平移到东八区(见 utils/functions 的注释) */
|
/** n-date-picker 按浏览器时区渲染,存取都要平移到东八区(见 utils/functions 的注释) */
|
||||||
const pickerValue = computed<[number, number] | null>(() =>
|
const pickerValue = computed<[number, number] | null>(() =>
|
||||||
custom.value ? [toPickerValue(custom.value[0]), toPickerValue(custom.value[1])] : null,
|
custom.value ? [toPickerValue(custom.value[0]), toPickerValue(custom.value[1])] : null,
|
||||||
@@ -99,14 +118,31 @@ const loading = ref(false)
|
|||||||
const failed = ref("")
|
const failed = ref("")
|
||||||
const stats = ref<SubmissionStatistics | null>(null)
|
const stats = ref<SubmissionStatistics | null>(null)
|
||||||
const grid = ref<SubmissionStatisticsGrid | null>(null)
|
const grid = ref<SubmissionStatisticsGrid | null>(null)
|
||||||
|
const lessons = ref<SubmissionLessons | null>(null)
|
||||||
const flow = ref<FlowchartStatistics | null>(null)
|
const flow = ref<FlowchartStatistics | null>(null)
|
||||||
|
|
||||||
|
type Mode = "lessons" | "byClass" | "people"
|
||||||
|
/** 三种看法,见顶上的注释 */
|
||||||
|
const mode = computed<Mode>(() =>
|
||||||
|
className.value || studentParam.value ? "people" : problem.value.trim() ? "byClass" : "lessons",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 一次列几节课,「更早的课」每点一次多 50 节 */
|
||||||
|
const LESSON_PAGE = 50
|
||||||
|
const lessonLimit = ref(LESSON_PAGE)
|
||||||
|
const loadingMore = ref(false)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 这一份数据是按什么条件查出来的。页面上的标签(按班级汇总还是看人、「做完」还是
|
* 这一份数据是按什么条件查出来的。页面上的标签(按班级汇总还是看人、「做完」还是
|
||||||
* 「做对过题」、时间范围那句话)跟着它走,不跟着输入框 —— 老师边打字边看时,数据还是
|
* 「做对过题」、时间范围那句话)跟着它走,不跟着输入框 —— 老师边打字边看时,数据还是
|
||||||
* 上一次的,标签先变了就对不上
|
* 上一次的,标签先变了就对不上
|
||||||
*/
|
*/
|
||||||
const shown = ref({ byClass: true, explicit: false, rangeText: "", withinToday: false })
|
const shown = ref<{ mode: Mode; explicit: boolean; rangeText: string; withinToday: boolean }>({
|
||||||
|
mode: "lessons",
|
||||||
|
explicit: false,
|
||||||
|
rangeText: "",
|
||||||
|
withinToday: false,
|
||||||
|
})
|
||||||
let seq = 0
|
let seq = 0
|
||||||
|
|
||||||
const ERROR_TEXT: Record<string, string> = {
|
const ERROR_TEXT: Record<string, string> = {
|
||||||
@@ -119,7 +155,7 @@ async function load() {
|
|||||||
const range = periodRange(periodValue.value, custom.value)
|
const range = periodRange(periodValue.value, custom.value)
|
||||||
const duration = { start: range.start, end: range.end }
|
const duration = { start: range.start, end: range.end }
|
||||||
const snapshot = {
|
const snapshot = {
|
||||||
byClass: !studentParam.value && !className.value,
|
mode: mode.value,
|
||||||
explicit: !!problem.value.trim(),
|
explicit: !!problem.value.trim(),
|
||||||
rangeText: range.text,
|
rangeText: range.text,
|
||||||
withinToday: range.withinToday,
|
withinToday: range.withinToday,
|
||||||
@@ -136,6 +172,16 @@ async function load() {
|
|||||||
)
|
)
|
||||||
if (current !== seq) return
|
if (current !== seq) return
|
||||||
flow.value = res
|
flow.value = res
|
||||||
|
} else if (snapshot.mode === "lessons") {
|
||||||
|
// 一行一节课不要方块串:那个接口最多 5000 条,课在后端切
|
||||||
|
lessonLimit.value = LESSON_PAGE
|
||||||
|
const [s, l] = await Promise.all([
|
||||||
|
getSubmissionStatistics(duration, problem.value, studentParam.value, className.value),
|
||||||
|
getSubmissionLessons(duration, lessonLimit.value),
|
||||||
|
])
|
||||||
|
if (current !== seq) return
|
||||||
|
stats.value = s
|
||||||
|
lessons.value = l
|
||||||
} else {
|
} else {
|
||||||
const [s, g] = await Promise.all([
|
const [s, g] = await Promise.all([
|
||||||
getSubmissionStatistics(duration, problem.value, studentParam.value, className.value),
|
getSubmissionStatistics(duration, problem.value, studentParam.value, className.value),
|
||||||
@@ -151,6 +197,7 @@ async function load() {
|
|||||||
// 失败了就别留着上一次的结果配这一次的条件 —— 清掉,说清楚为什么
|
// 失败了就别留着上一次的结果配这一次的条件 —— 清掉,说清楚为什么
|
||||||
stats.value = null
|
stats.value = null
|
||||||
grid.value = null
|
grid.value = null
|
||||||
|
lessons.value = null
|
||||||
flow.value = null
|
flow.value = null
|
||||||
const code = (error as { error?: string })?.error ?? ""
|
const code = (error as { error?: string })?.error ?? ""
|
||||||
failed.value = ERROR_TEXT[code] ?? "没拉下来,改一下条件再试"
|
failed.value = ERROR_TEXT[code] ?? "没拉下来,改一下条件再试"
|
||||||
@@ -159,6 +206,26 @@ async function load() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 「更早的课」:只重拉课那一份,数字行不动 */
|
||||||
|
async function loadMore() {
|
||||||
|
const current = seq
|
||||||
|
loadingMore.value = true
|
||||||
|
try {
|
||||||
|
const range = periodRange(periodValue.value, custom.value)
|
||||||
|
const next = await getSubmissionLessons(
|
||||||
|
{ start: range.start, end: range.end },
|
||||||
|
lessonLimit.value + LESSON_PAGE,
|
||||||
|
)
|
||||||
|
if (current !== seq) return
|
||||||
|
lessonLimit.value += LESSON_PAGE
|
||||||
|
lessons.value = next
|
||||||
|
} catch {
|
||||||
|
if (current === seq) failed.value = "更早的课没拉下来,再点一次试试"
|
||||||
|
} finally {
|
||||||
|
loadingMore.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(load)
|
onMounted(load)
|
||||||
watch([tab, className, periodValue, custom], load)
|
watch([tab, className, periodValue, custom], load)
|
||||||
watchDebounced([username, problem], load, { debounce: 600, maxWait: 1500 })
|
watchDebounced([username, problem], load, { debounce: 600, maxWait: 1500 })
|
||||||
@@ -176,11 +243,13 @@ const judged = computed(() =>
|
|||||||
stats.value ? stats.value.submissionCount - stats.value.judgingCount : 0,
|
stats.value ? stats.value.submissionCount - stats.value.judgingCount : 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
// ---------- 两个入口 ----------
|
// ---------- 右上角的入口 ----------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 1366 以下(机房 1280 的屏)右上两个入口只留图标。弹框比屏幕窄一圈、还多一个关闭,
|
* 1366 以下(机房 1280 的屏)「在提交列表里看」只留图标。弹框比屏幕窄一圈、还多一个关闭,
|
||||||
* 1366 的屏上整排放不下,所以弹框里宽一档才出字
|
* 1366 的屏上整排放不下,所以弹框里宽一档才出字。
|
||||||
|
* 原来旁边还有一个「这节课去课堂看板」:顶栏已经有看板,统计里再放一个成了干扰项(用户说的),
|
||||||
|
* 现在只在有班正在上课时,在课的列表上方引过去
|
||||||
*/
|
*/
|
||||||
const narrow = useMediaQuery(props.modal ? "(max-width: 1439px)" : "(max-width: 1365px)")
|
const narrow = useMediaQuery(props.modal ? "(max-width: 1439px)" : "(max-width: 1365px)")
|
||||||
|
|
||||||
@@ -194,16 +263,79 @@ function openList() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- 点进去 / 回来 ----------
|
||||||
|
|
||||||
|
type Snapshot = Record<"className" | "username" | "problem" | "period" | "from" | "to", string>
|
||||||
|
/** 从「一行一节课」或「按班级汇总」点进去之前的条件,「← 全部」拿它回去 */
|
||||||
|
const backTo = ref<Snapshot | null>(null)
|
||||||
|
|
||||||
|
function remember() {
|
||||||
|
backTo.value = {
|
||||||
|
className: className.value,
|
||||||
|
username: username.value,
|
||||||
|
problem: problem.value,
|
||||||
|
period: period.value,
|
||||||
|
from: from.value,
|
||||||
|
to: to.value,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
const snap = backTo.value
|
||||||
|
if (!snap) return
|
||||||
|
backTo.value = null
|
||||||
|
className.value = snap.className
|
||||||
|
username.value = snap.username
|
||||||
|
problem.value = snap.problem
|
||||||
|
period.value = snap.period
|
||||||
|
from.value = snap.from
|
||||||
|
to.value = snap.to
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自己把条件清回了总览,就不用再显示「← 全部」
|
||||||
|
watch(mode, (value) => {
|
||||||
|
if (value === "lessons") backTo.value = null
|
||||||
|
})
|
||||||
|
|
||||||
function pickClass(value: string) {
|
function pickClass(value: string) {
|
||||||
|
remember()
|
||||||
className.value = value
|
className.value = value
|
||||||
username.value = ""
|
username.value = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 点一节课:这个班、这节课的题、这节课第一条到最后一条(多留 1 秒:库里存到微秒,转成毫秒会截掉零头) */
|
||||||
|
function pickLesson(lesson: SubmissionLessons["lessons"][number]) {
|
||||||
|
remember()
|
||||||
|
className.value = lesson.className
|
||||||
|
username.value = ""
|
||||||
|
problem.value = lesson.problems.join(" ")
|
||||||
|
from.value = String(Date.parse(lesson.start))
|
||||||
|
to.value = String(Date.parse(lesson.end) + 1000)
|
||||||
|
period.value = "lesson"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 点「错得最多的题」里的一道:只看这道,按班级汇总 */
|
||||||
|
function pickProblem(displayId: string) {
|
||||||
|
remember()
|
||||||
|
problem.value = displayId
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 数字行的结果条 ----------
|
||||||
|
|
||||||
|
const results = computed(() => stats.value?.results ?? [])
|
||||||
|
function resultWidth(value: number) {
|
||||||
|
const total = stats.value?.submissionCount ?? 0
|
||||||
|
return total ? `${(value / total) * 100}%` : "0"
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="stats" :class="{ modal }">
|
<div class="stats" :class="{ modal }">
|
||||||
<div class="filters">
|
<div class="filters">
|
||||||
<span class="title">数据统计</span>
|
<span class="title">统计</span>
|
||||||
|
<button v-if="backTo" type="button" class="back" title="回到点进来之前" @click="goBack">
|
||||||
|
<Icon icon="ph:caret-left-bold" :width="13" />全部
|
||||||
|
</button>
|
||||||
<n-radio-group v-model:value="tab" size="small">
|
<n-radio-group v-model:value="tab" size="small">
|
||||||
<n-radio-button value="code">代码</n-radio-button>
|
<n-radio-button value="code">代码</n-radio-button>
|
||||||
<n-radio-button value="flow">流程图</n-radio-button>
|
<n-radio-button value="flow">流程图</n-radio-button>
|
||||||
@@ -235,7 +367,7 @@ function pickClass(value: string) {
|
|||||||
:value="periodValue"
|
:value="periodValue"
|
||||||
class="w-period"
|
class="w-period"
|
||||||
size="small"
|
size="small"
|
||||||
:options="PERIOD_OPTIONS.map((o) => ({ label: o.label, value: o.value }))"
|
:options="periodOptions"
|
||||||
:consistent-menu-width="false"
|
:consistent-menu-width="false"
|
||||||
@update:value="(v: string) => (period = v)"
|
@update:value="(v: string) => (period = v)"
|
||||||
/>
|
/>
|
||||||
@@ -255,16 +387,6 @@ function pickClass(value: string) {
|
|||||||
>选一下开始和结束的日子,没选先按今天算</span
|
>选一下开始和结束的日子,没选先按今天算</span
|
||||||
>
|
>
|
||||||
<span class="spacer"></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">
|
<n-button size="small" title="在提交列表里看" aria-label="在提交列表里看" @click="openList">
|
||||||
<template #icon><Icon icon="ph:arrow-square-out" /></template>
|
<template #icon><Icon icon="ph:arrow-square-out" /></template>
|
||||||
<template v-if="!narrow">在提交列表里看</template>
|
<template v-if="!narrow">在提交列表里看</template>
|
||||||
@@ -278,7 +400,7 @@ function pickClass(value: string) {
|
|||||||
<FlowStats v-if="flow" :data="flow" />
|
<FlowStats v-if="flow" :data="flow" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="stats && grid">
|
<template v-else-if="stats && (shown.mode === 'lessons' ? lessons : grid)">
|
||||||
<div class="summary">
|
<div class="summary">
|
||||||
<StatItem label="提交" :value="stats.submissionCount" unit="条" />
|
<StatItem label="提交" :value="stats.submissionCount" unit="条" />
|
||||||
<StatItem
|
<StatItem
|
||||||
@@ -286,6 +408,14 @@ function pickClass(value: string) {
|
|||||||
:value="stats.submissionCount ? `${Math.round(stats.correctRate)}%` : '—'"
|
:value="stats.submissionCount ? `${Math.round(stats.correctRate)}%` : '—'"
|
||||||
:unit="stats.judgingCount ? `判题中 ${stats.judgingCount}` : ''"
|
:unit="stats.judgingCount ? `判题中 ${stats.judgingCount}` : ''"
|
||||||
/>
|
/>
|
||||||
|
<template v-if="shown.mode === 'lessons' && lessons">
|
||||||
|
<StatItem label="交过的人" :value="stats.data.length" unit="人" />
|
||||||
|
<StatItem
|
||||||
|
label="上了几节课"
|
||||||
|
:value="`${lessons.lessons.length}${lessons.hasMore ? '+' : ''}`"
|
||||||
|
unit="节"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
<template v-if="personCount">
|
<template v-if="personCount">
|
||||||
<StatItem
|
<StatItem
|
||||||
:label="hasProblems ? '做完' : '做对过题'"
|
:label="hasProblems ? '做完' : '做对过题'"
|
||||||
@@ -307,6 +437,22 @@ function pickClass(value: string) {
|
|||||||
tone="warning"
|
tone="warning"
|
||||||
/>
|
/>
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
|
<div v-if="results.length" class="result-bar">
|
||||||
|
<div class="result-track">
|
||||||
|
<span
|
||||||
|
v-for="item in results"
|
||||||
|
:key="item.result"
|
||||||
|
:style="{ width: resultWidth(item.count), background: resultColor(item.result) }"
|
||||||
|
:title="`${resultName(item.result)} ${item.count}`"
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
<div class="result-legend">
|
||||||
|
<span v-for="item in results.slice(0, 3)" :key="item.result">
|
||||||
|
<i :style="{ background: resultColor(item.result) }"></i>{{ resultName(item.result) }}
|
||||||
|
<b>{{ item.count }}</b>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<span class="ring-wrap">
|
<span class="ring-wrap">
|
||||||
<RingChart
|
<RingChart
|
||||||
:value="stats.acceptedCount"
|
:value="stats.acceptedCount"
|
||||||
@@ -326,8 +472,22 @@ function pickClass(value: string) {
|
|||||||
<span class="ring-label">班级<br />完成度</span>
|
<span class="ring-label">班级<br />完成度</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<ByClass v-if="shown.byClass" :grid="grid" :explicit="shown.explicit" @pick="pickClass" />
|
<Lessons
|
||||||
<CodeStats v-else :stats="stats" :grid="grid" :explicit="shown.explicit" />
|
v-if="shown.mode === 'lessons' && lessons"
|
||||||
|
:data="lessons"
|
||||||
|
:timeline="shown.withinToday"
|
||||||
|
:loading-more="loadingMore"
|
||||||
|
@pick="pickLesson"
|
||||||
|
@problem="pickProblem"
|
||||||
|
@more="loadMore"
|
||||||
|
/>
|
||||||
|
<ByClass
|
||||||
|
v-else-if="shown.mode === 'byClass' && grid"
|
||||||
|
:grid="grid"
|
||||||
|
:explicit="shown.explicit"
|
||||||
|
@pick="pickClass"
|
||||||
|
/>
|
||||||
|
<CodeStats v-else-if="grid" :stats="stats" :grid="grid" :explicit="shown.explicit" />
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -369,6 +529,56 @@ function pickClass(value: string) {
|
|||||||
margin-right: 6px;
|
margin-right: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.back {
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 10px 0 6px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: v-bind("theme.textColor2");
|
||||||
|
background: v-bind("theme.actionColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
.back:hover {
|
||||||
|
color: v-bind("theme.primaryColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-bar {
|
||||||
|
min-width: 220px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-track {
|
||||||
|
display: flex;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-legend {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: v-bind("theme.textColor2");
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-legend i {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 2px;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.vsep {
|
.vsep {
|
||||||
width: 1px;
|
width: 1px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import StatisticsView from "./components/StatisticsView.vue"
|
|||||||
import { DEFAULT_PERIOD } from "./period"
|
import { DEFAULT_PERIOD } from "./period"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 数据统计的页面形态:课堂看板「回头看」、题目页统计页签、今日统计从别处进来时用。
|
* 统计的页面形态:课堂看板「回头看」、题目页统计页签这些从别处进来时用。
|
||||||
* 提交列表里是弹框(用户定的),两边是同一个 StatisticsView。
|
* 提交列表里是弹框(用户定的),两边是同一个 StatisticsView。
|
||||||
*
|
*
|
||||||
* 查询条件都在地址栏里:带过来、刷新、转发给别的老师都还是这一份。
|
* 查询条件都在地址栏里:带过来、刷新、转发给别的老师都还是这一份。
|
||||||
|
|||||||
@@ -22,7 +22,11 @@ export const PERIOD_OPTIONS = [
|
|||||||
{ label: "自己选日子", value: "custom" },
|
{ label: "自己选日子", value: "custom" },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export type Period = (typeof PERIOD_OPTIONS)[number]["value"]
|
/**
|
||||||
|
* `lesson`:从「一行一节课」点进某一节课,from / to 是那节课第一条、最后一条提交的时刻
|
||||||
|
* (精确到毫秒,不是「自己选日子」那种按天)。它不在下拉里 —— 选中时下拉临时多出一项「那节课」
|
||||||
|
*/
|
||||||
|
export type Period = (typeof PERIOD_OPTIONS)[number]["value"] | "lesson"
|
||||||
export const DEFAULT_PERIOD: Period = "1h"
|
export const DEFAULT_PERIOD: Period = "1h"
|
||||||
|
|
||||||
/** 东八区某天 0 点对应的 UTC 时刻 */
|
/** 东八区某天 0 点对应的 UTC 时刻 */
|
||||||
@@ -74,6 +78,14 @@ export function periodRange(period: Period, custom: [number, number] | null, now
|
|||||||
case "term":
|
case "term":
|
||||||
start = termStart(now)
|
start = termStart(now)
|
||||||
break
|
break
|
||||||
|
case "lesson":
|
||||||
|
if (custom) {
|
||||||
|
start = custom[0]
|
||||||
|
end = custom[1]
|
||||||
|
} else {
|
||||||
|
start = now - HOUR
|
||||||
|
}
|
||||||
|
break
|
||||||
case "custom":
|
case "custom":
|
||||||
if (custom) {
|
if (custom) {
|
||||||
start = custom[0]
|
start = custom[0]
|
||||||
@@ -100,6 +112,9 @@ function describe(start: number | null, end: number, period: Period) {
|
|||||||
if (period === "1h" || period === "2h") {
|
if (period === "1h" || period === "2h") {
|
||||||
return `${parseTime(new Date(start), "HH:mm")}–${parseTime(new Date(end), "HH:mm")}`
|
return `${parseTime(new Date(start), "HH:mm")}–${parseTime(new Date(end), "HH:mm")}`
|
||||||
}
|
}
|
||||||
|
if (period === "lesson") {
|
||||||
|
return `${parseTime(new Date(start), "M月D日 HH:mm")}–${parseTime(new Date(end), "HH:mm")}`
|
||||||
|
}
|
||||||
const from = parseTime(new Date(start), "M月D日")
|
const from = parseTime(new Date(start), "M月D日")
|
||||||
const to = parseTime(new Date(end - 1), "M月D日")
|
const to = parseTime(new Date(end - 1), "M月D日")
|
||||||
return from === to ? from : `${from}–${to}`
|
return from === to ? from : `${from}–${to}`
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { useThemeVars } from "naive-ui"
|
||||||
|
import { JUDGE_STATUS } from "utils/constants"
|
||||||
|
import type { SUBMISSION_RESULT } from "utils/types"
|
||||||
|
import { useTone, type Tone } from "oj/submission/composables/tone"
|
||||||
|
|
||||||
|
export function resultName(result: number) {
|
||||||
|
return JUDGE_STATUS[result as SUBMISSION_RESULT]?.name ?? "其他"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 结果条的颜色:答案正确绿、运行时错误橙、答案错误红、编译失败和判题中灰。
|
||||||
|
* 数字行那根结果条和「错得最多的题」的小条共用(原来在今日统计里)
|
||||||
|
*/
|
||||||
|
export function useResultColor() {
|
||||||
|
const theme = useThemeVars()
|
||||||
|
const tone = useTone()
|
||||||
|
return function resultColor(result: number) {
|
||||||
|
if (result === -2) return theme.value.textColor3
|
||||||
|
// 等待评分 / 判题中:还没有结果,不该和运行时错误一样是橙色
|
||||||
|
if (result === 6 || result === 7) return theme.value.borderColor
|
||||||
|
const kind: Tone = JUDGE_STATUS[result as SUBMISSION_RESULT]?.type ?? "default"
|
||||||
|
if (kind !== "success" && kind !== "error" && kind !== "warning") return theme.value.borderColor
|
||||||
|
return tone(kind).color
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,29 +2,22 @@
|
|||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "@iconify/vue"
|
||||||
import { useThemeVars } from "naive-ui"
|
import { useThemeVars } from "naive-ui"
|
||||||
import { getTodaySubmissionStatistics } from "oj/api"
|
import { getTodaySubmissionStatistics } from "oj/api"
|
||||||
import { JUDGE_STATUS, LANGUAGE_SHOW_VALUE } from "utils/constants"
|
|
||||||
import { parseTime, zonedParts } from "utils/functions"
|
import { parseTime, zonedParts } from "utils/functions"
|
||||||
import type { SUBMISSION_RESULT, TodaySubmissionStatistics } from "utils/types"
|
import type { TodaySubmissionStatistics } from "utils/types"
|
||||||
import { useTone, type Tone } from "../composables/tone"
|
|
||||||
import { classLabel } from "../utils"
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 今日统计(设计稿「今日统计重设计」)。一般是老师在看:今天哪几个班上了课、几点到几点、
|
* 今日统计(设计稿「今日统计重设计」学生版),**只给学生**:自己今天怎样、大家在做哪几道。
|
||||||
* 错得最多的是哪几道(老师 B)。学生版从简:自己今天怎样、大家在做哪几道。
|
* 老师那一版(今天哪几个班上了课、几点到几点、错得最多的题)2026-09 并进了「统计」的
|
||||||
* 盯人、实时刷新是课堂看板的活,这里不做;有班正在上课时直接引过去。
|
* 「一行一节课」(oj/statistics/components/Lessons.vue),老师的提交列表上不再有这颗标签。
|
||||||
*/
|
*/
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
close: []
|
close: []
|
||||||
openProblem: [problem: string]
|
openProblem: [problem: string]
|
||||||
/** 点班级名:在提交列表的数据统计弹框里看这个班今天 */
|
|
||||||
openClass: [className: string]
|
|
||||||
/** 拉到数之后把总数报回去:按钮上的数只在进页面时拉过一次,停久了会对不上 */
|
/** 拉到数之后把总数报回去:按钮上的数只在进页面时拉过一次,停久了会对不上 */
|
||||||
loaded: [total: number]
|
loaded: [total: number]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const theme = useThemeVars()
|
const theme = useThemeVars()
|
||||||
const tone = useTone()
|
|
||||||
|
|
||||||
const stats = ref<TodaySubmissionStatistics | null>(null)
|
const stats = ref<TodaySubmissionStatistics | null>(null)
|
||||||
const failed = ref(false)
|
const failed = ref(false)
|
||||||
@@ -38,8 +31,6 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const teacher = computed(() => stats.value?.classes != null)
|
|
||||||
|
|
||||||
const WEEKDAYS = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"]
|
const WEEKDAYS = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"]
|
||||||
|
|
||||||
const headText = computed(() => {
|
const headText = computed(() => {
|
||||||
@@ -49,101 +40,6 @@ const headText = computed(() => {
|
|||||||
return `${p.month}月${p.day}日 ${weekday} · 截至 ${parseTime(at, "HH:mm")}`
|
return `${p.month}月${p.day}日 ${weekday} · 截至 ${parseTime(at, "HH:mm")}`
|
||||||
})
|
})
|
||||||
|
|
||||||
function resultName(result: number) {
|
|
||||||
return JUDGE_STATUS[result as SUBMISSION_RESULT]?.name ?? "其他"
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 结果条的颜色:答案正确绿、运行时错误橙、答案错误红、编译失败和判题中灰 */
|
|
||||||
function resultColor(result: number) {
|
|
||||||
if (result === -2) return theme.value.textColor3
|
|
||||||
// 等待评分 / 判题中:还没有结果,不该和运行时错误一样是橙色
|
|
||||||
if (result === 6 || result === 7) return theme.value.borderColor
|
|
||||||
const kind: Tone = JUDGE_STATUS[result as SUBMISSION_RESULT]?.type ?? "default"
|
|
||||||
if (kind !== "success" && kind !== "error" && kind !== "warning") return theme.value.borderColor
|
|
||||||
return tone(kind).color
|
|
||||||
}
|
|
||||||
|
|
||||||
function percent(value: number, total: number) {
|
|
||||||
return total ? `${(value / total) * 100}%` : "0"
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- 老师:各班时间轴 ----------
|
|
||||||
|
|
||||||
/** 东八区的「几点几分」换成小时的小数,给时间轴定位 */
|
|
||||||
function hourOf(value: string) {
|
|
||||||
const p = zonedParts(value)!
|
|
||||||
return p.hour + p.minute / 60
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 横轴默认 7 点到 20 点(机房上课的时段),有更早更晚的提交就往外扩 */
|
|
||||||
const axis = computed(() => {
|
|
||||||
const list = stats.value?.classes ?? []
|
|
||||||
const start = Math.min(7, ...list.map((item) => Math.floor(hourOf(item.start))))
|
|
||||||
const end = Math.max(20, ...list.map((item) => Math.ceil(hourOf(item.end))))
|
|
||||||
return { start, end, ticks: Array.from({ length: end - start + 1 }, (_, i) => start + i) }
|
|
||||||
})
|
|
||||||
|
|
||||||
function axisLeft(hour: number) {
|
|
||||||
const { start, end } = axis.value
|
|
||||||
return `${((hour - start) / (end - start)) * 100}%`
|
|
||||||
}
|
|
||||||
|
|
||||||
function barStyle(item: { start: string; end: string; live: boolean }) {
|
|
||||||
const from = hourOf(item.start)
|
|
||||||
const to = hourOf(item.end)
|
|
||||||
const { start, end } = axis.value
|
|
||||||
return {
|
|
||||||
left: axisLeft(from),
|
|
||||||
width: `max(8px, ${((to - from) / (end - start)) * 100}%)`,
|
|
||||||
background: item.live ? theme.value.successColor : "#3f5f87",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 横条靠近右边时文字放到条的左边,不然「497 次 · 正确率 31%」被弹框右边裁掉 */
|
|
||||||
function textStyle(item: { start: string; end: string }) {
|
|
||||||
const { start, end } = axis.value
|
|
||||||
if ((hourOf(item.end) - start) / (end - start) > 0.72) {
|
|
||||||
return { right: `calc(100% - ${axisLeft(hourOf(item.start))} + 10px)` }
|
|
||||||
}
|
|
||||||
return { left: `calc(${axisLeft(hourOf(item.end))} + 14px)` }
|
|
||||||
}
|
|
||||||
|
|
||||||
const nowHour = computed(() => {
|
|
||||||
const at = stats.value?.asOf
|
|
||||||
if (!at) return null
|
|
||||||
const hour = hourOf(at)
|
|
||||||
return hour >= axis.value.start && hour <= axis.value.end ? hour : null
|
|
||||||
})
|
|
||||||
|
|
||||||
const liveClasses = computed(() => (stats.value?.classes ?? []).filter((item) => item.live))
|
|
||||||
|
|
||||||
/** 23 人来了、花名册却只有 22(有个号不是学生角色)时,不写「23 / 22」 */
|
|
||||||
function attendance(item: { userCount: number; classSize: number }) {
|
|
||||||
return item.classSize >= item.userCount
|
|
||||||
? `${item.userCount} / ${item.classSize} 人`
|
|
||||||
: `${item.userCount} 人`
|
|
||||||
}
|
|
||||||
|
|
||||||
const scatteredText = computed(() =>
|
|
||||||
(stats.value?.scattered ?? [])
|
|
||||||
.map(
|
|
||||||
(item) =>
|
|
||||||
`${item.className ? classLabel(item.className) : "没填班级的"} ${item.userCount} 人 ${item.total} 次`,
|
|
||||||
)
|
|
||||||
.join("、"),
|
|
||||||
)
|
|
||||||
|
|
||||||
const judgedResults = computed(() => stats.value?.results ?? [])
|
|
||||||
|
|
||||||
const languageText = computed(() => {
|
|
||||||
const list = stats.value?.languages ?? []
|
|
||||||
if (!list.length) return ""
|
|
||||||
if (list.length === 1) return `语言:全部是 ${LANGUAGE_SHOW_VALUE[list[0]!.language]}`
|
|
||||||
return `语言:${list.map((item) => `${LANGUAGE_SHOW_VALUE[item.language]} ${item.count}`).join(" · ")}`
|
|
||||||
})
|
|
||||||
|
|
||||||
// ---------- 学生 ----------
|
|
||||||
|
|
||||||
const meText = computed(() => {
|
const meText = computed(() => {
|
||||||
const me = stats.value?.me
|
const me = stats.value?.me
|
||||||
if (!me) return ""
|
if (!me) return ""
|
||||||
@@ -152,7 +48,7 @@ const meText = computed(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="today" :class="{ wide: teacher }">
|
<div class="today">
|
||||||
<div class="head">
|
<div class="head">
|
||||||
<b class="title">今日统计</b>
|
<b class="title">今日统计</b>
|
||||||
<n-text depth="3" class="small">{{ headText }}</n-text>
|
<n-text depth="3" class="small">{{ headText }}</n-text>
|
||||||
@@ -173,186 +69,6 @@ const meText = computed(() => {
|
|||||||
<n-text depth="3">第一条提交进来之后这里就有数了</n-text>
|
<n-text depth="3">第一条提交进来之后这里就有数了</n-text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ==================== 老师 ==================== -->
|
|
||||||
<template v-else-if="teacher">
|
|
||||||
<div class="numbers">
|
|
||||||
<div>
|
|
||||||
<b>{{ stats.total }}</b
|
|
||||||
><span>次提交</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<b>{{ stats.userCount }}</b
|
|
||||||
><span>人</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<b>{{ stats.correctRate }}%</b><span>提交正确率</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<b>{{ stats.classes!.length }}</b
|
|
||||||
><span>个班上了课</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="liveClasses.length"
|
|
||||||
class="live"
|
|
||||||
:style="{ background: tone('success').background, color: tone('success').color }"
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
{{ liveClasses.map((item) => classLabel(item.className)).join("、") }}正在上课
|
|
||||||
</span>
|
|
||||||
<span class="spacer"></span>
|
|
||||||
<a href="#" class="live-link" @click.prevent="router.push('/classroom')">
|
|
||||||
<Icon icon="ph:monitor" :width="14" />去课堂看板
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template v-if="stats.classes!.length">
|
|
||||||
<div class="section-title">
|
|
||||||
<b>今天哪几个班上了课</b>
|
|
||||||
<n-text depth="3" class="small">点班级名去数据统计看今天</n-text>
|
|
||||||
</div>
|
|
||||||
<div class="timeline">
|
|
||||||
<div v-for="item in stats.classes" :key="item.className" class="lane">
|
|
||||||
<div class="lane-label">
|
|
||||||
<a href="#" class="lane-name" @click.prevent="emit('openClass', item.className)">
|
|
||||||
{{ classLabel(item.className) }}
|
|
||||||
</a>
|
|
||||||
<n-text depth="3" class="tiny">
|
|
||||||
{{ attendance(item) }} · {{ item.fromProblemSet ? "题单" : ""
|
|
||||||
}}{{ item.problemCount }} 道题
|
|
||||||
</n-text>
|
|
||||||
</div>
|
|
||||||
<div class="lane-track">
|
|
||||||
<span
|
|
||||||
v-for="tick in axis.ticks"
|
|
||||||
:key="tick"
|
|
||||||
class="grid"
|
|
||||||
:style="{ left: axisLeft(tick) }"
|
|
||||||
></span>
|
|
||||||
<span class="lane-bar" :style="barStyle(item)"></span>
|
|
||||||
<span class="lane-text" :style="textStyle(item)">
|
|
||||||
{{ item.total }} 次 ·
|
|
||||||
{{ item.live ? "上课中" : `正确率 ${Math.round(item.correctRate)}%` }}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-if="nowHour !== null"
|
|
||||||
class="now"
|
|
||||||
:style="{ left: axisLeft(nowHour) }"
|
|
||||||
></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="lane axis-row">
|
|
||||||
<div class="lane-label"></div>
|
|
||||||
<div class="lane-track axis">
|
|
||||||
<span v-for="tick in axis.ticks" :key="tick" :style="{ left: axisLeft(tick) }">
|
|
||||||
{{ tick }}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-if="nowHour !== null"
|
|
||||||
class="now-label"
|
|
||||||
:style="{ left: axisLeft(nowHour) }"
|
|
||||||
>
|
|
||||||
现在
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<n-text v-if="scatteredText" depth="3" class="small">
|
|
||||||
{{ stats.classes!.length ? "另有零散提交:" : "今天没有哪个班一起上课,零散提交:"
|
|
||||||
}}{{ scatteredText }}
|
|
||||||
</n-text>
|
|
||||||
|
|
||||||
<template v-if="stats.hardProblems?.length">
|
|
||||||
<div class="section-title">
|
|
||||||
<b>错得最多的题</b>
|
|
||||||
<n-text depth="3" class="small">按没过的次数</n-text>
|
|
||||||
</div>
|
|
||||||
<table class="hard">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>题目</th>
|
|
||||||
<th>哪个班</th>
|
|
||||||
<th>提交</th>
|
|
||||||
<th>没过的原因</th>
|
|
||||||
<th class="right">做对</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="item in stats.hardProblems" :key="item.problemDisplayId">
|
|
||||||
<td class="hard-title">
|
|
||||||
<a href="#" @click.prevent="emit('openProblem', item.problemDisplayId)">
|
|
||||||
<n-text depth="3">{{ item.problemDisplayId }}</n-text> {{ item.problemTitle }}
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<n-text depth="2">{{ item.className ? classLabel(item.className) : "—" }}</n-text>
|
|
||||||
</td>
|
|
||||||
<td>{{ item.total }} 次</td>
|
|
||||||
<td>
|
|
||||||
<span class="mini">
|
|
||||||
<span
|
|
||||||
:style="{
|
|
||||||
width: percent(item.accepted, item.total),
|
|
||||||
background: resultColor(0),
|
|
||||||
}"
|
|
||||||
></span>
|
|
||||||
<span
|
|
||||||
v-for="fail in item.failures"
|
|
||||||
:key="fail.result"
|
|
||||||
:style="{
|
|
||||||
width: percent(fail.count, item.total),
|
|
||||||
background: resultColor(fail.result),
|
|
||||||
}"
|
|
||||||
></span>
|
|
||||||
</span>
|
|
||||||
<n-text depth="2" class="small">
|
|
||||||
{{ resultName(item.failures[0]!.result) }} {{ item.failures[0]!.count }}
|
|
||||||
</n-text>
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
class="right"
|
|
||||||
:style="{
|
|
||||||
color: item.acceptedUsers < item.userCount ? tone('error').color : undefined,
|
|
||||||
fontWeight: item.acceptedUsers < item.userCount ? 600 : undefined,
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
{{ item.acceptedUsers }} / {{ item.userCount }} 人
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div class="foot">
|
|
||||||
<div class="foot-bar">
|
|
||||||
<div class="results">
|
|
||||||
<span
|
|
||||||
v-for="item in judgedResults"
|
|
||||||
:key="item.result"
|
|
||||||
:style="{
|
|
||||||
width: percent(item.count, stats.total),
|
|
||||||
background: resultColor(item.result),
|
|
||||||
}"
|
|
||||||
></span>
|
|
||||||
</div>
|
|
||||||
<div class="legend">
|
|
||||||
<span v-for="item in judgedResults.slice(0, 5)" :key="item.result">
|
|
||||||
<i :style="{ background: resultColor(item.result) }"></i
|
|
||||||
>{{ resultName(item.result) }}
|
|
||||||
<b>{{ item.count }}</b>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<n-text depth="3" class="small foot-meta">
|
|
||||||
{{ languageText }} ·
|
|
||||||
{{ stats.flowchartCount ? `流程图 ${stats.flowchartCount} 张` : "流程图今天没人画" }}
|
|
||||||
</n-text>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- ==================== 学生:从简 ==================== -->
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div v-if="meText" class="me">
|
<div v-if="meText" class="me">
|
||||||
<b>你今天</b>
|
<b>你今天</b>
|
||||||
@@ -412,10 +128,6 @@ const meText = computed(() => {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.today.wide {
|
|
||||||
width: min(880px, calc(100vw - 32px));
|
|
||||||
}
|
|
||||||
|
|
||||||
.head {
|
.head {
|
||||||
flex: none;
|
flex: none;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -470,10 +182,6 @@ const meText = computed(() => {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiny {
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.center {
|
.center {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -521,200 +229,12 @@ const meText = computed(() => {
|
|||||||
color: v-bind("theme.textColor3");
|
color: v-bind("theme.textColor3");
|
||||||
}
|
}
|
||||||
|
|
||||||
.live {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 10px 14px;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-link {
|
|
||||||
color: inherit;
|
|
||||||
font-weight: 600;
|
|
||||||
text-decoration: none;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-title {
|
.section-title {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lane {
|
|
||||||
display: flex;
|
|
||||||
min-height: 42px;
|
|
||||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
|
||||||
}
|
|
||||||
|
|
||||||
.lane.axis-row {
|
|
||||||
min-height: 24px;
|
|
||||||
border-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lane-label {
|
|
||||||
width: 150px;
|
|
||||||
flex: none;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lane-name {
|
|
||||||
font-weight: 600;
|
|
||||||
color: v-bind("theme.textColor1");
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lane-name:hover {
|
|
||||||
color: v-bind("theme.primaryColor");
|
|
||||||
}
|
|
||||||
|
|
||||||
.lane-track {
|
|
||||||
position: relative;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
width: 1px;
|
|
||||||
background: v-bind("theme.dividerColor");
|
|
||||||
}
|
|
||||||
|
|
||||||
.lane-bar {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
height: 16px;
|
|
||||||
margin-top: -8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lane-text {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
font-size: 13px;
|
|
||||||
white-space: nowrap;
|
|
||||||
color: v-bind("theme.textColor2");
|
|
||||||
}
|
|
||||||
|
|
||||||
.now {
|
|
||||||
position: absolute;
|
|
||||||
top: -4px;
|
|
||||||
bottom: -4px;
|
|
||||||
width: 2px;
|
|
||||||
background: v-bind("theme.successColor");
|
|
||||||
}
|
|
||||||
|
|
||||||
.axis span {
|
|
||||||
position: absolute;
|
|
||||||
top: 4px;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
font-size: 11px;
|
|
||||||
color: v-bind("theme.textColor3");
|
|
||||||
}
|
|
||||||
|
|
||||||
.axis .now-label {
|
|
||||||
top: 4px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: v-bind("theme.successColor");
|
|
||||||
background: v-bind("theme.cardColor");
|
|
||||||
padding: 0 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hard {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hard th {
|
|
||||||
text-align: left;
|
|
||||||
font-weight: normal;
|
|
||||||
color: v-bind("theme.textColor3");
|
|
||||||
padding: 4px 8px 6px 0;
|
|
||||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
|
||||||
}
|
|
||||||
|
|
||||||
.hard td {
|
|
||||||
padding: 8px 8px 8px 0;
|
|
||||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hard .right {
|
|
||||||
text-align: right;
|
|
||||||
padding-right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hard-title a {
|
|
||||||
color: v-bind("theme.textColor1");
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hard-title a:hover {
|
|
||||||
color: v-bind("theme.primaryColor");
|
|
||||||
}
|
|
||||||
|
|
||||||
.mini {
|
|
||||||
display: inline-flex;
|
|
||||||
width: 70px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 2px;
|
|
||||||
overflow: hidden;
|
|
||||||
gap: 1px;
|
|
||||||
margin-right: 8px;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.foot {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
gap: 24px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.foot-bar {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 280px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.results {
|
|
||||||
display: flex;
|
|
||||||
height: 10px;
|
|
||||||
border-radius: 3px;
|
|
||||||
overflow: hidden;
|
|
||||||
gap: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.legend {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 4px 14px;
|
|
||||||
font-size: 13px;
|
|
||||||
color: v-bind("theme.textColor2");
|
|
||||||
}
|
|
||||||
|
|
||||||
.legend i {
|
|
||||||
display: inline-block;
|
|
||||||
width: 9px;
|
|
||||||
height: 9px;
|
|
||||||
border-radius: 2px;
|
|
||||||
margin-right: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.me {
|
.me {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
getTodaySubmissionCount,
|
getTodaySubmissionCount,
|
||||||
retryFlowchartSubmission,
|
retryFlowchartSubmission,
|
||||||
} from "oj/api"
|
} from "oj/api"
|
||||||
import { DEFAULT_PERIOD, type Period } from "oj/statistics/period"
|
import { DEFAULT_PERIOD } from "oj/statistics/period"
|
||||||
import { useContestStore } from "oj/store/contest"
|
import { useContestStore } from "oj/store/contest"
|
||||||
import Pagination from "shared/components/Pagination.vue"
|
import Pagination from "shared/components/Pagination.vue"
|
||||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
@@ -38,7 +38,7 @@ import { classLabel, submissionClockText, submissionDayText } from "./utils"
|
|||||||
*
|
*
|
||||||
* 老师课上的三种用法 —— 一条条翻全班的代码、看某一个学生、看完成情况 —— 前两种在
|
* 老师课上的三种用法 —— 一条条翻全班的代码、看某一个学生、看完成情况 —— 前两种在
|
||||||
* 这一页里做完:↑ ↓ 换条,右边跟着换,不再「点开弹框 → 关掉 → 点下一行」。
|
* 这一页里做完:↑ ↓ 换条,右边跟着换,不再「点开弹框 → 关掉 → 点下一行」。
|
||||||
* 完成情况在课堂看板和「数据统计」里,这页不重复造一张表。
|
* 完成情况在课堂看板和「统计」里,这页不重复造一张表。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// 右栏和今日统计弹框都不在关键路径上:右栏要等选中一条才有内容,弹框默认关着
|
// 右栏和今日统计弹框都不在关键路径上:右栏要等选中一条才有内容,弹框默认关着
|
||||||
@@ -190,7 +190,8 @@ async function getTodayCount() {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
listSubmissions()
|
listSubmissions()
|
||||||
if (route.name === "submissions") getTodayCount()
|
// 「今日统计 N」只给学生:老师那一版并进了「统计」
|
||||||
|
if (route.name === "submissions" && !teacher.value) getTodayCount()
|
||||||
})
|
})
|
||||||
|
|
||||||
watchDebounced(() => [query.username, query.problem], listSubmissions, {
|
watchDebounced(() => [query.username, query.problem], listSubmissions, {
|
||||||
@@ -483,9 +484,6 @@ const scope = computed({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 1366 以下(机房 1280 的屏)老师那排放不下:「今日统计」只留图标和数字 */
|
|
||||||
const narrowBar = useMediaQuery("(max-width: 1365px)")
|
|
||||||
|
|
||||||
const today = computed({
|
const today = computed({
|
||||||
get: () => query.today === "1",
|
get: () => query.today === "1",
|
||||||
set: (value: boolean) => (query.today = value ? "1" : "0"),
|
set: (value: boolean) => (query.today = value ? "1" : "0"),
|
||||||
@@ -564,7 +562,7 @@ function filterUser(username: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 「数据统计」弹框的查询条件。每次打开从这张列表现在的班级 / 学生 / 题号 / 代码或流程图
|
* 「统计」弹框的查询条件。每次打开从这张列表现在的班级 / 学生 / 题号 / 代码或流程图
|
||||||
* 带进去(「今天」带过去就是统计的「今天」);时间段沿用上次在弹框里选的
|
* 带进去(「今天」带过去就是统计的「今天」);时间段沿用上次在弹框里选的
|
||||||
*/
|
*/
|
||||||
const statsQuery = reactive({
|
const statsQuery = reactive({
|
||||||
@@ -577,14 +575,18 @@ const statsQuery = reactive({
|
|||||||
to: "",
|
to: "",
|
||||||
})
|
})
|
||||||
|
|
||||||
function openStatistics(preset?: { className: string; period: Period }) {
|
/**
|
||||||
todayPanel.value = false
|
* 老师的「统计」:带着这张列表现在的班级 / 学生 / 题号 / 代码或流程图打开。原来旁边还有一颗
|
||||||
|
* 「今日统计」,和「数据统计」名字分不清(用户说的),老师那一版并进来了 —— 什么都没筛时
|
||||||
|
* 打开就是「一行一节课」,选「今天」就是原来今日统计看的东西
|
||||||
|
*/
|
||||||
|
function openStatistics() {
|
||||||
Object.assign(statsQuery, {
|
Object.assign(statsQuery, {
|
||||||
tab: flowMode.value ? "flow" : "code",
|
tab: flowMode.value ? "flow" : "code",
|
||||||
className: preset ? preset.className : query.className,
|
className: query.className,
|
||||||
username: preset ? "" : query.username,
|
username: query.username,
|
||||||
problem: preset ? "" : query.problem,
|
problem: query.problem,
|
||||||
...(preset ? { period: preset.period } : query.today === "1" ? { period: "today" } : {}),
|
...(query.today === "1" ? { period: "today" } : {}),
|
||||||
})
|
})
|
||||||
statsPanel.value = true
|
statsPanel.value = true
|
||||||
}
|
}
|
||||||
@@ -814,27 +816,28 @@ function dayBreak(index: number) {
|
|||||||
<n-button size="small" quaternary @click="clear">清空</n-button>
|
<n-button size="small" quaternary @click="clear">清空</n-button>
|
||||||
|
|
||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
|
<!-- 老师只有「统计」(今日统计并进去了);学生看不到统计,仍是「今日统计」 -->
|
||||||
<n-button
|
<n-button
|
||||||
v-if="route.name === 'submissions' && !flowMode"
|
v-if="teacher && route.name === 'submissions'"
|
||||||
|
size="small"
|
||||||
|
quaternary
|
||||||
|
title="统计:这节课、今天,或者回头看以前的课"
|
||||||
|
@click="openStatistics()"
|
||||||
|
>
|
||||||
|
<template #icon><Icon icon="ph:chart-bar" /></template>
|
||||||
|
统计
|
||||||
|
</n-button>
|
||||||
|
<n-button
|
||||||
|
v-else-if="route.name === 'submissions' && !flowMode"
|
||||||
size="small"
|
size="small"
|
||||||
quaternary
|
quaternary
|
||||||
:title="`今日统计:今天全站 ${todayCount} 条`"
|
:title="`今日统计:今天全站 ${todayCount} 条`"
|
||||||
@click="toggleTodayPanel(true)"
|
@click="toggleTodayPanel(true)"
|
||||||
>
|
>
|
||||||
<template #icon><Icon icon="ph:chart-bar" /></template>
|
<template #icon><Icon icon="ph:chart-bar" /></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
|
|
||||||
v-if="teacher && route.name === 'submissions'"
|
|
||||||
size="small"
|
|
||||||
quaternary
|
|
||||||
title="数据统计:按班级、题号、时间段回头看"
|
|
||||||
@click="openStatistics()"
|
|
||||||
>
|
|
||||||
<template #icon><Icon icon="ph:chart-pie-slice" /></template>
|
|
||||||
数据统计
|
|
||||||
</n-button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 手机:只留全部/我的,其余收进「筛选」 -->
|
<!-- 手机:只留全部/我的,其余收进「筛选」 -->
|
||||||
@@ -1109,7 +1112,6 @@ function dayBreak(index: number) {
|
|||||||
@close="todayPanel = false"
|
@close="todayPanel = false"
|
||||||
@loaded="(total: number) => (todayCount = total)"
|
@loaded="(total: number) => (todayCount = total)"
|
||||||
@open-problem="(id: string) => openProblem({ problemDisplayId: id } as Row)"
|
@open-problem="(id: string) => openProblem({ problemDisplayId: id } as Row)"
|
||||||
@open-class="(name: string) => openStatistics({ className: name, period: 'today' })"
|
|
||||||
/>
|
/>
|
||||||
</n-modal>
|
</n-modal>
|
||||||
|
|
||||||
|
|||||||
@@ -255,7 +255,7 @@ const options = computed<Array<DropdownOption | DropdownDividerOption>>(() => {
|
|||||||
<div class="actions">
|
<div class="actions">
|
||||||
<ProblemJumpBox v-if="isDesktop" :compact="compact" />
|
<ProblemJumpBox v-if="isDesktop" :compact="compact" />
|
||||||
<template v-if="isDesktop && collabStore.isTeacher">
|
<template v-if="isDesktop && collabStore.isTeacher">
|
||||||
<!-- 和提交列表的「今日统计」「数据统计」同一种按钮:平时没底色,悬停出底色 -->
|
<!-- 和提交列表的「统计」同一种按钮:平时没底色,悬停出底色 -->
|
||||||
<n-button
|
<n-button
|
||||||
quaternary
|
quaternary
|
||||||
class="tool-link"
|
class="tool-link"
|
||||||
|
|||||||
@@ -401,6 +401,79 @@ export const submissionStatisticsSchema = z.object({
|
|||||||
* 就像统计只认成功的提交。dataUnaccepted 没有花名册就真的算不出来,仍然为空。
|
* 就像统计只认成功的提交。dataUnaccepted 没有花名册就真的算不出来,仍然为空。
|
||||||
*/
|
*/
|
||||||
dataAttempted: z.array(attemptedStudentSchema),
|
dataAttempted: z.array(attemptedStudentSchema),
|
||||||
|
/** 按判题结果,条数倒序 —— 数字行里那根结果条(含还在判的) */
|
||||||
|
results: z.array(z.object({ result: judgeStatusSchema, count: z.number().int() })),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计「一行一节课」的总览(GET /submissions/statistics/lessons,设计稿「统计合二为一」B)。
|
||||||
|
* 没选班、没填学生、没填题号时用它;原来「今日统计」老师那一版并进来了,换成任意时间段。
|
||||||
|
*
|
||||||
|
* **一节课怎么切**:只算普通学生(和方块串同一个口径),按「班 + 东八区哪一天」分组,
|
||||||
|
* 相邻两条提交隔 30 分钟以上就切开;切出来的一段里有一道题 5 人以上做过,才算一节课。
|
||||||
|
* 其余的是零散提交(补做、课后自己练)。这样 26计算机0班 9 月 29 日是 14:04–15:38,
|
||||||
|
* 不会被 16:24、18:42 那两条课后提交拖成「14:04–18:42」。
|
||||||
|
*/
|
||||||
|
const lessonFailureSchema = z.object({ result: judgeStatusSchema, count: z.number().int() })
|
||||||
|
|
||||||
|
export const submissionLessonsSchema = z.object({
|
||||||
|
/** 最近的 N 节课,按开始时间倒序(界面上按天分组,这节课在最上面) */
|
||||||
|
lessons: z.array(
|
||||||
|
z.object({
|
||||||
|
className: z.string(),
|
||||||
|
/** 东八区的日子,`2026-09-29` */
|
||||||
|
day: z.string(),
|
||||||
|
/** 这一段第一条、最后一条提交 */
|
||||||
|
start: z.string(),
|
||||||
|
end: z.string(),
|
||||||
|
userCount: z.number().int(),
|
||||||
|
/** 班里的学生数(未禁用的普通用户) */
|
||||||
|
classSize: z.number().int(),
|
||||||
|
/** 这节课做的题(这一段里 5 人以上做过的),做的人多的在前,最多 20 道 —— 点进去就筛这几道 */
|
||||||
|
problems: z.array(z.string()),
|
||||||
|
/** 多半是从题单里交的 —— 「题单 7 道」 */
|
||||||
|
fromProblemSet: z.boolean(),
|
||||||
|
/** 这节课的题(上面 problems)交了几次 —— 和点进去看到的对得上 */
|
||||||
|
total: z.number().int(),
|
||||||
|
/** 同上,判完的里面对了多少,百分数 */
|
||||||
|
correctRate: z.number(),
|
||||||
|
/** 最后一条在 15 分钟内:还在上课,界面上引去课堂看板 */
|
||||||
|
live: z.boolean(),
|
||||||
|
/** 这节课的题里没过次数最多的那道;一次都没错过为 null */
|
||||||
|
hardest: z
|
||||||
|
.object({
|
||||||
|
problemDisplayId: z.string(),
|
||||||
|
problemTitle: z.string(),
|
||||||
|
failed: z.number().int(),
|
||||||
|
})
|
||||||
|
.nullable(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
/** 零散提交,按天、按班(null = 没填班级的号)。只给列出来的这些课覆盖到的日子 */
|
||||||
|
scattered: z.array(
|
||||||
|
z.object({
|
||||||
|
day: z.string(),
|
||||||
|
className: z.string().nullable(),
|
||||||
|
userCount: z.number().int(),
|
||||||
|
total: z.number().int(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
/** 整个时间段里错得最多的题,按没过的次数倒序,最多 6 道 */
|
||||||
|
hardProblems: z.array(
|
||||||
|
z.object({
|
||||||
|
problemDisplayId: z.string(),
|
||||||
|
problemTitle: z.string(),
|
||||||
|
/** 这道题交得最多的那个班 */
|
||||||
|
className: z.string().nullable(),
|
||||||
|
total: z.number().int(),
|
||||||
|
accepted: z.number().int(),
|
||||||
|
failures: z.array(lessonFailureSchema),
|
||||||
|
userCount: z.number().int(),
|
||||||
|
acceptedUsers: z.number().int(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
/** 更早还有课没列出来(「这学期」「全部」那种),界面上给「再往前看」 */
|
||||||
|
hasMore: z.boolean(),
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -416,9 +489,9 @@ export const submissionStatisticsSchema = z.object({
|
|||||||
const todayFailureSchema = z.object({ result: judgeStatusSchema, count: z.number().int() })
|
const todayFailureSchema = z.object({ result: judgeStatusSchema, count: z.number().int() })
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 「今日统计」(提交列表顶上那颗标签点开的)。一般是老师在看(设计稿「今日统计重设计」老师 B):
|
* 「今日统计」(提交列表顶上那颗标签点开的),**只剩学生版**:自己今天怎样、大家在做哪几道。
|
||||||
* 今天哪几个班上了课、各在几点、错得最多的是哪几道。学生版从简:自己今天怎样、大家在做哪几道。
|
* 老师那一版(今天哪几个班上了课、错得最多的题)2026-09 并进了统计(submissionLessonsSchema),
|
||||||
* 口径:东八区今天 + 非比赛提交。
|
* 老师的提交列表上不再有这颗标签。口径:东八区今天 + 非比赛提交。
|
||||||
*/
|
*/
|
||||||
export const todaySubmissionStatisticsSchema = z.object({
|
export const todaySubmissionStatisticsSchema = z.object({
|
||||||
/** 这份数据是几点算的 —— 标题上的「截至 19:40」 */
|
/** 这份数据是几点算的 —— 标题上的「截至 19:40」 */
|
||||||
@@ -456,56 +529,6 @@ export const todaySubmissionStatisticsSchema = z.object({
|
|||||||
mine: z.enum(["accepted", "tried", "none"]).nullable(),
|
mine: z.enum(["accepted", "tried", "none"]).nullable(),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
/**
|
|
||||||
* 老师:今天上了课的班(同班 5 人以上做了同一道题),按开始时间排。学生为 null。
|
|
||||||
* 时段是这个班今天第一条到最后一条提交,`live` = 最后一条在 15 分钟内(正在上课,
|
|
||||||
* 界面上引去课堂看板 —— 盯人是看板的活)
|
|
||||||
*/
|
|
||||||
classes: z
|
|
||||||
.array(
|
|
||||||
z.object({
|
|
||||||
className: z.string(),
|
|
||||||
start: z.string(),
|
|
||||||
end: z.string(),
|
|
||||||
userCount: z.number().int(),
|
|
||||||
/** 班里的学生数 */
|
|
||||||
classSize: z.number().int(),
|
|
||||||
/** 这节课做的题数(班里 5 人以上做过的) */
|
|
||||||
problemCount: z.number().int(),
|
|
||||||
/** 多半是从题单里交的 —— 「题单 9 道」 */
|
|
||||||
fromProblemSet: z.boolean(),
|
|
||||||
total: z.number().int(),
|
|
||||||
correctRate: z.number(),
|
|
||||||
live: z.boolean(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.nullable(),
|
|
||||||
/** 老师:没上课的零星提交,按班(null = 没填班级的号) */
|
|
||||||
scattered: z
|
|
||||||
.array(
|
|
||||||
z.object({
|
|
||||||
className: z.string().nullable(),
|
|
||||||
userCount: z.number().int(),
|
|
||||||
total: z.number().int(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.nullable(),
|
|
||||||
/** 老师:错得最多的题,按没过的次数倒序,最多 6 道 */
|
|
||||||
hardProblems: z
|
|
||||||
.array(
|
|
||||||
z.object({
|
|
||||||
problemDisplayId: z.string(),
|
|
||||||
problemTitle: z.string(),
|
|
||||||
/** 这道题今天交得最多的那个班 */
|
|
||||||
className: z.string().nullable(),
|
|
||||||
total: z.number().int(),
|
|
||||||
accepted: z.number().int(),
|
|
||||||
failures: z.array(todayFailureSchema),
|
|
||||||
userCount: z.number().int(),
|
|
||||||
acceptedUsers: z.number().int(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.nullable(),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export const formatCodeRequestSchema = z.object({
|
export const formatCodeRequestSchema = z.object({
|
||||||
@@ -579,6 +602,7 @@ export type SubmissionDetail = z.infer<typeof submissionDetailSchema>
|
|||||||
export type SubmissionUpdate = z.infer<typeof submissionUpdateSchema>
|
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 SubmissionLessons = z.infer<typeof submissionLessonsSchema>
|
||||||
export type SubmissionStatisticsUser = z.infer<typeof submissionStatisticsUserSchema>
|
export type SubmissionStatisticsUser = z.infer<typeof submissionStatisticsUserSchema>
|
||||||
export type SubmissionStatisticsGrid = z.infer<typeof submissionStatisticsGridSchema>
|
export type SubmissionStatisticsGrid = z.infer<typeof submissionStatisticsGridSchema>
|
||||||
export type UnacceptedStudent = z.infer<typeof unacceptedStudentSchema>
|
export type UnacceptedStudent = z.infer<typeof unacceptedStudentSchema>
|
||||||
|
|||||||
Reference in new issue
Block a user