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,343 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import type { StatisticInfo, TrialRunResponse } from "@oj2/contract"
import { errorMessage } from "utils/api"
import { SubmissionStatus } from "utils/constants"
import { trialRun } from "utils/judge"
import { explainCCompileError } from "oj/problem/utils/cError"
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"
const CodeEditor = defineAsyncComponent(() => import("shared/components/CodeEditor.vue"))
/**
* 正文里的一段代码:就地改、就地运行(设计稿「代码块就地运行」那块画板)。
*
* 运行走题目页同一台判题机(POST /trial-runs,带 tutorialId),不算提交。
* 报错用题目页那套中文解释(pythonError / cError / runtimeError),原文收在下面。
* 代码里读输入(input() / scanf)才出输入框。
*/
const props = defineProps<{
code: string
lang: "python" | "c"
tutorialId: number
}>()
const userStore = useUserStore()
const authModal = useAuthModalStore()
const message = useMessage()
const { copy } = useClipboard({ legacy: true })
const value = ref(props.code)
const input = ref("")
const running = ref(false)
type Outcome =
| { kind: "output"; text: string }
| { kind: "error"; title: string; message: string; raw: string }
| { kind: "busy"; message: string }
const outcome = ref<Outcome | null>(null)
// 换课时组件实例可能被复用(v-for 的 key 是段落序号),跟着原文重置
watch(
() => props.code,
(code) => {
value.value = code
input.value = ""
outcome.value = null
},
)
const edited = computed(() => value.value !== props.code)
const needsInput = computed(() =>
props.lang === "python"
? /\binput\s*\(/.test(value.value)
: /\b(scanf|getchar|gets|fgets|cin)\b/.test(value.value),
)
const language = computed(() => (props.lang === "c" ? "C" : "Python"))
const lineCount = computed(() => value.value.split("\n").length)
const TOO_MUCH_OUTPUT =
"程序输出太多了,一直停不下来:多半是循环没有结束的条件(条件一直成立、循环变量忘了改),在不停地打印。"
/** 判题机的临时目录名对学生没有意义,只留文件名 */
function cleanRaw(text: string) {
return text.replace(/\/judger\/run\/[^/"]+\//g, "").trim()
}
function compileOutcome(raw: string): Outcome {
const ex =
props.lang === "python"
? explainPythonCompileError(raw, value.value)
: explainCCompileError(raw, value.value, "C")
return {
kind: "error",
title: ex?.line ? `第 ${ex.line} 行出错` : "代码有错,没能运行",
message: ex?.message ?? "",
raw: cleanRaw(raw),
}
}
function runtimeOutcome(
result: SubmissionStatus,
output: string,
info: NonNullable<StatisticInfo["runtime_error"]> | undefined,
): Outcome {
if (result === SubmissionStatus.runtime_error) {
return {
kind: "error",
title: info?.line ? `运行到第 ${info.line} 行出错了` : "运行到一半出错了",
message: info ? explainRuntimeError(info, value.value) : "",
raw: cleanRaw(output),
}
}
if (
result === SubmissionStatus.cpu_time_limit_exceeded ||
result === SubmissionStatus.real_time_limit_exceeded
) {
return {
kind: "error",
title: "运行超时了",
message: needsInput.value
? "程序一直没停下来:看看是不是在等输入(上面的输入框填够了吗),或者循环停不下来。"
: "程序一直没停下来:多半是循环没有结束的条件。",
raw: cleanRaw(output),
}
}
if (result === SubmissionStatus.memory_limit_exceeded) {
return { kind: "error", title: "内存用太多了", message: "", raw: cleanRaw(output) }
}
return { kind: "busy", message: "运行没成功,过一会儿再点一次。" }
}
function toOutcome(response: TrialRunResponse): Outcome {
if (response.status === "compile-error") return compileOutcome(response.message)
if (response.status === "too-much-output") {
return { kind: "error", title: "输出太多了", message: TOO_MUCH_OUTPUT, raw: "" }
}
const [item] = response.cases
if (!item) return { kind: "busy", message: "运行没成功,过一会儿再点一次。" }
const result = item.result as SubmissionStatus
if (result === SubmissionStatus.accepted || result === SubmissionStatus.wrong_answer) {
return { kind: "output", text: item.output.trimEnd() }
}
return runtimeOutcome(result, item.output, item.runtimeError)
}
async function run() {
if (!userStore.isAuthed) {
authModal.openLoginModal()
return
}
if (running.value || !value.value.trim()) return
running.value = true
try {
const response = await trialRun({
tutorialId: props.tutorialId,
language: language.value,
code: value.value,
cases: [{ input: input.value }],
})
outcome.value = toOutcome(response)
} catch (error) {
// 后端的文案是给学生看的(「上一次还没跑完」「试运行的人太多了」)
outcome.value = {
kind: "busy",
message: errorMessage(error, "运行的服务暂时连不上,过一会儿再试。"),
}
} finally {
running.value = false
}
}
function restore() {
value.value = props.code
outcome.value = null
}
async function copyCode() {
await copy(value.value)
message.success("已复制")
}
</script>
<template>
<div class="block">
<div class="bar">
<span class="lang">{{ language }}</span>
<span class="grow" />
<template v-if="edited">
<span class="edited">改过</span>
<button type="button" class="tool" @click="restore">
<Icon icon="ph:arrow-counter-clockwise" :width="14" />还原
</button>
</template>
<button 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>
<button type="button" class="tool" aria-label="复制" @click="copyCode">
<Icon icon="ph:copy" :width="14" />
</button>
</div>
<div class="editor" :style="{ minHeight: `${lineCount * 22 + 16}px` }">
<CodeEditor v-model:value="value" :language="language" :font-size="14" height="auto" />
</div>
<div v-if="needsInput" class="pane input">
<div class="label">输入</div>
<n-input
v-model:value="input"
type="textarea"
:autosize="{ minRows: 1, maxRows: 6 }"
placeholder="程序要读的内容写在这里,一行一个"
class="mono"
/>
</div>
<div v-if="outcome?.kind === 'output'" class="pane">
<div class="label">输出</div>
<pre class="out" :class="{ empty: !outcome.text }">{{
outcome.text || "(什么都没有输出)"
}}</pre>
</div>
<div v-else-if="outcome?.kind === 'error'" class="pane error">
<div class="label strong">{{ outcome.title }}</div>
<div v-if="outcome.message" class="explain">{{ outcome.message }}</div>
<pre v-if="outcome.raw" class="raw">{{ outcome.raw }}</pre>
</div>
<div v-else-if="outcome?.kind === 'busy'" class="pane">
<div class="busy-text">{{ outcome.message }}</div>
</div>
</div>
</template>
<style scoped>
.block {
margin: 0 0 16px;
border: 1px solid var(--learn-line);
border-radius: 6px;
overflow: hidden;
background: var(--learn-soft);
}
.bar {
height: 36px;
box-sizing: border-box;
padding: 0 6px 0 14px;
display: flex;
align-items: center;
gap: 6px;
border-bottom: 1px solid var(--learn-line);
font-size: 12px;
}
.lang {
opacity: 0.55;
}
.grow {
flex: 1;
}
.edited {
color: var(--learn-orange-text);
}
.tool {
height: 26px;
padding: 0 8px;
border: 0;
border-radius: 4px;
background: transparent;
color: inherit;
opacity: 0.7;
font: inherit;
font-size: 12px;
display: inline-flex;
align-items: center;
gap: 4px;
cursor: pointer;
}
.tool:hover {
opacity: 1;
background: var(--learn-soft);
}
.run {
height: 26px;
box-sizing: border-box;
padding: 0 10px;
border-radius: 4px;
border: 1px solid var(--learn-green);
background: transparent;
color: var(--learn-green-text);
font: inherit;
font-size: 13px;
font-weight: 600;
display: inline-flex;
align-items: center;
gap: 5px;
cursor: pointer;
}
.run:hover {
background: var(--learn-green-bg);
}
.run.busy {
border-color: var(--learn-line);
color: inherit;
opacity: 0.6;
font-weight: 400;
cursor: default;
}
.editor :deep(.cm-editor) {
background: transparent;
}
.editor :deep(.cm-gutters) {
background: transparent;
border-right: 0;
}
.editor :deep(.cm-scroller) {
font-family: var(--learn-code-font);
line-height: 22px;
padding: 8px 0;
}
.pane {
border-top: 1px solid var(--learn-line);
padding: 10px 14px 12px;
background: var(--learn-pane);
}
.label {
font-size: 12px;
opacity: 0.6;
margin-bottom: 4px;
}
.label.strong {
opacity: 1;
font-weight: 600;
color: var(--learn-red-text);
}
.out,
.raw {
margin: 0;
font-family: var(--learn-code-font);
font-size: 14px;
line-height: 22px;
white-space: pre-wrap;
word-break: break-all;
}
.out.empty {
opacity: 0.5;
}
.explain {
font-size: 14px;
line-height: 1.7;
margin-bottom: 6px;
}
.raw {
font-size: 12px;
line-height: 18px;
opacity: 0.55;
}
.busy-text {
font-size: 14px;
color: var(--learn-orange-text);
}
.mono :deep(textarea) {
font-family: var(--learn-code-font);
}
</style>