Compare commits

...
3 Commits
Author SHA1 Message Date
xuyueandClaude Opus 5.5 a1d14a06bc refactor(卡点分析): 右栏一道题的详情拆成 StuckDetail 组件
Deploy / deploy (push) Canceled after 0s
Stuck.vue 从 1006 行减到 544 行,右栏(做过几人、一次就对、错在哪、各班、交了几次才对、
历年、还没做对的人)连同它的计算和样式挪到 components/StuckDetail.vue;取数、三栏联动、
手机上整屏盖住的那层还在 Stuck.vue。两边都用的百分比和平均次数放 stuckMath.ts,
两边都用的几条小样式(muted / num / track …)各留一份。

纯搬家。全部班级、选一个班、手机打开一道题的详情,各自的顶部和滚到底改前改后截图一致。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 20:40:27 +08:00
xuyueandClaude Opus 5.5 ef7d065419 refactor(课堂看板): 布置题号、请假框拆成组件,格子的显示规则挪成纯函数
board.vue 从 1480 行减到 1283 行:
- components/LessonForm.vue:布置这节课的题(顶上一行的小条和刚上课时的大框是同一个组件的
  两种样子,语言默认、推断的题带进框里这些规则跟着走)
- components/LeaveModal.vue:请假框(谁能算请假的规则还在看板,框里只管勾)
- boardCells.ts:格子的字、颜色、悬停说明和「卡住」的次数线,同 errorKind.ts 的做法

纯搬家。看板、选中一道题、请假框、勾了请假、顶上编辑、刚上课的大框(电脑 / 手机)改前改后
截图一致(唯一不同是测试学生的「在线」五分钟后过期变成「5 分钟前在」);顶上一行布置 /
清掉、大框布置、请假确定后「请假 1 人」并排到最下、再打开勾选还在、全部取消都正常。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 20:37:13 +08:00
xuyueandClaude Opus 5.5 871861220e refactor(提交列表): 选中翻阅、实时更新、「这节课」、带条件去统计拆成四个 composable
list.vue 从 1834 行减到 1604 行,拆出 oj/submission/composables/ 下的
rowCursor(↑ ↓ 换条、页头页尾自动翻页)、listLive(正在评分的 3 秒刷新、第一页的
「有 N 条新提交」)、lessonFilter(「这节课」一键筛)、statsLink(和统计之间来回带条件)。
注释原样跟着走。

纯搬家。七个状态(列表、↓↓、↑、流程图、我的、手机列表、手机详情)改前改后截图一致;
连按 31 下 ↓ 翻到第 2 页,别人交一条后 18 秒内出「有 1 条新提交」,「这节课」筛到
班级 + 题号 + 今天。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:48:43 +08:00
13 changed files with 1320 additions and 988 deletions

No files matched your search

