Files
OJ2/apps/web/src/admin/tutorial/detail.vue
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

359 lines
9.6 KiB
Vue
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.
<script lang="ts" setup>
import { Icon } from "@iconify/vue"
import type { AdminTutorialProblem } from "@oj2/contract"
import { errorMessage } from "utils/api"
import MarkdownEditor from "shared/components/MarkdownEditor.vue"
import type { Exercise, TutorialEdit, TutorialListItem } from "utils/types"
import { parseLesson } from "oj/learn/composables/useLessonParse"
import { joinTitle, lessonPath, splitTitle } from "oj/learn/lesson"
import LessonBody from "oj/learn/components/LessonBody.vue"
import "oj/learn/learn.css"
import {
createTutorial,
deleteTutorial,
getTutorial,
getTutorialList,
updateTutorial,
} from "../api"
import ExerciseManager from "./components/ExerciseManager.vue"
import RelatedProblems from "./components/RelatedProblems.vue"
/**
* 编辑一课(设计稿「后台 · 编辑一课」):知识点 / 情境 / 项目三个字段,正文左边写、
* 右边用学生端同一套组件预览(练一练、能运行的代码块都看得到),右栏是练一练和相关题。
* 「示例代码」字段去掉了:正文里的代码块直接能运行。
*/
const props = defineProps<{ tutorialID?: string }>()
const route = useRoute()
const router = useRouter()
const message = useMessage()
const isCreate = computed(() => route.name === "admin tutorial create")
const tutorial = reactive<TutorialEdit>({
id: 0,
title: "",
content: "",
project: typeof route.query.project === "string" ? route.query.project : "",
isPublic: false,
order: 0,
type: route.query.type === "c" ? "c" : "python",
})
const kp = ref("")
const scene = ref("")
const problems = ref<AdminTutorialProblem[]>([])
const exercises = ref<Exercise[]>([])
const allTutorials = ref<{ [key: string]: TutorialListItem[] }>({ python: [], c: [] })
const saving = ref(false)
const editor = useTemplateRef<InstanceType<typeof MarkdownEditor>>("editor")
const typeOptions = [
{ label: "Python", value: "python" },
{ label: "C 语言", value: "c" },
]
const siblings = computed(() => allTutorials.value[tutorial.type] ?? [])
const projectOptions = computed(() => {
const names = [...new Set(siblings.value.map((item) => item.project).filter(Boolean))]
if (tutorial.project && !names.includes(tutorial.project)) names.push(tutorial.project)
return names.map((name) => ({ label: name, value: name }))
})
/** 学生那边是第几课:只数公开的 */
const step = computed(() => {
const visible = siblings.value.filter((item) => item.isPublic)
const index = visible.findIndex((item) => item.id === tutorial.id)
return index >= 0 ? index + 1 : null
})
const heading = computed(() => {
if (isCreate.value) return "新建一课"
const label = tutorial.type === "c" ? "C 语言" : "Python"
return step.value ? `${label} 第 ${step.value} 课` : `${label} · 没公开`
})
const preview = computed(() => parseLesson(tutorial.content, exercises.value))
async function init() {
allTutorials.value = await getTutorialList().catch(() => ({ python: [], c: [] }))
if (!props.tutorialID) {
// 新建:顺序排在这门课最后
tutorial.order = Math.max(0, ...siblings.value.map((item) => item.order)) + 1
return
}
const data = await getTutorial(parseInt(props.tutorialID))
tutorial.id = data.id
tutorial.title = data.title
tutorial.content = data.content
tutorial.project = data.project
tutorial.isPublic = data.isPublic
tutorial.order = data.order
tutorial.type = data.type
problems.value = data.problems
const parts = splitTitle(data.title)
kp.value = parts.kp
scene.value = parts.scene
}
async function submit() {
tutorial.title = joinTitle(kp.value, scene.value)
if (!kp.value.trim() || !tutorial.content.trim()) {
message.error("知识点和正文必填")
return
}
saving.value = true
try {
if (isCreate.value) {
const created = await createTutorial(tutorial)
message.success("已新建,接着可以加练一练和相关题")
router.replace({ name: "admin tutorial edit", params: { tutorialID: created.id } })
} else {
await updateTutorial(tutorial)
message.success("已保存")
allTutorials.value = await getTutorialList().catch(() => allTutorials.value)
}
} catch (err) {
message.error(errorMessage(err))
} finally {
saving.value = false
}
}
async function remove() {
try {
await deleteTutorial(tutorial.id)
message.success("已删除")
router.push({ name: "admin tutorial list" })
} catch (err) {
message.error(errorMessage(err))
}
}
function insertExercise(id: number) {
editor.value?.insert(`[[exercise:${id}]]`)
}
function openStudentView() {
if (step.value) window.open(router.resolve(lessonPath(tutorial.type, step.value)).href, "_blank")
}
watch(() => props.tutorialID, init)
onMounted(init)
</script>
<template>
<div class="page">
<div class="top">
<router-link :to="{ name: 'admin tutorial list' }" class="back">
<Icon icon="ph:caret-left-bold" :width="13" />教程
</router-link>
<span class="slash">/</span>
<span class="heading">{{ heading }}</span>
<span class="grow" />
<label class="public">
<n-switch v-model:value="tutorial.isPublic" size="small" />
公开
</label>
<n-button v-if="!isCreate" :disabled="!step" @click="openStudentView">
<template #icon><Icon icon="ph:eye" /></template>
看学生那边
</n-button>
<n-popconfirm v-if="!isCreate" @positive-click="remove">
<template #trigger>
<n-button quaternary type="error">删除</n-button>
</template>
这一课连同它的练一练、学生的进度一起删掉,确定吗?
</n-popconfirm>
<n-button type="primary" :loading="saving" @click="submit">保存</n-button>
</div>
<div class="fields">
<label class="field">
<span>知识点</span>
<n-input v-model:value="kp" placeholder="条件语句基础" style="width: 200px" />
</label>
<label class="field">
<span>情境</span>
<n-input v-model:value="scene" placeholder="蜜橘园环境预警" style="width: 200px" />
</label>
<label class="field">
<span>项目</span>
<n-select
v-model:value="tutorial.project"
:options="projectOptions"
filterable
tag
clearable
placeholder="选一个,或者打字新建"
style="width: 250px"
/>
</label>
<label v-if="isCreate" class="field">
<span>语言</span>
<n-select v-model:value="tutorial.type" :options="typeOptions" style="width: 110px" />
</label>
</div>
<div class="work">
<section class="card editor-card">
<div class="card-head">
<span class="strong">正文</span>
<span class="muted">Markdown</span>
</div>
<div class="split">
<div class="source">
<MarkdownEditor
ref="editor"
v-model:value="tutorial.content"
:code-lang="tutorial.type"
height="100%"
/>
</div>
<div class="preview">
<LessonBody
:segments="preview.segments"
:lang="tutorial.type"
:tutorial-id="tutorial.id"
:exercise-count="preview.exerciseCount"
:solved-ids="new Set()"
/>
</div>
</div>
</section>
<aside class="side">
<section class="card pad">
<ExerciseManager
v-if="tutorial.id"
:tutorial-id="tutorial.id"
:content="tutorial.content"
@insert="insertExercise"
@change="exercises = $event"
/>
<div v-else class="muted">保存之后就能加练一练</div>
</section>
<section class="card pad">
<RelatedProblems
v-if="tutorial.id"
:tutorial-id="tutorial.id"
:problems="problems"
@update="problems = $event"
/>
<div v-else class="muted">保存之后就能挑相关题</div>
</section>
</aside>
</div>
</div>
</template>
<style scoped>
.page {
display: flex;
flex-direction: column;
gap: 14px;
height: calc(100vh - 96px);
min-height: 600px;
}
.top {
display: flex;
align-items: center;
gap: 10px;
}
.back {
display: inline-flex;
align-items: center;
gap: 2px;
font-size: 13px;
color: #18a058;
text-decoration: none;
}
.slash {
opacity: 0.4;
}
.heading {
font-size: 18px;
font-weight: 700;
}
.grow {
flex: 1;
}
.public {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
}
.fields {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.field {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
}
.work {
flex: 1;
min-height: 0;
display: flex;
gap: 16px;
}
.card {
border: 1px solid rgba(128, 128, 128, 0.22);
border-radius: 8px;
}
.pad {
padding: 12px 14px;
}
.editor-card {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.card-head {
height: 38px;
flex-shrink: 0;
padding: 0 12px;
display: flex;
align-items: center;
gap: 6px;
border-bottom: 1px solid rgba(128, 128, 128, 0.22);
font-size: 13px;
}
.strong {
font-weight: 600;
}
.muted {
opacity: 0.6;
font-size: 13px;
}
.split {
flex: 1;
min-height: 0;
display: flex;
}
.source {
flex: 0 0 50%;
min-width: 0;
border-right: 1px solid rgba(128, 128, 128, 0.22);
}
.preview {
flex: 0 0 50%;
min-width: 0;
box-sizing: border-box;
padding: 12px 20px;
overflow-y: auto;
}
.side {
width: 330px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 14px;
overflow-y: auto;
}
</style>