Files
OJ2/apps/web/src/admin/learn/index.vue
T
xuyueandClaude Opus 5.5 845e41c060
Deploy / deploy (push) Canceled after 0s
feat(自学): 自学重做——首页课程地图、读一课按项目分目录、代码块就地运行、学完给题做
- 顶栏「自学」进新的自学首页 /learn:接着学(按服务端记录,不再串到上一个学生)+ 按项目分组的课程地图
- 读一课:左边目录按项目折叠,正文加宽,右边本课大纲带练一练标记;去掉了不能运行的示例代码栏
- 正文里的代码块能改能运行,报错用题目页那套中文解释;读输入的代码出输入框
- 课末:练一练做到几道 → 学完了 → 老师挑的几道题(必会标出来)→ 下一课
- 练一练统一成绿色提交、结果一行字;手机版目录从底部弹出
- 后台:教程列表按项目分组拖动排序,编辑页左写右看、练一练插到光标处、挑相关题;
  自学情况改成方块格 + 还没打开过的名单

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:40:34 +08:00

599 lines
16 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 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 "oj/submission/utils"
import { groupByProject, splitTitle, type TutorialType } from "oj/learn/lesson"
import "oj/learn/learn.css"
import ExerciseAttempts from "./ExerciseAttempts.vue"
/**
* 自学情况(设计稿「后台 · 自学情况」):一个班学到哪了。
*
* 中间是学生 × 课的方块格(浅绿 = 打开过,绿 = 学完,也就是练一练全对),一课没打开过的
* 学生收成一串名单;右边按课看练一练做对了多少,点一道看每个人最后错在哪。
* 不选班就按排名页同一套规则猜老师在教哪个班。
*/
const configStore = useConfigStore()
const KIND: Record<string, string> = {
mcq: "选择",
sort: "排序",
fill: "填空",
match: "连线",
predict: "预测输出",
debug: "找错",
group: "分组",
}
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 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 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 cellOf(row: LearnBoardStudent, id: number) {
if (row.done.includes(id)) return "done"
if (row.opened.includes(id)) return "opened"
return ""
}
function cellTitle(row: LearnBoardStudent, step: number, title: string, id: number) {
const state = cellOf(row, id)
return `第 ${step} 课 ${splitTitle(title).kp}:${state === "done" ? "学完了" : state === "opened" ? "打开过" : "没打开"}`
}
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="选班级"
style="width: 170px"
/>
</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-stay muted">停留</span>
<span class="c-last muted">最后学习</span>
</div>
<div v-for="row in started" :key="row.userId" class="line">
<span class="c-name"><UserName :username="row.username" /></span>
<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)"
:title="cellTitle(row, lesson.step, lesson.title, lesson.id)"
/>
</div>
</div>
<span class="c-num num">{{ row.exerciseSolved || "-" }}</span>
<span class="c-stay num">{{ readableDuration(row.totalSeconds) }}</span>
<span class="c-last num muted">{{
row.lastViewedAt ? parseTime(row.lastViewedAt, "M月D日 HH:mm") : "-"
}}</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">
<span class="strong">练一练</span>
<span class="grow" />
<n-select
v-model:value="lessonId"
:options="lessonOptions"
size="small"
style="width: 170px"
/>
</div>
<div v-if="!lessonExercises.length" class="muted none">这课没有练一练</div>
<div v-for="row in lessonExercises" :key="row.exerciseId" class="ex">
<button type="button" class="ex-main" @click="expanded = row.exerciseId">
<span class="ex-line">
<span class="kind">{{ 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>
</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 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: 10px;
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: 118px;
flex-shrink: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 14px;
}
.cells,
.cells-head {
display: flex;
gap: 8px;
}
.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;
}
.c-num {
width: 52px;
text-align: right;
font-size: 13px;
flex-shrink: 0;
}
.c-stay {
width: 80px;
text-align: right;
font-size: 13px;
white-space: nowrap;
flex-shrink: 0;
}
.c-last {
width: 92px;
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: 270px;
flex-shrink: 0;
padding: 14px 16px;
}
.side-head {
display: flex;
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);
}
</style>