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