Files
OJ2/apps/web/src/oj/learn/composables/useLessonParse.ts
T
xuyueandClaude Opus 5.5 6f47ad8358 perf(Markdown): 题面、教程、AI 提示每次打开都去 unpkg.com 拉四个库,机房网络下又慢又不稳
md-editor-v3 的 MdPreview 不管正文里有没有代码、公式,都会从 unpkg.com 加载
highlight.js、katex、mermaid、echarts(mermaid 还是另一份 11.x)。而我们只用到最基础的
Markdown:题面 95% 是富文本存的 HTML,教程是标题、表格、图片,AI 输出是标题、列表、
表格、少量代码块,库里一处公式、mermaid 都没有。

- 显示:新组件 shared/components/Markdown.vue,直接用 markdown-it,代码高亮走本地的
  highlight.js(和练一练共用 shared/highlight.ts,补了 C++ / SQL)。9 处 MdPreview 全换掉。
  正文默认 16px,教程也统一成 16px。
- 编辑:后台教程编辑器换成项目已有的 CodeMirror,加一排格式按钮;图片可以按按钮选、
  粘贴、拖进来,都是先上传再插入。md-editor-v3 从依赖里删掉。
- AI 生成的内容(AI 提示、AI 分析、班级报告)不再放行原生 HTML:学生能在代码里诱导
  AI 吐出 <img onerror>,老师在后台一打开就执行。库里 1196 条 AI 输出没有一个 HTML
  标签,关掉不影响显示。题面、教程照旧放行。
- 长英文改成在单词之间折行,原来 break-all 会把 "I live" 断成 "I l / ive"。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:02:54 +08:00

159 lines
5.2 KiB
TypeScript
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.
import type { Exercise } from "utils/types"
/**
* 把一课的 Markdown 正文拆成几段,各段交给不同的组件渲染:
*
* - `heading`:二级标题单独拿出来,自己出 id —— 右边「这一课」的大纲靠它跳转、高亮;
* - `goals`:「## 学习目标」那一节画成「学完这课你能」的方框;
* - `code`:带语言的代码块(```python / ```c)换成能改、能运行的代码块;
* 不带语言的(「输出结果」那种)留在 Markdown 里照常显示;
* - `exercise`:`[[exercise:36]]` 占位符换成练一练;
* - `md`:其余的交给 Markdown.vue。
*
* 正文开头的「# 标题」「**所属项目:…**」和紧跟着的项目配图不渲染:标题和项目页面上方
* 已经有了,每课都重复一张同样的项目图只是把正文往下推。
*/
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 }
}