feat(自学): C 教程里没写 main 的片段也能运行——上下套一层灰色的 main,灰的几行改不了;其余没 main 的片段不再给一按就报错的「运行」按钮
Deploy / deploy (push) Canceled after 0s

教程正文里写 ```c main 的片段才套。套的行就在编辑器里,报错行号和看到的一致,复制出去是完整程序。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-13 19:20:45 +08:00
1 parent dae10d0813
commit 27210eac1b
4 files changed
+89 -14

No files matched your search

@@ -9,6 +9,7 @@ import { explainPythonCompileError } from "oj/problem/utils/pythonError"
import { explainRuntimeError } from "oj/problem/utils/runtimeError"
import { useAuthModalStore } from "shared/store/authModal"
import { useUserStore } from "shared/store/user"
import { mainWrapExtensions, wrapMain } from "../wrapMain"
const CodeEditor = defineAsyncComponent(() => import("shared/components/CodeEditor.vue"))
@@ -18,11 +19,14 @@ const CodeEditor = defineAsyncComponent(() => import("shared/components/CodeEdit
* 运行走题目页同一台判题机(POST /trial-runs,带 tutorialId),不算提交。
* 报错用题目页那套中文解释(pythonError / cError / runtimeError),原文收在下面。
* 代码里读输入(input() / scanf)才出输入框。
* C 的片段(原文没有 main,只是讲一两行写法)跑不起来,不给「运行」按钮;
* 教程里标了 ```c main 的片段套上灰色的 main 再给(见 wrapMain.ts)。
*/
const props = defineProps<{
code: string
lang: "python" | "c"
tutorialId: number
wrapMain?: boolean
}>()
const userStore = useUserStore()
@@ -30,7 +34,9 @@ const authModal = useAuthModalStore()
const message = useMessage()
const { copy } = useClipboard({ legacy: true })
const value = ref(props.code)
const original = computed(() => (props.wrapMain ? wrapMain(props.code) : props.code))
const extensions = computed(() => (props.wrapMain ? mainWrapExtensions : []))
const value = ref(original.value)
const input = ref("")
const running = ref(false)
@@ -41,16 +47,15 @@ type Outcome =
const outcome = ref<Outcome | null>(null)
// 换课时组件实例可能被复用(v-for 的 key 是段落序号),跟着原文重置
watch(
() => props.code,
(code) => {
watch(original, (code) => {
value.value = code
input.value = ""
outcome.value = null
},
)
})
const edited = computed(() => value.value !== props.code)
const edited = computed(() => value.value !== original.value)
// 看原文不看改过的:学生把 main 删了,按钮还在,点了才知道错在哪
const runnable = computed(() => props.lang === "python" || /\bmain\s*\(/.test(original.value))
const needsInput = computed(() =>
props.lang === "python"
? /\binput\s*\(/.test(value.value)
@@ -173,7 +178,7 @@ async function run() {
}
function restore() {
value.value = props.code
value.value = original.value
outcome.value = null
}
@@ -194,7 +199,14 @@ async function copyCode() {
<Icon icon="ph:arrow-counter-clockwise" :width="14" />还原
</button>
</template>
<button type="button" class="run" :class="{ busy: running }" :disabled="running" @click="run">
<button
v-if="runnable"
type="button"
class="run"
:class="{ busy: running }"
:disabled="running"
@click="run"
>
<template v-if="running">运行中…</template>
<template v-else><Icon icon="ph:play-fill" :width="12" />运行</template>
</button>
@@ -203,10 +215,16 @@ async function copyCode() {
</button>
</div>
<div class="editor" :style="{ minHeight: `${lineCount * 22 + 16}px` }">
<CodeEditor v-model:value="value" :language="language" :font-size="14" height="auto" />
<CodeEditor
v-model:value="value"
:language="language"
:font-size="14"
height="auto"
:extra-extensions="extensions"
/>
</div>
<div
v-if="needsInput"
v-if="runnable && needsInput"
class="pane input"
:class="{ ask: outcome?.kind === 'error' && outcome.needInput }"
>
@@ -315,6 +333,9 @@ async function copyCode() {
background: transparent;
border-right: 0;
}
.editor :deep(.cm-main-wrap) {
opacity: 0.4;
}
.editor :deep(.cm-scroller) {
font-family: var(--learn-code-font);
line-height: 22px;
@@ -39,6 +39,7 @@ function inline(text: string) {
v-else-if="seg.type === 'code'"
:code="seg.code"
:lang="seg.lang"
:wrap-main="seg.wrapMain"
:tutorial-id="tutorialId"
/>
<ExerciseWidget
@@ -7,6 +7,7 @@ import type { Exercise } from "utils/types"
* - `goals`:「## 学习目标」那一节画成「学完这课你能」的方框;
* - `code`:带语言的代码块(```python / ```c)换成能改、能运行的代码块;
* 不带语言的(「输出结果」那种)留在 Markdown 里照常显示;
* ```c main 是没写 main 的片段,由代码块套上灰色的 main 再运行(见 CodeRunBlock);
* - `exercise`:`[[exercise:36]]` 占位符换成练一练;
* - `md`:其余的交给 Markdown.vue。
*
@@ -17,7 +18,7 @@ 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: "code"; lang: "python" | "c"; code: string; index: number; wrapMain: boolean }
| { type: "exercise"; exercise: Exercise; index: number }
/** 大纲里的一项:一个二级标题,挂着它这一节里的练一练 */
@@ -75,7 +76,7 @@ export function parseLesson(content: string, exercises: Exercise[]) {
let i = 0
while (i < lines.length) {
const line = lines[i]!
const fence = line.match(/^(\s*)(```+|~~~+)\s*([\w+#-]*)\s*$/)
const fence = line.match(/^(\s*)(```+|~~~+)\s*([\w+#-]*)(\s+main)?\s*$/)
if (fence) {
const marker = fence[2]!
const lang = CODE_LANGS[fence[3]!.toLowerCase()]
@@ -84,7 +85,13 @@ export function parseLesson(content: string, exercises: Exercise[]) {
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++ })
segments.push({
type: "code",
lang,
code: body.join("\n"),
index: codeIndex++,
wrapMain: lang === "c" && !!fence[4],
})
} else {
md.push(line, ...body, lines[j] ?? marker)
}
+46
View File
@@ -0,0 +1,46 @@
import { EditorState, RangeSetBuilder, Transaction, type Extension } from "@codemirror/state"
import { Decoration, EditorView } from "@codemirror/view"
/**
* C 教程里没写 main 的片段(```c main):运行时套上一层 main,套的这几行灰着、改不了。
*
* 套的行就放在编辑器里,不是另外拼在提交的代码上 —— 这样报错的行号和学生看到的一样,
* 复制出去的也是一份完整能跑的程序。
*/
const HEAD = ["#include <stdio.h>", "", "int main(void) {"]
const FOOT = [" return 0;", "}"]
export function wrapMain(code: string) {
const body = code.split("\n").map((line) => (line ? ` ${line}` : line))
return [...HEAD, ...body, ...FOOT].join("\n")
}
/** 头尾两段的字符范围:头到片段第一行行首,尾从片段最后一行行尾起(换行符算在套的那边) */
function lockedRanges(state: EditorState) {
const doc = state.doc
const headEnd = doc.line(HEAD.length + 1).from
const footStart = doc.line(doc.lines - FOOT.length + 1).from - 1
return { headEnd, footStart }
}
// 边界上插入是放行的(实测 changeFilter 只拦范围内部),所以片段首行行首、末行行尾照常能打字。
// 只拦学生的编辑:「还原」、换课时 CodeMirror.vue 整篇替换文档,那一下不带 userEvent
const lock = EditorState.changeFilter.of((tr) => {
if (!tr.annotation(Transaction.userEvent)) return true
const { headEnd, footStart } = lockedRanges(tr.startState)
return [0, headEnd, footStart, tr.startState.doc.length]
})
const dim = Decoration.line({ class: "cm-main-wrap" })
const dimLines = EditorView.decorations.compute(["doc"], (state) => {
const builder = new RangeSetBuilder<Decoration>()
const doc = state.doc
for (let n = 1; n <= doc.lines; n++) {
if (n <= HEAD.length || n > doc.lines - FOOT.length)
builder.add(doc.line(n).from, doc.line(n).from, dim)
}
return builder.finish()
})
export const mainWrapExtensions: Extension[] = [lock, dimLines]