feat(自学): 自学重做——首页课程地图、读一课按项目分目录、代码块就地运行、学完给题做
Deploy / deploy (push) Canceled after 0s

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-12 09:40:34 +08:00
1 parent 11194543a3
commit 845e41c060
43 files changed
+4675 -1294

No files matched your search

+522 -150
View File
@@ -1,129 +1,97 @@
<template>
<div class="learn-container">
<template v-if="tutorial.id">
<!-- 桌面端:目录 | 正文(居中限宽) | 可收起的示例代码 -->
<div v-if="isDesktop" class="learn-layout" :class="{ 'with-code': codeOpen }">
<aside class="rail">
<LearnSummary :titles="titles" :progress="progress" :traced="traced" />
<LessonList
:titles="titles"
:step="step"
:progress="progress"
:traced="traced"
@select="goToLesson"
/>
</aside>
<main class="reader">
<article class="reader-body">
<header class="lesson-head">
<n-text depth="3">第 {{ step }} / {{ titles.length }} 课</n-text>
<n-flex align="center" justify="space-between" :wrap="false">
<span />
<n-button v-if="tutorial.code" size="small" secondary @click="codeOpen = !codeOpen">
{{ codeOpen ? "收起示例代码" : "展开示例代码" }}
</n-button>
</n-flex>
</header>
<LessonBody :segments="segments" :lang="tutorial.type" />
</article>
<PagerBar :step="step" :total="titles.length" @go="goToLesson" />
</main>
<aside v-if="tutorial.code && codeOpen" class="code-panel">
<CodeEditor :language="editorLanguage" v-model="tutorial.code" height="100%" />
</aside>
</div>
<!-- 手机端 -->
<template v-else>
<LearnSummary :titles="titles" :progress="progress" :traced="traced" />
<n-tabs type="line" animated v-model:value="activeTab">
<n-tab-pane name="catalog" tab="目录">
<LessonList
:titles="titles"
:step="step"
:progress="progress"
:traced="traced"
@select="goToLesson"
/>
</n-tab-pane>
<n-tab-pane name="content" :tab="`第 ${step} 课`">
<LessonBody :segments="segments" :lang="tutorial.type" />
</n-tab-pane>
<n-tab-pane name="code" tab="示例代码" v-if="tutorial.code">
<CodeEditor :language="editorLanguage" v-model="tutorial.code" />
</n-tab-pane>
</n-tabs>
<PagerBar :step="step" :total="titles.length" @go="goToLesson" />
</template>
</template>
<n-empty v-if="isEmpty" description="该教程还没有公开" style="margin-top: 80px" />
</div>
</template>
<script setup lang="ts">
import type { Tutorial, Exercise, LANGUAGE, TutorialProgress } from "utils/types"
import { getTutorial, getTutorials, getExercises, getLearnProgress } from "../api"
import { parseExercises } from "./composables/useExerciseParse"
import { Icon } from "@iconify/vue"
import { useThemeVars } from "naive-ui"
import type { Tutorial, TutorialProgress, TutorialSummary } from "@oj2/contract"
import type { Exercise } from "utils/types"
import { getExercises, getLearnProgress, getTutorial, getTutorials } from "../api"
import { parseLesson } from "./composables/useLessonParse"
import { useLearnTrace } from "./composables/useLearnTrace"
import { useBreakpoints } from "shared/composables/breakpoints"
import { useLearnProgress } from "shared/composables/learnProgress"
import { useUserStore } from "shared/store/user"
import LessonList from "./components/LessonList.vue"
import LearnSummary from "./components/LearnSummary.vue"
import {
COURSES,
doneCount,
groupByProject,
lessonPath,
lessonState,
splitTitle,
type TutorialType,
} from "./lesson"
import LessonBody from "./components/LessonBody.vue"
import PagerBar from "./components/PagerBar.vue"
const CodeEditor = defineAsyncComponent(() => import("shared/components/CodeEditor.vue"))
import LessonEnd from "./components/LessonEnd.vue"
import LessonOutline from "./components/LessonOutline.vue"
import ProjectTree from "./components/ProjectTree.vue"
import SegBar from "./components/SegBar.vue"
import "./learn.css"
/**
* 读一课。设计稿「自学重设计」页的阅读页 A(选定):左边目录按项目折叠、中间正文、
* 右边这一课的小节和练一练;手机上是顶部课程条 + 底部弹出的目录 + 底部上一课/下一课。
*/
const route = useRoute()
const router = useRouter()
const { isDesktop } = useBreakpoints()
const { learnStep } = useLearnProgress()
const userStore = useUserStore()
const theme = useThemeVars()
// 未登录也能看教程(学习页本来就不要求登录),只是不留痕
// 未登录也能看教程,只是不留痕、不记进度
const traced = computed(() => userStore.isAuthed)
const type = computed<TutorialType>(() => (route.params.type === "c" ? "c" : "python"))
const courseLabel = computed(() => COURSES.find((item) => item.type === type.value)!.label)
const step = computed(() => {
const value = route.params.step as string | undefined
if (!value) return 1
return parseInt(value)
const value = parseInt((route.params.step as string | undefined) ?? "1")
return Number.isFinite(value) && value > 0 ? value : 1
})
const type = computed<"python" | "c">(() => (route.params.type === "c" ? "c" : "python"))
const tutorial = ref<Partial<Tutorial>>({
id: 0,
title: "",
content: "",
code: "",
})
const editorLanguage = computed<LANGUAGE>(() => (tutorial.value.type === "c" ? "C" : "Python"))
const titles = ref<{ id: number; title: string }[]>([])
const titles = ref<TutorialSummary[]>([])
const progress = ref<Record<number, TutorialProgress>>({})
const tutorial = ref<Tutorial | null>(null)
const exercises = ref<Exercise[]>([])
const activeTab = ref("content")
// 示例代码栏默认展开,收起后正文独占版面;偏好记在本机
const codeOpen = useStorage("oj2:learn-code-open", true)
const solvedIds = ref(new Set<number>())
const loading = ref(true)
const isEmpty = ref(false)
const menuOpen = ref(false)
const segments = computed(() => parseExercises(tutorial.value.content ?? "", exercises.value))
const groups = computed(() => groupByProject(titles.value))
const parsed = computed(() =>
tutorial.value
? parseLesson(tutorial.value.content, exercises.value)
: { segments: [], outline: [], exerciseCount: 0 },
)
const parts = computed(() => splitTitle(tutorial.value?.title ?? ""))
const solvedCount = computed(
() => exercises.value.filter((item) => solvedIds.value.has(item.id)).length,
)
const firstUnsolved = computed(() => {
for (const seg of parsed.value.segments) {
if (seg.type === "exercise" && !solvedIds.value.has(seg.exercise.id)) {
return { id: seg.exercise.id, index: seg.index + 1 }
}
}
return null
})
const prev = computed(() => lessonAt(step.value - 1))
const next = computed(() => lessonAt(step.value + 1))
const done = computed(() => doneCount(titles.value, progress.value))
function lessonAt(at: number) {
const item = titles.value[at - 1]
return item ? { title: item.title, to: lessonPath(type.value, at) } : null
}
function stateOf(id: number) {
return lessonState(progress.value[id])
}
// 留痕的计时器。tutorial.id 变了才算换课 —— 用 step 会在内容还没加载好时就上报
useLearnTrace(
computed(() => tutorial.value.id ?? 0),
computed(() => tutorial.value?.id ?? 0),
traced,
)
function goToLesson(lessonNumber: number) {
activeTab.value = "content"
router.push(`/learn/${type.value}/${lessonNumber.toString().padStart(2, "0")}`)
}
/**
* 拉自己的自学留痕,给目录打勾。失败就当没有 —— 目录少几个勾不影响上课,
* 拉自己的自学留痕,给目录打勾。失败就当没有 —— 目录少几个勾不影响学,
* 但弹个错会把「我是不是没学」的焦虑塞给学生。
*/
async function loadProgress() {
@@ -139,85 +107,489 @@ async function loadProgress() {
}
}
let generation = 0
async function init() {
const res1 = await getTutorials(type.value)
titles.value = res1
isEmpty.value = titles.value.length === 0
if (isEmpty.value) return
const id = titles.value[step.value - 1].id
const [res2, exs] = await Promise.allSettled([getTutorial(id), getExercises(id)])
if (res2.status === "fulfilled") tutorial.value = res2.value
const mine = ++generation
loading.value = true
menuOpen.value = false
const list = await getTutorials(type.value)
if (mine !== generation) return
titles.value = list
isEmpty.value = list.length === 0
if (isEmpty.value) {
loading.value = false
return
}
if (step.value > list.length) {
router.replace(lessonPath(type.value, list.length))
return
}
const id = list[step.value - 1]!.id
const [lesson, exs] = await Promise.allSettled([
getTutorial(id),
getExercises(id),
loadProgress(),
])
if (mine !== generation) return
tutorial.value = lesson.status === "fulfilled" ? lesson.value : null
exercises.value = exs.status === "fulfilled" ? exs.value : []
learnStep.value[type.value] = step.value
loadProgress()
solvedIds.value = new Set(exercises.value.filter((item) => item.solved).map((item) => item.id))
loading.value = false
activeId.value = ""
await nextTick()
reader.value?.scrollTo({ top: 0 })
if (!isDesktop.value) window.scrollTo({ top: 0 })
}
watch(
() => [route.params.type, route.params.step],
async () => {
() => {
if (route.name === "learn") init()
},
{ immediate: true },
)
// 在教程页上登录/退出时把目录的勾重新拉一遍。学生多半是先点开教程、
// 被弹窗拦下才登录的,不盯着这个的话勾要等他刷新页面才出现
watch(traced, loadProgress)
// 在教程页上登录/退出时把勾重新拉一遍(学生多半是点「运行」被拦下才登录的)
watch(traced, async () => {
await loadProgress()
if (!tutorial.value) return
const exs = await getExercises(tutorial.value.id).catch(() => exercises.value)
exercises.value = exs
solvedIds.value = new Set([
...solvedIds.value,
...exs.filter((item) => item.solved).map((item) => item.id),
])
})
/** 刚做对一道:本课的进度就地加上,目录、进度条、课末跟着变,不用等刷新 */
function onSolved(exerciseId: number) {
if (solvedIds.value.has(exerciseId) || !tutorial.value) return
solvedIds.value = new Set([...solvedIds.value, exerciseId])
const id = tutorial.value.id
const row = progress.value[id] ?? {
tutorialId: id,
viewCount: 1,
totalSeconds: 0,
firstViewedAt: null,
lastViewedAt: null,
exerciseTotal: exercises.value.length,
exerciseSolved: 0,
}
progress.value = { ...progress.value, [id]: { ...row, exerciseSolved: solvedCount.value } }
}
// ------------------------------------------------------------------ 读到哪一节
const reader = ref<HTMLElement | null>(null)
const activeId = ref("")
function onScroll() {
const box = reader.value
if (!box) return
const top = box.getBoundingClientRect().top + 160
let current = parsed.value.outline[0]?.id ?? ""
for (const item of parsed.value.outline) {
const el = document.getElementById(item.id)
if (el && el.getBoundingClientRect().top <= top) current = item.id
}
activeId.value = current
}
function jump(id: string) {
document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" })
}
function jumpExercise(id: number) {
jump(`exercise-${id}`)
}
</script>
<template>
<div class="learn">
<n-empty v-if="isEmpty" description="这门课还没有公开的课" style="margin-top: 80px" />
<!-- 桌面:目录 | 正文 | 这一课 -->
<div v-else-if="isDesktop" class="layout">
<aside class="rail">
<router-link to="/learn" class="back">
<Icon icon="ph:caret-left-bold" :width="13" />自学首页
</router-link>
<div class="course">
<div class="course-head">
<span class="course-name">{{ courseLabel }}</span>
<span v-if="traced" class="muted num">学完 {{ done }} / {{ titles.length }}</span>
</div>
<SegBar
:groups="groups"
:state-of="stateOf"
:current="tutorial?.id"
:width="9"
:height="5"
:gap="2"
:group-gap="6"
/>
</div>
<ProjectTree :groups="groups" :progress="progress" :type="type" :current-step="step" />
<div v-if="!traced" class="login-tip muted">登录后记下学到哪</div>
</aside>
<main class="main">
<div ref="reader" class="reader" @scroll.passive="onScroll">
<article v-if="tutorial" class="article">
<div class="crumb muted">
<span v-if="tutorial.project">{{ tutorial.project }}</span>
<span v-if="tutorial.project">·</span>
<span class="num">第 {{ step }} / {{ titles.length }} 课</span>
</div>
<h1 class="h1">{{ parts.kp }}</h1>
<div v-if="parts.scene" class="scene">{{ parts.scene }}</div>
<LessonBody
:segments="parsed.segments"
:lang="type"
:tutorial-id="tutorial.id"
:exercise-count="parsed.exerciseCount"
:solved-ids="solvedIds"
@solved="onSolved"
/>
<LessonEnd
:step="step"
:total="titles.length"
:course-label="courseLabel"
:exercise-count="parsed.exerciseCount"
:solved-count="solvedCount"
:first-unsolved="firstUnsolved"
:problems="tutorial.problems"
:next="next"
:groups="groups"
:state-of="stateOf"
:done-count="done"
@jump="jumpExercise"
/>
</article>
<n-spin v-else-if="loading" class="spin" />
</div>
<nav class="pager">
<div class="pager-inner">
<n-button v-if="prev" tag="a" :href="prev.to" @click.prevent="router.push(prev.to)">
<template #icon><Icon icon="ph:caret-left-bold" /></template>
上一课<span class="pager-name muted">{{ splitTitle(prev.title).kp }}</span>
</n-button>
<span class="grow" />
<n-button
v-if="next"
type="primary"
icon-placement="right"
tag="a"
:href="next.to"
@click.prevent="router.push(next.to)"
>
<template #icon><Icon icon="ph:caret-right-bold" /></template>
下一课<span class="pager-name next-name">{{ splitTitle(next.title).kp }}</span>
</n-button>
</div>
</nav>
</main>
<aside class="side">
<LessonOutline
v-if="tutorial"
:items="parsed.outline"
:active-id="activeId || parsed.outline[0]?.id || ''"
:solved-ids="solvedIds"
:exercise-count="parsed.exerciseCount"
@jump="jump"
/>
</aside>
</div>
<!-- 手机 -->
<div v-else class="mobile">
<div class="mbar">
<n-button @click="menuOpen = true">
<template #icon><Icon icon="ph:list" /></template>
目录
</n-button>
<span class="num muted-strong">第 {{ step }} / {{ titles.length }} 课</span>
<span class="grow" />
<span v-if="parsed.exerciseCount" class="mpractice">
<span>练一练</span>
<span class="mboxes">
<span
v-for="n in parsed.exerciseCount"
:key="n"
class="mbox"
:class="{ on: n <= solvedCount }"
/>
</span>
</span>
</div>
<article v-if="tutorial" class="marticle">
<div v-if="tutorial.project" class="crumb muted">{{ tutorial.project }}</div>
<h1 class="h1 small">{{ parts.kp }}</h1>
<div v-if="parts.scene" class="scene">{{ parts.scene }}</div>
<LessonBody
:segments="parsed.segments"
:lang="type"
:tutorial-id="tutorial.id"
:exercise-count="parsed.exerciseCount"
:solved-ids="solvedIds"
@solved="onSolved"
/>
<LessonEnd
:step="step"
:total="titles.length"
:course-label="courseLabel"
:exercise-count="parsed.exerciseCount"
:solved-count="solvedCount"
:first-unsolved="firstUnsolved"
:problems="tutorial.problems"
:next="next"
:groups="groups"
:state-of="stateOf"
:done-count="done"
compact
@jump="jumpExercise"
/>
</article>
<n-spin v-else-if="loading" class="spin" />
<nav class="mpager">
<n-button v-if="prev" size="large" @click="router.push(prev.to)">
<template #icon><Icon icon="ph:caret-left-bold" /></template>
上一课
</n-button>
<span class="grow" />
<n-button size="large" quaternary @click="menuOpen = true">
<template #icon><Icon icon="ph:list" /></template>
目录
</n-button>
<span class="grow" />
<n-button
v-if="next"
size="large"
type="primary"
icon-placement="right"
@click="router.push(next.to)"
>
<template #icon><Icon icon="ph:caret-right-bold" /></template>
下一课
</n-button>
</nav>
<n-drawer v-model:show="menuOpen" placement="bottom" height="85%" class="sheet">
<n-drawer-content :native-scrollbar="false" closable>
<template #header>
<span class="sheet-head">
<span class="course-name">{{ courseLabel }}</span>
<span v-if="traced" class="muted num">学完 {{ done }} / {{ titles.length }}</span>
<router-link to="/learn" class="sheet-home">自学首页</router-link>
</span>
</template>
<ProjectTree
:groups="groups"
:progress="progress"
:type="type"
:current-step="step"
big
@go="menuOpen = false"
/>
</n-drawer-content>
</n-drawer>
</div>
</div>
</template>
<style scoped>
/* 桌面端固定高度,目录/正文/代码各自内部滚动;移动端交给页面整体滚动 */
@media (min-width: 769px) {
.learn-container {
height: calc(100vh - 134px);
.muted {
opacity: 0.6;
}
.num {
font-variant-numeric: tabular-nums;
}
.grow {
flex: 1;
}
.spin {
display: flex;
justify-content: center;
margin-top: 80px;
}
/* 桌面端固定高度,三栏各自滚动 */
.layout {
display: grid;
grid-template-columns: 260px minmax(0, 1fr) 220px;
grid-template-rows: minmax(0, 1fr);
height: calc(100vh - 134px);
}
@media (max-width: 1180px) {
.layout {
grid-template-columns: 240px minmax(0, 1fr);
}
.side {
display: none;
}
}
.learn-layout {
display: grid;
grid-template-columns: 260px minmax(0, 1fr);
gap: 24px;
height: 100%;
}
.learn-layout.with-code {
grid-template-columns: 240px minmax(0, 1fr) minmax(360px, 40%);
}
.rail,
.reader {
.rail {
box-sizing: border-box;
border-right: 1px solid var(--learn-line);
padding: 0 12px 16px 0;
overflow-y: auto;
height: 100%;
display: flex;
flex-direction: column;
gap: 4px;
}
.reader {
.back {
display: inline-flex;
align-items: center;
gap: 2px;
font-size: 13px;
padding: 0 8px;
color: var(--learn-green-text);
text-decoration: none;
}
.course {
padding: 10px 8px 12px;
}
.course-head {
display: flex;
align-items: baseline;
gap: 8px;
margin-bottom: 8px;
font-size: 13px;
}
.course-name {
font-size: 17px;
font-weight: 700;
}
.login-tip {
font-size: 13px;
padding: 8px;
}
.main {
min-width: 0;
display: flex;
flex-direction: column;
}
.reader-body {
.reader {
flex: 1;
width: 100%;
max-width: 820px;
min-height: 0;
overflow-y: auto;
padding: 4px 40px 0;
}
.article {
max-width: 720px;
margin: 0 auto;
}
.reader :deep(.pager) {
max-width: 820px;
width: 100%;
margin-left: auto;
margin-right: auto;
.crumb {
display: flex;
gap: 8px;
font-size: 13px;
margin-bottom: 6px;
}
.lesson-head h1,
.mobile-title {
margin: 4px 0 12px;
font-size: 26px;
.h1 {
margin: 0;
font-size: 28px;
line-height: 1.3;
}
.mobile-title {
font-size: 20px;
.h1.small {
font-size: 24px;
}
.scene {
font-size: 15px;
opacity: 0.8;
margin: 4px 0 18px;
}
.pager {
flex-shrink: 0;
border-top: 1px solid var(--learn-line);
padding: 12px 40px;
}
.pager-inner {
max-width: 720px;
margin: 0 auto;
display: flex;
align-items: center;
gap: 12px;
}
.pager-name {
margin-left: 6px;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.next-name {
opacity: 0.85;
}
.side {
box-sizing: border-box;
padding: 24px 0 0 16px;
overflow-y: auto;
}
.code-panel {
height: 100%;
overflow: hidden;
border-radius: 8px;
/* 手机 */
/* 外层 n-layout 是 overflow: hidden,sticky 粘不住:顶部的条跟着滚走,
上一课 / 下一课(带目录按钮)固定在屏幕底部 */
.mobile {
padding-bottom: 72px;
}
.mbar {
height: 48px;
margin: 0 -12px;
padding: 0 12px;
box-sizing: border-box;
border-bottom: 1px solid var(--learn-line);
background: v-bind("theme.bodyColor");
display: flex;
align-items: center;
gap: 8px;
}
.muted-strong {
font-size: 13px;
opacity: 0.8;
}
.mpractice {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
}
.mboxes {
display: flex;
gap: 3px;
}
.mbox {
width: 14px;
height: 6px;
border-radius: 3px;
background: var(--learn-todo);
}
.mbox.on {
background: var(--learn-green);
}
.marticle {
padding-top: 16px;
}
.mpager {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 10;
padding: 10px 12px;
border-top: 1px solid var(--learn-line);
display: flex;
align-items: center;
background: v-bind("theme.bodyColor");
}
.sheet-head {
display: flex;
align-items: baseline;
gap: 8px;
font-size: 13px;
}
.sheet-home {
margin-left: 12px;
color: var(--learn-green-text);
text-decoration: none;
}
</style>