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>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-12 14:02:54 +08:00
1 parent c497ccadc8
commit 6f47ad8358
24 files changed
+504 -308

No files matched your search

+163
View File
@@ -0,0 +1,163 @@
<script setup lang="ts">
import { useThemeVars } from "naive-ui"
import { renderMarkdown } from "shared/markdown"
import "shared/highlight.css"
/**
* 显示一段 Markdown / HTML 正文:题面、教程、AI 的提示和报告。
* `html` 只给老师写的内容(题面、教程)打开,AI 生成的别开 —— 理由见 shared/markdown.ts。
* 默认 16px / 1.75,和原来 md-editor 一样;各页可以用自己的 class 盖掉。
*/
const { content, html = false } = defineProps<{
content: string | null | undefined
html?: boolean
}>()
const theme = useThemeVars()
const rendered = computed(() => renderMarkdown(content ?? "", html))
</script>
<template>
<div class="oj-markdown" v-html="rendered" />
</template>
<!-- 不加 scoped:v-html 进来的节点不带 scoped 的属性,靠 .oj-markdown 前缀收住范围 -->
<style>
.oj-markdown {
font-size: 16px;
line-height: 1.75;
/* 长英文、长网址在单词之间折;实在折不开再断词。原来 md-editor 是 break-all,
题面里「Hello, I am …」这种一句话会从单词中间断开 */
overflow-wrap: break-word;
}
.oj-markdown > :first-child {
margin-top: 0;
}
.oj-markdown > :last-child {
margin-bottom: 0;
}
.oj-markdown p {
margin: 0.7em 0;
}
.oj-markdown h1,
.oj-markdown h2,
.oj-markdown h3,
.oj-markdown h4,
.oj-markdown h5,
.oj-markdown h6 {
margin: 1.2em 0 0.5em;
font-weight: 600;
line-height: 1.4;
}
.oj-markdown h1 {
font-size: 1.5em;
}
.oj-markdown h2 {
font-size: 1.3em;
}
.oj-markdown h3 {
font-size: 1.15em;
}
.oj-markdown h4,
.oj-markdown h5,
.oj-markdown h6 {
font-size: 1em;
}
.oj-markdown ul,
.oj-markdown ol {
margin: 0.5em 0;
padding-left: 1.6em;
}
.oj-markdown li + li {
margin-top: 0.25em;
}
.oj-markdown li > p {
margin: 0.25em 0;
}
.oj-markdown a {
color: v-bind("theme.primaryColor");
text-decoration: none;
}
.oj-markdown a:hover {
text-decoration: underline;
}
.oj-markdown img {
max-width: 100%;
height: auto;
vertical-align: middle;
}
.oj-markdown hr {
margin: 1.2em 0;
border: 0;
border-top: 1px solid v-bind("theme.dividerColor");
}
.oj-markdown blockquote {
margin: 0.8em 0;
padding: 0.1em 1em;
border-left: 4px solid v-bind("theme.dividerColor");
color: v-bind("theme.textColor3");
}
.oj-markdown code {
padding: 0.1em 0.45em;
border-radius: 4px;
background-color: rgba(128, 128, 128, 0.12);
font-family: Consolas, Monaco, monospace;
/* 等宽字体比同字号的中文矮,再缩就和旁边的字差一截 */
font-size: 0.95em;
}
.oj-markdown pre {
margin: 0.8em 0;
padding: 12px 14px;
border-radius: 6px;
background-color: rgba(128, 128, 128, 0.08);
overflow-x: auto;
line-height: 1.6;
}
.oj-markdown pre code {
padding: 0;
border-radius: 0;
background: none;
font-size: 14px;
white-space: pre;
}
/* 宽表自己横着滚,不把整页撑出横向滚动条 */
.oj-markdown table {
display: block;
max-width: 100%;
overflow-x: auto;
margin: 0.8em 0;
border-collapse: collapse;
}
.oj-markdown th,
.oj-markdown td {
padding: 6px 12px;
border: 1px solid v-bind("theme.dividerColor");
text-align: left;
}
.oj-markdown th {
font-weight: 600;
background-color: rgba(128, 128, 128, 0.08);
}
</style>
+233 -41
View File
@@ -1,60 +1,252 @@
<template>
<MdEditor
ref="editor"
:theme="isDark ? 'dark' : 'light'"
v-model="modelValue"
:preview="preview"
:style="height ? { height } : undefined"
@onUploadImg="onUploadImg"
/>
</template>
<script setup lang="ts">
import { MdEditor, type ExposeParam } from "md-editor-v3"
import "md-editor-v3/lib/style.css"
import { markdown } from "@codemirror/lang-markdown"
import { EditorSelection } from "@codemirror/state"
import { EditorView, keymap } from "@codemirror/view"
import { Icon } from "@iconify/vue"
import { Codemirror } from "vue-codemirror"
import { uploadImage } from "../../admin/api"
import { styleTheme } from "../extensions/baseTheme"
import { oneDark } from "../themes/oneDark"
import { smoothy } from "../themes/smoothy"
/**
* `preview` 关掉自带的预览:教程编辑页右边要用学生端同一套组件预览(练一练、能运行的
* 代码块),md-editor 自己那份预览认不出 `[[exercise:N]]`。
* 后台教程正文的 Markdown 编辑器:左边写,右边由教程编辑页用学生端同一套组件预览。
* 原来是 md-editor-v3,但它每次打开都去 unpkg.com 拉 highlight.js、katex、mermaid、echarts,
* 而这里只需要一个能插格式、能传图的文本框,所以换成项目里已有的 CodeMirror。
* 图片三种进法:工具栏按钮选文件、粘贴、拖进来,都是先传到服务器再插 ![](地址)。
*/
const { preview = true, height } = defineProps<{ preview?: boolean; height?: string }>()
const isDark = useDark()
const editor = useTemplateRef<ExposeParam>("editor")
const { height, codeLang = "python" } = defineProps<{
height?: string
/** 「代码块」按钮插入的语言:教程里标了 python / c 的代码块在学生端是能运行的 */
codeLang?: string
}>()
const modelValue = defineModel<string>("value")
const isDark = useDark()
const message = useMessage()
const view = shallowRef<EditorView>()
/** 在光标处插一段文字(教程编辑页插练一练占位符用),自成一行 */
/** 选中了就把选中的包起来,没选中就插一段占位文字并选中它,方便直接改 */
function wrap(before: string, after: string, placeholder: string) {
const v = view.value
if (!v) return
v.dispatch(
v.state.changeByRange((range) => {
const text = range.empty ? placeholder : v.state.sliceDoc(range.from, range.to)
return {
changes: { from: range.from, to: range.to, insert: before + text + after },
range: EditorSelection.range(
range.from + before.length,
range.from + before.length + text.length,
),
}
}),
)
v.focus()
}
/** 给选中的每一行(没选中就是光标那行)行首加前缀:标题、列表、引用 */
function prefixLines(prefix: (index: number) => string) {
const v = view.value
if (!v) return
const { from, to } = v.state.selection.main
const first = v.state.doc.lineAt(from).number
const last = v.state.doc.lineAt(to).number
const changes = []
for (let n = first; n <= last; n++) {
changes.push({ from: v.state.doc.line(n).from, insert: prefix(n - first) })
}
v.dispatch({ changes })
v.focus()
}
/**
* 在光标处插一段自成一行的内容;`cursor` 是插完后光标落在这段文字里的位置(默认放末尾)
*/
function insertBlock(text: string, cursor = text.length) {
const v = view.value
if (!v) return
const { from, to } = v.state.selection.main
const line = v.state.doc.lineAt(from)
const lead = from === line.from ? "" : "\n"
const insert = `${lead}${text}\n`
v.dispatch({
changes: { from, to, insert },
selection: { anchor: from + lead.length + cursor },
})
v.focus()
}
/** 教程编辑页插练一练占位符用 */
function insert(text: string) {
editor.value?.insert(() => ({
targetValue: `\n${text}\n`,
select: false,
deviationStart: 0,
deviationEnd: 0,
}))
insertBlock(text)
}
defineExpose({ insert })
const onUploadImg = async (files: File[], callback: (urls: string[]) => void) => {
const uploading = ref(false)
const fileInput = useTemplateRef<HTMLInputElement>("fileInput")
async function uploadAndInsert(files: File[]) {
const images = files.filter((file) => file.type.startsWith("image/"))
if (!images.length) return
uploading.value = true
try {
const res = await Promise.all(
files.map(async (file) => {
const path = await uploadImage(file)
if (!path) {
message.error("图片上传失败")
return ""
}
return path
}),
)
callback(res.filter((url) => url !== ""))
} catch (err) {
message.error("图片上传失败")
callback([])
const paths = await Promise.all(images.map((file) => uploadImage(file).catch(() => "")))
if (paths.some((path) => !path)) message.error("有图片没传上去")
const ok = paths.filter(Boolean)
if (ok.length) insertBlock(ok.map((path) => `![](${path})`).join("\n"))
} finally {
uploading.value = false
}
}
function onPickImages(e: Event) {
const input = e.target as HTMLInputElement
void uploadAndInsert([...(input.files ?? [])])
input.value = ""
}
const imageEvents = EditorView.domEventHandlers({
paste(event) {
const files = [...(event.clipboardData?.files ?? [])]
if (!files.some((file) => file.type.startsWith("image/"))) return false
event.preventDefault()
void uploadAndInsert(files)
return true
},
drop(event, v) {
const files = [...(event.dataTransfer?.files ?? [])]
if (!files.some((file) => file.type.startsWith("image/"))) return false
event.preventDefault()
// 插到松手的位置
const pos = v.posAtCoords({ x: event.clientX, y: event.clientY })
if (pos !== null) v.dispatch({ selection: { anchor: pos } })
void uploadAndInsert(files)
return true
},
})
const shortcuts = keymap.of([
{ key: "Mod-b", run: () => (wrap("**", "**", "加粗文字"), true) },
{ key: "Mod-i", run: () => (wrap("*", "*", "斜体文字"), true) },
])
/** styleTheme 只写了 Monaco,机房的 Windows 没有它;和 Markdown.vue 里代码的字体保持一致 */
const fontTheme = EditorView.theme({
"& .cm-scroller": { fontFamily: "Consolas, Monaco, monospace" },
})
const extensions = computed(() => [
styleTheme,
fontTheme,
markdown(),
EditorView.lineWrapping,
imageEvents,
shortcuts,
isDark.value ? oneDark : smoothy,
])
const tools = computed(() => [
{ icon: "ph:text-h-two-bold", title: "标题", run: () => prefixLines(() => "## ") },
{ icon: "ph:text-b-bold", title: "加粗(Ctrl+B)", run: () => wrap("**", "**", "加粗文字") },
{ icon: "ph:code-bold", title: "行内代码", run: () => wrap("`", "`", "代码") },
{
icon: "ph:code-block-bold",
title: `代码块(${codeLang},学生那边能运行)`,
run: () => insertBlock(`\`\`\`${codeLang}\n\n\`\`\``, codeLang.length + 4),
},
{ icon: "ph:list-bullets-bold", title: "无序列表", run: () => prefixLines(() => "- ") },
{ icon: "ph:list-numbers-bold", title: "有序列表", run: () => prefixLines((i) => `${i + 1}. `) },
{ icon: "ph:quotes-bold", title: "引用", run: () => prefixLines(() => "> ") },
{
icon: "ph:table-bold",
title: "表格",
run: () => insertBlock("| 列 1 | 列 2 |\n| --- | --- |\n| | |"),
},
{ icon: "ph:link-bold", title: "链接", run: () => wrap("[", "](https://)", "链接文字") },
])
</script>
<template>
<div class="editor-box" :style="height ? { height } : undefined">
<div class="toolbar">
<button
v-for="tool in tools"
:key="tool.icon"
type="button"
class="tool"
:title="tool.title"
:aria-label="tool.title"
@click="tool.run"
>
<Icon :icon="tool.icon" :width="16" />
</button>
<button
type="button"
class="tool"
title="图片(也可以直接粘贴、拖进来)"
aria-label="插入图片"
:disabled="uploading"
@click="fileInput?.click()"
>
<Icon icon="ph:image-bold" :width="16" />
</button>
<input ref="fileInput" type="file" accept="image/*" multiple hidden @change="onPickImages" />
</div>
<div class="body">
<Codemirror
v-model="modelValue"
:extensions="extensions"
:tab-size="4"
:style="{ height: '100%', fontSize: '14px' }"
@ready="view = $event.view"
/>
</div>
</div>
</template>
<style scoped>
.editor-box {
display: flex;
flex-direction: column;
min-height: 300px;
}
.toolbar {
flex: none;
display: flex;
flex-wrap: wrap;
gap: 2px;
padding: 4px 6px;
border-bottom: 1px solid rgba(128, 128, 128, 0.22);
}
.tool {
width: 30px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
border-radius: 4px;
background: transparent;
color: inherit;
cursor: pointer;
}
.tool:hover:not(:disabled) {
background-color: rgba(128, 128, 128, 0.14);
}
.tool:disabled {
cursor: wait;
opacity: 0.6;
}
.body {
flex: 1;
min-height: 0;
}
</style>
+50
View File
@@ -0,0 +1,50 @@
/* 代码高亮配色(明 / 暗):练一练的题型组件和 Markdown.vue 的代码块共用,见 shared/highlight.ts */
.hljs-keyword,
.hljs-operator,
.hljs-selector-tag {
color: #d73a49;
}
.hljs-string,
.hljs-regexp,
.hljs-template-literal {
color: #032f62;
}
.hljs-comment,
.hljs-quote {
color: #6a737d;
font-style: italic;
}
.hljs-number,
.hljs-literal {
color: #005cc5;
}
.hljs-built_in,
.hljs-title.function_,
.hljs-class .hljs-title {
color: #6f42c1;
}
.dark .hljs-keyword,
.dark .hljs-operator,
.dark .hljs-selector-tag {
color: #c678dd;
}
.dark .hljs-string,
.dark .hljs-regexp,
.dark .hljs-template-literal {
color: #98c379;
}
.dark .hljs-comment,
.dark .hljs-quote {
color: #7f848e;
font-style: italic;
}
.dark .hljs-number,
.dark .hljs-literal {
color: #e5c07b;
}
.dark .hljs-built_in,
.dark .hljs-title.function_,
.dark .hljs-class .hljs-title {
color: #61afef;
}
+59
View File
@@ -0,0 +1,59 @@
/**
* 代码高亮:自学的练一练、以及 Markdown.vue 里的代码块共用这一份(配色在 highlight.css)。
* 只注册判题机认的几种语言,别的语言和不标语言的代码块按原文显示。
*/
import hljs from "highlight.js/lib/core"
import c from "highlight.js/lib/languages/c"
import cpp from "highlight.js/lib/languages/cpp"
import python from "highlight.js/lib/languages/python"
import sql from "highlight.js/lib/languages/sql"
hljs.registerLanguage("python", python)
hljs.registerLanguage("c", c)
hljs.registerLanguage("cpp", cpp)
hljs.registerLanguage("sql", sql)
const LANGUAGE_ALIASES: Record<string, string> = {
python: "python",
py: "python",
python3: "python",
c: "c",
cpp: "cpp",
"c++": "cpp",
sql: "sql",
}
export function escapeHtml(text: string): string {
return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
}
function normalizeLang(lang?: string): string | null {
return LANGUAGE_ALIASES[lang?.toLowerCase() ?? ""] ?? null
}
// 把整段代码高亮为 HTML;不支持的语言或异常时回退为转义文本
export function highlight(code: string, lang?: string): string {
const language = normalizeLang(lang)
if (language) {
try {
return hljs.highlight(code, { language }).value
} catch {
// fall through
}
}
return escapeHtml(code)
}
// 按行高亮:整体高亮后再按行切分,保证跨行 token 着色正确,返回逐行 HTML 数组
export function highlightLines(lines: string[], lang?: string): string[] {
const language = normalizeLang(lang)
if (language) {
try {
const html = hljs.highlight(lines.join("\n"), { language }).value
return html.split("\n")
} catch {
// fall through
}
}
return lines.map((line) => escapeHtml(line))
}
+34
View File
@@ -0,0 +1,34 @@
/**
* Markdown / HTML 正文渲染,配 shared/components/Markdown.vue 用。
*
* 原来用 md-editor-v3 的 MdPreview:不管正文里有没有代码、公式,每次打开都去 unpkg.com
* 拉 highlight.js、katex、mermaid、echarts(实测,6.x 起就这样),机房网络下慢且不稳;
* 而我们只用到最基础的 Markdown —— 题面 95% 是富文本编辑器存的 HTML,教程是标题、表格、
* 图片,AI 的输出是标题、列表、表格、少量代码块,库里一处公式、mermaid 都没有。
* 现在直接用 markdown-it(md-editor 内部也是它),代码高亮走本地的 shared/highlight.ts。
*
* 两档信任:
* - 题面、教程是老师写的,原生 HTML 必须放行(富文本存的就是 HTML),原来 md-editor 也是放行的;
* - AI 生成的东西不放行。学生能在代码里夹带指令,诱导 AI 吐出 <img onerror> 一类的 HTML,
* 老师在后台一打开就执行。库里 1196 条 AI 输出一个 HTML 标签都没有,关掉不影响显示。
*/
import MarkdownIt from "markdown-it"
import { highlight } from "./highlight"
function create(html: boolean) {
return new MarkdownIt({
html,
// 和 md-editor 的默认值一致:单个换行就换行、裸网址变链接
breaks: true,
linkify: true,
highlight: (code, lang) => highlight(code, lang),
})
}
let trusted: MarkdownIt | null = null
let untrusted: MarkdownIt | null = null
export function renderMarkdown(content: string, allowHtml: boolean): string {
if (allowHtml) return (trusted ??= create(true)).render(content)
return (untrusted ??= create(false)).render(content)
}