Files
OJ2/apps/web/src/admin/learn/index.vue
T
xuyueandClaude Opus 5.5 309ca0ece2
Deploy / deploy (push) Canceled after 0s
refactor(前端): 全站共用的班级下拉、胶囊配色挪出提交列表目录,编辑器不再偷读题目页的 store
- classLabel / groupClassOptions / classSelectOptions 挪到 utils/classes.ts,useTone 挪到
  shared/composables/tone.ts:原来放在 oj/submission/ 下,被前台、后台、shared 引了 50 多处
- 流程图编辑器的草稿键改由题目页传进来(键名不变,学生存着的草稿照样读得到)。原来编辑器
  自己读题目页的 store 和路由拼,后台出题页也用这个组件,那边 store 是空的
- SQL 表名补全改由题目页传表进来。原来补全直接读题目页的 store,自学页的 SQL 代码块会
  补出上一道看过的题的表名

实跑:提交列表、题目页、排名、后台题目页、SQL 题、流程图题打开无报错;SQL 题输入 stu
补出 students;流程图加一步,草稿照旧存在 flowchart-v2-problem-<题号>。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:59:55 +08:00

848 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { useThemeVars } from "naive-ui"
import type { LearnBoard, LearnBoardStudent, LearnExerciseProgress } from "utils/types"
import { getLearnBoard, getLearnExercises } from "admin/api"
import { parseTime, readableDuration } from "utils/functions"
import { useConfigStore } from "shared/store/config"
import UserName from "shared/components/UserName.vue"
import { groupClassOptions } from "utils/classes"
import { groupByProject, splitTitle, type TutorialType } from "oj/learn/lesson"
import "oj/learn/learn.css"
import ExerciseAttempts from "./ExerciseAttempts.vue"
import ProblemReaders, { type ProblemReader } from "./ProblemReaders.vue"
import StudentDrawer from "./StudentDrawer.vue"
import { EXERCISE_KIND } from "./kinds"
/**
* 自学情况(设计稿「后台 · 自学情况」):一个班学到哪了。
*
* 中间是学生 × 课的方块格(浅绿 = 打开过,绿 = 学完,也就是练一练全对;练一练做了一部分的
* 按做对的比例从下往上涨绿,一道没做对的底下压一条橙),一课没打开过的学生收成一串名单。
* 点名字看这个人每课每道练一练、每道配套题的明细;右边按课看练一练和配套题做对了多少,
* 点一道看每个人的情况。配套题做对不论早晚,读这课之前在课上做对的也算。
* 不选班就按排名页同一套规则猜老师在教哪个班。
*/
const configStore = useConfigStore()
const theme = useThemeVars()
const type = ref<TutorialType>("python")
const className = ref<string | null>(null)
const board = ref<LearnBoard | null>(null)
const exercises = ref<LearnExerciseProgress[]>([])
const lessonId = ref<number | null>(null)
const expanded = ref<number | null>(null)
const side = ref<"exercise" | "problem">("exercise")
const problemOpen = ref<number | null>(null)
const studentId = ref<number | null>(null)
const showStudent = ref(false)
const loading = ref(false)
const classOptions = computed(() => groupClassOptions(configStore.config?.classList ?? []))
const groups = computed(() =>
groupByProject(
(board.value?.tutorials ?? []).map(({ id, title, project }) => ({ id, title, project })),
),
)
const started = computed(() =>
(board.value?.students ?? [])
.filter((row) => row.opened.length || row.exerciseSolved)
.sort(
(a, b) =>
b.done.length - a.done.length ||
b.opened.length - a.opened.length ||
(b.lastViewedAt ?? "").localeCompare(a.lastViewedAt ?? ""),
),
)
const idle = computed(() =>
(board.value?.students ?? []).filter((row) => !row.opened.length && !row.exerciseSolved),
)
const finishedOne = computed(() => started.value.filter((row) => row.done.length > 0).length)
const avgSeconds = computed(() => {
const rows = started.value
return rows.length
? Math.round(rows.reduce((n, row) => n + row.totalSeconds, 0) / rows.length)
: 0
})
const openedCount = computed(() => {
const counts = new Map<number, number>()
for (const row of board.value?.students ?? []) {
for (const id of row.opened) counts.set(id, (counts.get(id) ?? 0) + 1)
}
return counts
})
const lessonProblems = computed(
() => new Map((board.value?.tutorials ?? []).map((row) => [row.id, row.problems])),
)
/** 学生 → 题 → 交过的情况 */
const problemStates = computed(
() =>
new Map(
(board.value?.students ?? []).map((row) => [
row.userId,
new Map(row.problems.map((problem) => [problem.problemId, problem])),
]),
),
)
const exerciseTotal = computed(
() => new Map((board.value?.tutorials ?? []).map((row) => [row.id, row.exerciseTotal])),
)
const steps = computed(
() =>
new Map(
groups.value.flatMap((group) => group.lessons.map((lesson) => [lesson.id, lesson.step])),
),
)
const maxOpened = computed(() => Math.max(1, ...openedCount.value.values()))
const lessonOptions = computed(() =>
groups.value.flatMap((group) =>
group.lessons.map((lesson) => ({
label: `第 ${lesson.step} 课 ${splitTitle(lesson.title).kp}`,
value: lesson.id,
})),
),
)
const lessonExercises = computed(() =>
exercises.value.filter((row) => row.tutorialId === lessonId.value),
)
/** 读过(或做过练一练)这一课的人 */
function readersOf(id: number) {
return started.value.filter(
(row) => row.opened.includes(id) || row.lessons.some((lesson) => lesson.tutorialId === id),
)
}
const sideProblems = computed(() => {
const id = lessonId.value
if (id === null) return []
const readers = readersOf(id)
return (lessonProblems.value.get(id) ?? []).map((problem) => {
const people: ProblemReader[] = readers.map((row) => ({
userId: row.userId,
username: row.username,
state: problemStates.value.get(row.userId)?.get(problem.id) ?? null,
}))
return {
...problem,
people,
solved: people.filter((person) => person.state?.solved).length,
}
})
})
const openProblem = computed(() => sideProblems.value.find((row) => row.id === problemOpen.value))
function lessonOf(row: LearnBoardStudent, id: number) {
return row.lessons.find((lesson) => lesson.tutorialId === id)
}
function cellOf(row: LearnBoardStudent, id: number) {
if (row.done.includes(id)) return "done"
const lesson = lessonOf(row, id)
if (lesson) return lesson.solved ? "partial" : "partial stuck"
if (row.opened.includes(id)) return "opened"
return ""
}
/** 做了一部分的格子:做对几成,绿就从下往上涨几成 */
function cellStyle(row: LearnBoardStudent, id: number) {
const lesson = lessonOf(row, id)
const total = exerciseTotal.value.get(id) ?? 0
if (!lesson || !total || row.done.includes(id)) return undefined
return { "--fill": `${Math.round((lesson.solved / total) * 100)}%` }
}
function cellTitle(row: LearnBoardStudent, step: number, title: string, id: number) {
const head = `第 ${step} 课 ${splitTitle(title).kp}`
const total = exerciseTotal.value.get(id) ?? 0
const lesson = lessonOf(row, id)
const wrong = lesson?.wrongAttempts ? `,错了 ${lesson.wrongAttempts} 次` : ""
const problems = lessonProblems.value.get(id) ?? []
const states = problemStates.value.get(row.userId)
const tail = problems.length
? ` · 配套题 ${problems.filter((problem) => states?.get(problem.id)?.solved).length}/${problems.length}`
: ""
if (row.done.includes(id)) return `${head}:学完了${wrong}${tail}`
if (lesson) return `${head}:练一练 ${lesson.solved}/${total}${wrong}${tail}`
if (row.opened.includes(id)) return `${head}:${total ? "打开过,练一练没做" : "打开过"}${tail}`
return `${head}:没打开`
}
/** 他打开过或做过练一练的那些课 */
function touched(row: LearnBoardStudent) {
return new Set([...row.opened, ...row.lessons.map((lesson) => lesson.tutorialId)])
}
/** 应做 = 他碰过的那些课,练一练加起来几道 */
function exerciseDue(row: LearnBoardStudent) {
let due = 0
for (const id of touched(row)) due += exerciseTotal.value.get(id) ?? 0
return due
}
/** 他碰过的那些课的配套题,做对几道 / 一共几道(一道题挂在两课只算一次) */
function problemCount(row: LearnBoardStudent) {
const ids = new Set<number>()
for (const id of touched(row)) {
for (const problem of lessonProblems.value.get(id) ?? []) ids.add(problem.id)
}
const states = problemStates.value.get(row.userId)
let solved = 0
for (const id of ids) if (states?.get(id)?.solved) solved++
return { solved, total: ids.size }
}
function openStudent(row: LearnBoardStudent) {
studentId.value = row.userId
showStudent.value = true
}
function flag(row: LearnExerciseProgress) {
if (row.triedUsers < 3) return ""
if (row.firstTryUsers === 0 && row.solvedUsers > 0) return "没人一次对"
if (row.solvedUsers / row.triedUsers < 0.5) return "多数人卡住"
return ""
}
async function load() {
loading.value = true
expanded.value = null
try {
const params = { type: type.value, ...(className.value ? { className: className.value } : {}) }
const data = await getLearnBoard(params)
board.value = data
className.value = data.className
const ex = data.className
? await getLearnExercises({ type: type.value, className: data.className })
: { results: [] }
exercises.value = ex.results
// 练一练默认看大家打开得最多的那课
const busiest = [...openedCount.value.entries()].sort((a, b) => b[1] - a[1])[0]
lessonId.value = busiest?.[0] ?? data.tutorials[0]?.id ?? null
} finally {
loading.value = false
}
}
function pickType(value: TutorialType) {
type.value = value
load()
}
watch(className, (value, old) => {
if (old !== null && value !== old) load()
})
onMounted(load)
</script>
<template>
<div class="page">
<div class="top">
<span class="title">自学情况</span>
<span class="grow" />
<div class="seg">
<button type="button" :class="{ on: type === 'python' }" @click="pickType('python')">
Python
</button>
<button type="button" :class="{ on: type === 'c' }" @click="pickType('c')">C 语言</button>
</div>
<n-select
v-model:value="className"
:options="classOptions"
filterable
placeholder="选班级"
class="class-select"
/>
</div>
<n-spin :show="loading">
<n-empty v-if="board && !board.className" description="选一个班看看" style="margin: 60px 0" />
<template v-else-if="board">
<div class="stats">
<div class="stat">
<div class="stat-label">打开过教程</div>
<div class="stat-value">
{{ started.length }}<span class="unit"> / {{ board.students.length }} 人</span>
</div>
</div>
<div class="stat">
<div class="stat-label">学完过至少一课</div>
<div class="stat-value">{{ finishedOne }}<span class="unit"> 人</span></div>
</div>
<div class="stat">
<div class="stat-label">人均停留</div>
<div class="stat-value">{{ readableDuration(avgSeconds) }}</div>
</div>
</div>
<div class="main">
<section class="card grid-card">
<div class="grid-scroll">
<div class="line head">
<span class="c-name strong">学到哪了</span>
<div class="cells-head">
<div v-for="group in groups" :key="group.lessons[0]!.id" class="group-head">
<span class="group-no" :title="group.project">{{
group.no || group.name
}}</span>
<span class="group-steps">
<span
v-for="lesson in group.lessons"
:key="lesson.id"
class="step"
:title="splitTitle(lesson.title).kp"
>
{{ lesson.step }}
</span>
</span>
</div>
</div>
<span class="c-num muted">练一练</span>
<span class="c-num muted">配套题</span>
<span class="c-stay muted">停留</span>
<span class="c-last muted">最后学习</span>
</div>
<div v-for="row in started" :key="row.userId" class="line">
<button type="button" class="c-name name-btn" @click="openStudent(row)">
<UserName :username="row.username" />
</button>
<div class="cells">
<div v-for="group in groups" :key="group.lessons[0]!.id" class="group-cells">
<span
v-for="lesson in group.lessons"
:key="lesson.id"
class="cell"
:class="cellOf(row, lesson.id)"
:style="cellStyle(row, lesson.id)"
:title="cellTitle(row, lesson.step, lesson.title, lesson.id)"
/>
</div>
</div>
<span class="c-num num">
<template v-if="exerciseDue(row)"
>{{ row.exerciseSolved
}}<span class="muted">/{{ exerciseDue(row) }}</span></template
>
<template v-else>-</template>
</span>
<span class="c-num num">
<template v-if="problemCount(row).total"
>{{ problemCount(row).solved
}}<span class="muted">/{{ problemCount(row).total }}</span></template
>
<template v-else>-</template>
</span>
<span class="c-stay num">{{ readableDuration(row.totalSeconds) }}</span>
<span class="c-last num muted">{{
row.lastViewedAt ? parseTime(row.lastViewedAt, "M月D日") : "-"
}}</span>
</div>
<div v-if="!started.length" class="none muted">这个班还没人打开过</div>
<div v-else class="line foot">
<span class="c-name muted small">每课打开过</span>
<div class="cells bars">
<div v-for="group in groups" :key="group.lessons[0]!.id" class="group-cells">
<span v-for="lesson in group.lessons" :key="lesson.id" class="bar-col">
<span class="bar-num num">{{ openedCount.get(lesson.id) || "" }}</span>
<span
class="bar"
:class="{ zero: !openedCount.get(lesson.id) }"
:style="{
height: `${Math.max(2, ((openedCount.get(lesson.id) ?? 0) / maxOpened) * 40)}px`,
}"
/>
</span>
</div>
</div>
</div>
</div>
<div v-if="idle.length" class="idle">
<div class="idle-head">
<span class="strong">还没打开过</span>
<span class="muted num">{{ idle.length }} 人</span>
</div>
<div class="chips">
<span v-for="row in idle" :key="row.userId" class="chip"
><UserName :username="row.username"
/></span>
</div>
</div>
</section>
<section class="card side">
<div class="side-head">
<div class="seg small">
<button
type="button"
:class="{ on: side === 'exercise' }"
@click="side = 'exercise'"
>
练一练
</button>
<button type="button" :class="{ on: side === 'problem' }" @click="side = 'problem'">
配套题
</button>
</div>
<n-select
v-model:value="lessonId"
:options="lessonOptions"
size="small"
class="lesson-select"
/>
</div>
<template v-if="side === 'problem'">
<div v-if="!sideProblems.length" class="muted none">这课没有配套题</div>
<div v-for="row in sideProblems" :key="row.id" class="ex">
<button type="button" class="ex-main" @click="problemOpen = row.id">
<span class="ex-line">
<span class="kind num">{{ row.displayId }}</span>
<span class="question">{{ row.title }}</span>
<Icon icon="ph:caret-right-bold" :width="12" class="muted" />
</span>
<span class="ex-line small num">
<span>做对 {{ row.solved }} / 读过 {{ row.people.length }}</span>
</span>
</button>
</div>
</template>
<div v-else-if="!lessonExercises.length" class="muted none">这课没有练一练</div>
<div
v-for="row in side === 'exercise' ? lessonExercises : []"
:key="row.exerciseId"
class="ex"
>
<button type="button" class="ex-main" @click="expanded = row.exerciseId">
<span class="ex-line">
<span class="kind">{{ EXERCISE_KIND[row.type] ?? row.type }}</span>
<span class="question">{{ row.question || "(没有题干)" }}</span>
<Icon icon="ph:caret-right-bold" :width="12" class="muted" />
</span>
<span class="ex-line small num">
<span>做对 {{ row.solvedUsers }} / 做过 {{ row.triedUsers }}</span>
<span class="muted">一次就对 {{ row.firstTryUsers }}</span>
<span v-if="flag(row)" class="flag">{{ flag(row) }}</span>
</span>
</button>
</div>
</section>
</div>
</template>
</n-spin>
<!-- 点一道练一练:每个人做了几次、最后一次错在哪 -->
<n-modal
:show="expanded !== null"
preset="card"
:title="exercises.find((row) => row.exerciseId === expanded)?.question || '练一练'"
style="width: 820px; max-width: 95vw"
@update:show="(show: boolean) => !show && (expanded = null)"
>
<ExerciseAttempts
v-if="expanded !== null"
:exercise-id="expanded"
:class-name="board?.className ?? ''"
/>
</n-modal>
<!-- 点一道配套题:读过这课的人各自做没做对 -->
<n-modal
:show="problemOpen !== null"
preset="card"
:title="openProblem ? `${openProblem.displayId} ${openProblem.title}` : '配套题'"
style="width: 640px; max-width: 95vw"
@update:show="(show: boolean) => !show && (problemOpen = null)"
>
<ProblemReaders
v-if="openProblem"
:display-id="openProblem.displayId"
:people="openProblem.people"
/>
</n-modal>
<StudentDrawer v-model:show="showStudent" :user-id="studentId" :type="type" :steps="steps" />
</div>
</template>
<style scoped>
.page {
display: flex;
flex-direction: column;
gap: 16px;
}
.top {
display: flex;
align-items: center;
gap: 12px;
}
.title {
font-size: 20px;
font-weight: 700;
}
.grow {
flex: 1;
}
.muted {
opacity: 0.6;
}
.small {
font-size: 12px;
}
.strong {
font-weight: 700;
}
.num {
font-variant-numeric: tabular-nums;
}
.seg {
display: flex;
}
.seg button {
height: 32px;
padding: 0 12px;
border: 1px solid rgba(128, 128, 128, 0.3);
background: transparent;
color: inherit;
font: inherit;
font-size: 13px;
cursor: pointer;
}
.seg button:first-child {
border-radius: 4px 0 0 4px;
}
.seg button:last-child {
border-radius: 0 4px 4px 0;
margin-left: -1px;
}
.seg.small {
flex-shrink: 0;
}
.seg.small button {
height: 26px;
padding: 0 10px;
font-size: 12px;
white-space: nowrap;
}
.seg button.on {
border-color: #18a058;
color: var(--learn-green-text);
font-weight: 600;
position: relative;
}
.card {
border: 1px solid rgba(128, 128, 128, 0.22);
border-radius: 8px;
}
.stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.stat {
border: 1px solid rgba(128, 128, 128, 0.22);
border-radius: 8px;
padding: 14px 18px;
}
.stat-label {
font-size: 13px;
opacity: 0.6;
}
.stat-value {
font-size: 26px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.unit {
font-size: 15px;
font-weight: 400;
opacity: 0.6;
}
.main {
display: flex;
gap: 16px;
align-items: flex-start;
}
.grid-card {
flex: 1;
min-width: 0;
overflow: hidden;
}
.grid-scroll {
overflow-x: auto;
}
.line {
display: flex;
align-items: center;
gap: 8px;
min-height: 34px;
padding: 0 16px;
border-top: 1px solid rgba(128, 128, 128, 0.1);
width: max-content;
min-width: 100%;
box-sizing: border-box;
}
.line.head {
align-items: flex-end;
padding-top: 12px;
padding-bottom: 6px;
border-top: 0;
}
.c-name {
width: 110px;
flex-shrink: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 14px;
}
.cells,
.cells-head {
display: flex;
gap: 6px;
}
.group-head {
display: flex;
flex-direction: column;
gap: 3px;
}
.group-no {
font-size: 11px;
opacity: 0.6;
text-align: center;
border-bottom: 1px solid rgba(128, 128, 128, 0.25);
padding-bottom: 2px;
white-space: nowrap;
}
.group-steps,
.group-cells {
display: flex;
gap: 2px;
}
.step {
width: 18px;
text-align: center;
font-size: 11px;
opacity: 0.6;
font-variant-numeric: tabular-nums;
}
.cell {
width: 18px;
height: 18px;
border-radius: 3px;
background: rgba(128, 128, 128, 0.12);
}
.cell.opened {
background: rgba(24, 160, 88, 0.3);
}
.cell.done {
background: #18a058;
}
.cell.partial {
background: linear-gradient(to top, #18a058 var(--fill), rgba(24, 160, 88, 0.3) var(--fill));
}
.cell.stuck {
box-shadow: inset 0 -4px 0 v-bind("theme.warningColor");
}
.name-btn {
border: 0;
padding: 0;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.name-btn:hover {
text-decoration: underline;
}
.c-num {
width: 44px;
text-align: right;
font-size: 13px;
flex-shrink: 0;
}
.c-stay {
width: 70px;
text-align: right;
font-size: 13px;
white-space: nowrap;
flex-shrink: 0;
}
.c-last {
width: 64px;
text-align: right;
font-size: 13px;
white-space: nowrap;
flex-shrink: 0;
}
.foot {
align-items: flex-end;
padding-top: 10px;
padding-bottom: 12px;
border-top-color: rgba(128, 128, 128, 0.22);
}
.bars {
align-items: flex-end;
}
.bars .group-cells {
align-items: flex-end;
}
.bar-col {
width: 18px;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
}
.bar-num {
font-size: 11px;
opacity: 0.8;
}
.bar {
width: 12px;
border-radius: 2px;
background: rgba(24, 160, 88, 0.45);
}
.bar.zero {
background: rgba(128, 128, 128, 0.2);
}
.none {
padding: 16px;
font-size: 13px;
}
.idle {
padding: 14px 16px 16px;
border-top: 1px solid rgba(128, 128, 128, 0.22);
}
.idle-head {
display: flex;
gap: 8px;
margin-bottom: 10px;
font-size: 14px;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.chip {
height: 26px;
padding: 0 10px;
border: 1px solid rgba(128, 128, 128, 0.25);
border-radius: 13px;
display: inline-flex;
align-items: center;
font-size: 12px;
}
.side {
width: 250px;
flex-shrink: 0;
padding: 14px 16px;
}
.side-head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.ex {
border-top: 1px solid rgba(128, 128, 128, 0.12);
}
.ex-main {
width: 100%;
padding: 10px 0;
border: 0;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
display: flex;
flex-direction: column;
gap: 6px;
cursor: pointer;
}
.ex-line {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.kind {
height: 22px;
padding: 0 8px;
border-radius: 3px;
font-size: 12px;
display: inline-flex;
align-items: center;
background: rgba(128, 128, 128, 0.12);
flex-shrink: 0;
}
.question {
flex: 1;
min-width: 0;
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.flag {
color: var(--learn-orange-text);
}
.class-select {
width: 170px;
}
.lesson-select {
flex: 1 0 100%;
min-width: 0;
}
/* 手机上:方块格在自己的框里横着滑(名字列钉在左边),练一练排到下面 */
@media (max-width: 768px) {
.top {
flex-wrap: wrap;
}
.top .title {
width: 100%;
}
.top .grow {
display: none;
}
.class-select {
flex: 1;
width: auto;
min-width: 0;
}
.stats {
gap: 8px;
margin-bottom: 12px;
}
.stat {
padding: 10px 12px;
}
.stat-label {
font-size: 12px;
}
.stat-value {
font-size: 18px;
}
.unit {
font-size: 13px;
}
.main {
flex-direction: column;
align-items: stretch;
}
.c-name {
position: sticky;
left: 0;
z-index: 1;
width: 96px;
margin-left: -16px;
padding-left: 16px;
background: v-bind("theme.bodyColor");
}
.side {
width: auto;
}
}
</style>