feat(统计): 数据统计从弹框改成独立页面,左边题目、右边学生,一道题看全班每人的每一次
- 课堂看板管这节课,统计管回头看:时间段默认「两节课」,还有这节课、今天、7 天、30 天、这学期、全部、自己选日子;不再自动刷新 - 选一道题看每人在这道题上的方块串,鼠标停上去看那次的代码;选「全部」每人一行、展开按题看 - 没选班级(从题目页进来)先按班级汇总;流程图统计有等级分布、四项雷达、各项平均分、词云和每人最好的一次 - 提交列表、题目页的「数据统计」改成新标签打开这个页面 - 「请假隐藏」挪到课堂看板:点名字旁的 × 藏两个小时,做完人数的分母一起减掉 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f09da88ff7
commit
ed55ee5af6
16 files changed
+2256
-76
No files matched your search
@@ -40,6 +40,7 @@ import {
|
||||
type FlowchartStatistics,
|
||||
type SubmissionStatistics,
|
||||
type SubmissionStatisticsItems,
|
||||
type SubmissionStatisticsGrid,
|
||||
type TodaySubmissionStatistics,
|
||||
} from "@oj2/contract"
|
||||
import api from "utils/api"
|
||||
@@ -197,6 +198,17 @@ export function getSubmissionStatistics(
|
||||
})
|
||||
}
|
||||
|
||||
/** 统计页的方块串:范围内每个学生的每一次提交(口径见契约 submissionStatisticsGridSchema) */
|
||||
export function getSubmissionStatisticsGrid(
|
||||
duration: { start?: string; end: string },
|
||||
problemDisplayId?: string,
|
||||
username?: string,
|
||||
) {
|
||||
return api.get<SubmissionStatisticsGrid>("submissions/statistics/grid", {
|
||||
params: { ...duration, problemDisplayId, username },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 全服榜单。上限(100 名)由服务端定,调用方只管翻页 ——
|
||||
* 「全服 Top10」就是这个榜的第一页,取 limit=10 即可,不需要另一个上限参数。
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { getClassBoard, setClassLesson } from "oj/api"
|
||||
import { useHiddenStudents } from "shared/composables/hiddenStudents"
|
||||
import { useCollabStore } from "shared/store/collab"
|
||||
import { useConfigStore } from "shared/store/config"
|
||||
import { errorMessage } from "utils/api"
|
||||
@@ -14,7 +15,7 @@ import type { ClassBoard, ClassBoardCell, ClassBoardStudent } from "utils/types"
|
||||
* 没交过 → 卡住 → 在做 → 做完,没交过的名单还单独摆在最上面,老师扫一眼就能走过去。
|
||||
*
|
||||
* 班级**不记本地**,默认由后端猜最近两小时在交题的班 —— 记本地的话,上一节课的班会
|
||||
* 悄悄留在框里,老师看的整个是别人的班。(提交统计弹窗是记一小时的,见 composables/statisticsQuery.ts,
|
||||
* 悄悄留在框里,老师看的整个是别人的班。(原来的提交统计弹窗是记一小时的,
|
||||
* 那边是老师自己手选的查询;这里后端本来就猜得出,没必要冒这个险。)
|
||||
*/
|
||||
|
||||
@@ -137,14 +138,27 @@ function nameOf(student: ClassBoardStudent) {
|
||||
return student.realName || student.username
|
||||
}
|
||||
|
||||
/**
|
||||
* 请假隐藏(原来在「数据统计」弹框里,统计改成回头看之后挪到这儿 —— 盯这节课的是看板)。
|
||||
* 请假、转班、学号错了的那几个一直挂在「还没交过」里,会盖住真正要去看的人。
|
||||
* 藏两个小时(够一节课),下节课自动回来;只记在这台电脑上,换人换机器都不继承。
|
||||
* 键沿用统计弹框那份,老师上午藏的人这里接着藏着
|
||||
*/
|
||||
const { hideMode, hideStudent, showAll, isHidden } = useHiddenStudents("oj_hidden_students")
|
||||
const hiddenStudents = computed(() =>
|
||||
(board.value?.students ?? []).filter((student) => isHidden(student.username)),
|
||||
)
|
||||
|
||||
const students = computed(() =>
|
||||
[...(board.value?.students ?? [])].sort(
|
||||
(a, b) =>
|
||||
GROUP_ORDER[groupOf(a)] - GROUP_ORDER[groupOf(b)] ||
|
||||
// 同样是没交过,经常不动手的排前面
|
||||
Number(oftenIdle(b)) - Number(oftenIdle(a)) ||
|
||||
nameOf(a).localeCompare(nameOf(b), "zh-CN"),
|
||||
),
|
||||
[...(board.value?.students ?? [])]
|
||||
.filter((student) => !isHidden(student.username))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
GROUP_ORDER[groupOf(a)] - GROUP_ORDER[groupOf(b)] ||
|
||||
// 同样是没交过,经常不动手的排前面
|
||||
Number(oftenIdle(b)) - Number(oftenIdle(a)) ||
|
||||
nameOf(a).localeCompare(nameOf(b), "zh-CN"),
|
||||
),
|
||||
)
|
||||
|
||||
const idle = computed(() => students.value.filter((student) => groupOf(student) === "idle"))
|
||||
@@ -164,7 +178,8 @@ const stuckCount = computed(
|
||||
)
|
||||
|
||||
const summary = computed(() => {
|
||||
const all = board.value?.students ?? []
|
||||
// 分母也减掉请假的,不然「做完 30/38」永远到不了头
|
||||
const all = students.value
|
||||
return (board.value?.problems ?? []).map((problem, i) => ({
|
||||
...problem,
|
||||
done: all.filter((student) => student.cells[i]?.status === "accepted").length,
|
||||
@@ -311,20 +326,48 @@ function submissionsHref(student: ClassBoardStudent, problemDisplayId?: string)
|
||||
|
||||
<template v-if="!projector">
|
||||
<n-alert
|
||||
v-if="idle.length"
|
||||
v-if="idle.length || hiddenStudents.length"
|
||||
type="warning"
|
||||
:title="`今天还没交过(${idle.length} 人)`"
|
||||
class="section"
|
||||
>
|
||||
<div v-if="idleOften.length">
|
||||
<b>经常没交</b>(最近 {{ board.recentLessons }} 节课最多交过 1 节):{{
|
||||
idleOften.map(nameOf).join("、")
|
||||
}}
|
||||
</div>
|
||||
<div v-if="idleOthers.length">
|
||||
<template v-if="idleOften.length"><b>其他</b>:</template
|
||||
>{{ idleOthers.map(nameOf).join("、") }}
|
||||
</div>
|
||||
<n-flex align="center" :size="10" class="hide-bar">
|
||||
<n-switch v-model:value="hideMode" size="small">
|
||||
<template #checked>请假隐藏中 · 点名字旁的 × 藏起来</template>
|
||||
<template #unchecked>请假隐藏</template>
|
||||
</n-switch>
|
||||
<n-button
|
||||
v-if="hiddenStudents.length"
|
||||
size="tiny"
|
||||
text
|
||||
type="primary"
|
||||
@click="showAll"
|
||||
>
|
||||
隐藏了 {{ hiddenStudents.length }} 位:{{ hiddenStudents.map(nameOf).join("、") }} ·
|
||||
恢复
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<n-flex v-if="hideMode" :size="8">
|
||||
<n-tag
|
||||
v-for="student in idle"
|
||||
:key="student.userId"
|
||||
closable
|
||||
@close="hideStudent(student.username)"
|
||||
>
|
||||
{{ nameOf(student) }}
|
||||
</n-tag>
|
||||
</n-flex>
|
||||
<template v-else>
|
||||
<div v-if="idleOften.length">
|
||||
<b>经常没交</b>(最近 {{ board.recentLessons }} 节课最多交过 1 节):{{
|
||||
idleOften.map(nameOf).join("、")
|
||||
}}
|
||||
</div>
|
||||
<div v-if="idleOthers.length">
|
||||
<template v-if="idleOften.length"><b>其他</b>:</template
|
||||
>{{ idleOthers.map(nameOf).join("、") }}
|
||||
</div>
|
||||
</template>
|
||||
</n-alert>
|
||||
|
||||
<n-text v-if="stuckCount" type="error" class="meta">
|
||||
@@ -420,6 +463,10 @@ function submissionsHref(student: ClassBoardStudent, problemDisplayId?: string)
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.hide-bar {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.board {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
|
||||
@@ -13,7 +13,7 @@ import { Icon } from "@iconify/vue"
|
||||
import { NFlex, useThemeVars } from "naive-ui"
|
||||
import SubmitCode from "./SubmitCode.vue"
|
||||
import { useProblemPageContext } from "../composables/problemPageContext"
|
||||
import { statisticsOpen, useEditorMenu } from "../composables/editorMenu"
|
||||
import { openStatistics, useEditorMenu } from "../composables/editorMenu"
|
||||
import { useTeacherCollab } from "../composables/teacherCollab"
|
||||
|
||||
const SubmitFlowchart = defineAsyncComponent(() => import("./SubmitFlowchart.vue"))
|
||||
@@ -228,7 +228,7 @@ const changeLanguage = (v: LANGUAGE) => {
|
||||
<n-button
|
||||
v-if="ctx.classStats && statisticsInline && userStore.isTeacherOrAbove"
|
||||
:size="buttonSize"
|
||||
@click="statisticsOpen = true"
|
||||
@click="openStatistics(problem!._id)"
|
||||
>
|
||||
课堂统计
|
||||
</n-button>
|
||||
|
||||
@@ -12,10 +12,12 @@ import { useTeacherCollab } from "./teacherCollab"
|
||||
import { useProblemPageContext } from "./problemPageContext"
|
||||
|
||||
/**
|
||||
* 「课堂统计」弹窗开着没有。弹窗挂在题目页上(StatisticsModal),菜单可能在工具栏里、
|
||||
* 也可能在手机页签行的「⋯」里,所以这个开关放在模块级,大家共用一个
|
||||
* 「课堂统计」:新标签打开数据统计页,带上这道题(原来是题目页上的弹框)。
|
||||
* 工具栏按钮和手机页签行的「⋯」都走这一个。新标签是因为老师多半正开着这道题在看
|
||||
*/
|
||||
export const statisticsOpen = ref(false)
|
||||
export function openStatistics(problemDisplayId: string) {
|
||||
window.open(`/statistics?problem=${encodeURIComponent(problemDisplayId)}`, "_blank")
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑器的「更多」菜单:去自测猫 / 复制代码 / 重置代码 / 编辑题目,放不下时再加上课堂统计。
|
||||
@@ -111,7 +113,7 @@ export function useEditorMenu(statisticsInline: Ref<boolean>) {
|
||||
function select(key: string) {
|
||||
switch (key) {
|
||||
case "statistics":
|
||||
statisticsOpen.value = true
|
||||
openStatistics(problem.value!._id)
|
||||
return true
|
||||
case "testcat":
|
||||
goTestCat()
|
||||
|
||||
@@ -15,7 +15,6 @@ import type { RouteLocationNormalized } from "vue-router"
|
||||
import SubmissionEffects from "./components/SubmissionEffects.vue"
|
||||
import ResultTabLabel from "./components/ResultTabLabel.vue"
|
||||
import ContextBar from "./components/ContextBar.vue"
|
||||
import StatisticsModal from "./components/StatisticsModal.vue"
|
||||
import { useEditorMenu } from "./composables/editorMenu"
|
||||
import {
|
||||
DRAWER_TITLE,
|
||||
@@ -255,7 +254,6 @@ onBeforeUnmount(() => {
|
||||
<template>
|
||||
<template v-if="problem">
|
||||
<SubmissionEffects />
|
||||
<StatisticsModal />
|
||||
<!--
|
||||
桌面:两栏顶满整个内容区(设计稿「机房(方案 B)」),中间一条 1px 分隔线、可拖动。
|
||||
布局给内容区留了 16px 内边距,这里用负边距抵掉
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
<script lang="ts">
|
||||
import type { Submission } from "utils/types"
|
||||
|
||||
/**
|
||||
* 代码按提交 id 缓存,整页共用:交了就不会再变,老师在一排方块之间来回对比时
|
||||
* 不用每次都等网络。失败的不进缓存,下次停上去再试。
|
||||
*/
|
||||
const codeCache = new Map<string, Submission>()
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getSubmission } from "oj/api"
|
||||
import { JUDGE_STATUS, LANGUAGE_FORMAT_VALUE, SubmissionStatus } from "utils/constants"
|
||||
import { parseTime } from "utils/functions"
|
||||
import type { SUBMISSION_RESULT } from "utils/types"
|
||||
import { useTone, type Tone } from "oj/submission/composables/tone"
|
||||
|
||||
/**
|
||||
* 一个人在一道题上的每一次提交,一格一次、从早到晚(设计稿「统计重设计」)。
|
||||
* 鼠标停在一格上看那一次的代码,点一下新页面打开那条提交。
|
||||
*/
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
items: Array<{ id: string; result: SUBMISSION_RESULT; createTime: string }>
|
||||
size?: number
|
||||
}>(),
|
||||
{ size: 14 },
|
||||
)
|
||||
|
||||
const tone = useTone()
|
||||
|
||||
function toneOf(result: number): Tone {
|
||||
if (result === SubmissionStatus.accepted) return "success"
|
||||
if (
|
||||
result === SubmissionStatus.compile_error ||
|
||||
result === SubmissionStatus.runtime_error ||
|
||||
result === SubmissionStatus.ast_check_failed ||
|
||||
result === SubmissionStatus.partial_accepted
|
||||
) {
|
||||
return "warning"
|
||||
}
|
||||
if (result === SubmissionStatus.pending || result === SubmissionStatus.judging) return "info"
|
||||
return "error"
|
||||
}
|
||||
|
||||
const code = reactive<Record<string, Submission | "loading" | "failed">>({})
|
||||
|
||||
async function load(id: string) {
|
||||
const hit = codeCache.get(id)
|
||||
if (hit) {
|
||||
code[id] = hit
|
||||
return
|
||||
}
|
||||
if (code[id] === "loading") return
|
||||
code[id] = "loading"
|
||||
try {
|
||||
const res = await getSubmission(id)
|
||||
codeCache.set(id, res)
|
||||
code[id] = res
|
||||
} catch {
|
||||
code[id] = "failed"
|
||||
}
|
||||
}
|
||||
|
||||
function open(id: string) {
|
||||
window.open("/submission/" + id, "_blank", "noopener")
|
||||
}
|
||||
|
||||
const cells = computed(() =>
|
||||
props.items.map((item, index) => ({
|
||||
...item,
|
||||
index: index + 1,
|
||||
color: tone(toneOf(item.result)).solid,
|
||||
})),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="squares">
|
||||
<n-popover
|
||||
v-for="cell in cells"
|
||||
:key="cell.id"
|
||||
:delay="200"
|
||||
:style="{ maxWidth: '560px' }"
|
||||
@update:show="(show: boolean) => show && load(cell.id)"
|
||||
>
|
||||
<template #trigger>
|
||||
<button
|
||||
class="square"
|
||||
:style="{ width: `${size}px`, height: `${size}px`, background: cell.color }"
|
||||
:aria-label="`第 ${cell.index} 次:${JUDGE_STATUS[cell.result]?.name ?? ''}`"
|
||||
@click.stop="open(cell.id)"
|
||||
></button>
|
||||
</template>
|
||||
<div class="pop">
|
||||
<div class="pop-head">
|
||||
<b>{{ JUDGE_STATUS[cell.result]?.name }}</b>
|
||||
<span class="muted"
|
||||
>{{ parseTime(cell.createTime, "M月D日 HH:mm") }} · 第 {{ cell.index }} 次</span
|
||||
>
|
||||
</div>
|
||||
<n-code
|
||||
v-if="typeof code[cell.id] === 'object'"
|
||||
class="pop-code"
|
||||
:code="(code[cell.id] as Submission).code"
|
||||
:language="LANGUAGE_FORMAT_VALUE[(code[cell.id] as Submission).language]"
|
||||
show-line-numbers
|
||||
/>
|
||||
<span v-else-if="code[cell.id] === 'failed'" class="muted">代码没拉下来,点方块打开看</span>
|
||||
<span v-else class="muted">代码加载中…</span>
|
||||
<span class="muted small">点方块在新页面打开这一条</span>
|
||||
</div>
|
||||
</n-popover>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.squares {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.square {
|
||||
flex: none;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.square:hover {
|
||||
outline: 2px solid currentColor;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.pop {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pop-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.pop-code {
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.small {
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,204 @@
|
||||
<script setup lang="ts">
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import type { SubmissionStatisticsGrid } from "@oj2/contract"
|
||||
import { SubmissionStatus } from "utils/constants"
|
||||
import { useTone } from "oj/submission/composables/tone"
|
||||
import { classLabel, submissionDayText } from "oj/submission/utils"
|
||||
|
||||
/**
|
||||
* 没选班级、也没填学生时(多半是从题目页进来,只带了题号):先按班级列出来,
|
||||
* 点一个班再看人。「一道没交」要花名册人数,后端在这种情况下给 classSizes。
|
||||
*/
|
||||
const props = defineProps<{ grid: SubmissionStatisticsGrid }>()
|
||||
const emit = defineEmits<{ pick: [className: string] }>()
|
||||
|
||||
const theme = useThemeVars()
|
||||
const tone = useTone()
|
||||
|
||||
function isAc(result: number) {
|
||||
return result === SubmissionStatus.accepted || result === SubmissionStatus.ast_check_failed
|
||||
}
|
||||
|
||||
const classes = computed(() => {
|
||||
const need = props.grid.problems.map((problem) => problem.problemDisplayId)
|
||||
const groups = new Map<string, SubmissionStatisticsGrid["rows"]>()
|
||||
for (const row of props.grid.rows) {
|
||||
const key = row.className ?? ""
|
||||
groups.set(key, [...(groups.get(key) ?? []), row])
|
||||
}
|
||||
return [...groups.entries()]
|
||||
.map(([className, rows]) => {
|
||||
let submissions = 0
|
||||
let accepted = 0
|
||||
let last = ""
|
||||
let done = 0
|
||||
for (const row of rows) {
|
||||
const solved = new Set(
|
||||
row.submissions.filter((item) => isAc(item.result)).map((item) => item.problemDisplayId),
|
||||
)
|
||||
if (need.every((pid) => solved.has(pid))) done++
|
||||
submissions += row.submissions.length
|
||||
accepted += row.submissions.filter((item) => isAc(item.result)).length
|
||||
const lastTime = row.submissions.at(-1)?.createTime ?? ""
|
||||
if (lastTime > last) last = lastTime
|
||||
}
|
||||
const size = props.grid.classSizes[className] ?? rows.length
|
||||
return {
|
||||
className,
|
||||
label: className ? classLabel(className) : "没有班级",
|
||||
size,
|
||||
done,
|
||||
tried: rows.length - done,
|
||||
none: Math.max(0, size - rows.length),
|
||||
rate: submissions ? `${Math.round((accepted / submissions) * 100)}%` : "—",
|
||||
last,
|
||||
}
|
||||
})
|
||||
.sort((a, b) => b.last.localeCompare(a.last))
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="by-class">
|
||||
<div class="cols">
|
||||
<span class="c-class">班级</span>
|
||||
<span class="c-done">做完</span>
|
||||
<span class="c-n">交了没对</span>
|
||||
<span class="c-n">没交</span>
|
||||
<span class="c-n">正确率</span>
|
||||
<span class="c-last">最近一次</span>
|
||||
<span class="spacer"></span>
|
||||
</div>
|
||||
<div class="rows">
|
||||
<div v-for="row in classes" :key="row.className" class="row">
|
||||
<b class="c-class">{{ row.label }}</b>
|
||||
<span class="c-done">
|
||||
<span class="bar">
|
||||
<span
|
||||
class="fill"
|
||||
:style="{ width: `${row.size ? (row.done / row.size) * 100 : 0}%` }"
|
||||
></span>
|
||||
</span>
|
||||
<span class="num">{{ row.done }}/{{ row.size }}</span>
|
||||
</span>
|
||||
<span class="c-n" :style="{ color: tone('warning').color }">{{ row.tried }}</span>
|
||||
<span class="c-n" :style="{ color: tone('error').color }">{{ row.none }}</span>
|
||||
<span class="c-n muted">{{ row.rate }}</span>
|
||||
<span class="c-last muted">{{ row.last ? submissionDayText(row.last) : "—" }}</span>
|
||||
<span class="spacer"></span>
|
||||
<n-button
|
||||
v-if="row.className"
|
||||
size="small"
|
||||
text
|
||||
type="primary"
|
||||
@click="emit('pick', row.className)"
|
||||
>
|
||||
看这个班 →
|
||||
</n-button>
|
||||
</div>
|
||||
<div v-if="!classes.length" class="empty">这段时间没有人交</div>
|
||||
<p class="note">没选班级,先按班级列出来;点一个班再看每个人。「做完」是这几道题都做对了。</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.by-class {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.cols {
|
||||
height: 32px;
|
||||
flex: none;
|
||||
box-sizing: border-box;
|
||||
padding: 0 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 12px;
|
||||
color: v-bind("theme.textColor3");
|
||||
background: v-bind("theme.actionColor");
|
||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||
}
|
||||
|
||||
.rows {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.row {
|
||||
height: 44px;
|
||||
box-sizing: border-box;
|
||||
padding: 0 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||
}
|
||||
|
||||
.c-class {
|
||||
width: 140px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.c-done {
|
||||
width: 240px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.c-n {
|
||||
width: 90px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.c-last {
|
||||
width: 110px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.bar {
|
||||
width: 150px;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: v-bind("theme.dividerColor");
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fill {
|
||||
display: block;
|
||||
height: 6px;
|
||||
background: v-bind("theme.successColor");
|
||||
}
|
||||
|
||||
.num {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: v-bind("theme.textColor3");
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 40px 16px;
|
||||
text-align: center;
|
||||
color: v-bind("theme.textColor3");
|
||||
}
|
||||
|
||||
.note {
|
||||
margin: 0;
|
||||
padding: 10px 20px;
|
||||
font-size: 12px;
|
||||
color: v-bind("theme.textColor3");
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,698 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import type { SubmissionStatistics, SubmissionStatisticsGrid } from "@oj2/contract"
|
||||
import { SubmissionStatus } from "utils/constants"
|
||||
import { parseTime } from "utils/functions"
|
||||
import StatusPill from "oj/submission/components/StatusPill.vue"
|
||||
import { useTone } from "oj/submission/composables/tone"
|
||||
import AttemptSquares from "./AttemptSquares.vue"
|
||||
|
||||
/**
|
||||
* 代码统计的主体(设计稿「统计重设计 · 定稿 B」):左边题目,右边学生。
|
||||
* 选一道题看全班每人在这道题上的每一次;选「全部」看每人几道做完、展开按题看。
|
||||
*
|
||||
* 数字口径(做完、没交)跟着统计接口走(stats),这里的方块串和每题做完几人
|
||||
* 从 grid 里的逐条提交算。
|
||||
*/
|
||||
const props = defineProps<{
|
||||
stats: SubmissionStatistics
|
||||
grid: SubmissionStatisticsGrid
|
||||
}>()
|
||||
|
||||
const theme = useThemeVars()
|
||||
const tone = useTone()
|
||||
|
||||
type Attempt = SubmissionStatisticsGrid["rows"][number]["submissions"][number]
|
||||
|
||||
function isAc(result: number) {
|
||||
return result === SubmissionStatus.accepted || result === SubmissionStatus.ast_check_failed
|
||||
}
|
||||
|
||||
interface Student {
|
||||
username: string
|
||||
name: string
|
||||
/** 题号 → 这道题上的每一次,从早到晚 */
|
||||
byProblem: Map<string, Attempt[]>
|
||||
total: number
|
||||
accepted: number
|
||||
last: string | null
|
||||
}
|
||||
|
||||
/** 花名册上的每一个人:交过的从 grid 来,一条没交的从统计接口的 dataUnaccepted 来 */
|
||||
const students = computed<Student[]>(() => {
|
||||
const list: Student[] = props.grid.rows.map((row) => {
|
||||
const byProblem = new Map<string, Attempt[]>()
|
||||
for (const item of row.submissions) {
|
||||
const bucket = byProblem.get(item.problemDisplayId) ?? []
|
||||
bucket.push(item)
|
||||
byProblem.set(item.problemDisplayId, bucket)
|
||||
}
|
||||
return {
|
||||
username: row.username,
|
||||
name: row.realName || row.username,
|
||||
byProblem,
|
||||
total: row.submissions.length,
|
||||
accepted: row.submissions.filter((item) => isAc(item.result)).length,
|
||||
last: row.submissions.at(-1)?.createTime ?? null,
|
||||
}
|
||||
})
|
||||
const seen = new Set(list.map((item) => item.username))
|
||||
for (const row of props.stats.dataUnaccepted) {
|
||||
if (seen.has(row.username)) continue
|
||||
list.push({
|
||||
username: row.username,
|
||||
name: row.realName || row.username,
|
||||
byProblem: new Map(),
|
||||
total: 0,
|
||||
accepted: 0,
|
||||
last: null,
|
||||
})
|
||||
}
|
||||
return list
|
||||
})
|
||||
|
||||
const problems = computed(() => props.grid.problems)
|
||||
const personCount = computed(() => props.stats.personCount || students.value.length)
|
||||
|
||||
function solvedOn(student: Student, pid: string) {
|
||||
return (student.byProblem.get(pid) ?? []).some((item) => isAc(item.result))
|
||||
}
|
||||
|
||||
function doneCount(student: Student) {
|
||||
return problems.value.filter((problem) => solvedOn(student, problem.problemDisplayId)).length
|
||||
}
|
||||
|
||||
const problemRows = computed(() =>
|
||||
problems.value.map((problem) => {
|
||||
const pid = problem.problemDisplayId
|
||||
const tried = students.value.filter((student) => student.byProblem.has(pid))
|
||||
const done = tried.filter((student) => solvedOn(student, pid)).length
|
||||
const attempts = tried.reduce((sum, student) => sum + student.byProblem.get(pid)!.length, 0)
|
||||
return {
|
||||
...problem,
|
||||
done,
|
||||
tried: tried.length,
|
||||
avg: tried.length ? (attempts / tried.length).toFixed(1) : "—",
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const allDone = computed(
|
||||
() => students.value.filter((student) => doneCount(student) === problems.value.length).length,
|
||||
)
|
||||
|
||||
// ---------- 选中哪道题、筛哪些人 ----------
|
||||
|
||||
/** "" = 全部。只有一道题时直接选中它 */
|
||||
const focus = ref("")
|
||||
watch(
|
||||
problems,
|
||||
(list) => {
|
||||
if (list.length === 1) focus.value = list[0]!.problemDisplayId
|
||||
else if (!list.some((problem) => problem.problemDisplayId === focus.value)) focus.value = ""
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
/** 默认只看没做完的:复盘要找的是他们,做完的一长串沉在下面也看不见 */
|
||||
const onlyUnfinished = ref(true)
|
||||
|
||||
const focusRow = computed(() =>
|
||||
problemRows.value.find((row) => row.problemDisplayId === focus.value),
|
||||
)
|
||||
|
||||
/** 这道题上:没过的在前(交得越多越靠前 —— 卡得最久),一次没交的其次,做完的沉底 */
|
||||
const problemStudents = computed(() => {
|
||||
const pid = focus.value
|
||||
const list = students.value.map((student) => {
|
||||
const items = student.byProblem.get(pid) ?? []
|
||||
return { student, items, solved: items.some((item) => isAc(item.result)) }
|
||||
})
|
||||
const rank = (row: (typeof list)[number]) => (row.solved ? 2 : row.items.length ? 0 : 1)
|
||||
return list
|
||||
.filter((row) => !onlyUnfinished.value || !row.solved)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
rank(a) - rank(b) ||
|
||||
b.items.length - a.items.length ||
|
||||
a.student.name.localeCompare(b.student.name, "zh-CN"),
|
||||
)
|
||||
})
|
||||
|
||||
/** 全部题:没做完的在前(交得多的更前),一道没交的其次,全做完的沉底 */
|
||||
const allStudents = computed(() => {
|
||||
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)
|
||||
return list
|
||||
.filter((row) => !onlyUnfinished.value || row.done < n)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
rank(a) - rank(b) ||
|
||||
b.student.total - a.student.total ||
|
||||
a.student.name.localeCompare(b.student.name, "zh-CN"),
|
||||
)
|
||||
})
|
||||
|
||||
const unfinishedCount = computed(() =>
|
||||
focus.value
|
||||
? students.value.filter((student) => !solvedOn(student, focus.value)).length
|
||||
: students.value.filter((student) => doneCount(student) < problems.value.length).length,
|
||||
)
|
||||
|
||||
const expanded = ref("")
|
||||
watch([focus, onlyUnfinished], () => (expanded.value = ""))
|
||||
|
||||
function toggle(username: string) {
|
||||
expanded.value = expanded.value === username ? "" : username
|
||||
}
|
||||
|
||||
/** 展开一个人:没过的题在前、交得多的更前 */
|
||||
function problemsOf(student: Student) {
|
||||
return [...problems.value].sort((a, b) => {
|
||||
const sa = solvedOn(student, a.problemDisplayId)
|
||||
const sb = solvedOn(student, b.problemDisplayId)
|
||||
if (sa !== sb) return sa ? 1 : -1
|
||||
return (
|
||||
(student.byProblem.get(b.problemDisplayId)?.length ?? 0) -
|
||||
(student.byProblem.get(a.problemDisplayId)?.length ?? 0)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function rate(student: Student) {
|
||||
return student.total ? `${Math.round((student.accepted / student.total) * 100)}%` : "—"
|
||||
}
|
||||
|
||||
function dayText(time: string | null) {
|
||||
return time ? parseTime(time, "M月D日 HH:mm") : "—"
|
||||
}
|
||||
|
||||
function pct(done: number, total: number) {
|
||||
return total > 0 ? `${Math.min(100, (done / total) * 100)}%` : "0%"
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="code-stats">
|
||||
<!-- 左:题目 -->
|
||||
<section class="problems">
|
||||
<div class="col-head">题目(按你填的顺序)</div>
|
||||
<div class="problem-list">
|
||||
<button
|
||||
v-if="problems.length > 1"
|
||||
class="problem"
|
||||
:class="{ on: focus === '' }"
|
||||
@click="focus = ''"
|
||||
>
|
||||
<span class="p-line">
|
||||
<b>全部 {{ problems.length }} 道</b>
|
||||
<span class="spacer"></span>
|
||||
<span class="muted">做完 {{ allDone }}/{{ personCount }} 人</span>
|
||||
</span>
|
||||
<span class="bar"
|
||||
><span class="fill" :style="{ width: pct(allDone, personCount) }"></span
|
||||
></span>
|
||||
</button>
|
||||
<button
|
||||
v-for="row in problemRows"
|
||||
:key="row.problemDisplayId"
|
||||
class="problem"
|
||||
:class="{ on: focus === row.problemDisplayId }"
|
||||
@click="focus = row.problemDisplayId"
|
||||
>
|
||||
<span class="p-line">
|
||||
<span class="muted">{{ row.problemDisplayId }}</span>
|
||||
<b class="p-title">{{ row.title }}</b>
|
||||
<span class="spacer"></span>
|
||||
<span class="muted">做完 {{ row.done }}/{{ personCount }} · 平均 {{ row.avg }} 次</span>
|
||||
</span>
|
||||
<span class="bar"
|
||||
><span class="fill" :style="{ width: pct(row.done, personCount) }"></span
|
||||
></span>
|
||||
</button>
|
||||
<div v-if="!problems.length" class="empty">这段时间没有人交</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 右:学生 -->
|
||||
<section class="students">
|
||||
<div class="s-head">
|
||||
<template v-if="focusRow">
|
||||
<b class="s-title">{{ focusRow.problemDisplayId }} {{ focusRow.title }}</b>
|
||||
<span class="muted">
|
||||
做完 {{ focusRow.done }}/{{ personCount }} · 交了没对
|
||||
{{ focusRow.tried - focusRow.done }} · 没交
|
||||
{{ Math.max(0, personCount - focusRow.tried) }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<b class="s-title">全部 {{ problems.length }} 道</b>
|
||||
<span class="muted">
|
||||
做完 {{ allDone }}/{{ personCount }} · 没做完 {{ unfinishedCount }} · 一道没交
|
||||
{{ stats.dataUnaccepted.length }}
|
||||
</span>
|
||||
</template>
|
||||
<span class="spacer"></span>
|
||||
<button class="chip" :class="{ on: onlyUnfinished }" @click="onlyUnfinished = true">
|
||||
没做完 <span class="n">{{ unfinishedCount }}</span>
|
||||
</button>
|
||||
<button class="chip" :class="{ on: !onlyUnfinished }" @click="onlyUnfinished = false">
|
||||
全部 <span class="n">{{ students.length }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 一道题:每人一行,后面是这道题上的每一次 -->
|
||||
<template v-if="focusRow">
|
||||
<div class="cols">
|
||||
<span class="c-name">学生</span>
|
||||
<span class="c-last">最后一次</span>
|
||||
<span class="c-n">次数</span>
|
||||
<span class="c-seq">每一次(从早到晚)</span>
|
||||
<span class="c-when">最近一次在</span>
|
||||
</div>
|
||||
<div class="rows">
|
||||
<div v-for="row in problemStudents" :key="row.student.username" class="row">
|
||||
<span class="c-name name" :title="row.student.username">{{ row.student.name }}</span>
|
||||
<span class="c-last">
|
||||
<StatusPill v-if="row.items.length" :result="row.items.at(-1)!.result" />
|
||||
<b v-else class="none" :style="{ color: tone('error').color }">没交</b>
|
||||
</span>
|
||||
<span class="c-n muted">{{ row.items.length || "—" }}</span>
|
||||
<span class="c-seq"><AttemptSquares :items="row.items" /></span>
|
||||
<span class="c-when muted">{{ dayText(row.items.at(-1)?.createTime ?? null) }}</span>
|
||||
</div>
|
||||
<div v-if="!problemStudents.length" class="empty">全都做完了</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 全部题:每人一行,一格一道题;点开按题看方块串 -->
|
||||
<template v-else>
|
||||
<div class="cols">
|
||||
<span class="c-caret"></span>
|
||||
<span class="c-name">学生</span>
|
||||
<span class="c-done">做完</span>
|
||||
<span class="c-n">提交</span>
|
||||
<span class="c-rate">正确率</span>
|
||||
<span class="c-grid">{{ problems.map((p) => p.problemDisplayId).join(" · ") }}</span>
|
||||
</div>
|
||||
<div class="rows">
|
||||
<template v-for="row in allStudents" :key="row.student.username">
|
||||
<div
|
||||
class="row clickable"
|
||||
:class="{ on: expanded === row.student.username }"
|
||||
@click="toggle(row.student.username)"
|
||||
>
|
||||
<span class="c-caret">
|
||||
<Icon
|
||||
:icon="
|
||||
expanded === row.student.username ? 'ph:caret-down-bold' : 'ph:caret-right-bold'
|
||||
"
|
||||
:width="12"
|
||||
/>
|
||||
</span>
|
||||
<span class="c-name name" :title="row.student.username">{{ row.student.name }}</span>
|
||||
<span
|
||||
class="c-done done"
|
||||
:style="{
|
||||
color:
|
||||
row.done === problems.length
|
||||
? tone('success').color
|
||||
: row.student.total
|
||||
? theme.textColor1
|
||||
: tone('error').color,
|
||||
}"
|
||||
>
|
||||
{{ row.done }}/{{ problems.length }}
|
||||
</span>
|
||||
<span class="c-n muted">{{ row.student.total }}</span>
|
||||
<span class="c-rate muted">{{ rate(row.student) }}</span>
|
||||
<span class="c-grid cells">
|
||||
<span
|
||||
v-for="problem in problems"
|
||||
:key="problem.problemDisplayId"
|
||||
class="cell"
|
||||
:title="problem.problemDisplayId + ' ' + problem.title"
|
||||
: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 }
|
||||
"
|
||||
>
|
||||
{{
|
||||
solvedOn(row.student, problem.problemDisplayId)
|
||||
? "✓"
|
||||
: (row.student.byProblem.get(problem.problemDisplayId)?.length ?? "·")
|
||||
}}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="expanded === row.student.username" class="detail">
|
||||
<div
|
||||
v-for="problem in problemsOf(row.student)"
|
||||
:key="problem.problemDisplayId"
|
||||
class="d-line"
|
||||
>
|
||||
<span class="d-title"
|
||||
><span class="muted">{{ problem.problemDisplayId }}</span>
|
||||
{{ problem.title }}</span
|
||||
>
|
||||
<span
|
||||
class="d-state"
|
||||
:style="{
|
||||
color: solvedOn(row.student, problem.problemDisplayId)
|
||||
? tone('success').color
|
||||
: tone('error').color,
|
||||
}"
|
||||
>
|
||||
{{ row.student.byProblem.get(problem.problemDisplayId)?.length ?? 0 }} 次 ·
|
||||
{{
|
||||
solvedOn(row.student, problem.problemDisplayId)
|
||||
? "已通过"
|
||||
: row.student.byProblem.has(problem.problemDisplayId)
|
||||
? "还没过"
|
||||
: "没交"
|
||||
}}
|
||||
</span>
|
||||
<AttemptSquares
|
||||
:items="row.student.byProblem.get(problem.problemDisplayId) ?? []"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="!allStudents.length" class="empty">全都做完了</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="grid.truncated" class="truncated">
|
||||
范围太大,方块串只画了最近 5000 条;上面的人数、正确率仍是全部算的。
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.code-stats {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.problems {
|
||||
width: 380px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid v-bind("theme.dividerColor");
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.col-head {
|
||||
height: 36px;
|
||||
flex: none;
|
||||
box-sizing: border-box;
|
||||
padding: 0 16px 0 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
color: v-bind("theme.textColor3");
|
||||
background: v-bind("theme.actionColor");
|
||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||
}
|
||||
|
||||
.problem-list {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.problem {
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
box-sizing: border-box;
|
||||
padding: 0 16px 0 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.problem:hover {
|
||||
background: v-bind("theme.hoverColor");
|
||||
}
|
||||
|
||||
.problem.on {
|
||||
background: v-bind("tone('success').background");
|
||||
}
|
||||
|
||||
.p-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.p-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bar {
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: v-bind("theme.dividerColor");
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fill {
|
||||
display: block;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: v-bind("theme.successColor");
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: v-bind("theme.textColor3");
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.students {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.s-head {
|
||||
height: 44px;
|
||||
flex: none;
|
||||
box-sizing: border-box;
|
||||
padding: 0 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.s-title {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid v-bind("theme.borderColor");
|
||||
border-radius: 14px;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
color: v-bind("theme.textColor2");
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chip.on {
|
||||
border-color: v-bind("theme.primaryColor");
|
||||
background: v-bind("tone('success').background");
|
||||
color: v-bind("tone('success').color");
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chip .n {
|
||||
opacity: 0.75;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.cols {
|
||||
height: 32px;
|
||||
flex: none;
|
||||
box-sizing: border-box;
|
||||
padding: 0 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 12px;
|
||||
color: v-bind("theme.textColor3");
|
||||
background: v-bind("theme.actionColor");
|
||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||
}
|
||||
|
||||
.rows {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.row {
|
||||
min-height: 38px;
|
||||
box-sizing: border-box;
|
||||
padding: 6px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||
}
|
||||
|
||||
.row.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.row.clickable:hover {
|
||||
background: v-bind("theme.hoverColor");
|
||||
}
|
||||
|
||||
.row.on {
|
||||
background: v-bind("tone('success').background");
|
||||
}
|
||||
|
||||
.c-caret {
|
||||
width: 16px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
color: v-bind("theme.textColor3");
|
||||
}
|
||||
.c-name {
|
||||
width: 110px;
|
||||
flex: none;
|
||||
}
|
||||
.c-last {
|
||||
width: 120px;
|
||||
flex: none;
|
||||
}
|
||||
.c-n {
|
||||
width: 50px;
|
||||
flex: none;
|
||||
}
|
||||
.c-seq {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
.c-when {
|
||||
width: 100px;
|
||||
flex: none;
|
||||
}
|
||||
.c-done {
|
||||
width: 60px;
|
||||
flex: none;
|
||||
}
|
||||
.c-rate {
|
||||
width: 60px;
|
||||
flex: none;
|
||||
}
|
||||
.c-grid {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.done {
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.none {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.cells {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.cell {
|
||||
width: 30px;
|
||||
height: 18px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.detail {
|
||||
padding: 6px 20px 8px 46px;
|
||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||
background: v-bind("theme.actionColor");
|
||||
}
|
||||
|
||||
.d-line {
|
||||
min-height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.d-title {
|
||||
width: 190px;
|
||||
flex: none;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.d-state {
|
||||
width: 100px;
|
||||
flex: none;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 40px 16px;
|
||||
text-align: center;
|
||||
color: v-bind("theme.textColor3");
|
||||
}
|
||||
|
||||
.truncated {
|
||||
padding: 8px 20px;
|
||||
font-size: 12px;
|
||||
color: v-bind("tone('warning').color");
|
||||
border-top: 1px solid v-bind("theme.dividerColor");
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,450 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ArcElement,
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
Chart as ChartJS,
|
||||
Filler,
|
||||
Legend,
|
||||
LinearScale,
|
||||
LineElement,
|
||||
PointElement,
|
||||
RadialLinearScale,
|
||||
Tooltip,
|
||||
} from "chart.js"
|
||||
import { WordCloudController, WordElement } from "chartjs-chart-wordcloud"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { Bar, Doughnut, Radar } from "vue-chartjs"
|
||||
import type { FlowchartStatistics } from "@oj2/contract"
|
||||
import { useChartTheme } from "shared/composables/chartTheme"
|
||||
import { FLOWCHART_CRITERIA_ORDER } from "utils/constants"
|
||||
import { useTone } from "oj/submission/composables/tone"
|
||||
import RingChart from "./RingChart.vue"
|
||||
import StatItem from "./StatItem.vue"
|
||||
|
||||
ChartJS.register(
|
||||
ArcElement,
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
Filler,
|
||||
Legend,
|
||||
LinearScale,
|
||||
LineElement,
|
||||
PointElement,
|
||||
RadialLinearScale,
|
||||
Tooltip,
|
||||
WordCloudController,
|
||||
WordElement,
|
||||
)
|
||||
|
||||
/**
|
||||
* 流程图统计(设计稿「统计重设计 · 流程图」):等级分布、四项评分、各项平均分、
|
||||
* 高频问题词云、每人最好的一次。图表都是 chart.js,和站里别的图一套主题。
|
||||
*/
|
||||
const props = defineProps<{ data: FlowchartStatistics }>()
|
||||
|
||||
const theme = useThemeVars()
|
||||
const tone = useTone()
|
||||
const { chartKey } = useChartTheme()
|
||||
|
||||
const GRADES = ["S", "A", "B", "C"] as const
|
||||
const gradeColor = computed(() => ({
|
||||
S: tone("success").solid,
|
||||
A: tone("info").solid,
|
||||
B: tone("warning").solid,
|
||||
C: tone("error").solid,
|
||||
}))
|
||||
|
||||
/** 拿到 A / S 算完成(和题单、课堂条的口径一致) */
|
||||
const goodCount = computed(
|
||||
() => props.data.people.filter((row) => row.bestGrade === "S" || row.bestGrade === "A").length,
|
||||
)
|
||||
const personCount = computed(() => props.data.personCount || props.data.people.length)
|
||||
|
||||
const gradeData = computed(() => ({
|
||||
labels: GRADES.map((grade) => `${grade} 级`),
|
||||
datasets: [
|
||||
{
|
||||
data: GRADES.map((grade) => props.data.gradeDistribution[grade] ?? 0),
|
||||
backgroundColor: GRADES.map((grade) => gradeColor.value[grade]),
|
||||
borderWidth: 0,
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
const gradeOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
cutout: "60%",
|
||||
plugins: { legend: { position: "right" as const } },
|
||||
}
|
||||
|
||||
/** 四项按固定顺序(jsonb 不保留键序,逻辑正确性要在最前) */
|
||||
const criteria = computed(() =>
|
||||
FLOWCHART_CRITERIA_ORDER.filter((key) => props.data.criteriaAverages[key]).map((key) => ({
|
||||
key,
|
||||
...props.data.criteriaAverages[key]!,
|
||||
})),
|
||||
)
|
||||
|
||||
const radarData = computed(() => ({
|
||||
labels: criteria.value.map((item) => item.key),
|
||||
datasets: [
|
||||
{
|
||||
label: "平均得分率",
|
||||
data: criteria.value.map((item) => Math.round((item.avg / (item.max || 1)) * 100)),
|
||||
backgroundColor: "rgba(32, 128, 240, 0.18)",
|
||||
borderColor: tone("info").solid,
|
||||
pointBackgroundColor: tone("info").solid,
|
||||
fill: true,
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
const radarOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
callbacks: { label: (context: { parsed: { r: number } }) => `${context.parsed.r}%` },
|
||||
},
|
||||
},
|
||||
scales: { r: { min: 0, max: 100, ticks: { stepSize: 25, display: false } } },
|
||||
}
|
||||
|
||||
const barData = computed(() => ({
|
||||
labels: criteria.value.map((item) => item.key),
|
||||
datasets: [
|
||||
{
|
||||
label: "平均分",
|
||||
data: criteria.value.map((item) => item.avg),
|
||||
backgroundColor: tone("info").solid,
|
||||
borderRadius: 4,
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
const barOptions = computed(() => ({
|
||||
indexAxis: "y" as const,
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (context: { dataIndex: number; parsed: { x: number | null } }) =>
|
||||
`${context.parsed.x ?? 0} / ${criteria.value[context.dataIndex]?.max ?? 100}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: { x: { min: 0, max: Math.max(...criteria.value.map((item) => item.max), 10) } },
|
||||
}))
|
||||
|
||||
// ---------- 词云(chart.js 的 wordCloud 插件,canvas 上画,要自己管实例) ----------
|
||||
|
||||
const WORD_COLORS = ["#2080f0", "#18a058", "#d03050", "#f0a020", "#7a3e9d", "#13c2c2"]
|
||||
const cloudCanvas = useTemplateRef<HTMLCanvasElement>("cloudCanvas")
|
||||
let cloud: ChartJS | null = null
|
||||
|
||||
function drawCloud() {
|
||||
cloud?.destroy()
|
||||
cloud = null
|
||||
const words = props.data.wordFrequencies
|
||||
if (!cloudCanvas.value || !words.length) return
|
||||
const max = Math.max(...words.map((word) => word.count))
|
||||
cloud = new ChartJS(cloudCanvas.value, {
|
||||
type: "wordCloud" as never,
|
||||
data: {
|
||||
labels: words.map((word) => word.word),
|
||||
datasets: [
|
||||
{
|
||||
label: "",
|
||||
data: words.map((word) => 12 + (word.count / max) * 42),
|
||||
color: words.map((_, i) => WORD_COLORS[i % WORD_COLORS.length]),
|
||||
rotate: 0,
|
||||
} as never,
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (context: { dataIndex: number }) => {
|
||||
const word = words[context.dataIndex]
|
||||
return word ? `${word.word}:${word.count} 次` : ""
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.data.wordFrequencies, chartKey.value],
|
||||
() => nextTick(drawCloud),
|
||||
{ immediate: true },
|
||||
)
|
||||
onUnmounted(() => cloud?.destroy())
|
||||
|
||||
// ---------- 每人最好的一次:没交的在前,其次按分数从低到高 ----------
|
||||
|
||||
const people = computed(() => [
|
||||
...props.data.dataUnaccepted.map((row) => ({
|
||||
username: row.username,
|
||||
name: row.realName,
|
||||
grade: null as string | null,
|
||||
score: null as number | null,
|
||||
count: 0,
|
||||
})),
|
||||
...props.data.people.map((row) => ({
|
||||
username: row.username,
|
||||
name: row.realName,
|
||||
grade: row.bestGrade,
|
||||
score: row.bestScore,
|
||||
count: row.count,
|
||||
})),
|
||||
])
|
||||
|
||||
function gradeStyle(grade: string | null) {
|
||||
const map: Record<string, ReturnType<typeof tone>> = {
|
||||
S: tone("success"),
|
||||
A: tone("info"),
|
||||
B: tone("warning"),
|
||||
C: tone("error"),
|
||||
}
|
||||
const t = grade ? map[grade] : null
|
||||
return t
|
||||
? { color: t.color, background: t.background }
|
||||
: { color: theme.value.textColor3, background: theme.value.actionColor }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flow-stats">
|
||||
<div class="summary">
|
||||
<StatItem label="提交" :value="data.totalCount" unit="张" />
|
||||
<StatItem label="平均分" :value="data.avgScore" />
|
||||
<StatItem
|
||||
label="拿到 A / S"
|
||||
:value="`${goodCount} / ${personCount}`"
|
||||
unit="人"
|
||||
tone="success"
|
||||
/>
|
||||
<StatItem
|
||||
label="一张没交"
|
||||
:value="data.dataUnaccepted.length"
|
||||
unit="人"
|
||||
tone="error"
|
||||
:hidden="!data.personCount"
|
||||
/>
|
||||
<span class="spacer"></span>
|
||||
<!-- 没选班级就没有花名册,「班级完成度」无从谈起 -->
|
||||
<span v-if="data.personCount" class="ring-wrap">
|
||||
<RingChart
|
||||
:value="goodCount"
|
||||
:total="personCount"
|
||||
:color="tone('info').solid"
|
||||
:labels="['拿到 A / S', '还没有']"
|
||||
/>
|
||||
<span class="ring-label">班级<br />完成度</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="!data.totalCount && !data.dataUnaccepted.length" class="empty">
|
||||
这段时间没有人交流程图
|
||||
</div>
|
||||
<div v-else class="body">
|
||||
<div class="row-cards">
|
||||
<div class="card grade">
|
||||
<div class="card-title">等级分布</div>
|
||||
<div class="chart">
|
||||
<Doughnut :key="chartKey" :data="gradeData" :options="gradeOptions" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="criteria.length" class="card radar">
|
||||
<div class="card-title">四项评分(平均得分率)</div>
|
||||
<div class="chart">
|
||||
<Radar :key="chartKey" :data="radarData" :options="radarOptions" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="criteria.length" class="card grow">
|
||||
<div class="card-title">各项平均分</div>
|
||||
<div class="chart"><Bar :key="chartKey" :data="barData" :options="barOptions" /></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row-cards bottom">
|
||||
<div class="card grow">
|
||||
<div class="card-title">常见问题高频词(最近的评语里)</div>
|
||||
<div class="cloud"><canvas ref="cloudCanvas"></canvas></div>
|
||||
<div v-if="!data.wordFrequencies.length" class="muted">评语太少,还拼不出词云</div>
|
||||
</div>
|
||||
<div class="card people">
|
||||
<div class="card-title">每人最好的一次(没交的在前)</div>
|
||||
<div class="people-list">
|
||||
<div v-for="row in people" :key="row.username" class="person">
|
||||
<b class="p-name" :title="row.username">{{ row.name }}</b>
|
||||
<span class="grade-pill" :style="gradeStyle(row.grade)">
|
||||
{{ row.grade ? `${row.grade} 级 ${row.score ?? 0} 分` : "没交" }}
|
||||
</span>
|
||||
<span class="muted">{{ row.count }} 次</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.flow-stats {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.summary {
|
||||
height: 72px;
|
||||
flex: none;
|
||||
box-sizing: border-box;
|
||||
padding: 0 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 36px;
|
||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||
background: v-bind("theme.actionColor");
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.ring-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ring-label {
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: v-bind("theme.textColor3");
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
box-sizing: border-box;
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.row-cards {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
height: 220px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.row-cards.bottom {
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.card {
|
||||
box-sizing: border-box;
|
||||
border: 1px solid v-bind("theme.dividerColor");
|
||||
border-radius: 8px;
|
||||
background: v-bind("theme.cardColor");
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card.grade {
|
||||
width: 340px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.card.radar {
|
||||
width: 300px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.card.grow {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.card.people {
|
||||
width: 420px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: v-bind("theme.textColor2");
|
||||
}
|
||||
|
||||
.chart,
|
||||
.cloud {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.people-list {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.person {
|
||||
height: 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.p-name {
|
||||
width: 80px;
|
||||
flex: none;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.grade-pill {
|
||||
width: 100px;
|
||||
flex: none;
|
||||
height: 22px;
|
||||
border-radius: 11px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: v-bind("theme.textColor3");
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 60px 16px;
|
||||
text-align: center;
|
||||
color: v-bind("theme.textColor3");
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
import { ArcElement, Chart as ChartJS, Tooltip } from "chart.js"
|
||||
import { Doughnut } from "vue-chartjs"
|
||||
import { useChartTheme } from "shared/composables/chartTheme"
|
||||
|
||||
ChartJS.register(ArcElement, Tooltip)
|
||||
|
||||
/**
|
||||
* 数字行右边的小圆环(提交正确率、班级完成度)。中间写百分比,
|
||||
* 鼠标停上去看两段各是多少 —— 这是 chart.js 现成的提示。
|
||||
*/
|
||||
const props = defineProps<{
|
||||
value: number
|
||||
total: number
|
||||
color: string
|
||||
/** 两段的名字,提示里用:["正确", "没对"] */
|
||||
labels: [string, string]
|
||||
size?: number
|
||||
}>()
|
||||
|
||||
const { chartKey, gridColor } = useChartTheme()
|
||||
|
||||
const pct = computed(() => (props.total > 0 ? Math.round((props.value / props.total) * 100) : 0))
|
||||
|
||||
const data = computed(() => ({
|
||||
labels: props.labels,
|
||||
datasets: [
|
||||
{
|
||||
data: [props.value, Math.max(0, props.total - props.value)],
|
||||
backgroundColor: [props.color, gridColor.value],
|
||||
borderWidth: 0,
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
const options = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
cutout: "72%",
|
||||
animation: false as const,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (context: { label: string; parsed: number }) =>
|
||||
`${context.label}:${context.parsed}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="ring" :style="{ width: `${size ?? 48}px`, height: `${size ?? 48}px` }">
|
||||
<Doughnut :key="chartKey" :data="data" :options="options" />
|
||||
<span class="center">{{ total > 0 ? `${pct}%` : "—" }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ring {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.center {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { useTone, type Tone } from "oj/submission/composables/tone"
|
||||
|
||||
/** 数字行的一格:小字标签 + 大号数字 + 单位 */
|
||||
const props = defineProps<{
|
||||
label: string
|
||||
value: string | number
|
||||
unit?: string
|
||||
tone?: Tone
|
||||
/** 算不出来的(没选班级就没有花名册)整格不显示,免得写个 0 让人误会 */
|
||||
hidden?: boolean
|
||||
}>()
|
||||
|
||||
const theme = useThemeVars()
|
||||
const toneOf = useTone()
|
||||
const color = computed(() => (props.tone ? toneOf(props.tone).color : theme.value.textColor1))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!hidden" class="stat">
|
||||
<span class="label">{{ label }}</span>
|
||||
<span class="value-line">
|
||||
<b class="value" :style="{ color }">{{ value }}</b>
|
||||
<span v-if="unit" class="unit">{{ unit }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.label,
|
||||
.unit {
|
||||
font-size: 12px;
|
||||
color: v-bind("theme.textColor3");
|
||||
}
|
||||
|
||||
.value-line {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 22px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,397 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { useRouteQuery } from "@vueuse/router"
|
||||
import type {
|
||||
FlowchartStatistics,
|
||||
SubmissionStatistics,
|
||||
SubmissionStatisticsGrid,
|
||||
} from "@oj2/contract"
|
||||
import {
|
||||
getFlowchartStatistics,
|
||||
getSubmissionStatistics,
|
||||
getSubmissionStatisticsGrid,
|
||||
} from "oj/api"
|
||||
import { useConfigStore } from "shared/store/config"
|
||||
import { fromPickerValue, toPickerValue } from "utils/functions"
|
||||
import { useTone } from "oj/submission/composables/tone"
|
||||
import { classLabel } from "oj/submission/utils"
|
||||
import ByClass from "./components/ByClass.vue"
|
||||
import CodeStats from "./components/CodeStats.vue"
|
||||
import RingChart from "./components/RingChart.vue"
|
||||
import StatItem from "./components/StatItem.vue"
|
||||
import { DEFAULT_PERIOD, PERIOD_OPTIONS, periodRange, type Period } from "./period"
|
||||
|
||||
/**
|
||||
* 数据统计(设计稿「统计重设计 · 定稿 B」)。从提交列表、题目页的弹框改成独立页面。
|
||||
*
|
||||
* 分工:**课堂看板管这节课,这里管回头看**(用户定的)。所以这页不自动刷新、没有
|
||||
* 投影用的大字名单和请假隐藏 —— 那些在看板上。课堂统计一般看一两节课,时间段默认「两节课」。
|
||||
*
|
||||
* 查询条件都在地址栏里:从提交列表、题目页带过来,刷新、转发给别的老师都还是这一份。
|
||||
*/
|
||||
const FlowStats = defineAsyncComponent(() => import("./components/FlowStats.vue"))
|
||||
|
||||
const router = useRouter()
|
||||
const configStore = useConfigStore()
|
||||
const theme = useThemeVars()
|
||||
const tone = useTone()
|
||||
const message = useMessage()
|
||||
|
||||
const tab = useRouteQuery<string>("tab", "code", { mode: "replace" })
|
||||
const className = useRouteQuery<string>("className", "", { mode: "replace" })
|
||||
const username = useRouteQuery<string>("username", "", { mode: "replace" })
|
||||
const problem = useRouteQuery<string>("problem", "", { mode: "replace" })
|
||||
const period = useRouteQuery<string>("period", DEFAULT_PERIOD, { mode: "replace" })
|
||||
const from = useRouteQuery<string>("from", "", { mode: "replace" })
|
||||
const to = useRouteQuery<string>("to", "", { mode: "replace" })
|
||||
|
||||
const periodValue = computed<Period>(() =>
|
||||
PERIOD_OPTIONS.some((option) => option.value === period.value)
|
||||
? (period.value as Period)
|
||||
: DEFAULT_PERIOD,
|
||||
)
|
||||
|
||||
const custom = computed<[number, number] | null>(() =>
|
||||
from.value && to.value ? [Number(from.value), Number(to.value)] : null,
|
||||
)
|
||||
|
||||
/** n-date-picker 按浏览器时区渲染,存取都要平移到东八区(见 utils/functions 的注释) */
|
||||
const pickerValue = computed<[number, number] | null>(() =>
|
||||
custom.value ? [toPickerValue(custom.value[0]), toPickerValue(custom.value[1])] : null,
|
||||
)
|
||||
function pickDates(value: [number, number] | null) {
|
||||
if (!value) {
|
||||
from.value = ""
|
||||
to.value = ""
|
||||
return
|
||||
}
|
||||
from.value = String(fromPickerValue(value[0]))
|
||||
to.value = String(fromPickerValue(value[1]))
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计接口的「用户名」是 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)
|
||||
|
||||
const classOptions = computed(() => {
|
||||
const list = configStore.config?.classList ?? []
|
||||
const all = className.value && !list.includes(className.value) ? [className.value, ...list] : list
|
||||
return all.map((item) => ({ label: classLabel(item), value: item }))
|
||||
})
|
||||
|
||||
// ---------- 取数 ----------
|
||||
|
||||
const loading = ref(false)
|
||||
const failed = ref(false)
|
||||
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)
|
||||
let seq = 0
|
||||
|
||||
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 }
|
||||
loading.value = true
|
||||
failed.value = false
|
||||
try {
|
||||
if (tab.value === "flow") {
|
||||
const res = await getFlowchartStatistics(duration, problem.value, usernameParam.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),
|
||||
])
|
||||
if (current !== seq) return
|
||||
stats.value = s
|
||||
grid.value = g
|
||||
}
|
||||
} 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("有题号找不到,检查一下是不是打错了")
|
||||
} finally {
|
||||
if (current === seq) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
watch([tab, className, periodValue, custom], load)
|
||||
watchDebounced([username, problem], load, { debounce: 600, maxWait: 1500 })
|
||||
|
||||
// ---------- 数字行(代码) ----------
|
||||
|
||||
const doneCount = computed(() => stats.value?.data.filter((row) => row.done).length ?? 0)
|
||||
/**
|
||||
* 没填题号时后端的「做完」是「至少做对一道」—— 一个月里零零散散几十道题,谈不上
|
||||
* 「全做完」。这时这一格叫「做对过题」,别和左边「全部 N 道 · 做完 0 人」打架
|
||||
*/
|
||||
const hasProblems = computed(() => !!problem.value.trim())
|
||||
const personCount = computed(() => stats.value?.personCount ?? 0)
|
||||
const judged = computed(() =>
|
||||
stats.value ? stats.value.submissionCount - stats.value.judgingCount : 0,
|
||||
)
|
||||
|
||||
// ---------- 两个入口 ----------
|
||||
|
||||
/** 1366 以下(机房 1280 的屏)右上两个入口只留图标 */
|
||||
const narrow = useMediaQuery("(max-width: 1365px)")
|
||||
|
||||
function openList() {
|
||||
const href = router.resolve({
|
||||
name: "submissions",
|
||||
query: {
|
||||
...(tab.value === "flow" ? { language: "Flowchart" } : {}),
|
||||
...(className.value && !username.value ? { className: className.value } : {}),
|
||||
...(username.value ? { username: usernameParam.value } : {}),
|
||||
...(problem.value ? { problem: problem.value } : {}),
|
||||
...(withinToday.value ? { today: "1" } : {}),
|
||||
},
|
||||
}).href
|
||||
window.open(href, "_blank")
|
||||
}
|
||||
|
||||
function pickClass(value: string) {
|
||||
className.value = value
|
||||
username.value = ""
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="filters">
|
||||
<span class="title">数据统计</span>
|
||||
<n-radio-group v-model:value="tab" size="small">
|
||||
<n-radio-button value="code">代码</n-radio-button>
|
||||
<n-radio-button value="flow">流程图</n-radio-button>
|
||||
</n-radio-group>
|
||||
<span class="vsep"></span>
|
||||
<n-select
|
||||
:value="className || null"
|
||||
class="w-class"
|
||||
size="small"
|
||||
:options="classOptions"
|
||||
placeholder="全部班级"
|
||||
clearable
|
||||
filterable
|
||||
@update:value="(v: string | null) => (className = v ?? '')"
|
||||
/>
|
||||
<n-input v-model:value="username" class="w-user" size="small" clearable placeholder="学生">
|
||||
<template #prefix><Icon icon="ph:magnifying-glass" /></template>
|
||||
</n-input>
|
||||
<n-input
|
||||
v-model:value="problem"
|
||||
class="w-problem"
|
||||
size="small"
|
||||
clearable
|
||||
placeholder="题号,几道用空格隔开"
|
||||
>
|
||||
<template #prefix><Icon icon="ph:hash" /></template>
|
||||
</n-input>
|
||||
<n-select
|
||||
:value="periodValue"
|
||||
class="w-period"
|
||||
size="small"
|
||||
:options="PERIOD_OPTIONS.map((o) => ({ label: o.label, value: o.value }))"
|
||||
:consistent-menu-width="false"
|
||||
@update:value="(v: string) => (period = v)"
|
||||
/>
|
||||
<n-date-picker
|
||||
v-if="periodValue === 'custom'"
|
||||
:value="pickerValue"
|
||||
type="daterange"
|
||||
size="small"
|
||||
class="w-dates"
|
||||
clearable
|
||||
@update:value="pickDates"
|
||||
/>
|
||||
<span v-else class="range">{{ rangeText }}</span>
|
||||
<n-spin v-if="loading" :size="14" />
|
||||
<n-text v-if="failed" type="error" class="range">没拉下来,改一下条件再试</n-text>
|
||||
<span class="spacer"></span>
|
||||
<n-button
|
||||
size="small"
|
||||
quaternary
|
||||
title="这节课去课堂看板"
|
||||
aria-label="这节课去课堂看板"
|
||||
@click="router.push('/classroom')"
|
||||
>
|
||||
<template #icon><Icon icon="ph:monitor" /></template>
|
||||
<template v-if="!narrow">这节课去课堂看板</template>
|
||||
</n-button>
|
||||
<n-button size="small" title="在提交列表里看" aria-label="在提交列表里看" @click="openList">
|
||||
<template #icon><Icon icon="ph:arrow-square-out" /></template>
|
||||
<template v-if="!narrow">在提交列表里看</template>
|
||||
</n-button>
|
||||
</div>
|
||||
|
||||
<template v-if="tab === 'flow'">
|
||||
<FlowStats v-if="flow" :data="flow" />
|
||||
</template>
|
||||
|
||||
<template v-else-if="stats && grid">
|
||||
<div class="summary">
|
||||
<StatItem label="提交" :value="stats.submissionCount" unit="条" />
|
||||
<StatItem
|
||||
label="正确率"
|
||||
:value="`${Math.round(stats.correctRate)}%`"
|
||||
:unit="stats.judgingCount ? `判题中 ${stats.judgingCount}` : ''"
|
||||
/>
|
||||
<template v-if="personCount">
|
||||
<StatItem
|
||||
:label="hasProblems ? '做完' : '做对过题'"
|
||||
:value="`${doneCount} / ${personCount}`"
|
||||
unit="人"
|
||||
tone="success"
|
||||
/>
|
||||
<StatItem
|
||||
v-if="hasProblems"
|
||||
label="完成度"
|
||||
:value="`${personCount ? Math.round((doneCount / personCount) * 100) : 0}%`"
|
||||
/>
|
||||
<StatItem label="一道没交" :value="stats.dataUnaccepted.length" unit="人" tone="error" />
|
||||
</template>
|
||||
<StatItem
|
||||
:label="hasProblems ? '交了没全对' : '一道没做对'"
|
||||
:value="stats.dataAttempted.length"
|
||||
unit="人"
|
||||
tone="warning"
|
||||
/>
|
||||
<span class="spacer"></span>
|
||||
<span class="ring-wrap">
|
||||
<RingChart
|
||||
:value="stats.acceptedCount"
|
||||
:total="judged"
|
||||
:color="tone('success').solid"
|
||||
:labels="['正确', '没对']"
|
||||
/>
|
||||
<span class="ring-label">提交<br />正确率</span>
|
||||
</span>
|
||||
<span v-if="personCount && hasProblems" class="ring-wrap">
|
||||
<RingChart
|
||||
:value="doneCount"
|
||||
:total="personCount"
|
||||
:color="tone('info').solid"
|
||||
:labels="['做完', '没做完']"
|
||||
/>
|
||||
<span class="ring-label">班级<br />完成度</span>
|
||||
</span>
|
||||
</div>
|
||||
<ByClass v-if="byClass" :grid="grid" @pick="pickClass" />
|
||||
<CodeStats v-else :stats="stats" :grid="grid" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 铺满顶栏以下,和提交列表同一个做法(负边距吃掉外层 16px) */
|
||||
.page {
|
||||
margin: -16px;
|
||||
height: calc(100vh - 56px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: v-bind("theme.cardColor");
|
||||
}
|
||||
|
||||
.filters {
|
||||
height: 52px;
|
||||
flex: none;
|
||||
box-sizing: border-box;
|
||||
padding: 0 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.vsep {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
margin: 0 4px;
|
||||
background: v-bind("theme.dividerColor");
|
||||
}
|
||||
|
||||
.w-class {
|
||||
width: 132px;
|
||||
}
|
||||
.w-user {
|
||||
width: 110px;
|
||||
}
|
||||
.w-problem {
|
||||
width: 220px;
|
||||
}
|
||||
.w-period {
|
||||
width: 150px;
|
||||
}
|
||||
.w-dates {
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
@media (max-width: 1365px) {
|
||||
.w-problem {
|
||||
width: 190px;
|
||||
}
|
||||
}
|
||||
|
||||
.range {
|
||||
font-size: 12px;
|
||||
color: v-bind("theme.textColor3");
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.summary {
|
||||
height: 72px;
|
||||
flex: none;
|
||||
box-sizing: border-box;
|
||||
padding: 0 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 36px;
|
||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||
background: v-bind("theme.actionColor");
|
||||
}
|
||||
|
||||
.ring-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ring-label {
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: v-bind("theme.textColor3");
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,101 @@
|
||||
import { TIME_ZONE_OFFSET_MINUTES } from "@oj2/contract"
|
||||
import { parseTime, zonedParts } from "utils/functions"
|
||||
|
||||
const OFFSET_MS = TIME_ZONE_OFFSET_MINUTES * 60_000
|
||||
const HOUR = 3_600_000
|
||||
const DAY = 86_400_000
|
||||
|
||||
/**
|
||||
* 统计页的时间段。课堂统计一般看一两节课(用户原话),所以这两档在最前面、
|
||||
* 默认「两节课」;后面几档是回头看。原来弹框里的 10 / 20 / 30 分钟是上课盯人用的,
|
||||
* 那件事现在归课堂看板。
|
||||
*/
|
||||
export const PERIOD_OPTIONS = [
|
||||
{ label: "这节课(1 小时内)", value: "1h" },
|
||||
{ label: "两节课(2 小时内)", value: "2h" },
|
||||
{ label: "今天", value: "today" },
|
||||
{ label: "最近 7 天", value: "7d" },
|
||||
{ label: "最近 30 天", value: "30d" },
|
||||
{ label: "这学期", value: "term" },
|
||||
{ label: "全部", value: "all" },
|
||||
{ label: "自己选日子", value: "custom" },
|
||||
] as const
|
||||
|
||||
export type Period = (typeof PERIOD_OPTIONS)[number]["value"]
|
||||
export const DEFAULT_PERIOD: Period = "2h"
|
||||
|
||||
/** 东八区某天 0 点对应的 UTC 时刻 */
|
||||
function dayStartOf(year: number, month: number, day: number) {
|
||||
return Date.UTC(year, month - 1, day) - OFFSET_MS
|
||||
}
|
||||
|
||||
/** 东八区「现在」这一天的 0 点 */
|
||||
function todayStart(now: number) {
|
||||
const p = zonedParts(new Date(now))!
|
||||
return dayStartOf(p.year, p.month, p.day)
|
||||
}
|
||||
|
||||
/** 这学期从哪天起:9 月到次年 2 月算秋季学期(9 月 1 日起),3 到 8 月算春季(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)
|
||||
return dayStartOf(p.year - 1, 9, 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* 时间段换成接口要的 start / end(ISO),外加一句给人看的范围。
|
||||
* `custom` 用的是 [开始日 0 点, 结束日 24 点),两个都是 date picker 取回来的 UTC 时刻。
|
||||
*/
|
||||
export function periodRange(period: Period, custom: [number, number] | null, now = Date.now()) {
|
||||
let start: number | null
|
||||
let end = now
|
||||
switch (period) {
|
||||
case "1h":
|
||||
start = now - HOUR
|
||||
break
|
||||
case "2h":
|
||||
start = now - 2 * HOUR
|
||||
break
|
||||
case "today":
|
||||
start = todayStart(now)
|
||||
break
|
||||
case "7d":
|
||||
start = todayStart(now) - 6 * DAY
|
||||
break
|
||||
case "30d":
|
||||
start = todayStart(now) - 29 * DAY
|
||||
break
|
||||
case "term":
|
||||
start = termStart(now)
|
||||
break
|
||||
case "custom":
|
||||
if (custom) {
|
||||
start = custom[0]
|
||||
end = custom[1] + DAY
|
||||
} else {
|
||||
start = todayStart(now)
|
||||
}
|
||||
break
|
||||
default:
|
||||
start = null
|
||||
}
|
||||
const text = describe(start, end, period)
|
||||
return {
|
||||
start: start === null ? undefined : new Date(start).toISOString(),
|
||||
end: new Date(end).toISOString(),
|
||||
text,
|
||||
/** 范围落在今天以内:提交列表那边能用「今天」接上 */
|
||||
withinToday: start !== null && start >= todayStart(now),
|
||||
}
|
||||
}
|
||||
|
||||
function describe(start: number | null, end: number, period: Period) {
|
||||
if (start === null) return ""
|
||||
if (period === "1h" || period === "2h") {
|
||||
return `${parseTime(new Date(start), "HH:mm")}–${parseTime(new Date(end), "HH:mm")}`
|
||||
}
|
||||
const from = parseTime(new Date(start), "M月D日")
|
||||
const to = parseTime(new Date(end - 1), "M月D日")
|
||||
return from === to ? from : `${from}–${to}`
|
||||
}
|
||||
@@ -44,10 +44,6 @@ import { classLabel, submissionClockText, submissionDayText } from "./utils"
|
||||
// (光 chart.js 就 197KB)
|
||||
const SubmissionPane = defineAsyncComponent(() => import("./components/SubmissionPane.vue"))
|
||||
const FlowchartPane = defineAsyncComponent(() => import("./components/FlowchartPane.vue"))
|
||||
const StatisticsPanel = defineAsyncComponent(() => import("shared/components/StatisticsPanel.vue"))
|
||||
const FlowchartStatisticsPanel = defineAsyncComponent(
|
||||
() => import("shared/components/FlowchartStatisticsPanel.vue"),
|
||||
)
|
||||
const TodayStatistics = defineAsyncComponent(() => import("./components/TodayStatistics.vue"))
|
||||
|
||||
interface SubmissionQuery {
|
||||
@@ -272,11 +268,10 @@ function move(step: 1 | -1) {
|
||||
query.page = page
|
||||
}
|
||||
|
||||
const [statisticPanel, toggleStatisticPanel] = useToggle(false)
|
||||
const [todayPanel, toggleTodayPanel] = useToggle(false)
|
||||
|
||||
onKeyStroke(["ArrowUp", "ArrowDown"], (e: KeyboardEvent) => {
|
||||
if (!isDesktop.value || statisticPanel.value || todayPanel.value) return
|
||||
if (!isDesktop.value || todayPanel.value) return
|
||||
// 焦点在输入框、下拉框里时不抢方向键
|
||||
const target = e.target as HTMLElement | null
|
||||
if (
|
||||
@@ -504,6 +499,24 @@ function filterUser(username: string) {
|
||||
query.username = username
|
||||
}
|
||||
|
||||
/**
|
||||
* 「数据统计」:新标签打开统计页,带上现在的班级 / 学生 / 题号 / 代码或流程图
|
||||
* (原来是这一页上的弹框)。「今天」带过去就是统计页的「今天」
|
||||
*/
|
||||
function openStatistics() {
|
||||
const href = router.resolve({
|
||||
name: "statistics",
|
||||
query: {
|
||||
...(flowMode.value ? { tab: "flow" } : {}),
|
||||
...(query.className ? { className: query.className } : {}),
|
||||
...(query.username ? { username: query.username } : {}),
|
||||
...(query.problem ? { problem: query.problem } : {}),
|
||||
...(query.today === "1" ? { period: "today" } : {}),
|
||||
},
|
||||
}).href
|
||||
window.open(href, "_blank")
|
||||
}
|
||||
|
||||
function filterProblem(displayId: string) {
|
||||
query.problem = displayId
|
||||
}
|
||||
@@ -692,11 +705,7 @@ function dayBreak(index: number) {
|
||||
<template v-if="!(teacher && narrowBar)">今日统计</template>
|
||||
<span v-if="todayCount" class="count">{{ todayCount }}</span>
|
||||
</n-button>
|
||||
<n-button
|
||||
v-if="teacher && route.name === 'submissions'"
|
||||
size="small"
|
||||
@click="toggleStatisticPanel(true)"
|
||||
>
|
||||
<n-button v-if="teacher && route.name === 'submissions'" size="small" @click="openStatistics">
|
||||
<template #icon><Icon icon="ph:chart-pie-slice" /></template>
|
||||
数据统计
|
||||
</n-button>
|
||||
@@ -929,25 +938,6 @@ function dayBreak(index: number) {
|
||||
</n-drawer-content>
|
||||
</n-drawer>
|
||||
|
||||
<n-modal
|
||||
v-if="teacher"
|
||||
v-model:show="statisticPanel"
|
||||
preset="card"
|
||||
:style="{ maxWidth: isDesktop && '800px', maxHeight: '80vh' }"
|
||||
:content-style="{ overflow: 'auto' }"
|
||||
:title="flowMode ? '流程图提交的统计' : '提交记录的统计'"
|
||||
>
|
||||
<FlowchartStatisticsPanel
|
||||
v-if="flowMode"
|
||||
:problem="query.problem"
|
||||
:username="query.className ? 'ks' + query.className : query.username"
|
||||
/>
|
||||
<StatisticsPanel
|
||||
v-else
|
||||
:problem="query.problem"
|
||||
:username="query.className ? 'ks' + query.className : query.username"
|
||||
/>
|
||||
</n-modal>
|
||||
<n-modal
|
||||
v-model:show="todayPanel"
|
||||
preset="card"
|
||||
|
||||
@@ -75,6 +75,12 @@ export const ojs: RouteRecordRaw = {
|
||||
component: () => import("oj/classroom/board.vue"),
|
||||
meta: { requiresAuth: true, requiresTeacherAdmin: true },
|
||||
},
|
||||
{
|
||||
path: "statistics",
|
||||
name: "statistics",
|
||||
component: () => import("oj/statistics/index.vue"),
|
||||
meta: { requiresAuth: true, requiresTeacherAdmin: true },
|
||||
},
|
||||
{
|
||||
path: "class",
|
||||
name: "class",
|
||||
|
||||
@@ -156,8 +156,6 @@ export const STORAGE_KEY = {
|
||||
ADMIN_SIDER_COLLAPSED: "adminSiderCollapsed",
|
||||
DEMO_MODE: "demoMode",
|
||||
LOGIN_CLASS: "loginClass",
|
||||
STATISTICS_QUERY: "statisticsQuery",
|
||||
FLOWCHART_STATISTICS_QUERY: "flowchartStatisticsQuery",
|
||||
}
|
||||
|
||||
export const DIFFICULTY = {
|
||||
@@ -328,23 +326,6 @@ export const DURATION_OPTIONS = [
|
||||
{ label: "一年内", value: "years:1" },
|
||||
] as const
|
||||
|
||||
/**
|
||||
* 两个统计面板(提交统计、流程图统计)的时段下拉。
|
||||
*
|
||||
* 比通用的那份多了头尾:前面三档分钟级是给**上课当场**用的 —— 老师布置完一道题,
|
||||
* 想看的就是「刚才这十分钟谁交了」;末尾的 `all` 不是一个时长,`query.duration === "all"`
|
||||
* 会走各自的分支不带时间条件,所以它永远不会进 durationFromValue()。
|
||||
*
|
||||
* 原来这份列表在两个面板里逐字抄了两遍。
|
||||
*/
|
||||
export const PANEL_DURATION_OPTIONS = [
|
||||
{ label: "10分钟内", value: "minutes:10" },
|
||||
{ label: "20分钟内", value: "minutes:20" },
|
||||
{ label: "30分钟内", value: "minutes:30" },
|
||||
...DURATION_OPTIONS,
|
||||
{ label: "全部时段", value: "all" },
|
||||
] as const
|
||||
|
||||
/**
|
||||
* 榜单和班级对比用的长时段,一周起步。
|
||||
*
|
||||
|
||||
Reference in new issue
Block a user