+5 -467
View File
@@ -2,17 +2,16 @@
import { useRouteQuery } from "@vueuse/router"
import { Icon } from "@iconify/vue"
import { useThemeVars } from "naive-ui"
import { zonedParts } from "utils/functions"
import type { StuckProblem, StuckProblemDetail, StuckStudent } from "utils/types"
import type { StuckProblem, StuckProblemDetail } from "utils/types"
import { getStuckProblemDetail, getStuckProblems } from "admin/api"
import { useConfigStore } from "shared/store/config"
import UserName from "shared/components/UserName.vue"
import { classLabel, groupClassOptions } from "utils/classes"
import { groupClassOptions } from "utils/classes"
import { periodRange, type Period } from "oj/statistics/period"
import { resultName, useResultColor } from "oj/statistics/results"
import { useBackClose } from "shared/composables/backClose"
import { useBreakpoints } from "shared/composables/breakpoints"
import "oj/learn/learn.css"
import StuckDetail from "./components/StuckDetail.vue"
import { avgTries, rate } from "./components/stuckMath"
/**
* 卡点分析(设计稿「卡点与趋势」页的定稿 StuckFinalAll / StuckFinalClass):
@@ -26,7 +25,6 @@ const router = useRouter()
const configStore = useConfigStore()
const { isMobile } = useBreakpoints()
const theme = useThemeVars()
const resultColor = useResultColor()
const PERIODS: { label: string; value: Period }[] = [
{ label: "这学期", value: "term" },
@@ -53,18 +51,10 @@ const thresholdOptions = computed(() =>
THRESHOLDS.map((n) => ({ label: `至少 ${n} 人做过`, value: n })),
)
function rate(part: number, whole: number) {
return whole ? Math.round((part / whole) * 100) : 0
}
function firstRate(row: StuckProblem) {
return rate(row.firstTry, row.users)
}
function avgTries(row: { solved: number; solvedTries: number }) {
return row.solved ? (row.solvedTries / row.solved).toFixed(1) : "-"
}
function range() {
return periodRange(period.value as Period, null).start
}
@@ -175,117 +165,6 @@ async function pick(row: StuckProblem) {
watch([className, period], load)
watch(() => [selected.value?.id, problems.value] as const, loadDetail)
onMounted(load)
// ---------------------------------------------------------------- 一道题
const students = computed(() => detail.value?.students ?? [])
const summary = computed(() => {
const rows = students.value
const solved = rows.filter((row) => row.solved)
return {
users: rows.length,
firstRate: rate(rows.filter((row) => row.firstTry).length, rows.length),
avgTries: avgTries({
solved: solved.length,
solvedTries: solved.reduce((sum, row) => sum + row.tries, 0),
}),
stuck: rows.length - solved.length,
}
})
const errorTotal = computed(() =>
(detail.value?.errors ?? []).reduce((sum, row) => sum + row.count, 0),
)
const byClass = computed(() => {
const groups = new Map<string, StuckStudent[]>()
for (const row of students.value) {
const key = row.className ?? ""
groups.set(key, [...(groups.get(key) ?? []), row])
}
return [...groups]
.map(([name, rows]) => {
const solved = rows.filter((row) => row.solved)
return {
name,
users: rows.length,
rate: rate(rows.filter((row) => row.firstTry).length, rows.length),
avgTries: avgTries({
solved: solved.length,
solvedTries: solved.reduce((sum, row) => sum + row.tries, 0),
}),
stuck: rows.length - solved.length,
}
})
.sort((a, b) => b.users - a.users)
})
const BUCKETS = [
{ label: "1 次", min: 1, max: 1 },
{ label: "2 次", min: 2, max: 2 },
{ label: "3 次", min: 3, max: 3 },
{ label: "4–5 次", min: 4, max: 5 },
{ label: "6 次以上", min: 6, max: Infinity },
]
const distribution = computed(() => {
const solved = students.value.filter((row) => row.solved)
const rows = BUCKETS.map((bucket) => ({
...bucket,
count: solved.filter((row) => row.tries >= bucket.min && row.tries <= bucket.max).length,
}))
const max = Math.max(1, ...rows.map((row) => row.count))
return rows.map((row) => ({ ...row, height: Math.max(2, (row.count / max) * 64) }))
})
const mostTries = computed(() =>
students.value
.filter((row) => row.solved && row.tries >= 3)
.sort((a, b) => b.tries - a.tries)
.slice(0, 6),
)
const stuckStudents = computed(() =>
students.value.filter((row) => !row.solved).sort((a, b) => b.tries - a.tries),
)
const currentYear = (() => {
const now = zonedParts(new Date())!
return now.month >= 9 ? now.year : now.year - 1
})()
const history = computed(() =>
(detail.value?.history ?? []).map((row) => ({
label: `${String(row.year).slice(2)}–${String(row.year + 1).slice(2)}`,
current: row.year === currentYear,
users: row.users,
rate: rate(row.firstTry, row.users),
})),
)
function gap(minutes: number | null) {
if (minutes === null) return ""
if (minutes < 1) return "不到 1 分钟"
if (minutes < 60) return `${minutes} 分钟`
if (minutes < 1440) return `${Math.round(minutes / 60)} 小时`
return `隔了 ${Math.round(minutes / 1440)} 天`
}
function href(to: Parameters<typeof router.resolve>[0]) {
return router.resolve(to).href
}
const links = computed(() => {
const row = detail.value?.problem
if (!row) return null
const scope = className.value ? { className: className.value } : {}
return {
problem: href({ name: "problem", params: { problemID: row.displayId } }),
statistics: href({
name: "statistics",
query: { problem: row.displayId, period: period.value, ...scope },
}),
submissions: href({ name: "submissions", query: { problem: row.displayId, ...scope } }),
}
})
</script>
<template>
@@ -396,155 +275,7 @@ const links = computed(() => {
<span class="strong">{{ activeTag || "全部" }}</span>
</div>
<n-spin :show="detailLoading">
<div v-if="detail && links" class="detail-body">
<div class="line dhead">
<span class="muted num">{{ detail.problem.displayId }}</span>
<span class="dtitle">{{ detail.problem.title }}</span>
<span v-for="name in detail.problem.tags" :key="name" class="tag">{{ name }}</span>
<span class="links">
<a :href="links.problem" target="_blank" class="link">去题目 ›</a>
<a :href="links.statistics" target="_blank" class="link">数据统计 ›</a>
</span>
</div>
<div class="stats">
<div>
<div class="muted small">做过</div>
<div class="big num">{{ summary.users }}<span class="unit"> 人</span></div>
</div>
<div>
<div class="muted small">一次就对</div>
<div class="big num">{{ summary.firstRate }}%</div>
</div>
<div>
<div class="muted small">做对平均交</div>
<div class="big num">{{ summary.avgTries }}<span class="unit"> 次</span></div>
</div>
<div>
<div class="muted small">还没做对</div>
<div class="big num" :class="{ red: summary.stuck }">
{{ summary.stuck }}<span class="unit"> 人</span>
</div>
</div>
</div>
<div v-if="errorTotal" class="block">
<div class="muted small">错在哪 · {{ errorTotal }} 次</div>
<div class="errbar">
<span
v-for="row in detail.errors"
:key="row.result"
:style="{
width: `${(row.count / errorTotal) * 100}%`,
background: resultColor(row.result),
}"
/>
</div>
<div class="line small num errtext">
<span v-for="row in detail.errors" :key="row.result" class="legend">
<i :style="{ background: resultColor(row.result) }" />{{
resultName(row.result)
}}
{{ row.count }}
</span>
</div>
</div>
<div v-if="!className" class="block">
<div class="muted small">各班</div>
<div v-for="row in byClass" :key="row.name" class="crow">
<span class="cname">{{ classLabel(row.name || null) }}</span>
<span class="num cusers">{{ row.users }} 人</span>
<span class="track mid"><span :style="{ width: `${row.rate}%` }" /></span>
<span class="pct num">{{ row.rate }}%</span>
<span class="muted num cavg">平均 {{ row.avgTries }} 次</span>
<span class="grow" />
<span v-if="row.stuck" class="stuck num">{{ row.stuck }} 人没对</span>
<span v-else class="muted">都对了</span>
</div>
</div>
<template v-else>
<div class="block">
<div class="muted small">交了几次才对</div>
<div class="dist">
<div v-for="row in distribution" :key="row.label" class="dcol">
<span class="small num strong">{{ row.count }} 人</span>
<span
class="dbar"
:class="{ first: row.min === 1, many: row.min === 6 }"
:style="{ height: `${row.height}px` }"
/>
<span class="muted small">{{ row.label }}</span>
</div>
</div>
</div>
<div v-if="mostTries.length" class="block">
<div class="muted small">交得最多的</div>
<div class="names">
<div v-for="row in mostTries" :key="row.username" class="nrow">
<UserName :username="row.username" class="uname" />
<span class="num strong ntries">交了 {{ row.tries }} 次</span>
<span class="muted num">{{ gap(row.minutes) }}</span>
<span class="grow" />
<a
:href="
href({
name: 'submissions',
query: { problem: detail.problem.displayId, username: row.username },
})
"
target="_blank"
class="link small"
>看代码</a
>
</div>
</div>
</div>
</template>
<div v-if="history.length" class="block">
<div class="muted small">历年一次就对{{ className ? "(全站)" : "" }}</div>
<div v-for="row in history" :key="row.label" class="hrow num">
<span class="hlabel" :class="{ muted: !row.current }">
{{ row.label }}{{ row.current ? " 这学年" : "" }}
</span>
<span class="track mid" :class="{ past: !row.current }"
><span :style="{ width: `${row.rate}%` }"
/></span>
<span class="pct" :class="{ strong: row.current }">{{ row.rate }}%</span>
<span class="muted small">{{ row.users }} 人</span>
</div>
</div>
<div v-if="stuckStudents.length" class="block">
<div class="line">
<span class="muted small">还没做对的 {{ stuckStudents.length }} 人</span>
<span class="grow" />
<a :href="links.submissions" target="_blank" class="link small"
>在提交列表里看 ›</a
>
</div>
<div class="names">
<div v-for="row in stuckStudents" :key="row.username" class="nrow">
<UserName :username="row.username" class="uname" />
<span class="muted num ntries">交了 {{ row.tries }} 次</span>
<span class="legend" :style="{ color: resultColor(row.lastResult) }">
<i :style="{ background: resultColor(row.lastResult) }" />{{
resultName(row.lastResult)
}}
</span>
<span class="grow" />
<a
:href="href({ path: `/submission/${row.lastSubmissionId}` })"
target="_blank"
class="link small"
>看代码</a
>
</div>
</div>
</div>
</div>
<StuckDetail v-if="detail" :detail="detail" :class-name="className" :period="period" />
</n-spin>
</section>
</div>
@@ -682,11 +413,6 @@ const links = computed(() => {
.nowrap {
white-space: nowrap;
}
.links {
display: flex;
gap: 12px;
margin-left: auto;
}
.pid {
width: 34px;
flex-shrink: 0;
@@ -724,12 +450,6 @@ const links = computed(() => {
.track.short {
width: 90px;
}
.track.mid {
width: 90px;
}
.track.past > span {
background: var(--learn-green-line);
}
.pct {
width: 38px;
flex-shrink: 0;
@@ -740,161 +460,6 @@ const links = computed(() => {
padding: 24px 16px;
font-size: 13px;
}
.detail-body {
padding: 14px 18px;
display: flex;
flex-direction: column;
gap: 16px;
}
.dhead {
flex-wrap: wrap;
row-gap: 4px;
}
.dtitle {
font-size: 16px;
font-weight: 700;
}
.tag {
flex-shrink: 0;
height: 20px;
padding: 0 6px;
border-radius: 3px;
font-size: 12px;
line-height: 20px;
background: var(--learn-soft);
}
.link {
flex-shrink: 0;
color: var(--learn-green-text);
font-size: 13px;
text-decoration: none;
white-space: nowrap;
}
.link.small {
font-size: 12px;
}
.stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.big {
font-size: 22px;
font-weight: 700;
}
.big.red {
color: var(--learn-red-text);
}
.unit {
font-size: 13px;
font-weight: 400;
opacity: 0.6;
}
.block {
display: flex;
flex-direction: column;
gap: 6px;
}
.errbar {
display: flex;
gap: 1px;
max-width: 440px;
height: 10px;
border-radius: 4px;
overflow: hidden;
}
.errtext {
gap: 12px;
opacity: 0.85;
}
.legend {
display: inline-flex;
align-items: center;
gap: 4px;
white-space: nowrap;
}
.legend i {
width: 8px;
height: 8px;
border-radius: 2px;
}
.crow {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
height: 32px;
border-top: 1px solid var(--learn-line);
font-size: 13px;
}
.cname {
width: 100px;
flex-shrink: 0;
}
.cusers {
width: 40px;
text-align: right;
}
.cavg {
width: 72px;
}
.dist {
display: flex;
align-items: flex-end;
gap: 6px;
}
.dcol {
width: 64px;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.dbar {
width: 40px;
border-radius: 3px 3px 0 0;
background: var(--learn-green-line);
}
.dbar.first {
background: var(--learn-green);
}
.dbar.many {
background: var(--learn-orange);
}
.names {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(290px, 1fr));
column-gap: 24px;
}
.nrow {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
height: 28px;
font-size: 13px;
min-width: 0;
}
.uname {
width: 110px;
flex-shrink: 0;
}
.ntries {
width: 64px;
flex-shrink: 0;
}
.hrow {
display: flex;
align-items: center;
gap: 10px;
white-space: nowrap;
height: 24px;
font-size: 13px;
}
.hlabel {
width: 96px;
flex-shrink: 0;
}
@media (max-width: 768px) {
.top {
@@ -975,32 +540,5 @@ const links = computed(() => {
border-bottom: 1px solid var(--learn-line);
background: v-bind("theme.bodyColor");
}
.detail-body {
padding: 14px 16px 32px;
}
.links {
margin-left: 0;
width: 100%;
}
.stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
row-gap: 12px;
}
.cavg {
display: none;
}
.cname {
width: 84px;
}
.track.mid {
width: 64px;
}
.dcol {
flex: 1;
width: auto;
}
.names {
grid-template-columns: minmax(0, 1fr);
}
}
</style>
@@ -0,0 +1,524 @@
<script setup lang="ts">
import { zonedParts } from "utils/functions"
import type { StuckProblemDetail, StuckStudent } from "utils/types"
import UserName from "shared/components/UserName.vue"
import { classLabel } from "utils/classes"
import { resultName, useResultColor } from "oj/statistics/results"
import { avgTries, rate } from "./stuckMath"
/**
* 卡点分析右栏:一道题的详情(做过几人、一次就对、错在哪、各班、交了几次才对、历年、
* 还没做对的人)。从 Stuck.vue 拆出来,取数和三栏的联动还在那边。
*/
const props = defineProps<{
detail: StuckProblemDetail
/** 选了一个班:不分班列、改列这个班交了几次才对 */
className: string
period: string
}>()
const router = useRouter()
const resultColor = useResultColor()
const students = computed(() => props.detail.students ?? [])
const summary = computed(() => {
const rows = students.value
const solved = rows.filter((row) => row.solved)
return {
users: rows.length,
firstRate: rate(rows.filter((row) => row.firstTry).length, rows.length),
avgTries: avgTries({
solved: solved.length,
solvedTries: solved.reduce((sum, row) => sum + row.tries, 0),
}),
stuck: rows.length - solved.length,
}
})
const errorTotal = computed(() =>
(props.detail.errors ?? []).reduce((sum, row) => sum + row.count, 0),
)
const byClass = computed(() => {
const groups = new Map<string, StuckStudent[]>()
for (const row of students.value) {
const key = row.className ?? ""
groups.set(key, [...(groups.get(key) ?? []), row])
}
return [...groups]
.map(([name, rows]) => {
const solved = rows.filter((row) => row.solved)
return {
name,
users: rows.length,
rate: rate(rows.filter((row) => row.firstTry).length, rows.length),
avgTries: avgTries({
solved: solved.length,
solvedTries: solved.reduce((sum, row) => sum + row.tries, 0),
}),
stuck: rows.length - solved.length,
}
})
.sort((a, b) => b.users - a.users)
})
const BUCKETS = [
{ label: "1 次", min: 1, max: 1 },
{ label: "2 次", min: 2, max: 2 },
{ label: "3 次", min: 3, max: 3 },
{ label: "4–5 次", min: 4, max: 5 },
{ label: "6 次以上", min: 6, max: Infinity },
]
const distribution = computed(() => {
const solved = students.value.filter((row) => row.solved)
const rows = BUCKETS.map((bucket) => ({
...bucket,
count: solved.filter((row) => row.tries >= bucket.min && row.tries <= bucket.max).length,
}))
const max = Math.max(1, ...rows.map((row) => row.count))
return rows.map((row) => ({ ...row, height: Math.max(2, (row.count / max) * 64) }))
})
const mostTries = computed(() =>
students.value
.filter((row) => row.solved && row.tries >= 3)
.sort((a, b) => b.tries - a.tries)
.slice(0, 6),
)
const stuckStudents = computed(() =>
students.value.filter((row) => !row.solved).sort((a, b) => b.tries - a.tries),
)
const currentYear = (() => {
const now = zonedParts(new Date())!
return now.month >= 9 ? now.year : now.year - 1
})()
const history = computed(() =>
(props.detail.history ?? []).map((row) => ({
label: `${String(row.year).slice(2)}–${String(row.year + 1).slice(2)}`,
current: row.year === currentYear,
users: row.users,
rate: rate(row.firstTry, row.users),
})),
)
function gap(minutes: number | null) {
if (minutes === null) return ""
if (minutes < 1) return "不到 1 分钟"
if (minutes < 60) return `${minutes} 分钟`
if (minutes < 1440) return `${Math.round(minutes / 60)} 小时`
return `隔了 ${Math.round(minutes / 1440)} 天`
}
function href(to: Parameters<typeof router.resolve>[0]) {
return router.resolve(to).href
}
const links = computed(() => {
const row = props.detail.problem
if (!row) return null
const scope = props.className ? { className: props.className } : {}
return {
problem: href({ name: "problem", params: { problemID: row.displayId } }),
statistics: href({
name: "statistics",
query: { problem: row.displayId, period: props.period, ...scope },
}),
submissions: href({ name: "submissions", query: { problem: row.displayId, ...scope } }),
}
})
</script>
<template>
<div v-if="links" class="detail-body">
<div class="line dhead">
<span class="muted num">{{ detail.problem.displayId }}</span>
<span class="dtitle">{{ detail.problem.title }}</span>
<span v-for="name in detail.problem.tags" :key="name" class="tag">{{ name }}</span>
<span class="links">
<a :href="links.problem" target="_blank" class="link">去题目 ›</a>
<a :href="links.statistics" target="_blank" class="link">数据统计 ›</a>
</span>
</div>
<div class="stats">
<div>
<div class="muted small">做过</div>
<div class="big num">{{ summary.users }}<span class="unit"> 人</span></div>
</div>
<div>
<div class="muted small">一次就对</div>
<div class="big num">{{ summary.firstRate }}%</div>
</div>
<div>
<div class="muted small">做对平均交</div>
<div class="big num">{{ summary.avgTries }}<span class="unit"> 次</span></div>
</div>
<div>
<div class="muted small">还没做对</div>
<div class="big num" :class="{ red: summary.stuck }">
{{ summary.stuck }}<span class="unit"> 人</span>
</div>
</div>
</div>
<div v-if="errorTotal" class="block">
<div class="muted small">错在哪 · {{ errorTotal }} 次</div>
<div class="errbar">
<span
v-for="row in detail.errors"
:key="row.result"
:style="{
width: `${(row.count / errorTotal) * 100}%`,
background: resultColor(row.result),
}"
/>
</div>
<div class="line small num errtext">
<span v-for="row in detail.errors" :key="row.result" class="legend">
<i :style="{ background: resultColor(row.result) }" />{{ resultName(row.result) }}
{{ row.count }}
</span>
</div>
</div>
<div v-if="!className" class="block">
<div class="muted small">各班</div>
<div v-for="row in byClass" :key="row.name" class="crow">
<span class="cname">{{ classLabel(row.name || null) }}</span>
<span class="num cusers">{{ row.users }} 人</span>
<span class="track mid"><span :style="{ width: `${row.rate}%` }" /></span>
<span class="pct num">{{ row.rate }}%</span>
<span class="muted num cavg">平均 {{ row.avgTries }} 次</span>
<span class="grow" />
<span v-if="row.stuck" class="stuck num">{{ row.stuck }} 人没对</span>
<span v-else class="muted">都对了</span>
</div>
</div>
<template v-else>
<div class="block">
<div class="muted small">交了几次才对</div>
<div class="dist">
<div v-for="row in distribution" :key="row.label" class="dcol">
<span class="small num strong">{{ row.count }} 人</span>
<span
class="dbar"
:class="{ first: row.min === 1, many: row.min === 6 }"
:style="{ height: `${row.height}px` }"
/>
<span class="muted small">{{ row.label }}</span>
</div>
</div>
</div>
<div v-if="mostTries.length" class="block">
<div class="muted small">交得最多的</div>
<div class="names">
<div v-for="row in mostTries" :key="row.username" class="nrow">
<UserName :username="row.username" class="uname" />
<span class="num strong ntries">交了 {{ row.tries }} 次</span>
<span class="muted num">{{ gap(row.minutes) }}</span>
<span class="grow" />
<a
:href="
href({
name: 'submissions',
query: { problem: detail.problem.displayId, username: row.username },
})
"
target="_blank"
class="link small"
>看代码</a
>
</div>
</div>
</div>
</template>
<div v-if="history.length" class="block">
<div class="muted small">历年一次就对{{ className ? "(全站)" : "" }}</div>
<div v-for="row in history" :key="row.label" class="hrow num">
<span class="hlabel" :class="{ muted: !row.current }">
{{ row.label }}{{ row.current ? " 这学年" : "" }}
</span>
<span class="track mid" :class="{ past: !row.current }"
><span :style="{ width: `${row.rate}%` }"
/></span>
<span class="pct" :class="{ strong: row.current }">{{ row.rate }}%</span>
<span class="muted small">{{ row.users }} 人</span>
</div>
</div>
<div v-if="stuckStudents.length" class="block">
<div class="line">
<span class="muted small">还没做对的 {{ stuckStudents.length }} 人</span>
<span class="grow" />
<a :href="links.submissions" target="_blank" class="link small">在提交列表里看 ›</a>
</div>
<div class="names">
<div v-for="row in stuckStudents" :key="row.username" class="nrow">
<UserName :username="row.username" class="uname" />
<span class="muted num ntries">交了 {{ row.tries }} 次</span>
<span class="legend" :style="{ color: resultColor(row.lastResult) }">
<i :style="{ background: resultColor(row.lastResult) }" />{{
resultName(row.lastResult)
}}
</span>
<span class="grow" />
<a
:href="href({ path: `/submission/${row.lastSubmissionId}` })"
target="_blank"
class="link small"
>看代码</a
>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.grow {
flex: 1;
}
.muted {
opacity: 0.6;
}
.small {
font-size: 12px;
}
.strong {
font-weight: 700;
}
.num {
font-variant-numeric: tabular-nums;
}
.line {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.links {
display: flex;
gap: 12px;
margin-left: auto;
}
.stuck {
flex-shrink: 0;
font-size: 13px;
font-weight: 600;
color: var(--learn-red-text);
}
.track {
display: block;
flex-shrink: 0;
width: 170px;
height: 8px;
border-radius: 4px;
background: var(--learn-todo);
overflow: hidden;
}
.track > span {
display: block;
height: 100%;
background: var(--learn-green);
}
.track.mid {
width: 90px;
}
.track.past > span {
background: var(--learn-green-line);
}
.pct {
width: 38px;
flex-shrink: 0;
text-align: right;
font-weight: 600;
}
.detail-body {
padding: 14px 18px;
display: flex;
flex-direction: column;
gap: 16px;
}
.dhead {
flex-wrap: wrap;
row-gap: 4px;
}
.dtitle {
font-size: 16px;
font-weight: 700;
}
.tag {
flex-shrink: 0;
height: 20px;
padding: 0 6px;
border-radius: 3px;
font-size: 12px;
line-height: 20px;
background: var(--learn-soft);
}
.link {
flex-shrink: 0;
color: var(--learn-green-text);
font-size: 13px;
text-decoration: none;
white-space: nowrap;
}
.link.small {
font-size: 12px;
}
.stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.big {
font-size: 22px;
font-weight: 700;
}
.big.red {
color: var(--learn-red-text);
}
.unit {
font-size: 13px;
font-weight: 400;
opacity: 0.6;
}
.block {
display: flex;
flex-direction: column;
gap: 6px;
}
.errbar {
display: flex;
gap: 1px;
max-width: 440px;
height: 10px;
border-radius: 4px;
overflow: hidden;
}
.errtext {
gap: 12px;
opacity: 0.85;
}
.legend {
display: inline-flex;
align-items: center;
gap: 4px;
white-space: nowrap;
}
.legend i {
width: 8px;
height: 8px;
border-radius: 2px;
}
.crow {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
height: 32px;
border-top: 1px solid var(--learn-line);
font-size: 13px;
}
.cname {
width: 100px;
flex-shrink: 0;
}
.cusers {
width: 40px;
text-align: right;
}
.cavg {
width: 72px;
}
.dist {
display: flex;
align-items: flex-end;
gap: 6px;
}
.dcol {
width: 64px;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.dbar {
width: 40px;
border-radius: 3px 3px 0 0;
background: var(--learn-green-line);
}
.dbar.first {
background: var(--learn-green);
}
.dbar.many {
background: var(--learn-orange);
}
.names {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(290px, 1fr));
column-gap: 24px;
}
.nrow {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
height: 28px;
font-size: 13px;
min-width: 0;
}
.uname {
width: 110px;
flex-shrink: 0;
}
.ntries {
width: 64px;
flex-shrink: 0;
}
.hrow {
display: flex;
align-items: center;
gap: 10px;
white-space: nowrap;
height: 24px;
font-size: 13px;
}
.hlabel {
width: 96px;
flex-shrink: 0;
}
@media (max-width: 768px) {
.detail-body {
padding: 14px 16px 32px;
}
.links {
margin-left: 0;
width: 100%;
}
.stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
row-gap: 12px;
}
.cavg {
display: none;
}
.cname {
width: 84px;
}
.track.mid {
width: 64px;
}
.dcol {
flex: 1;
width: auto;
}
.names {
grid-template-columns: minmax(0, 1fr);
}
}
</style>
@@ -0,0 +1,11 @@
/** 卡点分析(Stuck.vue)和它的单题详情(StuckDetail.vue)共用的两个算法 */
/** 百分比,取整;分母是 0 时给 0 */
export function rate(part: number, whole: number) {
return whole ? Math.round((part / whole) * 100) : 0
}
/** 做对的人平均交了几次(只数做对那次及之前的) */
export function avgTries(row: { solved: number; solvedTries: number }) {
return row.solved ? (row.solvedTries / row.solved).toFixed(1) : "-"
}
+48 -245
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { useThemeVars } from "naive-ui"
import { getClassBoard, setClassLesson } from "oj/api"
import { getClassBoard } from "oj/api"
import { useTone } from "shared/composables/tone"
import { groupClassOptions } from "utils/classes"
import UserName from "shared/components/UserName.vue"
@@ -9,13 +9,20 @@ import { useLeaveStudents } from "shared/composables/leaveStudents"
import { useTeacherHelp } from "shared/composables/teacherHelp"
import { useCollabStore } from "shared/store/collab"
import { useConfigStore } from "shared/store/config"
import type { LessonLanguage } from "@oj2/contract"
import { errorMessage } from "utils/api"
import { LANGUAGE_SHOW_VALUE } from "utils/constants"
import { parseTime } from "utils/functions"
import type { ClassBoard, ClassBoardCell, ClassBoardProblem, ClassBoardStudent } from "utils/types"
import ExplainModal, { type ExplainPerson } from "./components/ExplainModal.vue"
import LeaveModal from "./components/LeaveModal.vue"
import LessonForm from "./components/LessonForm.vue"
import { errorKind, type ErrorKind } from "utils/errorKind"
import {
cellStyle as cellStyleOf,
cellText,
cellTitle as cellTitleOf,
STUCK_ATTEMPTS,
} from "./boardCells"
/**
* 课堂看板(老师,设计稿「课堂看板重设计」A+B):这个班、今天、这节课的几道题 × 全班学生。
@@ -31,8 +38,6 @@ import { errorKind, type ErrorKind } from "utils/errorKind"
* 悄悄留在框里,老师看的整个是别人的班。
*/
/** 同一道题今天交了这么多次还没过,算卡住 */
const STUCK_ATTEMPTS = 3
const REFRESH_MS = 15_000
const configStore = useConfigStore()
@@ -110,61 +115,15 @@ onUnmounted(pause)
// ---------- 这节课的题:上课时当场填 ----------
/**
* 这份作业用什么语言做,跟着这一次布置走(用户定的:不是给班级定死的设置)。默认是这个班
* 上一次布置时选的;选了之后看板和学生那边的「做完」都只认这个语言交对的
*/
const lessonLanguage = ref<LessonLanguage>("Python")
const LESSON_LANGUAGES: LessonLanguage[] = ["Python", "C", "C++", "SQL"]
const languageOptions = LESSON_LANGUAGES.map((value) => ({
label: LANGUAGE_SHOW_VALUE[value],
value,
}))
const lessonInput = ref("")
const editing = ref(false)
const saving = ref(false)
/** 还没有题(没布置、今天也推断不出来):填题号的框就是这页最大的东西 */
const needLesson = computed(() => !!board.value?.className && !board.value.problems.length)
// 换了班要把输入框换成那个班今天布置的题、布置时选的语言(15 秒一次的刷新不动它们)
watch(
() => [board.value?.className, board.value?.source] as const,
() => resetLessonInput(),
)
function resetLessonInput() {
lessonInput.value =
board.value?.source === "teacher"
? board.value.problems.map((problem) => problem.problemDisplayId).join(" ")
: ""
lessonLanguage.value = board.value?.language ?? board.value?.lastLanguage ?? "Python"
}
function startEdit() {
resetLessonInput()
// 推断出来的题也带进框里,老师多半只是确认一下
if (!lessonInput.value && board.value?.problems.length) {
lessonInput.value = board.value.problems.map((problem) => problem.problemDisplayId).join(" ")
}
editing.value = true
}
async function saveLesson() {
if (!className.value) return
const ids = lessonInput.value.split(/[\s,,、;;]+/).filter(Boolean)
saving.value = true
try {
await setClassLesson(className.value, ids, ids.length ? lessonLanguage.value : null)
message.success(ids.length ? "已布置,学生首页会显示这几道题" : "已清掉")
editing.value = false
selectedId.value = null
await load()
} catch (error) {
message.error(errorMessage(error))
} finally {
saving.value = false
}
async function lessonSaved() {
editing.value = false
selectedId.value = null
await load()
}
// ---------- 分组与排序 ----------
@@ -265,32 +224,11 @@ watchEffect(() => {
const leaveDialog = ref(false)
/** 弹框里能勾的:今天还没交过的(刚上课时就是全班),按名字排 */
const leaveCandidates = computed(() => boardStudents.value.filter(canLeave).sort(byName))
/** 今天没上过线的分在前面一堆:请假的人基本在这里面,剩下的是和同桌共用电脑的 */
const leaveSections = computed(() =>
[
{ label: "今天没上线", students: leaveCandidates.value.filter(unseen) },
{
label: "今天上过线",
students: leaveCandidates.value.filter((student) => !unseen(student)),
},
].filter((section) => section.students.length),
)
const leaveSelected = ref<string[]>([])
function selectAll(list: ClassBoardStudent[]) {
leaveSelected.value = [
...new Set([...leaveSelected.value, ...list.map((student) => student.username)]),
]
}
function openLeaveDialog() {
leaveSelected.value = leaveStudents.value.map((student) => student.username)
leaveDialog.value = true
}
function saveLeave() {
function saveLeave(selected: string[]) {
setLeave(
boardStudents.value.map((student) => student.username),
leaveSelected.value,
selected,
)
leaveDialog.value = false
}
function byName(a: ClassBoardStudent, b: ClassBoardStudent) {
@@ -429,46 +367,16 @@ function openExplain(problemIndex: number, userId: number) {
explainShow.value = true
}
// ---------- 格子 ----------
// ---------- 格子(显示规则在 boardCells.ts) ----------
/** 今天之前就通过了的,颜色淡一档 */
function acceptedToday(cell: ClassBoardCell) {
return !!cell.acceptedAt && parseTime(cell.acceptedAt, "YYYY-MM-DD") === board.value?.day
}
function cellText(cell: ClassBoardCell) {
if (cell.status === "accepted") return "✓"
if (cell.status === "tried") return cell.attempts ? `✗ ${cell.attempts}` : "✗"
return ""
}
function cellStyle(cell: ClassBoardCell) {
if (cell.status === "accepted") {
const t = tone("success")
return acceptedToday(cell)
? { background: t.background, color: t.color }
: { background: t.background, color: t.color, opacity: 0.5 }
}
if (cell.status === "tried") {
if (cell.attempts >= STUCK_ATTEMPTS) {
return { background: theme.value.errorColor, color: "#ffffff" }
}
const t = tone("warning")
return { background: t.background, color: t.color }
}
return { background: theme.value.actionColor }
}
function cellTitle(cell: ClassBoardCell, student: ClassBoardStudent) {
if (cell.status === "accepted") {
return acceptedToday(cell) ? `${parseTime(cell.acceptedAt!, "HH:mm")} 做对` : "以前就做对了"
}
if (cell.status === "tried") {
if (cell.latest) return `讲讲 ${student.username} 这道题的错`
return cell.attempts ? `今天画了 ${cell.flowchartAttempts} 张流程图` : "以前交过,没做对"
}
return ""
}
const cellColors = computed(() => ({
tone,
errorColor: theme.value.errorColor,
actionColor: theme.value.actionColor,
}))
const cellStyle = (cell: ClassBoardCell) => cellStyleOf(cell, board.value?.day, cellColors.value)
const cellTitle = (cell: ClassBoardCell, student: ClassBoardStudent) =>
cellTitleOf(cell, student, board.value?.day)
/**
* 点格子 / 名字到提交列表看他今天交的。新标签打开:看板一般开着不关,别把它顶掉。
@@ -521,23 +429,15 @@ function lastText(student: ClassBoardStudent) {
/>
<!-- 这节课的题:当场填,填完收成一排小块 -->
<div v-if="editing" class="lesson-edit">
<n-select
v-model:value="lessonLanguage"
:options="languageOptions"
aria-label="用什么语言做"
class="lang-select"
/>
<n-input
v-model:value="lessonInput"
placeholder="这节课的题号,用空格隔开"
clearable
autofocus
@keyup.enter="saveLesson"
/>
<n-button type="primary" :loading="saving" @click="saveLesson">布置</n-button>
<n-button @click="editing = false">取消</n-button>
</div>
<LessonForm
v-if="editing && board && className"
:board="board"
:class-name="className"
variant="inline"
class="lesson-edit"
@saved="lessonSaved"
@cancel="editing = false"
/>
<div v-else-if="board?.problems.length" class="lesson">
<span class="lesson-label">{{ board.source === "teacher" ? "这节课" : "今天在做" }}</span>
<span v-if="board.language" class="chip lang">{{
@@ -549,7 +449,7 @@ function lastText(student: ClassBoardStudent) {
<span v-if="board.assignedAt" class="assigned">
{{ parseTime(board.assignedAt, "HH:mm") }} 布置
</span>
<n-button text class="edit" @click="startEdit">
<n-button text class="edit" @click="editing = true">
<template #icon><Icon icon="ph:pencil-simple" /></template>
{{ board.source === "teacher" ? "改" : "布置" }}
</n-button>
@@ -560,7 +460,7 @@ function lastText(student: ClassBoardStudent) {
v-if="board?.className"
:type="leaveStudents.length ? 'warning' : 'default'"
:secondary="!!leaveStudents.length"
@click="openLeaveDialog"
@click="leaveDialog = true"
>
{{ leaveStudents.length ? `请假 ${leaveStudents.length} 人` : "请假" }}
</n-button>
@@ -580,25 +480,14 @@ function lastText(student: ClassBoardStudent) {
<!-- 刚上课还没有题:填题号的框是这页最大的东西 -->
<section v-if="needLesson" class="start">
<div class="start-title">这节课做哪几道题?</div>
<div class="start-row">
<n-select
v-model:value="lessonLanguage"
:options="languageOptions"
aria-label="用什么语言做"
size="large"
class="lang-select"
/>
<n-input
v-model:value="lessonInput"
size="large"
placeholder="输入题号,用空格隔开"
class="start-input"
@keyup.enter="saveLesson"
/>
<n-button type="primary" size="large" :loading="saving" @click="saveLesson">
布置
</n-button>
</div>
<LessonForm
v-if="className"
:board="board"
:class-name="className"
variant="start"
class="start-row"
@saved="lessonSaved"
/>
</section>
<template v-else>
@@ -834,57 +723,12 @@ function lastText(student: ClassBoardStudent) {
:people="explainPeople"
/>
<n-modal
<LeaveModal
v-model:show="leaveDialog"
preset="card"
:title="
leaveCandidates.length
? `今天还没交过的 ${leaveCandidates.length} 人里,谁请假了?`
: '今天全班都交过题了'
"
style="width: 640px; max-width: calc(100vw - 32px)"
>
<n-checkbox-group v-if="leaveCandidates.length" v-model:value="leaveSelected">
<div v-for="section in leaveSections" :key="section.label" class="leave-section">
<div v-if="leaveSections.length > 1 || unseen(section.students[0])" class="leave-head">
{{ section.label }} {{ section.students.length }} 人
<n-button
v-if="unseen(section.students[0])"
text
type="primary"
:disabled="
section.students.every((student) => leaveSelected.includes(student.username))
"
@click="selectAll(section.students)"
>
全勾上
</n-button>
</div>
<div class="leave-grid">
<n-checkbox
v-for="student in section.students"
:key="student.userId"
:value="student.username"
:label="student.username"
class="leave-option"
/>
</div>
</div>
</n-checkbox-group>
<n-text v-else depth="3">请假的人一定是没交过题的,现在没有人可以标。</n-text>
<template #footer>
<n-flex align="center" :wrap="false">
<n-text depth="3" class="meta" style="flex-grow: 1">
已选 {{ leaveSelected.length }} 人 · 只记在这台电脑上,今天有效
</n-text>
<n-button :disabled="!leaveSelected.length" @click="leaveSelected = []">
全部取消
</n-button>
<n-button @click="leaveDialog = false">取消</n-button>
<n-button type="primary" @click="saveLeave">确定</n-button>
</n-flex>
</template>
</n-modal>
:candidates="leaveCandidates"
:current="leaveStudents.map((student) => student.username)"
@save="saveLeave"
/>
</div>
</template>
@@ -924,11 +768,6 @@ function lastText(student: ClassBoardStudent) {
width: 160px;
}
.lang-select {
width: 110px;
flex-shrink: 0;
}
.lesson,
.lesson-edit {
display: flex;
@@ -1004,11 +843,6 @@ function lastText(student: ClassBoardStudent) {
gap: 10px;
}
.start-input {
flex: 1 1 0;
font-size: 18px;
}
/* ---------- 题卡片 ---------- */
.cards {
@@ -1396,32 +1230,6 @@ a.cell {
color: v-bind("tone('info').color");
}
.leave-section + .leave-section {
margin-top: 18px;
}
.leave-head {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 10px;
font-weight: 600;
}
.leave-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 10px 8px;
}
.leave-option {
font-size: 15px;
}
.meta {
font-size: var(--oj-fs-sec);
}
/* ---------- 手机 ---------- */
@media (max-width: 768px) {
@@ -1459,11 +1267,6 @@ a.cell {
flex-wrap: wrap;
}
.start-input {
flex-basis: 100%;
order: -1;
}
.grid .name {
padding-left: 4px;
padding-right: 8px;
+64
View File
@@ -0,0 +1,64 @@
import type { Tone } from "shared/composables/tone"
import { parseTime } from "utils/functions"
import type { ClassBoardCell, ClassBoardStudent } from "utils/types"
/**
* 课堂看板的格子:一个学生 × 一道题。显示什么字、什么颜色、悬停说什么,都是纯函数,
* 和 errorKind.ts 一样单放一个文件(看板组件只管排版和交互)。
*/
/** 同一道题今天交了这么多次还没过,算卡住 */
export const STUCK_ATTEMPTS = 3
/** 今天之前就通过了的,颜色淡一档。`day` 是看板的「今天」(东八区日期) */
export function acceptedToday(cell: ClassBoardCell, day: string | undefined) {
return !!cell.acceptedAt && parseTime(cell.acceptedAt, "YYYY-MM-DD") === day
}
export function cellText(cell: ClassBoardCell) {
if (cell.status === "accepted") return "✓"
if (cell.status === "tried") return cell.attempts ? `✗ ${cell.attempts}` : "✗"
return ""
}
export function cellStyle(
cell: ClassBoardCell,
day: string | undefined,
colors: {
tone: (kind: Tone) => { color: string; background: string }
errorColor: string
actionColor: string
},
) {
if (cell.status === "accepted") {
const t = colors.tone("success")
return acceptedToday(cell, day)
? { background: t.background, color: t.color }
: { background: t.background, color: t.color, opacity: 0.5 }
}
if (cell.status === "tried") {
if (cell.attempts >= STUCK_ATTEMPTS) {
return { background: colors.errorColor, color: "#ffffff" }
}
const t = colors.tone("warning")
return { background: t.background, color: t.color }
}
return { background: colors.actionColor }
}
export function cellTitle(
cell: ClassBoardCell,
student: ClassBoardStudent,
day: string | undefined,
) {
if (cell.status === "accepted") {
return acceptedToday(cell, day)
? `${parseTime(cell.acceptedAt!, "HH:mm")} 做对`
: "以前就做对了"
}
if (cell.status === "tried") {
if (cell.latest) return `讲讲 ${student.username} 这道题的错`
return cell.attempts ? `今天画了 ${cell.flowchartAttempts} 张流程图` : "以前交过,没做对"
}
return ""
}
@@ -0,0 +1,125 @@
<script setup lang="ts">
import type { ClassBoardStudent } from "utils/types"
/**
* 看板的「请假」框:勾今天不在的人。规则在看板那边(只有今天还没交过的人能算请假、
* 交了题或举手就自动算到,见 board.vue 的 canLeave),这里只管勾。
*/
const props = defineProps<{
/** 能勾的人:今天还没交过的(刚上课时就是全班),按名字排好 */
candidates: ClassBoardStudent[]
/** 打开时已经标着请假的 */
current: string[]
}>()
const emit = defineEmits<{ save: [usernames: string[]] }>()
const show = defineModel<boolean>("show", { required: true })
const selected = ref<string[]>([])
watch(show, (open) => {
if (open) selected.value = [...props.current]
})
/**
* 今天一次都没上过线。机房有两人共用一台电脑的,同桌只有一个登着,所以「谁没来」
* 看今天上没上过线,不看此刻在不在线(见契约 seenAt)
*/
const unseen = (student: ClassBoardStudent | undefined) => !!student && !student.seenAt
/** 今天没上过线的分在前面一堆:请假的人基本在这里面,剩下的是和同桌共用电脑的 */
const sections = computed(() =>
[
{ label: "今天没上线", students: props.candidates.filter(unseen) },
{
label: "今天上过线",
students: props.candidates.filter((student) => !unseen(student)),
},
].filter((section) => section.students.length),
)
function selectAll(list: ClassBoardStudent[]) {
selected.value = [...new Set([...selected.value, ...list.map((student) => student.username)])]
}
function save() {
emit("save", selected.value)
show.value = false
}
</script>
<template>
<n-modal
v-model:show="show"
preset="card"
:title="
candidates.length
? `今天还没交过的 ${candidates.length} 人里,谁请假了?`
: '今天全班都交过题了'
"
style="width: 640px; max-width: calc(100vw - 32px)"
>
<n-checkbox-group v-if="candidates.length" v-model:value="selected">
<div v-for="section in sections" :key="section.label" class="leave-section">
<div v-if="sections.length > 1 || unseen(section.students[0])" class="leave-head">
{{ section.label }} {{ section.students.length }} 人
<n-button
v-if="unseen(section.students[0])"
text
type="primary"
:disabled="section.students.every((student) => selected.includes(student.username))"
@click="selectAll(section.students)"
>
全勾上
</n-button>
</div>
<div class="leave-grid">
<n-checkbox
v-for="student in section.students"
:key="student.userId"
:value="student.username"
:label="student.username"
class="leave-option"
/>
</div>
</div>
</n-checkbox-group>
<n-text v-else depth="3">请假的人一定是没交过题的,现在没有人可以标。</n-text>
<template #footer>
<n-flex align="center" :wrap="false">
<n-text depth="3" class="meta" style="flex-grow: 1">
已选 {{ selected.length }} 人 · 只记在这台电脑上,今天有效
</n-text>
<n-button :disabled="!selected.length" @click="selected = []">全部取消</n-button>
<n-button @click="show = false">取消</n-button>
<n-button type="primary" @click="save">确定</n-button>
</n-flex>
</template>
</n-modal>
</template>
<style scoped>
.leave-section + .leave-section {
margin-top: 18px;
}
.leave-head {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 10px;
font-weight: 600;
}
.leave-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 10px 8px;
}
.leave-option {
font-size: 15px;
}
.meta {
font-size: var(--oj-fs-sec);
}
</style>
@@ -0,0 +1,119 @@
<script setup lang="ts">
import type { LessonLanguage } from "@oj2/contract"
import { setClassLesson } from "oj/api"
import { errorMessage } from "utils/api"
import { LANGUAGE_SHOW_VALUE } from "utils/constants"
import type { ClassBoard } from "utils/types"
/**
* 看板上布置「这节课的题」:题号是上课时当场填的。两种样子 ——
* - `inline`:顶上一行里点「改 / 布置」展开的那一小条;
* - `start`:刚上课还没有题时,这页最大的那个框。
*/
const props = defineProps<{
board: ClassBoard
className: string
variant: "inline" | "start"
}>()
const emit = defineEmits<{ saved: []; cancel: [] }>()
const message = useMessage()
/**
* 这份作业用什么语言做,跟着这一次布置走(用户定的:不是给班级定死的设置)。默认是这个班
* 上一次布置时选的;选了之后看板和学生那边的「做完」都只认这个语言交对的
*/
const lessonLanguage = ref<LessonLanguage>("Python")
const LESSON_LANGUAGES: LessonLanguage[] = ["Python", "C", "C++", "SQL"]
const languageOptions = LESSON_LANGUAGES.map((value) => ({
label: LANGUAGE_SHOW_VALUE[value],
value,
}))
const lessonInput = ref("")
const saving = ref(false)
function resetLessonInput() {
lessonInput.value =
props.board.source === "teacher"
? props.board.problems.map((problem) => problem.problemDisplayId).join(" ")
: ""
lessonLanguage.value = props.board.language ?? props.board.lastLanguage ?? "Python"
}
resetLessonInput()
// 推断出来的题也带进框里,老师多半只是确认一下
if (props.variant === "inline" && !lessonInput.value && props.board.problems.length) {
lessonInput.value = props.board.problems.map((problem) => problem.problemDisplayId).join(" ")
}
// 换了班要把输入框换成那个班今天布置的题、布置时选的语言(15 秒一次的刷新不动它们)
watch(() => [props.board.className, props.board.source] as const, resetLessonInput)
async function saveLesson() {
const ids = lessonInput.value.split(/[\s,,、;;]+/).filter(Boolean)
saving.value = true
try {
await setClassLesson(props.className, ids, ids.length ? lessonLanguage.value : null)
message.success(ids.length ? "已布置,学生首页会显示这几道题" : "已清掉")
emit("saved")
} catch (error) {
message.error(errorMessage(error))
} finally {
saving.value = false
}
}
</script>
<template>
<div v-if="variant === 'inline'">
<n-select
v-model:value="lessonLanguage"
:options="languageOptions"
aria-label="用什么语言做"
class="lang-select"
/>
<n-input
v-model:value="lessonInput"
placeholder="这节课的题号,用空格隔开"
clearable
autofocus
@keyup.enter="saveLesson"
/>
<n-button type="primary" :loading="saving" @click="saveLesson">布置</n-button>
<n-button @click="emit('cancel')">取消</n-button>
</div>
<div v-else>
<n-select
v-model:value="lessonLanguage"
:options="languageOptions"
aria-label="用什么语言做"
size="large"
class="lang-select"
/>
<n-input
v-model:value="lessonInput"
size="large"
placeholder="输入题号,用空格隔开"
class="start-input"
@keyup.enter="saveLesson"
/>
<n-button type="primary" size="large" :loading="saving" @click="saveLesson">布置</n-button>
</div>
</template>
<style scoped>
.lang-select {
width: 110px;
flex-shrink: 0;
}
.start-input {
flex: 1 1 0;
font-size: 18px;
}
@media (max-width: 768px) {
.start-input {
flex-basis: 100%;
order: -1;
}
}
</style>
@@ -0,0 +1,61 @@
import { computed, ref, type Ref } from "vue"
import { getClassLesson } from "oj/api"
import { classLabel } from "utils/classes"
import type { ClassLesson } from "utils/types"
import type { SubmissionQuery } from "./listQuery"
/** 提交列表的「这节课」:一键筛到 班级 + 这节课的题 + 今天。班级和题的取法同课堂看板 */
export function useLessonFilter(options: {
query: SubmissionQuery
flowMode: Ref<boolean>
info: (text: string) => void
}) {
const { query } = options
const lesson = ref<ClassLesson | null>(null)
const lessonIds = computed(
() => lesson.value?.problems.map((problem) => problem.problemDisplayId).join(",") ?? "",
)
const lessonActive = computed(
() =>
!!lesson.value?.className &&
!!lesson.value.problems.length &&
query.className === lesson.value.className &&
query.problem === lessonIds.value &&
query.today === "1",
)
const lessonLoading = ref(false)
async function toggleLesson() {
if (lessonActive.value) {
query.problem = ""
query.today = "0"
// 「这节课」顺带筛上的语言一起放掉;老师自己另选的别动
if (lesson.value?.language && query.language === lesson.value.language) query.language = ""
return
}
lessonLoading.value = true
try {
const res = await getClassLesson(query.className || undefined)
if (!res.className) {
options.info("猜不出是哪个班在上课,先在班级里选一个")
return
}
query.className = res.className
if (!res.problems.length) {
options.info(
`${classLabel(res.className)}今天还没有布置题,也还没人交。可以先去课堂看板布置`,
)
return
}
lesson.value = res
query.problem = lessonIds.value
query.today = "1"
// 老师布置时选了语言:看板上只数这个语言交的,列表也只列它(流程图那边不动)
if (res.language && !options.flowMode.value) query.language = res.language
} finally {
lessonLoading.value = false
}
}
return { lesson, lessonActive, lessonLoading, toggleLesson }
}
@@ -0,0 +1,90 @@
import { computed, ref, type Ref } from "vue"
import { useDocumentVisibility, useIntervalFn } from "@vueuse/core"
import { SubmissionStatus } from "utils/constants"
import {
FlowchartSubmissionStatus,
type FlowchartSubmissionListItem,
type SubmissionListItem,
} from "utils/types"
import type { Row, SubmissionQuery } from "./listQuery"
function isPending(row: Row) {
if ("result" in row) {
return row.result === SubmissionStatus.pending || row.result === SubmissionStatus.judging
}
return (
row.status === FlowchartSubmissionStatus.PENDING ||
row.status === FlowchartSubmissionStatus.PROCESSING
)
}
/**
* 提交列表的实时更新。两件事,一个定时器:
* - 本页有「正在评分」的,3 秒拉一次,把结果原地换上;
* - 第一页每 15 秒看一眼有没有新交的。**新的不直接插进来** —— 老师正看着第 5 行,
* 上面突然塞进 3 行,选中的那行就跑了。顶上给一条「有 N 条新提交」,点了才换。
* 页面切到后台就停。
*
* `requestSeq` 是列表那边的请求序号:拉的这期间筛选变了、翻页了,这份结果就作废。
*/
export function useListLive(options: {
query: SubmissionQuery
flowMode: Ref<boolean>
rows: Ref<Row[]>
submissions: Ref<SubmissionListItem[]>
flowcharts: Ref<FlowchartSubmissionListItem[]>
loading: Ref<boolean>
loaded: Ref<boolean>
fetchPage: () => Promise<{ results: Row[] }>
requestSeq: () => number
}) {
const { query, flowMode, rows, submissions, flowcharts } = options
const newCount = ref(0)
const newCountCapped = computed(() => newCount.value >= query.limit)
let lastPoll = Date.now()
const visibility = useDocumentVisibility()
const hasPending = computed(() => rows.value.some(isPending))
useIntervalFn(async () => {
if (visibility.value !== "visible" || options.loading.value || !options.loaded.value) return
const interval = hasPending.value ? 3000 : 15000
if (Date.now() - lastPoll < interval) return
if (!hasPending.value && query.page !== 1) return
lastPoll = Date.now()
const seq = options.requestSeq()
const mode = flowMode.value
let res
try {
res = await options.fetchPage()
} catch {
return
}
// 这期间筛选变了、翻页了,这份结果就作废
if (seq !== options.requestSeq() || mode !== flowMode.value) return
const fresh = new Map<string, Row>(res.results.map((row) => [row.id, row]))
if (mode) {
flowcharts.value = flowcharts.value.map(
(row) => (fresh.get(row.id) as FlowchartSubmissionListItem | undefined) ?? row,
)
} else {
submissions.value = submissions.value.map(
(row) => (fresh.get(row.id) as SubmissionListItem | undefined) ?? row,
)
}
if (query.page === 1) {
const current = new Set(rows.value.map((row) => row.id))
const newest = rows.value[0]?.createTime ?? ""
newCount.value = res.results.filter(
(row) => !current.has(row.id) && row.createTime > newest,
).length
}
}, 1000)
/** 刚点了重新判题 / 重新评分:下一秒就拉,不等满 3 秒 */
function pollSoon() {
lastPoll = 0
}
return { newCount, newCountCapped, pollSoon }
}
@@ -0,0 +1,17 @@
import type { FlowchartSubmissionListItem, LANGUAGE, SubmissionListItem } from "utils/types"
/** 提交列表(list.vue)的筛选条件,和它拆出去的几个 composable 共用 */
export interface SubmissionQuery {
page: number
limit: number
username: string
className: string
result: string
myself: "0" | "1"
problem: string
language: LANGUAGE | "" | "Flowchart"
today: "0" | "1"
}
/** 一行:代码提交或流程图提交(看 query.language 是不是 Flowchart) */
export type Row = SubmissionListItem | FlowchartSubmissionListItem
@@ -0,0 +1,131 @@
import { computed, nextTick, ref, type Ref } from "vue"
import { onKeyStroke } from "@vueuse/core"
import type { Row, SubmissionQuery } from "./listQuery"
/**
* 提交列表的「选中与翻阅」:右栏跟着选中的那条走,↑ ↓ 换条,到了页头页尾自动翻页。
*
* 翻页是异步的:先记下「落地后停在哪一头」(pendingJump),新一页回来之后由列表调
* `settleSelection()` 再选。
*/
export function useRowCursor(options: {
rows: Ref<Row[]>
total: Ref<number>
query: SubmissionQuery
loading: Ref<boolean>
isMobile: Ref<boolean>
isDesktop: Ref<boolean>
/** 开着弹框时方向键不归列表 */
keysBlocked: () => boolean
/** 地址里带着的 ?id=(手机上开着详情时刷新、别人发来的链接) */
wantedId: () => string
/** 这一页上没有 ?id= 那一条:详情开不出来,让列表把它从地址上摘掉 */
dropWanted: () => void
/** 跨页落地后这一页一条能看的都没有 */
onNothingToShow: () => void
}) {
const { rows, total, query, loading, isMobile, isDesktop } = options
const selectedId = ref("")
const selectedRow = computed(() => rows.value.find((row) => row.id === selectedId.value) ?? null)
/** 方向键只在能看的行之间走:跳到一条看不了的上面只会看到一把锁 */
const navigable = computed(() => rows.value.filter((row) => row.showLink))
const position = computed(() => {
const index = navigable.value.findIndex((row) => row.id === selectedId.value)
return index === -1 ? null : { index: index + 1, count: navigable.value.length }
})
// 翻页是异步的,先记下「落地后停在哪一头」,新一页回来再选
let pendingJump: "first" | "last" | null = null
/** 加载失败了别把「落地后停在哪一头」留给下一次不相干的加载 */
function forgetJump() {
pendingJump = null
}
function settleSelection() {
const jump = pendingJump
pendingJump = null
const list = navigable.value
if (jump) {
const row = jump === "first" ? list[0] : list[list.length - 1]
if (row) {
select(row.id)
return
}
options.onNothingToShow()
}
// 地址里带着 ?id= 的(手机上开着详情时刷新、别人发来的链接)就停在那一条
const wanted = options.wantedId()
if (wanted && rows.value.some((row) => row.id === wanted)) {
selectedId.value = wanted
return
}
// 这一页上没有那一条:详情开不出来,把 ?id= 摘掉
options.dropWanted()
if (rows.value.some((row) => row.id === selectedId.value)) return
// 手机上没有右栏,不用预先选一条
if (isMobile.value) {
selectedId.value = ""
return
}
selectedId.value = (list[0] ?? rows.value[0])?.id ?? ""
}
function select(id: string) {
selectedId.value = id
nextTick(() => {
document
.querySelector(`[data-row-id="${CSS.escape(id)}"]`)
?.scrollIntoView({ block: "nearest" })
})
}
const maxPage = computed(() => Math.max(1, Math.ceil(total.value / query.limit)))
function move(step: 1 | -1) {
// 这一页一条能看的都没有(学生看全站时常见):不翻页,不然按一下翻一页停不下来
if (!navigable.value.length) return
// 上一次跨页还没回来:按住 ↓ 或者在页尾连按两下,不然会整页跳过
if (loading.value || pendingJump) return
const all = rows.value
const start = all.findIndex((row) => row.id === selectedId.value)
for (let i = start + step; i >= 0 && i < all.length; i += step) {
if (all[i]!.showLink) {
select(all[i]!.id)
return
}
}
const page = query.page + step
if (page < 1 || page > maxPage.value) return
pendingJump = step > 0 ? "first" : "last"
query.page = page
}
// 只用 ↑ ↓(用户定的,← → 不要)
onKeyStroke(["ArrowUp", "ArrowDown"], (e: KeyboardEvent) => {
if (!isDesktop.value || options.keysBlocked()) return
// 别的控件已经处理了这次按键(下拉框自己会开菜单、换选项)
if (e.defaultPrevented) return
const target = e.target as HTMLElement | null
if (target) {
// 「代码 / 流程图」「全部 / 我的」是单选框:点过之后焦点停在上面,浏览器默认会拿方向键
// 切换选项 —— 老师想看下一条,结果模式被切了。单选框照常换条,下面的 preventDefault 挡掉切换
const radio = target.tagName === "INPUT" && (target as HTMLInputElement).type === "radio"
// 焦点在输入框、下拉框、弹框里时不抢方向键
if (
(!radio && (target.tagName === "INPUT" || target.tagName === "TEXTAREA")) ||
target.isContentEditable ||
target.closest(".n-base-selection, .n-base-select-menu, .n-dropdown-menu, .n-modal")
) {
return
}
}
if (!rows.value.length) return
e.preventDefault()
move(e.key === "ArrowUp" ? -1 : 1)
})
return { selectedId, selectedRow, position, maxPage, select, move, settleSelection, forgetJump }
}
@@ -0,0 +1,80 @@
import { reactive, type Ref } from "vue"
import { useRouter } from "vue-router"
import { DEFAULT_PERIOD } from "oj/statistics/period"
import type { SubmissionQuery } from "./listQuery"
/**
* 提交列表和「统计」之间来回带条件:打开统计时带上列表现在的班级 / 学生 / 题号 /
* 代码或流程图,统计里点「在提交列表里看」时把条件改回列表上。
*/
export function useStatsLink(options: {
query: SubmissionQuery
flowMode: Ref<boolean>
statsPanel: Ref<boolean>
/** 列表的「按整名只看一个人」,从统计回来时要放掉 */
exactFor: Ref<string>
}) {
const { query, flowMode, statsPanel } = options
const router = useRouter()
/**
* 「统计」弹框的查询条件。每次打开从这张列表现在的班级 / 学生 / 题号 / 代码或流程图
* 带进去(「今天」带过去就是统计的「今天」);时间段沿用上次在弹框里选的
*/
const statsQuery = reactive({
tab: "code",
className: "",
username: "",
problem: "",
period: DEFAULT_PERIOD as string,
from: "",
to: "",
})
/**
* 老师的「统计」:带着这张列表现在的班级 / 学生 / 题号 / 代码或流程图打开。原来旁边还有一颗
* 「今日统计」,和「数据统计」名字分不清(用户说的),老师那一版并进来了 —— 什么都没筛时
* 打开就是「一行一节课」,选「今天」就是原来今日统计看的东西
*/
function openStatistics() {
Object.assign(statsQuery, {
tab: flowMode.value ? "flow" : "code",
className: query.className,
username: query.username,
problem: query.problem,
...(query.today === "1" ? { period: "today" } : {}),
})
statsPanel.value = true
}
/** 手机上的「统计」:同样的条件,带去统计页 */
function openStatisticsPage() {
router.push({
name: "statistics",
query: {
tab: flowMode.value ? "flow" : "code",
...(query.className ? { className: query.className } : {}),
...(query.username ? { username: query.username } : {}),
...(query.problem ? { problem: query.problem } : {}),
...(query.today === "1" ? { period: "today" } : {}),
},
})
}
/** 统计里的「在提交列表里看」:直接改背后这张列表的筛选,关掉弹框 */
function listFromStatistics(next: Record<string, string>) {
statsPanel.value = false
options.exactFor.value = ""
Object.assign(query, {
username: next.username ?? "",
className: next.className ?? "",
result: "",
myself: "0",
problem: next.problem ?? "",
language: next.language === "Flowchart" ? "Flowchart" : flowMode.value ? "" : query.language,
today: next.today === "1" ? "1" : "0",
})
}
return { statsQuery, openStatistics, openStatisticsPage, listFromStatistics }
}
+45 -276
View File
@@ -5,13 +5,11 @@ import { useRouteQuery } from "@vueuse/router"
import { errorCode } from "utils/api"
import {
adminRejudge,
getClassLesson,
getFlowchartSubmissions,
getSubmissions,
getTodaySubmissionCount,
retryFlowchartSubmission,
} from "oj/api"
import { DEFAULT_PERIOD } from "oj/statistics/period"
import { useContestStore } from "oj/store/contest"
import Pagination from "shared/components/Pagination.vue"
import { useBackClose } from "shared/composables/backClose"
@@ -21,12 +19,7 @@ import { useConfigStore } from "shared/store/config"
import { useUserStore } from "shared/store/user"
import { LANGUAGE_SHOW_VALUE, SubmissionStatus } from "utils/constants"
import { parseTime } from "utils/functions"
import type {
ClassLesson,
FlowchartSubmissionListItem,
LANGUAGE,
SubmissionListItem,
} from "utils/types"
import type { FlowchartSubmissionListItem, LANGUAGE, SubmissionListItem } from "utils/types"
import { FlowchartSubmissionStatus } from "utils/types"
import FlowchartState from "./components/FlowchartState.vue"
import StatusPill from "./components/StatusPill.vue"
@@ -34,6 +27,11 @@ import UserName from "shared/components/UserName.vue"
import { useTone } from "shared/composables/tone"
import { classLabel, classSelectOptions } from "utils/classes"
import { submissionClockText, submissionDayText } from "./utils"
import { useLessonFilter } from "./composables/lessonFilter"
import { useListLive } from "./composables/listLive"
import type { Row, SubmissionQuery } from "./composables/listQuery"
import { useRowCursor } from "./composables/rowCursor"
import { useStatsLink } from "./composables/statsLink"
/**
* 提交列表(设计稿「提交列表重设计 · 定稿」):左边列表、右边常驻选中那条的代码。
@@ -52,16 +50,6 @@ const StatisticsView = defineAsyncComponent(
() => import("oj/statistics/components/StatisticsView.vue"),
)
interface SubmissionQuery {
username: string
className: string
result: string
myself: "0" | "1"
problem: string
language: LANGUAGE | "" | "Flowchart"
today: "0" | "1"
}
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
@@ -87,7 +75,7 @@ let leavingPane = false
const teacher = computed(() => userStore.isTeacherOrAbove)
// 一节课一个班要交一百来条,每页 10 条得翻十几页;30 条配上右栏的方向键正好
const { query, clearQuery } = usePagination<SubmissionQuery>(
const { query, clearQuery } = usePagination<Omit<SubmissionQuery, "page" | "limit">>(
{
username: useRouteQuery("username", "").value,
className: useRouteQuery("className", "").value,
@@ -138,7 +126,6 @@ watch(loading, (value) => {
})
onUnmounted(() => clearTimeout(loadingTimer))
type Row = SubmissionListItem | FlowchartSubmissionListItem
const rows = computed<Row[]>(() => (flowMode.value ? flowcharts.value : submissions.value))
function fetchPage() {
@@ -199,7 +186,7 @@ async function listSubmissions() {
settleSelection()
} catch {
// 失败了别把「落地后停在哪一头」留给下一次不相干的加载
if (seq === requestSeq) pendingJump = null
if (seq === requestSeq) forgetJump()
} finally {
if (seq === requestSeq) loading.value = false
}
@@ -288,106 +275,26 @@ watch(
// ==================== 选中与翻阅 ====================
const selectedId = ref("")
const selectedRow = computed(() => rows.value.find((row) => row.id === selectedId.value) ?? null)
/** 方向键只在能看的行之间走:跳到一条看不了的上面只会看到一把锁 */
const navigable = computed(() => rows.value.filter((row) => row.showLink))
const position = computed(() => {
const index = navigable.value.findIndex((row) => row.id === selectedId.value)
return index === -1 ? null : { index: index + 1, count: navigable.value.length }
})
// 翻页是异步的,先记下「落地后停在哪一头」,新一页回来再选
let pendingJump: "first" | "last" | null = null
function settleSelection() {
const jump = pendingJump
pendingJump = null
const list = navigable.value
if (jump) {
const row = jump === "first" ? list[0] : list[list.length - 1]
if (row) {
select(row.id)
return
}
message.info("这一页没有可以查看的代码")
}
// 地址里带着 ?id= 的(手机上开着详情时刷新、别人发来的链接)就停在那一条
const wanted = typeof route.query.id === "string" ? route.query.id : ""
if (wanted && rows.value.some((row) => row.id === wanted)) {
selectedId.value = wanted
return
}
// 这一页上没有那一条:详情开不出来,把 ?id= 摘掉
if (paneId.value) router.replace({ query: queryWithoutId() })
if (rows.value.some((row) => row.id === selectedId.value)) return
// 手机上没有右栏,不用预先选一条
if (isMobile.value) {
selectedId.value = ""
return
}
selectedId.value = (list[0] ?? rows.value[0])?.id ?? ""
}
function select(id: string) {
selectedId.value = id
nextTick(() => {
document
.querySelector(`[data-row-id="${CSS.escape(id)}"]`)
?.scrollIntoView({ block: "nearest" })
const { selectedId, selectedRow, position, maxPage, select, move, settleSelection, forgetJump } =
useRowCursor({
rows,
total,
query,
loading,
isMobile,
isDesktop,
keysBlocked: () => todayPanel.value || statsPanel.value,
wantedId: () => (typeof route.query.id === "string" ? route.query.id : ""),
dropWanted: () => {
if (paneId.value) router.replace({ query: queryWithoutId() })
},
onNothingToShow: () => message.info("这一页没有可以查看的代码"),
})
}
const maxPage = computed(() => Math.max(1, Math.ceil(total.value / query.limit)))
function move(step: 1 | -1) {
// 这一页一条能看的都没有(学生看全站时常见):不翻页,不然按一下翻一页停不下来
if (!navigable.value.length) return
// 上一次跨页还没回来:按住 ↓ 或者在页尾连按两下,不然会整页跳过
if (loading.value || pendingJump) return
const all = rows.value
const start = all.findIndex((row) => row.id === selectedId.value)
for (let i = start + step; i >= 0 && i < all.length; i += step) {
if (all[i]!.showLink) {
select(all[i]!.id)
return
}
}
const page = query.page + step
if (page < 1 || page > maxPage.value) return
pendingJump = step > 0 ? "first" : "last"
query.page = page
}
const [todayPanel, toggleTodayPanel] = useToggle(false)
useBackClose(todayPanel, isMobile)
const statsPanel = ref(false)
// 只用 ↑ ↓(用户定的,← → 不要)
onKeyStroke(["ArrowUp", "ArrowDown"], (e: KeyboardEvent) => {
if (!isDesktop.value || todayPanel.value || statsPanel.value) return
// 别的控件已经处理了这次按键(下拉框自己会开菜单、换选项)
if (e.defaultPrevented) return
const target = e.target as HTMLElement | null
if (target) {
// 「代码 / 流程图」「全部 / 我的」是单选框:点过之后焦点停在上面,浏览器默认会拿方向键
// 切换选项 —— 老师想看下一条,结果模式被切了。单选框照常换条,下面的 preventDefault 挡掉切换
const radio = target.tagName === "INPUT" && (target as HTMLInputElement).type === "radio"
// 焦点在输入框、下拉框、弹框里时不抢方向键
if (
(!radio && (target.tagName === "INPUT" || target.tagName === "TEXTAREA")) ||
target.isContentEditable ||
target.closest(".n-base-selection, .n-base-select-menu, .n-dropdown-menu, .n-modal")
) {
return
}
}
if (!rows.value.length) return
e.preventDefault()
move(e.key === "ArrowUp" ? -1 : 1)
})
// 手机上没有右栏:点一行从右边滑出整屏的详情,地址带上 ?id=(见 paneId)
const mobilePane = computed({
get: () => !!paneId.value,
@@ -447,64 +354,17 @@ watch(paneId, (id) => {
// ==================== 实时更新 ====================
/**
* 两件事,一个定时器:
* - 本页有「正在评分」的,3 秒拉一次,把结果原地换上;
* - 第一页每 15 秒看一眼有没有新交的。**新的不直接插进来** —— 老师正看着第 5 行,
* 上面突然塞进 3 行,选中的那行就跑了。顶上给一条「有 N 条新提交」,点了才换。
* 页面切到后台就停。
*/
const newCount = ref(0)
const newCountCapped = computed(() => newCount.value >= query.limit)
let lastPoll = Date.now()
const visibility = useDocumentVisibility()
function isPending(row: Row) {
if ("result" in row) {
return row.result === SubmissionStatus.pending || row.result === SubmissionStatus.judging
}
return (
row.status === FlowchartSubmissionStatus.PENDING ||
row.status === FlowchartSubmissionStatus.PROCESSING
)
}
const hasPending = computed(() => rows.value.some(isPending))
useIntervalFn(async () => {
if (visibility.value !== "visible" || loading.value || !loaded.value) return
const interval = hasPending.value ? 3000 : 15000
if (Date.now() - lastPoll < interval) return
if (!hasPending.value && query.page !== 1) return
lastPoll = Date.now()
const seq = requestSeq
const mode = flowMode.value
let res
try {
res = await fetchPage()
} catch {
return
}
// 这期间筛选变了、翻页了,这份结果就作废
if (seq !== requestSeq || mode !== flowMode.value) return
const fresh = new Map<string, Row>(res.results.map((row) => [row.id, row]))
if (mode) {
flowcharts.value = flowcharts.value.map(
(row) => (fresh.get(row.id) as FlowchartSubmissionListItem | undefined) ?? row,
)
} else {
submissions.value = submissions.value.map(
(row) => (fresh.get(row.id) as SubmissionListItem | undefined) ?? row,
)
}
if (query.page === 1) {
const current = new Set(rows.value.map((row) => row.id))
const newest = rows.value[0]?.createTime ?? ""
newCount.value = res.results.filter(
(row) => !current.has(row.id) && row.createTime > newest,
).length
}
}, 1000)
const { newCount, newCountCapped, pollSoon } = useListLive({
query,
flowMode,
rows,
submissions,
flowcharts,
loading,
loaded,
fetchPage,
requestSeq: () => requestSeq,
})
function showNew() {
newCount.value = 0
@@ -576,50 +436,11 @@ const today = computed({
set: (value: boolean) => (query.today = value ? "1" : "0"),
})
/** 「这节课」:一键筛到 班级 + 这节课的题 + 今天。班级和题的取法同课堂看板 */
const lesson = ref<ClassLesson | null>(null)
const lessonIds = computed(
() => lesson.value?.problems.map((problem) => problem.problemDisplayId).join(",") ?? "",
)
const lessonActive = computed(
() =>
!!lesson.value?.className &&
!!lesson.value.problems.length &&
query.className === lesson.value.className &&
query.problem === lessonIds.value &&
query.today === "1",
)
const lessonLoading = ref(false)
async function toggleLesson() {
if (lessonActive.value) {
query.problem = ""
query.today = "0"
// 「这节课」顺带筛上的语言一起放掉;老师自己另选的别动
if (lesson.value?.language && query.language === lesson.value.language) query.language = ""
return
}
lessonLoading.value = true
try {
const res = await getClassLesson(query.className || undefined)
if (!res.className) {
message.info("猜不出是哪个班在上课,先在班级里选一个")
return
}
query.className = res.className
if (!res.problems.length) {
message.info(`${classLabel(res.className)}今天还没有布置题,也还没人交。可以先去课堂看板布置`)
return
}
lesson.value = res
query.problem = lessonIds.value
query.today = "1"
// 老师布置时选了语言:看板上只数这个语言交的,列表也只列它(流程图那边不动)
if (res.language && !flowMode.value) query.language = res.language
} finally {
lessonLoading.value = false
}
}
const { lesson, lessonActive, lessonLoading, toggleLesson } = useLessonFilter({
query,
flowMode,
info: (text) => message.info(text),
})
const activeFilters = computed(
() =>
@@ -652,65 +473,13 @@ function filterUser(username: string) {
query.username = username
}
/**
* 「统计」弹框的查询条件。每次打开从这张列表现在的班级 / 学生 / 题号 / 代码或流程图
* 带进去(「今天」带过去就是统计的「今天」);时间段沿用上次在弹框里选的
*/
const statsQuery = reactive({
tab: "code",
className: "",
username: "",
problem: "",
period: DEFAULT_PERIOD as string,
from: "",
to: "",
const { statsQuery, openStatistics, openStatisticsPage, listFromStatistics } = useStatsLink({
query,
flowMode,
statsPanel,
exactFor,
})
/**
* 老师的「统计」:带着这张列表现在的班级 / 学生 / 题号 / 代码或流程图打开。原来旁边还有一颗
* 「今日统计」,和「数据统计」名字分不清(用户说的),老师那一版并进来了 —— 什么都没筛时
* 打开就是「一行一节课」,选「今天」就是原来今日统计看的东西
*/
function openStatistics() {
Object.assign(statsQuery, {
tab: flowMode.value ? "flow" : "code",
className: query.className,
username: query.username,
problem: query.problem,
...(query.today === "1" ? { period: "today" } : {}),
})
statsPanel.value = true
}
/** 手机上的「统计」:同样的条件,带去统计页 */
function openStatisticsPage() {
router.push({
name: "statistics",
query: {
tab: flowMode.value ? "flow" : "code",
...(query.className ? { className: query.className } : {}),
...(query.username ? { username: query.username } : {}),
...(query.problem ? { problem: query.problem } : {}),
...(query.today === "1" ? { period: "today" } : {}),
},
})
}
/** 统计里的「在提交列表里看」:直接改背后这张列表的筛选,关掉弹框 */
function listFromStatistics(next: Record<string, string>) {
statsPanel.value = false
exactFor.value = ""
Object.assign(query, {
username: next.username ?? "",
className: next.className ?? "",
result: "",
myself: "0",
problem: next.problem ?? "",
language: next.language === "Flowchart" ? "Flowchart" : flowMode.value ? "" : query.language,
today: next.today === "1" ? "1" : "0",
})
}
/** 右栏「只看这个班」:从看一个人换成看他整个班 */
function filterClass(name: string) {
exactFor.value = ""
@@ -747,7 +516,7 @@ async function rejudge(id: string) {
message.success("已重新判题,结果出来会自己更新")
const row = submissions.value.find((item) => item.id === id)
if (row) row.result = SubmissionStatus.pending
lastPoll = 0
pollSoon()
}
async function retryFlowchart(id: string) {
@@ -766,7 +535,7 @@ async function retryFlowchart(id: string) {
message.success("已重新评分,结果出来会自己更新")
const row = flowcharts.value.find((item) => item.id === id)
if (row) row.status = FlowchartSubmissionStatus.PENDING
lastPoll = 0
pollSoon()
}
/**