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

@@ -0,0 +1,158 @@
import type { Exercise } from "utils/types"
/**
* 把一课的 Markdown 正文拆成几段,各段交给不同的组件渲染:
*
* - `heading`:二级标题单独拿出来,自己出 id —— 右边「这一课」的大纲靠它跳转、高亮;
* - `goals`:「## 学习目标」那一节画成「学完这课你能」的方框;
* - `code`:带语言的代码块(```python / ```c)换成能改、能运行的代码块;
* 不带语言的(「输出结果」那种)留在 Markdown 里照常显示;
* - `exercise`:`[[exercise:36]]` 占位符换成练一练;
* - `md`:其余的交给 MdPreview。
*
* 正文开头的「# 标题」「**所属项目:…**」和紧跟着的项目配图不渲染:标题和项目页面上方
* 已经有了,每课都重复一张同样的项目图只是把正文往下推。
*/
export type LessonSegment =
| { type: "md"; content: string }
| { type: "heading"; id: string; text: string }
| { type: "goals"; id: string; items: string[] }
| { type: "code"; lang: "python" | "c"; code: string; index: number }
| { type: "exercise"; exercise: Exercise; index: number }
/** 大纲里的一项:一个二级标题,挂着它这一节里的练一练 */
export interface OutlineItem {
id: string
text: string
exerciseIds: number[]
}
const CODE_LANGS: Record<string, "python" | "c"> = {
python: "python",
py: "python",
python3: "python",
c: "c",
cpp: "c",
"c++": "c",
}
const EXERCISE_RE = /\[\[exercise:(\d+)\]\]/g
const GOALS_TITLE = "学习目标"
/** 去掉开头的标题、所属项目、项目配图(中间的空行一起) */
function stripPreamble(lines: string[]) {
let i = 0
const skipBlank = () => {
while (i < lines.length && !lines[i]!.trim()) i++
}
skipBlank()
if (lines[i]?.startsWith("# ")) i++
skipBlank()
if (/^\*\*所属项目[::]/.test(lines[i]?.trim() ?? "")) {
i++
skipBlank()
if (/^!\[[^\]]*\]\([^)]*\)\s*$/.test(lines[i]?.trim() ?? "")) i++
}
return lines.slice(i)
}
export function parseLesson(content: string, exercises: Exercise[]) {
const byId = new Map(exercises.map((item) => [item.id, item]))
const segments: LessonSegment[] = []
const outline: OutlineItem[] = []
let md: string[] = []
let codeIndex = 0
let exerciseIndex = 0
let headingIndex = 0
const flush = () => {
const text = md.join("\n")
if (text.trim()) segments.push({ type: "md", content: text })
md = []
}
const lines = stripPreamble(content.replace(/\r\n/g, "\n").split("\n"))
let i = 0
while (i < lines.length) {
const line = lines[i]!
const fence = line.match(/^(\s*)(```+|~~~+)\s*([\w+#-]*)\s*$/)
if (fence) {
const marker = fence[2]!
const lang = CODE_LANGS[fence[3]!.toLowerCase()]
const body: string[] = []
let j = i + 1
while (j < lines.length && !lines[j]!.trim().startsWith(marker)) body.push(lines[j++]!)
if (lang) {
flush()
segments.push({ type: "code", lang, code: body.join("\n"), index: codeIndex++ })
} else {
md.push(line, ...body, lines[j] ?? marker)
}
i = j + 1
continue
}
const heading = line.match(/^##\s+(.+?)\s*#*\s*$/)
if (heading) {
flush()
const text = heading[1]!.trim()
const id = `section-${headingIndex++}`
if (text === GOALS_TITLE) {
// 学习目标:收到下一个分隔线或标题为止,只要列表项
const items: string[] = []
let j = i + 1
while (j < lines.length && !/^(---+|##\s)/.test(lines[j]!.trim())) {
const item = lines[j]!.match(/^\s*[-*+]\s+(.+)$/)
if (item) items.push(item[1]!.trim())
j++
}
segments.push({ type: "goals", id, items })
outline.push({ id, text: "学完这课你能", exerciseIds: [] })
i = j
continue
}
segments.push({ type: "heading", id, text })
outline.push({ id, text, exerciseIds: [] })
i++
continue
}
// 节与节之间的分隔线:二级标题本身就隔开了,再画一道线是重复
if (/^\s*(---+|\*\*\*+)\s*$/.test(line)) {
i++
continue
}
if (EXERCISE_RE.test(line)) {
EXERCISE_RE.lastIndex = 0
let last = 0
for (const match of line.matchAll(EXERCISE_RE)) {
md.push(line.slice(last, match.index))
last = match.index! + match[0].length
const exercise = byId.get(Number(match[1]))
if (!exercise) continue
flush()
segments.push({ type: "exercise", exercise, index: exerciseIndex++ })
outline[outline.length - 1]?.exerciseIds.push(exercise.id)
}
md.push(line.slice(last))
i++
continue
}
md.push(line)
i++
}
flush()
// 正文里没放占位符的练一练接在最后:学完一课要全做对,看不见的题会让这课永远学不完
const placed = new Set(
segments.flatMap((item) => (item.type === "exercise" ? [item.exercise.id] : [])),
)
for (const exercise of exercises) {
if (placed.has(exercise.id)) continue
segments.push({ type: "exercise", exercise, index: exerciseIndex++ })
outline[outline.length - 1]?.exerciseIds.push(exercise.id)
}
return { segments, outline, exerciseCount: exerciseIndex }
}