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
@@ -53,7 +53,7 @@
|
||||
</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
<n-scrollbar style="max-height: 60vh; margin-top: 12px">
|
||||
<MdPreview :model-value="detail.analysis" />
|
||||
<Markdown :content="detail.analysis" />
|
||||
</n-scrollbar>
|
||||
</div>
|
||||
</n-spin>
|
||||
@@ -62,8 +62,7 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import PageHeader from "admin/components/PageHeader.vue"
|
||||
import { MdPreview } from "md-editor-v3"
|
||||
import "md-editor-v3/lib/preview.css"
|
||||
import Markdown from "shared/components/Markdown.vue"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import { parseTime } from "utils/functions"
|
||||
import { getAIReportList, getAIReportDetail, pinAIReport, getPinnedAIReports } from "../api"
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { EditorView } from "@codemirror/view"
|
||||
import { PROBLEM_TAG_MAX_LENGTH, TEST_CASE_MAX_CASES } from "@oj2/contract"
|
||||
import { MdPreview } from "md-editor-v3"
|
||||
import "md-editor-v3/lib/preview.css"
|
||||
import Markdown from "shared/components/Markdown.vue"
|
||||
import { StorageSerializers } from "@vueuse/core"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import PageHeader from "admin/components/PageHeader.vue"
|
||||
@@ -56,7 +55,6 @@ const message = useMessage()
|
||||
const router = useRouter()
|
||||
const theme = useThemeVars()
|
||||
const previewCollapsed = usePreviewCollapsed()
|
||||
const isDark = useDark()
|
||||
|
||||
const isCreate = computed(() => !props.problemID)
|
||||
|
||||
@@ -680,11 +678,7 @@ onMounted(async () => {
|
||||
</n-tag>
|
||||
</div>
|
||||
<h3 class="title">描述</h3>
|
||||
<MdPreview
|
||||
preview-theme="vuepress"
|
||||
:model-value="form.description"
|
||||
:theme="isDark ? 'dark' : 'light'"
|
||||
/>
|
||||
<Markdown html :content="form.description" />
|
||||
<p v-if="selected > 0" class="otherGroup">
|
||||
学生看到的是第 1 组,下面是第 {{ selected + 1 }} 组跑出来的样子
|
||||
</p>
|
||||
@@ -720,11 +714,7 @@ onMounted(async () => {
|
||||
</template>
|
||||
<template v-if="!isBlankHtml(form.hint)">
|
||||
<h3 class="title sqlTitle">提示</h3>
|
||||
<MdPreview
|
||||
preview-theme="vuepress"
|
||||
:model-value="form.hint"
|
||||
:theme="isDark ? 'dark' : 'light'"
|
||||
/>
|
||||
<Markdown html :content="form.hint" />
|
||||
</template>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -1003,14 +993,6 @@ onMounted(async () => {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.page :deep(.md-editor) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.page :deep(.md-editor-preview-wrapper) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.otherGroup {
|
||||
margin: 14px 0 0;
|
||||
padding: 6px 10px;
|
||||
|
||||
@@ -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 { useThemeVars } from "naive-ui"
|
||||
import { useMermaid } from "shared/composables/useMermaid"
|
||||
import { DIFFICULTY, SOURCES } from "utils/constants"
|
||||
@@ -42,7 +41,6 @@ const tab = defineModel<PreviewTab>("tab", { required: true })
|
||||
const emit = defineEmits<{ renderState: [ok: boolean]; collapse: [] }>()
|
||||
|
||||
const theme = useThemeVars()
|
||||
const isDark = useDark()
|
||||
|
||||
const isBlankHtml = (html: string) => !html.trim() || html === "<p><br></p>"
|
||||
|
||||
@@ -154,30 +152,17 @@ onMounted(() => nextTick(render))
|
||||
</div>
|
||||
|
||||
<h3 class="title">描述</h3>
|
||||
<MdPreview
|
||||
v-if="!isBlankHtml(description)"
|
||||
preview-theme="vuepress"
|
||||
:model-value="description"
|
||||
:theme="isDark ? 'dark' : 'light'"
|
||||
/>
|
||||
<Markdown html v-if="!isBlankHtml(description)" :content="description" />
|
||||
<p v-else class="placeholder">(还没写描述)</p>
|
||||
|
||||
<div class="io">
|
||||
<section>
|
||||
<h3 class="title">输入</h3>
|
||||
<MdPreview
|
||||
preview-theme="vuepress"
|
||||
:model-value="inputDescription"
|
||||
:theme="isDark ? 'dark' : 'light'"
|
||||
/>
|
||||
<Markdown html :content="inputDescription" />
|
||||
</section>
|
||||
<section>
|
||||
<h3 class="title">输出</h3>
|
||||
<MdPreview
|
||||
preview-theme="vuepress"
|
||||
:model-value="outputDescription"
|
||||
:theme="isDark ? 'dark' : 'light'"
|
||||
/>
|
||||
<Markdown html :content="outputDescription" />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -207,11 +192,7 @@ onMounted(() => nextTick(render))
|
||||
|
||||
<template v-if="!isBlankHtml(hint)">
|
||||
<h3 class="title">提示</h3>
|
||||
<MdPreview
|
||||
preview-theme="vuepress"
|
||||
:model-value="hint"
|
||||
:theme="isDark ? 'dark' : 'light'"
|
||||
/>
|
||||
<Markdown html :content="hint" />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -347,14 +328,6 @@ onMounted(() => nextTick(render))
|
||||
border-left: 3px solid v-bind("theme.primaryColor");
|
||||
}
|
||||
|
||||
.page :deep(.md-editor) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.page :deep(.md-editor-preview-wrapper) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.io {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
|
||||
@@ -203,7 +203,7 @@ onMounted(init)
|
||||
<MarkdownEditor
|
||||
ref="editor"
|
||||
v-model:value="tutorial.content"
|
||||
:preview="false"
|
||||
:code-lang="tutorial.type"
|
||||
height="100%"
|
||||
/>
|
||||
</div>
|
||||
@@ -340,10 +340,6 @@ onMounted(init)
|
||||
min-width: 0;
|
||||
border-right: 1px solid rgba(128, 128, 128, 0.22);
|
||||
}
|
||||
.source :deep(.md-editor) {
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
.preview {
|
||||
flex: 0 0 50%;
|
||||
min-width: 0;
|
||||
|
||||
@@ -33,14 +33,6 @@ body {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.md-editor-dark {
|
||||
--md-bk-color: var(--n-body-color) !important;
|
||||
}
|
||||
|
||||
.md-editor-dark div.vuepress-theme {
|
||||
--md-theme-color: var(--n-text-color) !important;
|
||||
}
|
||||
|
||||
.oj-mermaid-surface {
|
||||
box-sizing: border-box;
|
||||
padding: 18px;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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) => ``).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>
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
/* 练一练代码高亮配色(明 / 暗),由涉及代码高亮的题型组件统一引入 */
|
||||
/* 代码高亮配色(明 / 暗):练一练的题型组件和 Markdown.vue 的代码块共用,见 shared/highlight.ts */
|
||||
.hljs-keyword,
|
||||
.hljs-operator,
|
||||
.hljs-selector-tag {
|
||||
+21
-3
@@ -1,16 +1,34 @@
|
||||
/**
|
||||
* 代码高亮:自学的练一练、以及 Markdown.vue 里的代码块共用这一份(配色在 highlight.css)。
|
||||
* 只注册判题机认的几种语言,别的语言和不标语言的代码块按原文显示。
|
||||
*/
|
||||
import hljs from "highlight.js/lib/core"
|
||||
import python from "highlight.js/lib/languages/python"
|
||||
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, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||
}
|
||||
|
||||
function normalizeLang(lang?: string): "python" | "c" | null {
|
||||
return lang === "python" ? "python" : lang === "c" ? "c" : null
|
||||
function normalizeLang(lang?: string): string | null {
|
||||
return LANGUAGE_ALIASES[lang?.toLowerCase() ?? ""] ?? null
|
||||
}
|
||||
|
||||
// 把整段代码高亮为 HTML;不支持的语言或异常时回退为转义文本
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user