fix(提交列表/统计): 按方向键会顺手切掉「代码/流程图」、重判后右栏还是旧内容、没人交时统计说「全都做完了」
提交列表: - 焦点在单选框、下拉框上时方向键不再一边切控件一边换条;跨页没回来前不再连跳一整页 - 重判、重新评分之后右栏重新拉,不再命中旧缓存;同一条新旧请求不再互相覆盖 - 从「我的提交」点顶栏「提交」、浏览器后退时,地址里没有的筛选回到默认 - 找回点用户名开个人主页;「只看他 / 只看这题」学生也能用;重判不用等代码拉下来 - 手机上老师也能重判、能按语言筛,流程图行有手机排版;学生界面带着的班级筛选显示成能点掉的标签 - mermaid 画不出来时先说中文、原始报错折起来 统计页: - 没人交时说「这段时间没有人交」,不再说「全都做完了」;没填题号时各处统一叫「做对过题」 - 班级按 class_name 单独传;查询失败清掉旧数据并说清原因;标签跟着查出来的那一份走 - 「语法未过」的格子单独标「语」;正确率不算还在判的;截断提示说清哪些数字受影响 - 看板的请假隐藏只藏还没交过、没在举手的人,「恢复」只恢复这个班的 - 手机上筛选折行、上下排,不再挤出屏幕 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
26c8d65e89
commit
0bcc981e6f
15 files changed
+613
-197
No files matched your search
@@ -9,6 +9,7 @@ const codeCache = new Map<string, Submission>()
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { getSubmission } from "oj/api"
|
||||
import { JUDGE_STATUS, LANGUAGE_FORMAT_VALUE, SubmissionStatus } from "utils/constants"
|
||||
import { parseTime } from "utils/functions"
|
||||
@@ -28,6 +29,7 @@ const props = withDefaults(
|
||||
)
|
||||
|
||||
const tone = useTone()
|
||||
const theme = useThemeVars()
|
||||
|
||||
function toneOf(result: number): Tone {
|
||||
if (result === SubmissionStatus.accepted) return "success"
|
||||
@@ -131,7 +133,8 @@ const cells = computed(() =>
|
||||
}
|
||||
|
||||
.square:hover {
|
||||
outline: 2px solid currentColor;
|
||||
/* button 的 color 是浏览器默认的黑,暗色下 currentColor 描边会看不见 */
|
||||
outline: 2px solid v-bind("theme.textColor2");
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,11 @@ import { classLabel, submissionDayText } from "oj/submission/utils"
|
||||
* 没选班级、也没填学生时(多半是从题目页进来,只带了题号):先按班级列出来,
|
||||
* 点一个班再看人。「一道没交」要花名册人数,后端在这种情况下给 classSizes。
|
||||
*/
|
||||
const props = defineProps<{ grid: SubmissionStatisticsGrid }>()
|
||||
const props = defineProps<{
|
||||
grid: SubmissionStatisticsGrid
|
||||
/** 题号是老师填的。没填时「做完」没法谈(范围里各班各做各的题),改成「做对过题」 */
|
||||
explicit: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{ pick: [className: string] }>()
|
||||
|
||||
const theme = useThemeVars()
|
||||
@@ -36,8 +40,12 @@ const classes = computed(() => {
|
||||
const solved = new Set(
|
||||
row.submissions.filter((item) => isAc(item.result)).map((item) => item.problemDisplayId),
|
||||
)
|
||||
if (need.every((pid) => solved.has(pid))) done++
|
||||
submissions += row.submissions.length
|
||||
if (props.explicit ? need.every((pid) => solved.has(pid)) : solved.size > 0) done++
|
||||
// 正确率的分母不算还在判的,和数字行一个口径
|
||||
submissions += row.submissions.filter(
|
||||
(item) =>
|
||||
item.result !== SubmissionStatus.pending && item.result !== SubmissionStatus.judging,
|
||||
).length
|
||||
accepted += row.submissions.filter((item) => isAc(item.result)).length
|
||||
const lastTime = row.submissions.at(-1)?.createTime ?? ""
|
||||
if (lastTime > last) last = lastTime
|
||||
@@ -62,8 +70,8 @@ const classes = computed(() => {
|
||||
<div class="by-class">
|
||||
<div class="cols">
|
||||
<span class="c-class">班级</span>
|
||||
<span class="c-done">做完</span>
|
||||
<span class="c-n">交了没对</span>
|
||||
<span class="c-done">{{ explicit ? "做完" : "做对过题" }}</span>
|
||||
<span class="c-n">{{ explicit ? "交了没对" : "一道没对" }}</span>
|
||||
<span class="c-n">没交</span>
|
||||
<span class="c-n">正确率</span>
|
||||
<span class="c-last">最近一次</span>
|
||||
@@ -97,6 +105,9 @@ const classes = computed(() => {
|
||||
</n-button>
|
||||
</div>
|
||||
<div v-if="!classes.length" class="empty">这段时间没有人交</div>
|
||||
<p v-if="grid.truncated" class="note warn" :style="{ color: tone('warning').color }">
|
||||
范围太大,只取了最近的 5000 条提交,更早的班级可能少算或不在表里。缩短时间段就准了。
|
||||
</p>
|
||||
<p class="note">没选班级,先按班级列出来;点一个班再看每个人。「做完」是这几道题都做对了。</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -201,4 +212,22 @@ const classes = computed(() => {
|
||||
font-size: 12px;
|
||||
color: v-bind("theme.textColor3");
|
||||
}
|
||||
|
||||
/* 手机:这张表列多,横着滑 */
|
||||
@media (max-width: 767px) {
|
||||
.by-class {
|
||||
flex: none;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.cols,
|
||||
.row {
|
||||
min-width: 820px;
|
||||
}
|
||||
|
||||
.rows {
|
||||
flex: none;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -18,10 +18,17 @@ import AttemptSquares from "./AttemptSquares.vue"
|
||||
const props = defineProps<{
|
||||
stats: SubmissionStatistics
|
||||
grid: SubmissionStatisticsGrid
|
||||
/**
|
||||
* 题号是老师填的(true),还是没填、从范围里出现过的题凑出来的(false)。没填时一个月
|
||||
* 零零散散几十道题谈不上「全做完」,「做完」改成「做对过题」(做对至少一道),和数字行一致
|
||||
*/
|
||||
explicit: boolean
|
||||
}>()
|
||||
|
||||
const theme = useThemeVars()
|
||||
const tone = useTone()
|
||||
// 暗色下「做对了」那格的底色是浅绿,白字看不清
|
||||
const isDark = useDark()
|
||||
|
||||
type Attempt = SubmissionStatisticsGrid["rows"][number]["submissions"][number]
|
||||
|
||||
@@ -29,6 +36,10 @@ function isAc(result: number) {
|
||||
return result === SubmissionStatus.accepted || result === SubmissionStatus.ast_check_failed
|
||||
}
|
||||
|
||||
function isPending(result: number) {
|
||||
return result === SubmissionStatus.pending || result === SubmissionStatus.judging
|
||||
}
|
||||
|
||||
interface Student {
|
||||
username: string
|
||||
name: string
|
||||
@@ -36,6 +47,8 @@ interface Student {
|
||||
byProblem: Map<string, Attempt[]>
|
||||
total: number
|
||||
accepted: number
|
||||
/** 判完的条数,正确率的分母(和数字行一个口径:还在判的不算) */
|
||||
judged: number
|
||||
last: string | null
|
||||
}
|
||||
|
||||
@@ -54,6 +67,7 @@ const students = computed<Student[]>(() => {
|
||||
byProblem,
|
||||
total: row.submissions.length,
|
||||
accepted: row.submissions.filter((item) => isAc(item.result)).length,
|
||||
judged: row.submissions.filter((item) => !isPending(item.result)).length,
|
||||
last: row.submissions.at(-1)?.createTime ?? null,
|
||||
}
|
||||
})
|
||||
@@ -66,6 +80,7 @@ const students = computed<Student[]>(() => {
|
||||
byProblem: new Map(),
|
||||
total: 0,
|
||||
accepted: 0,
|
||||
judged: 0,
|
||||
last: null,
|
||||
})
|
||||
}
|
||||
@@ -79,6 +94,18 @@ function solvedOn(student: Student, pid: string) {
|
||||
return (student.byProblem.get(pid) ?? []).some((item) => isAc(item.result))
|
||||
}
|
||||
|
||||
/**
|
||||
* 答案对了、但「语法未通过」(AST 规则没按要求写),而且没有一次真正的「答案正确」。
|
||||
* 这类算做对了(全站口径),但教学上没达标,格子上单独标出来
|
||||
*/
|
||||
function astOnlyOn(student: Student, pid: string) {
|
||||
const items = student.byProblem.get(pid) ?? []
|
||||
return (
|
||||
items.some((item) => item.result === SubmissionStatus.ast_check_failed) &&
|
||||
!items.some((item) => item.result === SubmissionStatus.accepted)
|
||||
)
|
||||
}
|
||||
|
||||
function doneCount(student: Student) {
|
||||
return problems.value.filter((problem) => solvedOn(student, problem.problemDisplayId)).length
|
||||
}
|
||||
@@ -98,9 +125,14 @@ const problemRows = computed(() =>
|
||||
}),
|
||||
)
|
||||
|
||||
const allDone = computed(
|
||||
() => students.value.filter((student) => doneCount(student) === problems.value.length).length,
|
||||
)
|
||||
/** 算「做完」要做对几道:填了题号就要全对,没填就是做对过题 */
|
||||
const needed = computed(() => (props.explicit ? problems.value.length : 1))
|
||||
function finished(student: Student) {
|
||||
return problems.value.length > 0 && doneCount(student) >= needed.value
|
||||
}
|
||||
const doneLabel = computed(() => (props.explicit ? "做完" : "做对过题"))
|
||||
|
||||
const allDone = computed(() => students.value.filter(finished).length)
|
||||
|
||||
// ---------- 选中哪道题、筛哪些人 ----------
|
||||
|
||||
@@ -142,11 +174,16 @@ const problemStudents = computed(() => {
|
||||
|
||||
/** 全部题:没做完的在前(交得多的更前),一道没交的其次,全做完的沉底 */
|
||||
const allStudents = computed(() => {
|
||||
const n = problems.value.length
|
||||
const list = students.value.map((student) => ({ student, done: doneCount(student) }))
|
||||
const rank = (row: (typeof list)[number]) => (row.done === n ? 2 : row.student.total ? 0 : 1)
|
||||
// 这段时间没人交:没有题可言,别列一排「0 道」
|
||||
if (!problems.value.length) return []
|
||||
const list = students.value.map((student) => ({
|
||||
student,
|
||||
done: doneCount(student),
|
||||
finished: finished(student),
|
||||
}))
|
||||
const rank = (row: (typeof list)[number]) => (row.finished ? 2 : row.student.total ? 0 : 1)
|
||||
return list
|
||||
.filter((row) => !onlyUnfinished.value || row.done < n)
|
||||
.filter((row) => !onlyUnfinished.value || !row.finished)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
rank(a) - rank(b) ||
|
||||
@@ -158,7 +195,7 @@ const allStudents = computed(() => {
|
||||
const unfinishedCount = computed(() =>
|
||||
focus.value
|
||||
? students.value.filter((student) => !solvedOn(student, focus.value)).length
|
||||
: students.value.filter((student) => doneCount(student) < problems.value.length).length,
|
||||
: students.value.filter((student) => !finished(student)).length,
|
||||
)
|
||||
|
||||
const expanded = ref("")
|
||||
@@ -182,7 +219,7 @@ function problemsOf(student: Student) {
|
||||
}
|
||||
|
||||
function rate(student: Student) {
|
||||
return student.total ? `${Math.round((student.accepted / student.total) * 100)}%` : "—"
|
||||
return student.judged ? `${Math.round((student.accepted / student.judged) * 100)}%` : "—"
|
||||
}
|
||||
|
||||
function dayText(time: string | null) {
|
||||
@@ -209,7 +246,7 @@ function pct(done: number, total: number) {
|
||||
<span class="p-line">
|
||||
<b>全部 {{ problems.length }} 道</b>
|
||||
<span class="spacer"></span>
|
||||
<span class="muted">做完 {{ allDone }}/{{ personCount }} 人</span>
|
||||
<span class="muted">{{ doneLabel }} {{ allDone }}/{{ personCount }} 人</span>
|
||||
</span>
|
||||
<span class="bar"
|
||||
><span class="fill" :style="{ width: pct(allDone, personCount) }"></span
|
||||
@@ -247,18 +284,33 @@ function pct(done: number, total: number) {
|
||||
{{ Math.max(0, personCount - focusRow.tried) }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="!problems.length">
|
||||
<b class="s-title">这段时间没有人交</b>
|
||||
<span class="muted">换个时间段试试,比如「今天」或「最近 7 天」</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<b class="s-title">全部 {{ problems.length }} 道</b>
|
||||
<span class="muted">
|
||||
做完 {{ allDone }}/{{ personCount }} · 没做完 {{ unfinishedCount }} · 一道没交
|
||||
{{ doneLabel }} {{ allDone }}/{{ personCount }} · 没做完 {{ unfinishedCount }} ·
|
||||
一道没交
|
||||
{{ stats.dataUnaccepted.length }}
|
||||
</span>
|
||||
</template>
|
||||
<span class="spacer"></span>
|
||||
<button class="chip" :class="{ on: onlyUnfinished }" @click="onlyUnfinished = true">
|
||||
<button
|
||||
v-if="problems.length"
|
||||
class="chip"
|
||||
:class="{ on: onlyUnfinished }"
|
||||
@click="onlyUnfinished = true"
|
||||
>
|
||||
没做完 <span class="n">{{ unfinishedCount }}</span>
|
||||
</button>
|
||||
<button class="chip" :class="{ on: !onlyUnfinished }" @click="onlyUnfinished = false">
|
||||
<button
|
||||
v-if="problems.length"
|
||||
class="chip"
|
||||
:class="{ on: !onlyUnfinished }"
|
||||
@click="onlyUnfinished = false"
|
||||
>
|
||||
全部 <span class="n">{{ students.length }}</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -289,7 +341,7 @@ function pct(done: number, total: number) {
|
||||
|
||||
<!-- 全部题:每人一行,一格一道题;点开按题看方块串 -->
|
||||
<template v-else>
|
||||
<div class="cols">
|
||||
<div v-if="problems.length" class="cols">
|
||||
<span class="c-caret"></span>
|
||||
<span class="c-name">学生</span>
|
||||
<span class="c-done">做完</span>
|
||||
@@ -302,7 +354,12 @@ function pct(done: number, total: number) {
|
||||
<div
|
||||
class="row clickable"
|
||||
:class="{ on: expanded === row.student.username }"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-expanded="expanded === row.student.username"
|
||||
@click="toggle(row.student.username)"
|
||||
@keydown.enter.prevent="toggle(row.student.username)"
|
||||
@keydown.space.prevent="toggle(row.student.username)"
|
||||
>
|
||||
<span class="c-caret">
|
||||
<Icon
|
||||
@@ -316,15 +373,14 @@ function pct(done: number, total: number) {
|
||||
<span
|
||||
class="c-done done"
|
||||
:style="{
|
||||
color:
|
||||
row.done === problems.length
|
||||
? tone('success').color
|
||||
: row.student.total
|
||||
? theme.textColor1
|
||||
: tone('error').color,
|
||||
color: row.finished
|
||||
? tone('success').color
|
||||
: row.student.total
|
||||
? theme.textColor1
|
||||
: tone('error').color,
|
||||
}"
|
||||
>
|
||||
{{ row.done }}/{{ problems.length }}
|
||||
{{ explicit ? `${row.done}/${problems.length}` : `${row.done} 道` }}
|
||||
</span>
|
||||
<span class="c-n muted">{{ row.student.total }}</span>
|
||||
<span class="c-rate muted">{{ rate(row.student) }}</span>
|
||||
@@ -333,19 +389,30 @@ function pct(done: number, total: number) {
|
||||
v-for="problem in problems"
|
||||
:key="problem.problemDisplayId"
|
||||
class="cell"
|
||||
:title="problem.problemDisplayId + ' ' + problem.title"
|
||||
:title="
|
||||
problem.problemDisplayId +
|
||||
' ' +
|
||||
problem.title +
|
||||
(astOnlyOn(row.student, problem.problemDisplayId)
|
||||
? ':答案对了,但没按要求的写法写'
|
||||
: '')
|
||||
"
|
||||
:style="
|
||||
solvedOn(row.student, problem.problemDisplayId)
|
||||
? { background: tone('success').solid, color: '#fff' }
|
||||
: row.student.byProblem.has(problem.problemDisplayId)
|
||||
? { background: tone('error').background, color: tone('error').color }
|
||||
: { background: theme.actionColor, color: theme.textColor3 }
|
||||
astOnlyOn(row.student, problem.problemDisplayId)
|
||||
? { background: tone('warning').background, color: tone('warning').color }
|
||||
: solvedOn(row.student, problem.problemDisplayId)
|
||||
? { background: tone('success').solid, color: isDark ? '#18181c' : '#fff' }
|
||||
: row.student.byProblem.has(problem.problemDisplayId)
|
||||
? { background: tone('error').background, color: tone('error').color }
|
||||
: { background: theme.actionColor, color: theme.textColor3 }
|
||||
"
|
||||
>
|
||||
{{
|
||||
solvedOn(row.student, problem.problemDisplayId)
|
||||
? "✓"
|
||||
: (row.student.byProblem.get(problem.problemDisplayId)?.length ?? "·")
|
||||
astOnlyOn(row.student, problem.problemDisplayId)
|
||||
? "语"
|
||||
: solvedOn(row.student, problem.problemDisplayId)
|
||||
? "✓"
|
||||
: (row.student.byProblem.get(problem.problemDisplayId)?.length ?? "·")
|
||||
}}
|
||||
</span>
|
||||
</span>
|
||||
@@ -383,11 +450,12 @@ function pct(done: number, total: number) {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="!allStudents.length" class="empty">全都做完了</div>
|
||||
<div v-if="problems.length && !allStudents.length" class="empty">全都做完了</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="grid.truncated" class="truncated">
|
||||
范围太大,方块串只画了最近 5000 条;上面的人数、正确率仍是全部算的。
|
||||
范围太大,只取了最近的 5000 条提交:上面一排数字是全部算的,左边每道题的「做完」、
|
||||
右边的名单和方块串只按这 5000 条算,更早交的人可能不在里面。缩短时间段就准了。
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -695,4 +763,55 @@ function pct(done: number, total: number) {
|
||||
color: v-bind("tone('warning').color");
|
||||
border-top: 1px solid v-bind("theme.dividerColor");
|
||||
}
|
||||
|
||||
/* 手机:题目在上、学生在下,整页滚动 */
|
||||
@media (max-width: 767px) {
|
||||
.code-stats {
|
||||
flex-direction: column;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.problems {
|
||||
width: 100%;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||
}
|
||||
|
||||
.problem-list,
|
||||
.rows {
|
||||
flex: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.s-head {
|
||||
height: auto;
|
||||
flex-wrap: wrap;
|
||||
padding: 8px 14px;
|
||||
row-gap: 6px;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.cols {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.row {
|
||||
flex-wrap: wrap;
|
||||
padding: 8px 14px;
|
||||
row-gap: 6px;
|
||||
}
|
||||
|
||||
.c-seq,
|
||||
.c-grid {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.c-when {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.detail {
|
||||
padding-left: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -56,9 +56,11 @@ const gradeColor = computed(() => ({
|
||||
}))
|
||||
|
||||
/** 拿到 A / S 算完成(和题单、课堂条的口径一致) */
|
||||
const goodCount = computed(
|
||||
() => props.data.people.filter((row) => row.bestGrade === "S" || row.bestGrade === "A").length,
|
||||
)
|
||||
const goodCount = computed(() => {
|
||||
// 查几道题就要每道都拿到 A / S;没填题号时拿到一道就算
|
||||
const need = Math.max(1, props.data.problemCount)
|
||||
return props.data.people.filter((row) => row.goodProblems >= need).length
|
||||
})
|
||||
const personCount = computed(() => props.data.personCount || props.data.people.length)
|
||||
|
||||
const gradeData = computed(() => ({
|
||||
@@ -285,6 +287,7 @@ function gradeStyle(grade: string | null) {
|
||||
</div>
|
||||
<div class="card people">
|
||||
<div class="card-title">每人最好的一次(没交的在前)</div>
|
||||
<div v-if="data.peopleTruncated" class="muted">人太多,只列了成绩靠后的 500 位</div>
|
||||
<div class="people-list">
|
||||
<div v-for="row in people" :key="row.username" class="person">
|
||||
<b class="p-name" :title="row.username">{{ row.name }}</b>
|
||||
@@ -447,4 +450,47 @@ function gradeStyle(grade: string | null) {
|
||||
text-align: center;
|
||||
color: v-bind("theme.textColor3");
|
||||
}
|
||||
|
||||
/* 手机:卡片一张一行 */
|
||||
@media (max-width: 767px) {
|
||||
.flow-stats {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.summary {
|
||||
height: auto;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px 24px;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: none;
|
||||
overflow: visible;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.row-cards,
|
||||
.row-cards.bottom {
|
||||
flex-direction: column;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.card.grade,
|
||||
.card.radar,
|
||||
.card.people {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card .chart,
|
||||
.card .cloud {
|
||||
flex: none;
|
||||
height: 240px;
|
||||
}
|
||||
|
||||
.card.people .people-list {
|
||||
flex: none;
|
||||
max-height: 360px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -36,7 +36,6 @@ const router = useRouter()
|
||||
const configStore = useConfigStore()
|
||||
const theme = useThemeVars()
|
||||
const tone = useTone()
|
||||
const message = useMessage()
|
||||
|
||||
const tab = useRouteQuery<string>("tab", "code", { mode: "replace" })
|
||||
const className = useRouteQuery<string>("className", "", { mode: "replace" })
|
||||
@@ -52,9 +51,12 @@ const periodValue = computed<Period>(() =>
|
||||
: DEFAULT_PERIOD,
|
||||
)
|
||||
|
||||
const custom = computed<[number, number] | null>(() =>
|
||||
from.value && to.value ? [Number(from.value), Number(to.value)] : null,
|
||||
)
|
||||
/** 地址栏里的 from / to 是手改过、不是数字的话当没选(不然 toISOString 抛错整页空白) */
|
||||
const custom = computed<[number, number] | null>(() => {
|
||||
const a = Number(from.value)
|
||||
const b = Number(to.value)
|
||||
return from.value && to.value && Number.isFinite(a) && Number.isFinite(b) ? [a, b] : null
|
||||
})
|
||||
|
||||
/** n-date-picker 按浏览器时区渲染,存取都要平移到东八区(见 utils/functions 的注释) */
|
||||
const pickerValue = computed<[number, number] | null>(() =>
|
||||
@@ -70,22 +72,8 @@ function pickDates(value: [number, number] | null) {
|
||||
to.value = String(fromPickerValue(value[1]))
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计接口的「用户名」是 ilike:填 `ks253` 圈一个班。选了班级又填了学生时按学生查,
|
||||
* 学生名字不带班级前缀的话补上,免得别的班重名的混进来
|
||||
*/
|
||||
const usernameParam = computed(() => {
|
||||
const name = username.value.trim()
|
||||
if (name) {
|
||||
return className.value && !name.toLowerCase().startsWith("ks")
|
||||
? `ks${className.value}${name}`
|
||||
: name
|
||||
}
|
||||
return className.value ? `ks${className.value}` : ""
|
||||
})
|
||||
|
||||
/** 没选班级、没填学生:先按班级汇总(多半是从题目页进来的) */
|
||||
const byClass = computed(() => !usernameParam.value)
|
||||
/** 学生框:包含匹配。班级另外单独传(按 class_name 精确匹配,见后端 scopedUsers) */
|
||||
const studentParam = computed(() => username.value.trim())
|
||||
|
||||
const classOptions = computed(() => {
|
||||
const list = configStore.config?.classList ?? []
|
||||
@@ -96,42 +84,64 @@ const classOptions = computed(() => {
|
||||
// ---------- 取数 ----------
|
||||
|
||||
const loading = ref(false)
|
||||
const failed = ref(false)
|
||||
const failed = ref("")
|
||||
const stats = ref<SubmissionStatistics | null>(null)
|
||||
const grid = ref<SubmissionStatisticsGrid | null>(null)
|
||||
const flow = ref<FlowchartStatistics | null>(null)
|
||||
const rangeText = ref("")
|
||||
const withinToday = ref(false)
|
||||
|
||||
/**
|
||||
* 这一份数据是按什么条件查出来的。页面上的标签(按班级汇总还是看人、「做完」还是
|
||||
* 「做对过题」、时间范围那句话)跟着它走,不跟着输入框 —— 老师边打字边看时,数据还是
|
||||
* 上一次的,标签先变了就对不上
|
||||
*/
|
||||
const shown = ref({ byClass: true, explicit: false, rangeText: "", withinToday: false })
|
||||
let seq = 0
|
||||
|
||||
const ERROR_TEXT: Record<string, string> = {
|
||||
"problem-not-found": "有题号找不到,检查一下是不是打错了",
|
||||
"invalid-request": "一次最多查 20 道题",
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const current = ++seq
|
||||
const range = periodRange(periodValue.value, custom.value)
|
||||
rangeText.value = range.text
|
||||
withinToday.value = range.withinToday
|
||||
const duration = { start: range.start, end: range.end }
|
||||
const snapshot = {
|
||||
byClass: !studentParam.value && !className.value,
|
||||
explicit: !!problem.value.trim(),
|
||||
rangeText: range.text,
|
||||
withinToday: range.withinToday,
|
||||
}
|
||||
loading.value = true
|
||||
failed.value = false
|
||||
failed.value = ""
|
||||
try {
|
||||
if (tab.value === "flow") {
|
||||
const res = await getFlowchartStatistics(duration, problem.value, usernameParam.value)
|
||||
const res = await getFlowchartStatistics(
|
||||
duration,
|
||||
problem.value,
|
||||
studentParam.value,
|
||||
className.value,
|
||||
)
|
||||
if (current !== seq) return
|
||||
flow.value = res
|
||||
} else {
|
||||
const [s, g] = await Promise.all([
|
||||
getSubmissionStatistics(duration, problem.value, usernameParam.value),
|
||||
getSubmissionStatisticsGrid(duration, problem.value, usernameParam.value),
|
||||
getSubmissionStatistics(duration, problem.value, studentParam.value, className.value),
|
||||
getSubmissionStatisticsGrid(duration, problem.value, studentParam.value, className.value),
|
||||
])
|
||||
if (current !== seq) return
|
||||
stats.value = s
|
||||
grid.value = g
|
||||
}
|
||||
shown.value = snapshot
|
||||
} catch (error) {
|
||||
if (current !== seq) return
|
||||
failed.value = true
|
||||
// 题号打错是最常见的:后端 404 problem-not-found
|
||||
const code = (error as { error?: string })?.error
|
||||
if (code === "problem-not-found") message.warning("有题号找不到,检查一下是不是打错了")
|
||||
// 失败了就别留着上一次的结果配这一次的条件 —— 清掉,说清楚为什么
|
||||
stats.value = null
|
||||
grid.value = null
|
||||
flow.value = null
|
||||
const code = (error as { error?: string })?.error ?? ""
|
||||
failed.value = ERROR_TEXT[code] ?? "没拉下来,改一下条件再试"
|
||||
} finally {
|
||||
if (current === seq) loading.value = false
|
||||
}
|
||||
@@ -146,9 +156,9 @@ watchDebounced([username, problem], load, { debounce: 600, maxWait: 1500 })
|
||||
const doneCount = computed(() => stats.value?.data.filter((row) => row.done).length ?? 0)
|
||||
/**
|
||||
* 没填题号时后端的「做完」是「至少做对一道」—— 一个月里零零散散几十道题,谈不上
|
||||
* 「全做完」。这时这一格叫「做对过题」,别和左边「全部 N 道 · 做完 0 人」打架
|
||||
* 「全做完」。这时各处都叫「做对过题」(CodeStats、ByClass 同一个口径)
|
||||
*/
|
||||
const hasProblems = computed(() => !!problem.value.trim())
|
||||
const hasProblems = computed(() => shown.value.explicit)
|
||||
const personCount = computed(() => stats.value?.personCount ?? 0)
|
||||
const judged = computed(() =>
|
||||
stats.value ? stats.value.submissionCount - stats.value.judgingCount : 0,
|
||||
@@ -164,10 +174,10 @@ function openList() {
|
||||
name: "submissions",
|
||||
query: {
|
||||
...(tab.value === "flow" ? { language: "Flowchart" } : {}),
|
||||
...(className.value && !username.value ? { className: className.value } : {}),
|
||||
...(username.value ? { username: usernameParam.value } : {}),
|
||||
...(className.value ? { className: className.value } : {}),
|
||||
...(studentParam.value ? { username: studentParam.value } : {}),
|
||||
...(problem.value ? { problem: problem.value } : {}),
|
||||
...(withinToday.value ? { today: "1" } : {}),
|
||||
...(shown.value.withinToday ? { today: "1" } : {}),
|
||||
},
|
||||
}).href
|
||||
window.open(href, "_blank")
|
||||
@@ -227,9 +237,12 @@ function pickClass(value: string) {
|
||||
clearable
|
||||
@update:value="pickDates"
|
||||
/>
|
||||
<span v-else class="range">{{ rangeText }}</span>
|
||||
<span v-else class="range">{{ shown.rangeText }}</span>
|
||||
<n-spin v-if="loading" :size="14" />
|
||||
<n-text v-if="failed" type="error" class="range">没拉下来,改一下条件再试</n-text>
|
||||
<n-text v-if="failed" type="error" class="range">{{ failed }}</n-text>
|
||||
<span v-if="periodValue === 'custom' && !custom" class="range"
|
||||
>选一下开始和结束的日子,没选先按今天算</span
|
||||
>
|
||||
<span class="spacer"></span>
|
||||
<n-button
|
||||
size="small"
|
||||
@@ -256,7 +269,7 @@ function pickClass(value: string) {
|
||||
<StatItem label="提交" :value="stats.submissionCount" unit="条" />
|
||||
<StatItem
|
||||
label="正确率"
|
||||
:value="`${Math.round(stats.correctRate)}%`"
|
||||
:value="stats.submissionCount ? `${Math.round(stats.correctRate)}%` : '—'"
|
||||
:unit="stats.judgingCount ? `判题中 ${stats.judgingCount}` : ''"
|
||||
/>
|
||||
<template v-if="personCount">
|
||||
@@ -299,8 +312,8 @@ function pickClass(value: string) {
|
||||
<span class="ring-label">班级<br />完成度</span>
|
||||
</span>
|
||||
</div>
|
||||
<ByClass v-if="byClass" :grid="grid" @pick="pickClass" />
|
||||
<CodeStats v-else :stats="stats" :grid="grid" />
|
||||
<ByClass v-if="shown.byClass" :grid="grid" :explicit="shown.explicit" @pick="pickClass" />
|
||||
<CodeStats v-else :stats="stats" :grid="grid" :explicit="shown.explicit" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -394,4 +407,41 @@ function pickClass(value: string) {
|
||||
line-height: 1.5;
|
||||
color: v-bind("theme.textColor3");
|
||||
}
|
||||
|
||||
/* 手机:老师很少在手机上看统计,只保证不坏 —— 筛选和数字行折行,整页自然滚动 */
|
||||
@media (max-width: 767px) {
|
||||
.page {
|
||||
height: auto;
|
||||
min-height: calc(100vh - 56px);
|
||||
}
|
||||
|
||||
.filters,
|
||||
.summary {
|
||||
height: auto;
|
||||
flex-wrap: wrap;
|
||||
padding: 10px 14px;
|
||||
row-gap: 10px;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.summary {
|
||||
gap: 12px 24px;
|
||||
}
|
||||
|
||||
.vsep,
|
||||
.spacer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.w-class,
|
||||
.w-user,
|
||||
.w-period {
|
||||
width: calc(50% - 4px);
|
||||
}
|
||||
|
||||
.w-problem,
|
||||
.w-dates {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -35,11 +35,15 @@ function todayStart(now: number) {
|
||||
return dayStartOf(p.year, p.month, p.day)
|
||||
}
|
||||
|
||||
/** 这学期从哪天起:9 月到次年 2 月算秋季学期(9 月 1 日起),3 到 8 月算春季(3 月 1 日起) */
|
||||
/**
|
||||
* 这学期从哪天起:9 月到次年 1 月算秋季学期(9 月 1 日起),2 到 8 月算春季(2 月 1 日起)。
|
||||
* 春季不从 3 月算:2 月下旬就开学了,按 3 月 1 日算的话开学头一两周「这学期」会把整个
|
||||
* 上学期和寒假都带进来
|
||||
*/
|
||||
function termStart(now: number) {
|
||||
const p = zonedParts(new Date(now))!
|
||||
if (p.month >= 9) return dayStartOf(p.year, 9, 1)
|
||||
if (p.month >= 3) return dayStartOf(p.year, 3, 1)
|
||||
if (p.month >= 2) return dayStartOf(p.year, 2, 1)
|
||||
return dayStartOf(p.year - 1, 9, 1)
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user