Compare commits
3
Commits
ff3fdc0b1d
...
a1d14a06bc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1d14a06bc | ||
|
|
ef7d065419 | ||
|
|
871861220e |
No files matched your search
@@ -2,17 +2,16 @@
|
|||||||
import { useRouteQuery } from "@vueuse/router"
|
import { useRouteQuery } from "@vueuse/router"
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "@iconify/vue"
|
||||||
import { useThemeVars } from "naive-ui"
|
import { useThemeVars } from "naive-ui"
|
||||||
import { zonedParts } from "utils/functions"
|
import type { StuckProblem, StuckProblemDetail } from "utils/types"
|
||||||
import type { StuckProblem, StuckProblemDetail, StuckStudent } from "utils/types"
|
|
||||||
import { getStuckProblemDetail, getStuckProblems } from "admin/api"
|
import { getStuckProblemDetail, getStuckProblems } from "admin/api"
|
||||||
import { useConfigStore } from "shared/store/config"
|
import { useConfigStore } from "shared/store/config"
|
||||||
import UserName from "shared/components/UserName.vue"
|
import { groupClassOptions } from "utils/classes"
|
||||||
import { classLabel, groupClassOptions } from "utils/classes"
|
|
||||||
import { periodRange, type Period } from "oj/statistics/period"
|
import { periodRange, type Period } from "oj/statistics/period"
|
||||||
import { resultName, useResultColor } from "oj/statistics/results"
|
|
||||||
import { useBackClose } from "shared/composables/backClose"
|
import { useBackClose } from "shared/composables/backClose"
|
||||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
import "oj/learn/learn.css"
|
import "oj/learn/learn.css"
|
||||||
|
import StuckDetail from "./components/StuckDetail.vue"
|
||||||
|
import { avgTries, rate } from "./components/stuckMath"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 卡点分析(设计稿「卡点与趋势」页的定稿 StuckFinalAll / StuckFinalClass):
|
* 卡点分析(设计稿「卡点与趋势」页的定稿 StuckFinalAll / StuckFinalClass):
|
||||||
@@ -26,7 +25,6 @@ const router = useRouter()
|
|||||||
const configStore = useConfigStore()
|
const configStore = useConfigStore()
|
||||||
const { isMobile } = useBreakpoints()
|
const { isMobile } = useBreakpoints()
|
||||||
const theme = useThemeVars()
|
const theme = useThemeVars()
|
||||||
const resultColor = useResultColor()
|
|
||||||
|
|
||||||
const PERIODS: { label: string; value: Period }[] = [
|
const PERIODS: { label: string; value: Period }[] = [
|
||||||
{ label: "这学期", value: "term" },
|
{ label: "这学期", value: "term" },
|
||||||
@@ -53,18 +51,10 @@ const thresholdOptions = computed(() =>
|
|||||||
THRESHOLDS.map((n) => ({ label: `至少 ${n} 人做过`, value: n })),
|
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) {
|
function firstRate(row: StuckProblem) {
|
||||||
return rate(row.firstTry, row.users)
|
return rate(row.firstTry, row.users)
|
||||||
}
|
}
|
||||||
|
|
||||||
function avgTries(row: { solved: number; solvedTries: number }) {
|
|
||||||
return row.solved ? (row.solvedTries / row.solved).toFixed(1) : "-"
|
|
||||||
}
|
|
||||||
|
|
||||||
function range() {
|
function range() {
|
||||||
return periodRange(period.value as Period, null).start
|
return periodRange(period.value as Period, null).start
|
||||||
}
|
}
|
||||||
@@ -175,117 +165,6 @@ async function pick(row: StuckProblem) {
|
|||||||
watch([className, period], load)
|
watch([className, period], load)
|
||||||
watch(() => [selected.value?.id, problems.value] as const, loadDetail)
|
watch(() => [selected.value?.id, problems.value] as const, loadDetail)
|
||||||
onMounted(load)
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -396,155 +275,7 @@ const links = computed(() => {
|
|||||||
<span class="strong">{{ activeTag || "全部" }}</span>
|
<span class="strong">{{ activeTag || "全部" }}</span>
|
||||||
</div>
|
</div>
|
||||||
<n-spin :show="detailLoading">
|
<n-spin :show="detailLoading">
|
||||||
<div v-if="detail && links" class="detail-body">
|
<StuckDetail v-if="detail" :detail="detail" :class-name="className" :period="period" />
|
||||||
<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>
|
|
||||||
</n-spin>
|
</n-spin>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -682,11 +413,6 @@ const links = computed(() => {
|
|||||||
.nowrap {
|
.nowrap {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.links {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
.pid {
|
.pid {
|
||||||
width: 34px;
|
width: 34px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -724,12 +450,6 @@ const links = computed(() => {
|
|||||||
.track.short {
|
.track.short {
|
||||||
width: 90px;
|
width: 90px;
|
||||||
}
|
}
|
||||||
.track.mid {
|
|
||||||
width: 90px;
|
|
||||||
}
|
|
||||||
.track.past > span {
|
|
||||||
background: var(--learn-green-line);
|
|
||||||
}
|
|
||||||
.pct {
|
.pct {
|
||||||
width: 38px;
|
width: 38px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -740,161 +460,6 @@ const links = computed(() => {
|
|||||||
padding: 24px 16px;
|
padding: 24px 16px;
|
||||||
font-size: 13px;
|
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) {
|
@media (max-width: 768px) {
|
||||||
.top {
|
.top {
|
||||||
@@ -975,32 +540,5 @@ const links = computed(() => {
|
|||||||
border-bottom: 1px solid var(--learn-line);
|
border-bottom: 1px solid var(--learn-line);
|
||||||
background: v-bind("theme.bodyColor");
|
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>
|
</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) : "-"
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "@iconify/vue"
|
||||||
import { useThemeVars } from "naive-ui"
|
import { useThemeVars } from "naive-ui"
|
||||||
import { getClassBoard, setClassLesson } from "oj/api"
|
import { getClassBoard } from "oj/api"
|
||||||
import { useTone } from "shared/composables/tone"
|
import { useTone } from "shared/composables/tone"
|
||||||
import { groupClassOptions } from "utils/classes"
|
import { groupClassOptions } from "utils/classes"
|
||||||
import UserName from "shared/components/UserName.vue"
|
import UserName from "shared/components/UserName.vue"
|
||||||
@@ -9,13 +9,20 @@ import { useLeaveStudents } from "shared/composables/leaveStudents"
|
|||||||
import { useTeacherHelp } from "shared/composables/teacherHelp"
|
import { useTeacherHelp } from "shared/composables/teacherHelp"
|
||||||
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 type { LessonLanguage } from "@oj2/contract"
|
|
||||||
import { errorMessage } from "utils/api"
|
import { errorMessage } from "utils/api"
|
||||||
import { LANGUAGE_SHOW_VALUE } from "utils/constants"
|
import { LANGUAGE_SHOW_VALUE } from "utils/constants"
|
||||||
import { parseTime } from "utils/functions"
|
import { parseTime } from "utils/functions"
|
||||||
import type { ClassBoard, ClassBoardCell, ClassBoardProblem, ClassBoardStudent } from "utils/types"
|
import type { ClassBoard, ClassBoardCell, ClassBoardProblem, ClassBoardStudent } from "utils/types"
|
||||||
import ExplainModal, { type ExplainPerson } from "./components/ExplainModal.vue"
|
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 { errorKind, type ErrorKind } from "utils/errorKind"
|
||||||
|
import {
|
||||||
|
cellStyle as cellStyleOf,
|
||||||
|
cellText,
|
||||||
|
cellTitle as cellTitleOf,
|
||||||
|
STUCK_ATTEMPTS,
|
||||||
|
} from "./boardCells"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 课堂看板(老师,设计稿「课堂看板重设计」A+B):这个班、今天、这节课的几道题 × 全班学生。
|
* 课堂看板(老师,设计稿「课堂看板重设计」A+B):这个班、今天、这节课的几道题 × 全班学生。
|
||||||
@@ -31,8 +38,6 @@ import { errorKind, type ErrorKind } from "utils/errorKind"
|
|||||||
* 悄悄留在框里,老师看的整个是别人的班。
|
* 悄悄留在框里,老师看的整个是别人的班。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** 同一道题今天交了这么多次还没过,算卡住 */
|
|
||||||
const STUCK_ATTEMPTS = 3
|
|
||||||
const REFRESH_MS = 15_000
|
const REFRESH_MS = 15_000
|
||||||
|
|
||||||
const configStore = useConfigStore()
|
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 editing = ref(false)
|
||||||
const saving = ref(false)
|
|
||||||
|
|
||||||
/** 还没有题(没布置、今天也推断不出来):填题号的框就是这页最大的东西 */
|
/** 还没有题(没布置、今天也推断不出来):填题号的框就是这页最大的东西 */
|
||||||
const needLesson = computed(() => !!board.value?.className && !board.value.problems.length)
|
const needLesson = computed(() => !!board.value?.className && !board.value.problems.length)
|
||||||
|
|
||||||
// 换了班要把输入框换成那个班今天布置的题、布置时选的语言(15 秒一次的刷新不动它们)
|
async function lessonSaved() {
|
||||||
watch(
|
editing.value = false
|
||||||
() => [board.value?.className, board.value?.source] as const,
|
selectedId.value = null
|
||||||
() => resetLessonInput(),
|
await load()
|
||||||
)
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 分组与排序 ----------
|
// ---------- 分组与排序 ----------
|
||||||
@@ -265,32 +224,11 @@ watchEffect(() => {
|
|||||||
const leaveDialog = ref(false)
|
const leaveDialog = ref(false)
|
||||||
/** 弹框里能勾的:今天还没交过的(刚上课时就是全班),按名字排 */
|
/** 弹框里能勾的:今天还没交过的(刚上课时就是全班),按名字排 */
|
||||||
const leaveCandidates = computed(() => boardStudents.value.filter(canLeave).sort(byName))
|
const leaveCandidates = computed(() => boardStudents.value.filter(canLeave).sort(byName))
|
||||||
/** 今天没上过线的分在前面一堆:请假的人基本在这里面,剩下的是和同桌共用电脑的 */
|
function saveLeave(selected: string[]) {
|
||||||
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() {
|
|
||||||
setLeave(
|
setLeave(
|
||||||
boardStudents.value.map((student) => student.username),
|
boardStudents.value.map((student) => student.username),
|
||||||
leaveSelected.value,
|
selected,
|
||||||
)
|
)
|
||||||
leaveDialog.value = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function byName(a: ClassBoardStudent, b: ClassBoardStudent) {
|
function byName(a: ClassBoardStudent, b: ClassBoardStudent) {
|
||||||
@@ -429,46 +367,16 @@ function openExplain(problemIndex: number, userId: number) {
|
|||||||
explainShow.value = true
|
explainShow.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 格子 ----------
|
// ---------- 格子(显示规则在 boardCells.ts) ----------
|
||||||
|
|
||||||
/** 今天之前就通过了的,颜色淡一档 */
|
const cellColors = computed(() => ({
|
||||||
function acceptedToday(cell: ClassBoardCell) {
|
tone,
|
||||||
return !!cell.acceptedAt && parseTime(cell.acceptedAt, "YYYY-MM-DD") === board.value?.day
|
errorColor: theme.value.errorColor,
|
||||||
}
|
actionColor: theme.value.actionColor,
|
||||||
|
}))
|
||||||
function cellText(cell: ClassBoardCell) {
|
const cellStyle = (cell: ClassBoardCell) => cellStyleOf(cell, board.value?.day, cellColors.value)
|
||||||
if (cell.status === "accepted") return "✓"
|
const cellTitle = (cell: ClassBoardCell, student: ClassBoardStudent) =>
|
||||||
if (cell.status === "tried") return cell.attempts ? `✗ ${cell.attempts}` : "✗"
|
cellTitleOf(cell, student, board.value?.day)
|
||||||
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 ""
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 点格子 / 名字到提交列表看他今天交的。新标签打开:看板一般开着不关,别把它顶掉。
|
* 点格子 / 名字到提交列表看他今天交的。新标签打开:看板一般开着不关,别把它顶掉。
|
||||||
@@ -521,23 +429,15 @@ function lastText(student: ClassBoardStudent) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 这节课的题:当场填,填完收成一排小块 -->
|
<!-- 这节课的题:当场填,填完收成一排小块 -->
|
||||||
<div v-if="editing" class="lesson-edit">
|
<LessonForm
|
||||||
<n-select
|
v-if="editing && board && className"
|
||||||
v-model:value="lessonLanguage"
|
:board="board"
|
||||||
:options="languageOptions"
|
:class-name="className"
|
||||||
aria-label="用什么语言做"
|
variant="inline"
|
||||||
class="lang-select"
|
class="lesson-edit"
|
||||||
/>
|
@saved="lessonSaved"
|
||||||
<n-input
|
@cancel="editing = false"
|
||||||
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>
|
|
||||||
<div v-else-if="board?.problems.length" class="lesson">
|
<div v-else-if="board?.problems.length" class="lesson">
|
||||||
<span class="lesson-label">{{ board.source === "teacher" ? "这节课" : "今天在做" }}</span>
|
<span class="lesson-label">{{ board.source === "teacher" ? "这节课" : "今天在做" }}</span>
|
||||||
<span v-if="board.language" class="chip lang">{{
|
<span v-if="board.language" class="chip lang">{{
|
||||||
@@ -549,7 +449,7 @@ function lastText(student: ClassBoardStudent) {
|
|||||||
<span v-if="board.assignedAt" class="assigned">
|
<span v-if="board.assignedAt" class="assigned">
|
||||||
{{ parseTime(board.assignedAt, "HH:mm") }} 布置
|
{{ parseTime(board.assignedAt, "HH:mm") }} 布置
|
||||||
</span>
|
</span>
|
||||||
<n-button text class="edit" @click="startEdit">
|
<n-button text class="edit" @click="editing = true">
|
||||||
<template #icon><Icon icon="ph:pencil-simple" /></template>
|
<template #icon><Icon icon="ph:pencil-simple" /></template>
|
||||||
{{ board.source === "teacher" ? "改" : "布置" }}
|
{{ board.source === "teacher" ? "改" : "布置" }}
|
||||||
</n-button>
|
</n-button>
|
||||||
@@ -560,7 +460,7 @@ function lastText(student: ClassBoardStudent) {
|
|||||||
v-if="board?.className"
|
v-if="board?.className"
|
||||||
:type="leaveStudents.length ? 'warning' : 'default'"
|
:type="leaveStudents.length ? 'warning' : 'default'"
|
||||||
:secondary="!!leaveStudents.length"
|
:secondary="!!leaveStudents.length"
|
||||||
@click="openLeaveDialog"
|
@click="leaveDialog = true"
|
||||||
>
|
>
|
||||||
{{ leaveStudents.length ? `请假 ${leaveStudents.length} 人` : "请假" }}
|
{{ leaveStudents.length ? `请假 ${leaveStudents.length} 人` : "请假" }}
|
||||||
</n-button>
|
</n-button>
|
||||||
@@ -580,25 +480,14 @@ function lastText(student: ClassBoardStudent) {
|
|||||||
<!-- 刚上课还没有题:填题号的框是这页最大的东西 -->
|
<!-- 刚上课还没有题:填题号的框是这页最大的东西 -->
|
||||||
<section v-if="needLesson" class="start">
|
<section v-if="needLesson" class="start">
|
||||||
<div class="start-title">这节课做哪几道题?</div>
|
<div class="start-title">这节课做哪几道题?</div>
|
||||||
<div class="start-row">
|
<LessonForm
|
||||||
<n-select
|
v-if="className"
|
||||||
v-model:value="lessonLanguage"
|
:board="board"
|
||||||
:options="languageOptions"
|
:class-name="className"
|
||||||
aria-label="用什么语言做"
|
variant="start"
|
||||||
size="large"
|
class="start-row"
|
||||||
class="lang-select"
|
@saved="lessonSaved"
|
||||||
/>
|
/>
|
||||||
<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>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
@@ -834,57 +723,12 @@ function lastText(student: ClassBoardStudent) {
|
|||||||
:people="explainPeople"
|
:people="explainPeople"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<n-modal
|
<LeaveModal
|
||||||
v-model:show="leaveDialog"
|
v-model:show="leaveDialog"
|
||||||
preset="card"
|
:candidates="leaveCandidates"
|
||||||
:title="
|
:current="leaveStudents.map((student) => student.username)"
|
||||||
leaveCandidates.length
|
@save="saveLeave"
|
||||||
? `今天还没交过的 ${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>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -924,11 +768,6 @@ function lastText(student: ClassBoardStudent) {
|
|||||||
width: 160px;
|
width: 160px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lang-select {
|
|
||||||
width: 110px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lesson,
|
.lesson,
|
||||||
.lesson-edit {
|
.lesson-edit {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1004,11 +843,6 @@ function lastText(student: ClassBoardStudent) {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.start-input {
|
|
||||||
flex: 1 1 0;
|
|
||||||
font-size: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- 题卡片 ---------- */
|
/* ---------- 题卡片 ---------- */
|
||||||
|
|
||||||
.cards {
|
.cards {
|
||||||
@@ -1396,32 +1230,6 @@ a.cell {
|
|||||||
color: v-bind("tone('info').color");
|
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) {
|
@media (max-width: 768px) {
|
||||||
@@ -1459,11 +1267,6 @@ a.cell {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.start-input {
|
|
||||||
flex-basis: 100%;
|
|
||||||
order: -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid .name {
|
.grid .name {
|
||||||
padding-left: 4px;
|
padding-left: 4px;
|
||||||
padding-right: 8px;
|
padding-right: 8px;
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -5,13 +5,11 @@ import { useRouteQuery } from "@vueuse/router"
|
|||||||
import { errorCode } from "utils/api"
|
import { errorCode } from "utils/api"
|
||||||
import {
|
import {
|
||||||
adminRejudge,
|
adminRejudge,
|
||||||
getClassLesson,
|
|
||||||
getFlowchartSubmissions,
|
getFlowchartSubmissions,
|
||||||
getSubmissions,
|
getSubmissions,
|
||||||
getTodaySubmissionCount,
|
getTodaySubmissionCount,
|
||||||
retryFlowchartSubmission,
|
retryFlowchartSubmission,
|
||||||
} from "oj/api"
|
} from "oj/api"
|
||||||
import { DEFAULT_PERIOD } from "oj/statistics/period"
|
|
||||||
import { useContestStore } from "oj/store/contest"
|
import { useContestStore } from "oj/store/contest"
|
||||||
import Pagination from "shared/components/Pagination.vue"
|
import Pagination from "shared/components/Pagination.vue"
|
||||||
import { useBackClose } from "shared/composables/backClose"
|
import { useBackClose } from "shared/composables/backClose"
|
||||||
@@ -21,12 +19,7 @@ import { useConfigStore } from "shared/store/config"
|
|||||||
import { useUserStore } from "shared/store/user"
|
import { useUserStore } from "shared/store/user"
|
||||||
import { LANGUAGE_SHOW_VALUE, SubmissionStatus } from "utils/constants"
|
import { LANGUAGE_SHOW_VALUE, SubmissionStatus } from "utils/constants"
|
||||||
import { parseTime } from "utils/functions"
|
import { parseTime } from "utils/functions"
|
||||||
import type {
|
import type { FlowchartSubmissionListItem, LANGUAGE, SubmissionListItem } from "utils/types"
|
||||||
ClassLesson,
|
|
||||||
FlowchartSubmissionListItem,
|
|
||||||
LANGUAGE,
|
|
||||||
SubmissionListItem,
|
|
||||||
} from "utils/types"
|
|
||||||
import { FlowchartSubmissionStatus } from "utils/types"
|
import { FlowchartSubmissionStatus } from "utils/types"
|
||||||
import FlowchartState from "./components/FlowchartState.vue"
|
import FlowchartState from "./components/FlowchartState.vue"
|
||||||
import StatusPill from "./components/StatusPill.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 { useTone } from "shared/composables/tone"
|
||||||
import { classLabel, classSelectOptions } from "utils/classes"
|
import { classLabel, classSelectOptions } from "utils/classes"
|
||||||
import { submissionClockText, submissionDayText } from "./utils"
|
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"),
|
() => 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 route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
@@ -87,7 +75,7 @@ let leavingPane = false
|
|||||||
const teacher = computed(() => userStore.isTeacherOrAbove)
|
const teacher = computed(() => userStore.isTeacherOrAbove)
|
||||||
|
|
||||||
// 一节课一个班要交一百来条,每页 10 条得翻十几页;30 条配上右栏的方向键正好
|
// 一节课一个班要交一百来条,每页 10 条得翻十几页;30 条配上右栏的方向键正好
|
||||||
const { query, clearQuery } = usePagination<SubmissionQuery>(
|
const { query, clearQuery } = usePagination<Omit<SubmissionQuery, "page" | "limit">>(
|
||||||
{
|
{
|
||||||
username: useRouteQuery("username", "").value,
|
username: useRouteQuery("username", "").value,
|
||||||
className: useRouteQuery("className", "").value,
|
className: useRouteQuery("className", "").value,
|
||||||
@@ -138,7 +126,6 @@ watch(loading, (value) => {
|
|||||||
})
|
})
|
||||||
onUnmounted(() => clearTimeout(loadingTimer))
|
onUnmounted(() => clearTimeout(loadingTimer))
|
||||||
|
|
||||||
type Row = SubmissionListItem | FlowchartSubmissionListItem
|
|
||||||
const rows = computed<Row[]>(() => (flowMode.value ? flowcharts.value : submissions.value))
|
const rows = computed<Row[]>(() => (flowMode.value ? flowcharts.value : submissions.value))
|
||||||
|
|
||||||
function fetchPage() {
|
function fetchPage() {
|
||||||
@@ -199,7 +186,7 @@ async function listSubmissions() {
|
|||||||
settleSelection()
|
settleSelection()
|
||||||
} catch {
|
} catch {
|
||||||
// 失败了别把「落地后停在哪一头」留给下一次不相干的加载
|
// 失败了别把「落地后停在哪一头」留给下一次不相干的加载
|
||||||
if (seq === requestSeq) pendingJump = null
|
if (seq === requestSeq) forgetJump()
|
||||||
} finally {
|
} finally {
|
||||||
if (seq === requestSeq) loading.value = false
|
if (seq === requestSeq) loading.value = false
|
||||||
}
|
}
|
||||||
@@ -288,106 +275,26 @@ watch(
|
|||||||
|
|
||||||
// ==================== 选中与翻阅 ====================
|
// ==================== 选中与翻阅 ====================
|
||||||
|
|
||||||
const selectedId = ref("")
|
const { selectedId, selectedRow, position, maxPage, select, move, settleSelection, forgetJump } =
|
||||||
const selectedRow = computed(() => rows.value.find((row) => row.id === selectedId.value) ?? null)
|
useRowCursor({
|
||||||
|
rows,
|
||||||
/** 方向键只在能看的行之间走:跳到一条看不了的上面只会看到一把锁 */
|
total,
|
||||||
const navigable = computed(() => rows.value.filter((row) => row.showLink))
|
query,
|
||||||
const position = computed(() => {
|
loading,
|
||||||
const index = navigable.value.findIndex((row) => row.id === selectedId.value)
|
isMobile,
|
||||||
return index === -1 ? null : { index: index + 1, count: navigable.value.length }
|
isDesktop,
|
||||||
})
|
keysBlocked: () => todayPanel.value || statsPanel.value,
|
||||||
|
wantedId: () => (typeof route.query.id === "string" ? route.query.id : ""),
|
||||||
// 翻页是异步的,先记下「落地后停在哪一头」,新一页回来再选
|
dropWanted: () => {
|
||||||
let pendingJump: "first" | "last" | null = null
|
if (paneId.value) router.replace({ query: queryWithoutId() })
|
||||||
|
},
|
||||||
function settleSelection() {
|
onNothingToShow: () => message.info("这一页没有可以查看的代码"),
|
||||||
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 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)
|
const [todayPanel, toggleTodayPanel] = useToggle(false)
|
||||||
useBackClose(todayPanel, isMobile)
|
useBackClose(todayPanel, isMobile)
|
||||||
const statsPanel = ref(false)
|
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)
|
// 手机上没有右栏:点一行从右边滑出整屏的详情,地址带上 ?id=(见 paneId)
|
||||||
const mobilePane = computed({
|
const mobilePane = computed({
|
||||||
get: () => !!paneId.value,
|
get: () => !!paneId.value,
|
||||||
@@ -447,64 +354,17 @@ watch(paneId, (id) => {
|
|||||||
|
|
||||||
// ==================== 实时更新 ====================
|
// ==================== 实时更新 ====================
|
||||||
|
|
||||||
/**
|
const { newCount, newCountCapped, pollSoon } = useListLive({
|
||||||
* 两件事,一个定时器:
|
query,
|
||||||
* - 本页有「正在评分」的,3 秒拉一次,把结果原地换上;
|
flowMode,
|
||||||
* - 第一页每 15 秒看一眼有没有新交的。**新的不直接插进来** —— 老师正看着第 5 行,
|
rows,
|
||||||
* 上面突然塞进 3 行,选中的那行就跑了。顶上给一条「有 N 条新提交」,点了才换。
|
submissions,
|
||||||
* 页面切到后台就停。
|
flowcharts,
|
||||||
*/
|
loading,
|
||||||
const newCount = ref(0)
|
loaded,
|
||||||
const newCountCapped = computed(() => newCount.value >= query.limit)
|
fetchPage,
|
||||||
let lastPoll = Date.now()
|
requestSeq: () => requestSeq,
|
||||||
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)
|
|
||||||
|
|
||||||
function showNew() {
|
function showNew() {
|
||||||
newCount.value = 0
|
newCount.value = 0
|
||||||
@@ -576,50 +436,11 @@ const today = computed({
|
|||||||
set: (value: boolean) => (query.today = value ? "1" : "0"),
|
set: (value: boolean) => (query.today = value ? "1" : "0"),
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 「这节课」:一键筛到 班级 + 这节课的题 + 今天。班级和题的取法同课堂看板 */
|
const { lesson, lessonActive, lessonLoading, toggleLesson } = useLessonFilter({
|
||||||
const lesson = ref<ClassLesson | null>(null)
|
query,
|
||||||
const lessonIds = computed(
|
flowMode,
|
||||||
() => lesson.value?.problems.map((problem) => problem.problemDisplayId).join(",") ?? "",
|
info: (text) => message.info(text),
|
||||||
)
|
})
|
||||||
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 activeFilters = computed(
|
const activeFilters = computed(
|
||||||
() =>
|
() =>
|
||||||
@@ -652,65 +473,13 @@ function filterUser(username: string) {
|
|||||||
query.username = username
|
query.username = username
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
const { statsQuery, openStatistics, openStatisticsPage, listFromStatistics } = useStatsLink({
|
||||||
* 「统计」弹框的查询条件。每次打开从这张列表现在的班级 / 学生 / 题号 / 代码或流程图
|
query,
|
||||||
* 带进去(「今天」带过去就是统计的「今天」);时间段沿用上次在弹框里选的
|
flowMode,
|
||||||
*/
|
statsPanel,
|
||||||
const statsQuery = reactive({
|
exactFor,
|
||||||
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
|
|
||||||
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) {
|
function filterClass(name: string) {
|
||||||
exactFor.value = ""
|
exactFor.value = ""
|
||||||
@@ -747,7 +516,7 @@ async function rejudge(id: string) {
|
|||||||
message.success("已重新判题,结果出来会自己更新")
|
message.success("已重新判题,结果出来会自己更新")
|
||||||
const row = submissions.value.find((item) => item.id === id)
|
const row = submissions.value.find((item) => item.id === id)
|
||||||
if (row) row.result = SubmissionStatus.pending
|
if (row) row.result = SubmissionStatus.pending
|
||||||
lastPoll = 0
|
pollSoon()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function retryFlowchart(id: string) {
|
async function retryFlowchart(id: string) {
|
||||||
@@ -766,7 +535,7 @@ async function retryFlowchart(id: string) {
|
|||||||
message.success("已重新评分,结果出来会自己更新")
|
message.success("已重新评分,结果出来会自己更新")
|
||||||
const row = flowcharts.value.find((item) => item.id === id)
|
const row = flowcharts.value.find((item) => item.id === id)
|
||||||
if (row) row.status = FlowchartSubmissionStatus.PENDING
|
if (row) row.status = FlowchartSubmissionStatus.PENDING
|
||||||
lastPoll = 0
|
pollSoon()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in new issue
Block a user