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 FlowchartStatistics,
|
||||||
type SubmissionStatistics,
|
type SubmissionStatistics,
|
||||||
type SubmissionStatisticsItems,
|
type SubmissionStatisticsItems,
|
||||||
|
type SubmissionStatisticsGrid,
|
||||||
type TodaySubmissionStatistics,
|
type TodaySubmissionStatistics,
|
||||||
} from "@oj2/contract"
|
} from "@oj2/contract"
|
||||||
import api from "utils/api"
|
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 名)由服务端定,调用方只管翻页 ——
|
* 全服榜单。上限(100 名)由服务端定,调用方只管翻页 ——
|
||||||
* 「全服 Top10」就是这个榜的第一页,取 limit=10 即可,不需要另一个上限参数。
|
* 「全服 Top10」就是这个榜的第一页,取 limit=10 即可,不需要另一个上限参数。
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { getClassBoard, setClassLesson } from "oj/api"
|
import { getClassBoard, setClassLesson } from "oj/api"
|
||||||
|
import { useHiddenStudents } from "shared/composables/hiddenStudents"
|
||||||
import { useCollabStore } from "shared/store/collab"
|
import { useCollabStore } from "shared/store/collab"
|
||||||
import { useConfigStore } from "shared/store/config"
|
import { useConfigStore } from "shared/store/config"
|
||||||
import { errorMessage } from "utils/api"
|
import { errorMessage } from "utils/api"
|
||||||
@@ -14,7 +15,7 @@ import type { ClassBoard, ClassBoardCell, ClassBoardStudent } from "utils/types"
|
|||||||
* 没交过 → 卡住 → 在做 → 做完,没交过的名单还单独摆在最上面,老师扫一眼就能走过去。
|
* 没交过 → 卡住 → 在做 → 做完,没交过的名单还单独摆在最上面,老师扫一眼就能走过去。
|
||||||
*
|
*
|
||||||
* 班级**不记本地**,默认由后端猜最近两小时在交题的班 —— 记本地的话,上一节课的班会
|
* 班级**不记本地**,默认由后端猜最近两小时在交题的班 —— 记本地的话,上一节课的班会
|
||||||
* 悄悄留在框里,老师看的整个是别人的班。(提交统计弹窗是记一小时的,见 composables/statisticsQuery.ts,
|
* 悄悄留在框里,老师看的整个是别人的班。(原来的提交统计弹窗是记一小时的,
|
||||||
* 那边是老师自己手选的查询;这里后端本来就猜得出,没必要冒这个险。)
|
* 那边是老师自己手选的查询;这里后端本来就猜得出,没必要冒这个险。)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -137,14 +138,27 @@ function nameOf(student: ClassBoardStudent) {
|
|||||||
return student.realName || student.username
|
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(() =>
|
const students = computed(() =>
|
||||||
[...(board.value?.students ?? [])].sort(
|
[...(board.value?.students ?? [])]
|
||||||
(a, b) =>
|
.filter((student) => !isHidden(student.username))
|
||||||
GROUP_ORDER[groupOf(a)] - GROUP_ORDER[groupOf(b)] ||
|
.sort(
|
||||||
// 同样是没交过,经常不动手的排前面
|
(a, b) =>
|
||||||
Number(oftenIdle(b)) - Number(oftenIdle(a)) ||
|
GROUP_ORDER[groupOf(a)] - GROUP_ORDER[groupOf(b)] ||
|
||||||
nameOf(a).localeCompare(nameOf(b), "zh-CN"),
|
// 同样是没交过,经常不动手的排前面
|
||||||
),
|
Number(oftenIdle(b)) - Number(oftenIdle(a)) ||
|
||||||
|
nameOf(a).localeCompare(nameOf(b), "zh-CN"),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const idle = computed(() => students.value.filter((student) => groupOf(student) === "idle"))
|
const idle = computed(() => students.value.filter((student) => groupOf(student) === "idle"))
|
||||||
@@ -164,7 +178,8 @@ const stuckCount = computed(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const summary = computed(() => {
|
const summary = computed(() => {
|
||||||
const all = board.value?.students ?? []
|
// 分母也减掉请假的,不然「做完 30/38」永远到不了头
|
||||||
|
const all = students.value
|
||||||
return (board.value?.problems ?? []).map((problem, i) => ({
|
return (board.value?.problems ?? []).map((problem, i) => ({
|
||||||
...problem,
|
...problem,
|
||||||
done: all.filter((student) => student.cells[i]?.status === "accepted").length,
|
done: all.filter((student) => student.cells[i]?.status === "accepted").length,
|
||||||
@@ -311,20 +326,48 @@ function submissionsHref(student: ClassBoardStudent, problemDisplayId?: string)
|
|||||||
|
|
||||||
<template v-if="!projector">
|
<template v-if="!projector">
|
||||||
<n-alert
|
<n-alert
|
||||||
v-if="idle.length"
|
v-if="idle.length || hiddenStudents.length"
|
||||||
type="warning"
|
type="warning"
|
||||||
:title="`今天还没交过(${idle.length} 人)`"
|
:title="`今天还没交过(${idle.length} 人)`"
|
||||||
class="section"
|
class="section"
|
||||||
>
|
>
|
||||||
<div v-if="idleOften.length">
|
<n-flex align="center" :size="10" class="hide-bar">
|
||||||
<b>经常没交</b>(最近 {{ board.recentLessons }} 节课最多交过 1 节):{{
|
<n-switch v-model:value="hideMode" size="small">
|
||||||
idleOften.map(nameOf).join("、")
|
<template #checked>请假隐藏中 · 点名字旁的 × 藏起来</template>
|
||||||
}}
|
<template #unchecked>请假隐藏</template>
|
||||||
</div>
|
</n-switch>
|
||||||
<div v-if="idleOthers.length">
|
<n-button
|
||||||
<template v-if="idleOften.length"><b>其他</b>:</template
|
v-if="hiddenStudents.length"
|
||||||
>{{ idleOthers.map(nameOf).join("、") }}
|
size="tiny"
|
||||||
</div>
|
text
|
||||||
|
type="primary"
|
||||||
|
@click="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-alert>
|
||||||
|
|
||||||
<n-text v-if="stuckCount" type="error" class="meta">
|
<n-text v-if="stuckCount" type="error" class="meta">
|
||||||
@@ -420,6 +463,10 @@ function submissionsHref(student: ClassBoardStudent, problemDisplayId?: string)
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hide-bar {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.board {
|
.board {
|
||||||
max-width: 1400px;
|
max-width: 1400px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { Icon } from "@iconify/vue"
|
|||||||
import { NFlex, useThemeVars } from "naive-ui"
|
import { NFlex, useThemeVars } from "naive-ui"
|
||||||
import SubmitCode from "./SubmitCode.vue"
|
import SubmitCode from "./SubmitCode.vue"
|
||||||
import { useProblemPageContext } from "../composables/problemPageContext"
|
import { useProblemPageContext } from "../composables/problemPageContext"
|
||||||
import { statisticsOpen, useEditorMenu } from "../composables/editorMenu"
|
import { openStatistics, useEditorMenu } from "../composables/editorMenu"
|
||||||
import { useTeacherCollab } from "../composables/teacherCollab"
|
import { useTeacherCollab } from "../composables/teacherCollab"
|
||||||
|
|
||||||
const SubmitFlowchart = defineAsyncComponent(() => import("./SubmitFlowchart.vue"))
|
const SubmitFlowchart = defineAsyncComponent(() => import("./SubmitFlowchart.vue"))
|
||||||
@@ -228,7 +228,7 @@ const changeLanguage = (v: LANGUAGE) => {
|
|||||||
<n-button
|
<n-button
|
||||||
v-if="ctx.classStats && statisticsInline && userStore.isTeacherOrAbove"
|
v-if="ctx.classStats && statisticsInline && userStore.isTeacherOrAbove"
|
||||||
:size="buttonSize"
|
:size="buttonSize"
|
||||||
@click="statisticsOpen = true"
|
@click="openStatistics(problem!._id)"
|
||||||
>
|
>
|
||||||
课堂统计
|
课堂统计
|
||||||
</n-button>
|
</n-button>
|
||||||
|
|||||||
@@ -12,10 +12,12 @@ import { useTeacherCollab } from "./teacherCollab"
|
|||||||
import { useProblemPageContext } from "./problemPageContext"
|
import { useProblemPageContext } from "./problemPageContext"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 「课堂统计」弹窗开着没有。弹窗挂在题目页上(StatisticsModal),菜单可能在工具栏里、
|
* 「课堂统计」:新标签打开数据统计页,带上这道题(原来是题目页上的弹框)。
|
||||||
* 也可能在手机页签行的「⋯」里,所以这个开关放在模块级,大家共用一个
|
* 工具栏按钮和手机页签行的「⋯」都走这一个。新标签是因为老师多半正开着这道题在看
|
||||||
*/
|
*/
|
||||||
export const statisticsOpen = ref(false)
|
export function openStatistics(problemDisplayId: string) {
|
||||||
|
window.open(`/statistics?problem=${encodeURIComponent(problemDisplayId)}`, "_blank")
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 编辑器的「更多」菜单:去自测猫 / 复制代码 / 重置代码 / 编辑题目,放不下时再加上课堂统计。
|
* 编辑器的「更多」菜单:去自测猫 / 复制代码 / 重置代码 / 编辑题目,放不下时再加上课堂统计。
|
||||||
@@ -111,7 +113,7 @@ export function useEditorMenu(statisticsInline: Ref<boolean>) {
|
|||||||
function select(key: string) {
|
function select(key: string) {
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case "statistics":
|
case "statistics":
|
||||||
statisticsOpen.value = true
|
openStatistics(problem.value!._id)
|
||||||
return true
|
return true
|
||||||
case "testcat":
|
case "testcat":
|
||||||
goTestCat()
|
goTestCat()
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import type { RouteLocationNormalized } from "vue-router"
|
|||||||
import SubmissionEffects from "./components/SubmissionEffects.vue"
|
import SubmissionEffects from "./components/SubmissionEffects.vue"
|
||||||
import ResultTabLabel from "./components/ResultTabLabel.vue"
|
import ResultTabLabel from "./components/ResultTabLabel.vue"
|
||||||
import ContextBar from "./components/ContextBar.vue"
|
import ContextBar from "./components/ContextBar.vue"
|
||||||
import StatisticsModal from "./components/StatisticsModal.vue"
|
|
||||||
import { useEditorMenu } from "./composables/editorMenu"
|
import { useEditorMenu } from "./composables/editorMenu"
|
||||||
import {
|
import {
|
||||||
DRAWER_TITLE,
|
DRAWER_TITLE,
|
||||||
@@ -255,7 +254,6 @@ onBeforeUnmount(() => {
|
|||||||
<template>
|
<template>
|
||||||
<template v-if="problem">
|
<template v-if="problem">
|
||||||
<SubmissionEffects />
|
<SubmissionEffects />
|
||||||
<StatisticsModal />
|
|
||||||
<!--
|
<!--
|
||||||
桌面:两栏顶满整个内容区(设计稿「机房(方案 B)」),中间一条 1px 分隔线、可拖动。
|
桌面:两栏顶满整个内容区(设计稿「机房(方案 B)」),中间一条 1px 分隔线、可拖动。
|
||||||
布局给内容区留了 16px 内边距,这里用负边距抵掉
|
布局给内容区留了 16px 内边距,这里用负边距抵掉
|
||||||
|
|||||||
@@ -0,0 +1,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)
|
// (光 chart.js 就 197KB)
|
||||||
const SubmissionPane = defineAsyncComponent(() => import("./components/SubmissionPane.vue"))
|
const SubmissionPane = defineAsyncComponent(() => import("./components/SubmissionPane.vue"))
|
||||||
const FlowchartPane = defineAsyncComponent(() => import("./components/FlowchartPane.vue"))
|
const FlowchartPane = defineAsyncComponent(() => import("./components/FlowchartPane.vue"))
|
||||||
const StatisticsPanel = defineAsyncComponent(() => import("shared/components/StatisticsPanel.vue"))
|
|
||||||
const FlowchartStatisticsPanel = defineAsyncComponent(
|
|
||||||
() => import("shared/components/FlowchartStatisticsPanel.vue"),
|
|
||||||
)
|
|
||||||
const TodayStatistics = defineAsyncComponent(() => import("./components/TodayStatistics.vue"))
|
const TodayStatistics = defineAsyncComponent(() => import("./components/TodayStatistics.vue"))
|
||||||
|
|
||||||
interface SubmissionQuery {
|
interface SubmissionQuery {
|
||||||
@@ -272,11 +268,10 @@ function move(step: 1 | -1) {
|
|||||||
query.page = page
|
query.page = page
|
||||||
}
|
}
|
||||||
|
|
||||||
const [statisticPanel, toggleStatisticPanel] = useToggle(false)
|
|
||||||
const [todayPanel, toggleTodayPanel] = useToggle(false)
|
const [todayPanel, toggleTodayPanel] = useToggle(false)
|
||||||
|
|
||||||
onKeyStroke(["ArrowUp", "ArrowDown"], (e: KeyboardEvent) => {
|
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
|
const target = e.target as HTMLElement | null
|
||||||
if (
|
if (
|
||||||
@@ -504,6 +499,24 @@ function filterUser(username: string) {
|
|||||||
query.username = username
|
query.username = username
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「数据统计」:新标签打开统计页,带上现在的班级 / 学生 / 题号 / 代码或流程图
|
||||||
|
* (原来是这一页上的弹框)。「今天」带过去就是统计页的「今天」
|
||||||
|
*/
|
||||||
|
function openStatistics() {
|
||||||
|
const href = router.resolve({
|
||||||
|
name: "statistics",
|
||||||
|
query: {
|
||||||
|
...(flowMode.value ? { tab: "flow" } : {}),
|
||||||
|
...(query.className ? { className: query.className } : {}),
|
||||||
|
...(query.username ? { username: query.username } : {}),
|
||||||
|
...(query.problem ? { problem: query.problem } : {}),
|
||||||
|
...(query.today === "1" ? { period: "today" } : {}),
|
||||||
|
},
|
||||||
|
}).href
|
||||||
|
window.open(href, "_blank")
|
||||||
|
}
|
||||||
|
|
||||||
function filterProblem(displayId: string) {
|
function filterProblem(displayId: string) {
|
||||||
query.problem = displayId
|
query.problem = displayId
|
||||||
}
|
}
|
||||||
@@ -692,11 +705,7 @@ function dayBreak(index: number) {
|
|||||||
<template v-if="!(teacher && narrowBar)">今日统计</template>
|
<template v-if="!(teacher && narrowBar)">今日统计</template>
|
||||||
<span v-if="todayCount" class="count">{{ todayCount }}</span>
|
<span v-if="todayCount" class="count">{{ todayCount }}</span>
|
||||||
</n-button>
|
</n-button>
|
||||||
<n-button
|
<n-button v-if="teacher && route.name === 'submissions'" size="small" @click="openStatistics">
|
||||||
v-if="teacher && route.name === 'submissions'"
|
|
||||||
size="small"
|
|
||||||
@click="toggleStatisticPanel(true)"
|
|
||||||
>
|
|
||||||
<template #icon><Icon icon="ph:chart-pie-slice" /></template>
|
<template #icon><Icon icon="ph:chart-pie-slice" /></template>
|
||||||
数据统计
|
数据统计
|
||||||
</n-button>
|
</n-button>
|
||||||
@@ -929,25 +938,6 @@ function dayBreak(index: number) {
|
|||||||
</n-drawer-content>
|
</n-drawer-content>
|
||||||
</n-drawer>
|
</n-drawer>
|
||||||
|
|
||||||
<n-modal
|
|
||||||
v-if="teacher"
|
|
||||||
v-model:show="statisticPanel"
|
|
||||||
preset="card"
|
|
||||||
:style="{ maxWidth: isDesktop && '800px', maxHeight: '80vh' }"
|
|
||||||
:content-style="{ overflow: 'auto' }"
|
|
||||||
:title="flowMode ? '流程图提交的统计' : '提交记录的统计'"
|
|
||||||
>
|
|
||||||
<FlowchartStatisticsPanel
|
|
||||||
v-if="flowMode"
|
|
||||||
:problem="query.problem"
|
|
||||||
:username="query.className ? 'ks' + query.className : query.username"
|
|
||||||
/>
|
|
||||||
<StatisticsPanel
|
|
||||||
v-else
|
|
||||||
:problem="query.problem"
|
|
||||||
:username="query.className ? 'ks' + query.className : query.username"
|
|
||||||
/>
|
|
||||||
</n-modal>
|
|
||||||
<n-modal
|
<n-modal
|
||||||
v-model:show="todayPanel"
|
v-model:show="todayPanel"
|
||||||
preset="card"
|
preset="card"
|
||||||
|
|||||||
@@ -75,6 +75,12 @@ export const ojs: RouteRecordRaw = {
|
|||||||
component: () => import("oj/classroom/board.vue"),
|
component: () => import("oj/classroom/board.vue"),
|
||||||
meta: { requiresAuth: true, requiresTeacherAdmin: true },
|
meta: { requiresAuth: true, requiresTeacherAdmin: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "statistics",
|
||||||
|
name: "statistics",
|
||||||
|
component: () => import("oj/statistics/index.vue"),
|
||||||
|
meta: { requiresAuth: true, requiresTeacherAdmin: true },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "class",
|
path: "class",
|
||||||
name: "class",
|
name: "class",
|
||||||
|
|||||||
@@ -156,8 +156,6 @@ export const STORAGE_KEY = {
|
|||||||
ADMIN_SIDER_COLLAPSED: "adminSiderCollapsed",
|
ADMIN_SIDER_COLLAPSED: "adminSiderCollapsed",
|
||||||
DEMO_MODE: "demoMode",
|
DEMO_MODE: "demoMode",
|
||||||
LOGIN_CLASS: "loginClass",
|
LOGIN_CLASS: "loginClass",
|
||||||
STATISTICS_QUERY: "statisticsQuery",
|
|
||||||
FLOWCHART_STATISTICS_QUERY: "flowchartStatisticsQuery",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DIFFICULTY = {
|
export const DIFFICULTY = {
|
||||||
@@ -328,23 +326,6 @@ export const DURATION_OPTIONS = [
|
|||||||
{ label: "一年内", value: "years:1" },
|
{ label: "一年内", value: "years:1" },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
/**
|
|
||||||
* 两个统计面板(提交统计、流程图统计)的时段下拉。
|
|
||||||
*
|
|
||||||
* 比通用的那份多了头尾:前面三档分钟级是给**上课当场**用的 —— 老师布置完一道题,
|
|
||||||
* 想看的就是「刚才这十分钟谁交了」;末尾的 `all` 不是一个时长,`query.duration === "all"`
|
|
||||||
* 会走各自的分支不带时间条件,所以它永远不会进 durationFromValue()。
|
|
||||||
*
|
|
||||||
* 原来这份列表在两个面板里逐字抄了两遍。
|
|
||||||
*/
|
|
||||||
export const PANEL_DURATION_OPTIONS = [
|
|
||||||
{ label: "10分钟内", value: "minutes:10" },
|
|
||||||
{ label: "20分钟内", value: "minutes:20" },
|
|
||||||
{ label: "30分钟内", value: "minutes:30" },
|
|
||||||
...DURATION_OPTIONS,
|
|
||||||
{ label: "全部时段", value: "all" },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 榜单和班级对比用的长时段,一周起步。
|
* 榜单和班级对比用的长时段,一周起步。
|
||||||
*
|
*
|
||||||
|
|||||||
Reference in new issue
Block a user