Compare commits

...
2 Commits
5 changed files with 146 additions and 29 deletions

No files matched your search

+3 -2
View File
@@ -13,8 +13,9 @@ import type { ClassBoard, ClassBoardCell, ClassBoardStudent } from "utils/types"
* 学生一道都没交(占三分之一),交了 3 次以上还没过的平均不到 1 个。所以排序是 * 学生一道都没交(占三分之一),交了 3 次以上还没过的平均不到 1 个。所以排序是
* 没交过 → 卡住 → 在做 → 做完,没交过的名单还单独摆在最上面,老师扫一眼就能走过去。 * 没交过 → 卡住 → 在做 → 做完,没交过的名单还单独摆在最上面,老师扫一眼就能走过去。
* *
* 班级**不记本地**,默认由后端猜最近两小时在交题的班 —— 理由见 StatisticsPanel.vue * 班级**不记本地**,默认由后端猜最近两小时在交题的班 —— 记本地的话,上一节课的班会
* 那段注释(上一节课的班悄悄留在框里,老师看的整个是别人的班)。 * 悄悄留在框里,老师看的整个是别人的班。(提交统计弹窗是记一小时的,见 composables/statisticsQuery.ts,
* 那边是老师自己手选的查询;这里后端本来就猜得出,没必要冒这个险。)
*/ */
/** 同一道题今天交了这么多次还没过,算卡住 */ /** 同一道题今天交了这么多次还没过,算卡住 */
@@ -142,9 +142,10 @@
import { formatISO, sub, type Duration } from "date-fns" import { formatISO, sub, type Duration } from "date-fns"
import type { FlowchartStatistics } from "@oj2/contract" import type { FlowchartStatistics } from "@oj2/contract"
import { getFlowchartStatistics } from "oj/api" import { getFlowchartStatistics } from "oj/api"
import { PANEL_DURATION_OPTIONS, FLOWCHART_CRITERIA_ORDER } from "utils/constants" import { PANEL_DURATION_OPTIONS, FLOWCHART_CRITERIA_ORDER, STORAGE_KEY } from "utils/constants"
import { durationFromValue } from "utils/functions" import { durationFromValue } from "utils/functions"
import { useHiddenStudents } from "../composables/hiddenStudents" import { useHiddenStudents } from "../composables/hiddenStudents"
import { useStatisticsQuery } from "../composables/statisticsQuery"
import { Doughnut, Radar, Bar } from "vue-chartjs" import { Doughnut, Radar, Bar } from "vue-chartjs"
import { import {
Chart as ChartJS, Chart as ChartJS,
@@ -189,11 +190,8 @@ const message = useMessage()
const durationOptions: SelectOption[] = [...PANEL_DURATION_OPTIONS] const durationOptions: SelectOption[] = [...PANEL_DURATION_OPTIONS]
const query = reactive({ // 班级、题号、时间段记一小时,理由见 useStatisticsQuery
username: props.username, const { query, save: saveQuery } = useStatisticsQuery(STORAGE_KEY.FLOWCHART_STATISTICS_QUERY, props)
problem: props.problem,
duration: durationOptions[0].value,
})
const data = reactive<FlowchartStatistics>({ const data = reactive<FlowchartStatistics>({
totalCount: 0, totalCount: 0,
@@ -447,6 +445,7 @@ async function handleStatistics() {
try { try {
const res = await getFlowchartStatistics(duration, query.problem, query.username) const res = await getFlowchartStatistics(duration, query.problem, query.username)
Object.assign(data, res) Object.assign(data, res)
saveQuery()
} catch (error) { } catch (error) {
message.error("获取流程图统计失败") message.error("获取流程图统计失败")
console.error("获取流程图统计失败:", error) console.error("获取流程图统计失败:", error)
@@ -156,13 +156,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { h } from "vue" import { h } from "vue"
import { formatISO, sub, type Duration } from "date-fns" import { formatISO, sub, type Duration } from "date-fns"
import { getSubmissionStatistics, getSubmissionStatisticsItems } from "oj/api" import { getSubmission, getSubmissionStatistics, getSubmissionStatisticsItems } from "oj/api"
import { PANEL_DURATION_OPTIONS } from "utils/constants" import { LANGUAGE_FORMAT_VALUE, PANEL_DURATION_OPTIONS, STORAGE_KEY } from "utils/constants"
import { useConfigStore } from "../store/config" import { useConfigStore } from "../store/config"
import { useHiddenStudents } from "../composables/hiddenStudents" import { useHiddenStudents } from "../composables/hiddenStudents"
import { useStatisticsQuery } from "../composables/statisticsQuery"
import { Doughnut } from "vue-chartjs" import { Doughnut } from "vue-chartjs"
import { Chart as ChartJS, ArcElement, Title, Tooltip, Legend } from "chart.js" import { Chart as ChartJS, ArcElement, Title, Tooltip, Legend } from "chart.js"
import { NFlex, NTag, NText, NTooltip, type DataTableRowKey } from "naive-ui" import { NCode, NFlex, NPopover, NTag, NText, type DataTableRowKey } from "naive-ui"
import { JUDGE_STATUS, SubmissionStatus } from "utils/constants" import { JUDGE_STATUS, SubmissionStatus } from "utils/constants"
import { durationFromValue, parseTime } from "utils/functions" import { durationFromValue, parseTime } from "utils/functions"
import type { import type {
@@ -242,6 +243,50 @@ function groupByProblem(list: SubmissionStatisticsItems["items"]) {
.sort((a, b) => Number(a.solved) - Number(b.solved) || b.items.length - a.items.length) .sort((a, b) => Number(a.solved) - Number(b.solved) || b.items.length - a.items.length)
} }
/**
* 悬停方块时看到的代码,按提交编号缓存。
*
* 原来悬停只有「状态 · 时间 · 编号」,想看学生写了什么非得点开新页面,老师在一排
* 红方块之间来回对比时要开一堆标签页。代码**悬停时才拉**、不随明细一起下发:
* 明细一次最多 200 条、展开着每 15 秒还要重拉,而真正会被悬停的只是其中几个。
* 代码交了就不会再变,所以缓存不随重新统计清空(和 items 不一样)。
*/
type CodeState =
| { status: "loading" }
| { status: "error" }
| { status: "done"; code: string; language: string }
const codes = reactive<Record<string, CodeState>>({})
async function loadCode(id: string) {
const cached = codes[id]
if (cached && cached.status !== "error") return
codes[id] = { status: "loading" }
try {
const submission = await getSubmission(id)
codes[id] = {
status: "done",
code: submission.code,
language: LANGUAGE_FORMAT_VALUE[submission.language],
}
} catch {
codes[id] = { status: "error" }
}
}
function renderCode(id: string) {
const cached = codes[id]
if (!cached || cached.status === "loading") return h(NText, { depth: 3 }, () => "代码加载中…")
if (cached.status === "error")
return h(NText, { type: "error" }, () => "代码没拉下来,点方块打开看")
return h(
"div",
// 太长就在浮层里滚:鼠标移进浮层它不会消失,可以接着往下翻
{ style: "max-height: 360px; overflow: auto" },
h(NCode, { code: cached.code, language: cached.language, showLineNumbers: true }),
)
}
function openSubmission(id: string) { function openSubmission(id: string) {
window.open(`/submission/${id}`, "_blank", "noopener") window.open(`/submission/${id}`, "_blank", "noopener")
} }
@@ -288,8 +333,15 @@ const columns: DataTableColumn<SubmissionStatisticsUser>[] = [
h(NFlex, { size: 4, wrap: true, style: "flex: 1; min-width: 0" }, () => h(NFlex, { size: 4, wrap: true, style: "flex: 1; min-width: 0" }, () =>
group.items.map((item) => group.items.map((item) =>
h( h(
NTooltip, NPopover,
{ delay: 200 }, {
delay: 200,
// 代码比一行提示宽得多;限宽让长行在浮层里横向滚,不把浮层撑出屏幕
style: "max-width: 560px",
onUpdateShow: (show: boolean) => {
if (show) loadCode(item.id)
},
},
{ {
trigger: () => trigger: () =>
h("button", { h("button", {
@@ -310,9 +362,17 @@ const columns: DataTableColumn<SubmissionStatisticsUser>[] = [
}, },
}), }),
default: () => default: () =>
`${JUDGE_STATUS[item.result]?.name ?? item.result} · ` + h(NFlex, { vertical: true, size: "small" }, () => [
`${parseTime(item.createTime, "MM-DD HH:mm:ss")} · ` + h(
`${item.id.toString().slice(0, 12)}`, NText,
{ depth: 3 },
() =>
`${JUDGE_STATUS[item.result]?.name ?? item.result} · ` +
`${parseTime(item.createTime, "MM-DD HH:mm:ss")} · ` +
`${item.id.toString().slice(0, 12)}`,
),
renderCode(item.id),
]),
}, },
), ),
), ),
@@ -385,19 +445,8 @@ const classOptions = computed<SelectOption[]>(
})) ?? [], })) ?? [],
) )
/** // 班级、题号、时间段记一小时,理由见 useStatisticsQuery
* 班级/用户**不记本地**:这个选择框每次打开都从空的开始。 const { query, save: saveQuery } = useStatisticsQuery(STORAGE_KEY.STATISTICS_QUERY, props)
*
* 曾经把上次的班级写进 localStorage(statisticsClass),下次打开自动带上。但那正是
* 最危险的地方 —— 下课换了班、或者换个人坐这台机器,上一个人查的班悄悄留在框里,
* 老师没注意就按了「统计」,看到的整个是别人的班。宁可多选一次。
* 登录框那份 LOGIN_CLASS 是另一回事:那记的是这台机器的身份,不是一次临时查询。
*/
const query = reactive({
username: props.username,
problem: props.problem,
duration: options[0].value,
})
const count = reactive({ const count = reactive({
total: 0, total: 0,
@@ -675,6 +724,7 @@ async function fetchStatistics() {
.map((item) => item.trim()) .map((item) => item.trim())
.filter(Boolean) .filter(Boolean)
const res = await getSubmissionStatistics(duration, query.problem, query.username) const res = await getSubmissionStatistics(duration, query.problem, query.username)
saveQuery()
queriedProblemCount.value = new Set(problems.map((p) => p.toLowerCase())).size queriedProblemCount.value = new Set(problems.map((p) => p.toLowerCase())).size
count.total = res.submissionCount count.total = res.submissionCount
count.accepted = res.acceptedCount count.accepted = res.acceptedCount
@@ -0,0 +1,65 @@
import { reactive } from "vue"
import { PANEL_DURATION_OPTIONS } from "utils/constants"
import storage from "utils/storage"
/**
* 统计面板的查询条件(班级/用户、题号、时间段),**记一小时**:一小时内再打开,
* 框里是上次查的那一组。
*
* 老师一节课里会反复开关这个弹窗(看一眼、关掉、走下去辅导、回来再开),每次都从空的
* 选班级、敲题号很烦。但也不能一直记着 —— 曾经把上次的班级永久写进 localStorage,
* 下课换了班、或者换个人坐这台机器,上一个人查的班悄悄留在框里,老师没注意就按了
* 「统计」,看到的整个是别人的班。一小时是折中:一节课够用,隔一节课就自己清掉。
* 注意**连堂**时下一节仍在一小时内,框里会是上一个班,这一点是接受了的。
*
* 一小时从「最后一次查询」算起(调用方在每次查完后调 `save`,自动刷新也算),
* 开着面板盯一整节课不会中途过期。
*
* 调用方传进来的初值优先:题目页固定是这道题、提交列表带着自己的筛选条件,
* 那是比缓存更明确的上下文;为空的那几项才用缓存补上。
* 登录框那份 LOGIN_CLASS 是另一回事:那记的是这台机器的身份,不是一次临时查询。
*
* @param storageKey localStorage 的键。**两个面板各用各的** —— 流程图题和代码题的
* 题号不是一批,互相带过去只会查出一片空。
*/
export function useStatisticsQuery(
storageKey: string,
initial: { username: string; problem: string },
) {
const TTL = 60 * 60 * 1000
type Cached = { username: string; problem: string; duration: string; savedAt: number }
function load(): Partial<Cached> {
try {
const cached: Cached | null = storage.get(storageKey)
if (!cached || Date.now() - cached.savedAt > TTL) return {}
return cached
} catch {
return {}
}
}
const cached = load()
const query = reactive({
username: initial.username || cached.username || "",
problem: initial.problem || cached.problem || "",
// 选项表改过之后旧缓存里的值可能已经不在里面了,认不出来就回到第一档
duration: PANEL_DURATION_OPTIONS.some((option) => option.value === cached.duration)
? cached.duration!
: PANEL_DURATION_OPTIONS[0].value,
})
function save() {
storage.set(storageKey, {
// 选择框点了清除是 null,不是空串
username: query.username ?? "",
problem: query.problem ?? "",
duration: query.duration,
savedAt: Date.now(),
} satisfies Cached)
}
return { query, save }
}
+2
View File
@@ -156,6 +156,8 @@ 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 = {