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:
1 parent
c497ccadc8
commit
6f47ad8358
24 files changed
+504
-308
No files matched your search
@@ -19,15 +19,14 @@
|
||||
</template>
|
||||
开始分析
|
||||
</n-button>
|
||||
<MdPreview v-else :model-value="aiStore.mdContent" />
|
||||
<Markdown v-else :content="aiStore.mdContent" />
|
||||
</n-flex>
|
||||
</n-spin>
|
||||
</n-card>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { useAIStore } from "oj/store/ai"
|
||||
import { MdPreview } from "md-editor-v3"
|
||||
import "md-editor-v3/lib/preview.css"
|
||||
import Markdown from "shared/components/Markdown.vue"
|
||||
import { Icon } from "@iconify/vue"
|
||||
|
||||
const aiStore = useAIStore()
|
||||
@@ -86,8 +85,4 @@ onMounted(async () => {
|
||||
.container {
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
:deep(.md-editor-preview h1) {
|
||||
margin-top: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { ClassBattleItem, ClassPk, ClassPkPeriod } from "@oj2/contract"
|
||||
import { MdPreview } from "md-editor-v3"
|
||||
import "md-editor-v3/lib/preview.css"
|
||||
import Markdown from "shared/components/Markdown.vue"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { getClassBattle, getClassPk } from "oj/api"
|
||||
import { classLabel, groupClassOptions } from "oj/submission/utils"
|
||||
@@ -293,7 +292,7 @@ async function analyze() {
|
||||
<n-button size="small" :loading="ai.running.value" @click="analyze">重新分析</n-button>
|
||||
</div>
|
||||
<span v-if="ai.waiting.value" class="muted">正在看这几个班的数据…</span>
|
||||
<MdPreview v-if="report" class="report" :model-value="report" />
|
||||
<Markdown v-if="report" :content="report" />
|
||||
</section>
|
||||
</template>
|
||||
<div v-else-if="!loading" class="card empty">
|
||||
@@ -518,14 +517,6 @@ button.chip {
|
||||
font-size: var(--oj-fs-meta);
|
||||
}
|
||||
|
||||
.report {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.report :deep(.md-editor-preview-wrapper) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.compact .chip {
|
||||
font-size: var(--oj-fs-meta);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import ExerciseResult from "./ExerciseResult.vue"
|
||||
import type { Exercise, ExerciseDebugData } from "utils/types"
|
||||
import { highlightLines } from "../composables/useCodeHighlight"
|
||||
import "./exercise-highlight.css"
|
||||
import { highlightLines } from "shared/highlight"
|
||||
import "shared/highlight.css"
|
||||
|
||||
const props = defineProps<{ exercise: Exercise; lang?: string }>()
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import ExerciseResult from "./ExerciseResult.vue"
|
||||
import type { Exercise, ExerciseFillData } from "utils/types"
|
||||
import { highlight } from "../composables/useCodeHighlight"
|
||||
import "./exercise-highlight.css"
|
||||
import { highlight } from "shared/highlight"
|
||||
import "shared/highlight.css"
|
||||
|
||||
const props = defineProps<{ exercise: Exercise; lang?: string }>()
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import ExerciseResult from "./ExerciseResult.vue"
|
||||
import type { Exercise, ExercisePredictData } from "utils/types"
|
||||
import { highlight } from "../composables/useCodeHighlight"
|
||||
import "./exercise-highlight.css"
|
||||
import { highlight } from "shared/highlight"
|
||||
import "shared/highlight.css"
|
||||
|
||||
const props = defineProps<{ exercise: Exercise; lang?: string }>()
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
import ExerciseResult from "./ExerciseResult.vue"
|
||||
import type { Exercise, ExerciseSortData } from "utils/types"
|
||||
import { shuffle } from "../composables/useShuffle"
|
||||
import { highlightLines } from "../composables/useCodeHighlight"
|
||||
import "./exercise-highlight.css"
|
||||
import { highlightLines } from "shared/highlight"
|
||||
import "shared/highlight.css"
|
||||
|
||||
const props = defineProps<{ exercise: Exercise; lang?: string }>()
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { MdPreview } from "md-editor-v3"
|
||||
import "md-editor-v3/lib/preview.css"
|
||||
import Markdown from "shared/components/Markdown.vue"
|
||||
import type { LessonSegment } from "../composables/useLessonParse"
|
||||
import CodeRunBlock from "./CodeRunBlock.vue"
|
||||
|
||||
@@ -16,9 +15,7 @@ defineProps<{
|
||||
}>()
|
||||
const emit = defineEmits<{ solved: [exerciseId: number] }>()
|
||||
|
||||
const isDark = useDark()
|
||||
|
||||
/** 学习目标那几行只有行内的 **加粗** 和 `代码`,先转义再换成标签,别为它拉一个 MdPreview */
|
||||
/** 学习目标那几行只有行内的 **加粗** 和 `代码`,先转义再换成标签,别为它拉一整个 Markdown 渲染 */
|
||||
function inline(text: string) {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
@@ -32,13 +29,7 @@ function inline(text: string) {
|
||||
<template>
|
||||
<div class="body">
|
||||
<template v-for="(seg, i) in segments" :key="i">
|
||||
<MdPreview
|
||||
v-if="seg.type === 'md'"
|
||||
preview-theme="vuepress"
|
||||
:theme="isDark ? 'dark' : 'light'"
|
||||
:model-value="seg.content"
|
||||
class="md"
|
||||
/>
|
||||
<Markdown html v-if="seg.type === 'md'" :content="seg.content" />
|
||||
<h2 v-else-if="seg.type === 'heading'" :id="seg.id" class="h2">{{ seg.text }}</h2>
|
||||
<div v-else-if="seg.type === 'goals'" :id="seg.id" class="goals">
|
||||
<div class="goals-title">学完这课你能</div>
|
||||
@@ -65,16 +56,9 @@ function inline(text: string) {
|
||||
|
||||
<style scoped>
|
||||
.body {
|
||||
font-size: 15px;
|
||||
font-size: 16px;
|
||||
line-height: 1.85;
|
||||
}
|
||||
.md :deep(.md-editor-preview) {
|
||||
font-size: 15px;
|
||||
padding: 0;
|
||||
}
|
||||
.md :deep(.md-editor-preview-wrapper) {
|
||||
padding: 0;
|
||||
}
|
||||
.h2 {
|
||||
font-size: 20px;
|
||||
line-height: 1.4;
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
/* 练一练代码高亮配色(明 / 暗),由涉及代码高亮的题型组件统一引入 */
|
||||
.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;
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import hljs from "highlight.js/lib/core"
|
||||
import python from "highlight.js/lib/languages/python"
|
||||
import c from "highlight.js/lib/languages/c"
|
||||
|
||||
hljs.registerLanguage("python", python)
|
||||
hljs.registerLanguage("c", c)
|
||||
|
||||
export function escapeHtml(text: string): string {
|
||||
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||
}
|
||||
|
||||
function normalizeLang(lang?: string): "python" | "c" | null {
|
||||
return lang === "python" ? "python" : lang === "c" ? "c" : 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))
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import type { Exercise } from "utils/types"
|
||||
* - `code`:带语言的代码块(```python / ```c)换成能改、能运行的代码块;
|
||||
* 不带语言的(「输出结果」那种)留在 Markdown 里照常显示;
|
||||
* - `exercise`:`[[exercise:36]]` 占位符换成练一练;
|
||||
* - `md`:其余的交给 MdPreview。
|
||||
* - `md`:其余的交给 Markdown.vue。
|
||||
*
|
||||
* 正文开头的「# 标题」「**所属项目:…**」和紧跟着的项目配图不渲染:标题和项目页面上方
|
||||
* 已经有了,每课都重复一张同样的项目图只是把正文往下推。
|
||||
|
||||
@@ -6,9 +6,7 @@ import { useProblemStore } from "oj/store/problem"
|
||||
import { DIFFICULTY } from "utils/constants"
|
||||
import { copyToClipboard, getTagColor } from "utils/functions"
|
||||
import { useSubmissionStore } from "oj/store/submission"
|
||||
import { useDark } from "@vueuse/core"
|
||||
import { MdPreview } from "md-editor-v3"
|
||||
import "md-editor-v3/lib/preview.css"
|
||||
import Markdown from "shared/components/Markdown.vue"
|
||||
import SqlProblemData from "./SqlProblemData.vue"
|
||||
import SimilarProblems from "./SimilarProblems.vue"
|
||||
import { isFlowchartPass, useFlowchartStore } from "oj/store/flowchart"
|
||||
@@ -19,7 +17,6 @@ import { useProblemPageContext } from "../composables/problemPageContext"
|
||||
const ProblemFlowchart = defineAsyncComponent(() => import("./ProblemFlowchart.vue"))
|
||||
const MyFlowchart = defineAsyncComponent(() => import("./MyFlowchart.vue"))
|
||||
|
||||
const isDark = useDark()
|
||||
const theme = useThemeVars()
|
||||
const problemStore = useProblemStore()
|
||||
const { problem } = storeToRefs(problemStore)
|
||||
@@ -234,30 +231,18 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
|
||||
|
||||
<section class="block">
|
||||
<h3 class="title">描述</h3>
|
||||
<MdPreview
|
||||
preview-theme="vuepress"
|
||||
:model-value="problem.description"
|
||||
:theme="isDark ? 'dark' : 'light'"
|
||||
/>
|
||||
<Markdown html :content="problem.description" />
|
||||
</section>
|
||||
|
||||
<!-- 输入、输出一般都只有一两句,左右摆开省半屏 -->
|
||||
<div v-if="!isSQL" class="io">
|
||||
<section class="block">
|
||||
<h3 class="title">输入</h3>
|
||||
<MdPreview
|
||||
preview-theme="vuepress"
|
||||
:model-value="problem.inputDescription"
|
||||
:theme="isDark ? 'dark' : 'light'"
|
||||
/>
|
||||
<Markdown html :content="problem.inputDescription" />
|
||||
</section>
|
||||
<section class="block">
|
||||
<h3 class="title">输出</h3>
|
||||
<MdPreview
|
||||
preview-theme="vuepress"
|
||||
:model-value="problem.outputDescription"
|
||||
:theme="isDark ? 'dark' : 'light'"
|
||||
/>
|
||||
<Markdown html :content="problem.outputDescription" />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -388,20 +373,12 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
|
||||
|
||||
<section v-if="problem.hint" class="block">
|
||||
<h3 class="title">提示</h3>
|
||||
<MdPreview
|
||||
preview-theme="preview"
|
||||
:model-value="problem.hint"
|
||||
:theme="isDark ? 'dark' : 'light'"
|
||||
/>
|
||||
<Markdown html :content="problem.hint" />
|
||||
</section>
|
||||
|
||||
<section v-if="problem.source" class="block">
|
||||
<h3 class="title">来源</h3>
|
||||
<MdPreview
|
||||
preview-theme="vuepress"
|
||||
:model-value="problem.source"
|
||||
:theme="isDark ? 'dark' : 'light'"
|
||||
/>
|
||||
<Markdown html :content="problem.source" />
|
||||
</section>
|
||||
|
||||
<SimilarProblems v-if="showSimilar" title="相似题目推荐" />
|
||||
@@ -477,23 +454,6 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
|
||||
border-left: 3px solid v-bind("theme.primaryColor");
|
||||
}
|
||||
|
||||
/* md-editor 的预览自带一圈 padding、白底和段落外边距,放在小节标题下面显得很散 */
|
||||
:deep(.md-editor) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
:deep(.md-editor-preview-wrapper) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:deep(.md-editor-preview > :first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
:deep(.md-editor-preview > :last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.io {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
|
||||
@@ -23,9 +23,7 @@ import ResultHeader from "./ResultHeader.vue"
|
||||
import SimilarProblems from "./SimilarProblems.vue"
|
||||
import { useLessonStore } from "oj/store/lesson"
|
||||
import { useProblemPageContext } from "../composables/problemPageContext"
|
||||
import { MdPreview } from "md-editor-v3"
|
||||
import "md-editor-v3/lib/preview.css"
|
||||
import { useDark } from "@vueuse/core"
|
||||
import Markdown from "shared/components/Markdown.vue"
|
||||
|
||||
const props = defineProps<{
|
||||
submission?: Submission
|
||||
@@ -36,7 +34,6 @@ const props = defineProps<{
|
||||
peer?: boolean
|
||||
}>()
|
||||
|
||||
const isDark = useDark()
|
||||
const problemStore = useProblemStore()
|
||||
const theme = useThemeVars()
|
||||
|
||||
@@ -390,12 +387,7 @@ const columns: DataTableColumn<JudgeCaseResult>[] = [
|
||||
<template v-if="showAIHint && hintTarget">
|
||||
<n-card size="small" class="hint-card">
|
||||
<n-spin v-if="hintLoading && !hintContent" size="small" />
|
||||
<MdPreview
|
||||
v-if="hintContent"
|
||||
:model-value="hintContent"
|
||||
preview-theme="vuepress"
|
||||
:theme="isDark ? 'dark' : 'light'"
|
||||
/>
|
||||
<Markdown v-if="hintContent" :content="hintContent" />
|
||||
<!-- 等级和「再多一点提示」。按钮出不出由后端 done 里的 canEscalate 定,
|
||||
前端不自己推阶梯(要再交一次才升得动,规则在 services/hint-level.ts) -->
|
||||
<n-flex
|
||||
|
||||
@@ -30,7 +30,7 @@ export function useSubmissionHint() {
|
||||
const hintFeedbackSending = ref(false)
|
||||
|
||||
// 打字机:每 24ms 吐 3 个字,约 125 字/秒。步子不敢迈太小 ——
|
||||
// 每一帧都要让 MdPreview 重渲染一次 Markdown,机房那批机器扛不住逐字
|
||||
// 每一帧都要让 Markdown 重渲染一次 Markdown,机房那批机器扛不住逐字
|
||||
const TYPE_STEP = 3
|
||||
const TYPE_INTERVAL = 24
|
||||
let typingTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { ClassDetail } from "@oj2/contract"
|
||||
import { MdPreview } from "md-editor-v3"
|
||||
import "md-editor-v3/lib/preview.css"
|
||||
import Markdown from "shared/components/Markdown.vue"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { getClassDetail } from "oj/api"
|
||||
import { classLabel } from "oj/submission/utils"
|
||||
@@ -237,7 +236,7 @@ function go(path: string, query: Record<string, string>) {
|
||||
</n-button>
|
||||
</div>
|
||||
<span v-if="ai.waiting.value" class="muted">正在看这个班的数据…</span>
|
||||
<MdPreview v-if="report" class="report" :model-value="report" />
|
||||
<Markdown v-if="report" :content="report" />
|
||||
</section>
|
||||
|
||||
<a v-if="pk" href="#" class="pk" @click.prevent="go('/class', { classes: className! })"
|
||||
@@ -461,12 +460,4 @@ section {
|
||||
.ai-head b {
|
||||
color: v-bind("theme.textColor1");
|
||||
}
|
||||
|
||||
.report {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.report :deep(.md-editor-preview-wrapper) {
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user