fix(流程图渲染): 出错后再也画不出来、每失败一次往 body 漏一个 div
## 渲染失败后永久空白
四个渲染点都是同一个写法:
<n-alert v-if="renderError" ... />
<div v-else ref="mermaidContainer"></div>
一旦渲染出错,容器被 `v-else` 卸载,`mermaidContainer.value` 变成 null。而
`renderFlowchart` 一进来就先清 `renderError`、再因为 `!container` 直接 return ——
于是**报错提示消失了,图也再画不出来**,只剩一块空白,只能刷新页面。翻历史提交
最容易踩到。改成容器常驻、用 `v-show` 隐藏,和 MermaidEditor 本来的写法一致。
`FlowchartScoreDetail` 那处 Teleport 上不能挂 v-show,把条件挪到内层容器。
实测:修复前切回合法代码后 0 个 svg(永久空白),修复后正常渲染。
## 每次渲染失败都往 body 漏一个 div
`m.render(id, code)` 没传容器,mermaid 会在 `document.body` 上建一个临时
`div#d{id}`。而 `suppressErrorRendering` 默认关着,看 mermaid 源码,解析出错时
是先 `errorRenderer.draw()` 再 `throw`,**清理临时容器的那行在 throw 之后**,
永远执行不到;每次 render 用的又是新的随机 id,`removeExistingElements` 也清不掉
旧的。于是渲染失败一次就留一个。
出题页是边敲边预览,且没有防抖,每个字符触发一次完整渲染,中间态几乎全是语法
错误 —— 实测逐字符敲 26 个字符,body 里留下 16 个残留 div。打开
`suppressErrorRendering`(该分支是先清理再抛)+ 预览防抖 300ms,实测降到 0,
预览功能不受影响。
## 顺带
`loadMermaid` 缓存的是实例,两个组件同屏挂载时会双双落进 `if (!mermaidInstance)`,
import 和 initialize 各跑两次。改成缓存 Promise,并在失败时清掉缓存,避免一次
网络抖动把后续所有渲染都钉死在这个失败结果上。
`SubmitFlowchart` 的 `updatePage` / `openDetailModal` / `loadToEditor` 一并补了
错误兜底(同文件,见下一个提交的说明):前两个失败时 `rendering` 会卡在 true,
弹框永久转圈;`loadToEditor` 是裸 `JSON.parse`,老提交数据坏掉就点了没反应。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -22,7 +22,11 @@ watch(
|
|||||||
<n-alert v-if="renderError" type="error" title="渲染失败" size="small">
|
<n-alert v-if="renderError" type="error" title="渲染失败" size="small">
|
||||||
{{ renderError }}
|
{{ renderError }}
|
||||||
</n-alert>
|
</n-alert>
|
||||||
<div v-else ref="mermaidContainer" class="flowchart-container"></div>
|
<div
|
||||||
|
v-show="!renderError"
|
||||||
|
ref="mermaidContainer"
|
||||||
|
class="flowchart-container"
|
||||||
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ watch(() => problem.value?.mermaidCode, renderProblemFlowchart)
|
|||||||
{{ renderError }}
|
{{ renderError }}
|
||||||
</template>
|
</template>
|
||||||
</n-alert>
|
</n-alert>
|
||||||
<div v-else ref="mermaidContainer" class="container"></div>
|
<!-- 容器必须常驻:用 v-else 卸载掉之后 mermaidContainer 变成 null,
|
||||||
|
下一次渲染会因为拿不到容器直接 return,图就再也画不出来了 -->
|
||||||
|
<div v-show="!renderError" ref="mermaidContainer" class="container"></div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -314,26 +314,40 @@ async function getSubmission(submissionPage = 0) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 请求失败时 rendering 必须复位:拦截器对普通业务错误是静默 reject 的,
|
||||||
|
// 少了这个 finally,弹框就会永远停在转圈状态,也没有任何提示
|
||||||
async function updatePage(val: number) {
|
async function updatePage(val: number) {
|
||||||
page.value = val
|
page.value = val
|
||||||
rendering.value = true
|
rendering.value = true
|
||||||
await getSubmission(val)
|
try {
|
||||||
// 等待 DOM 更新
|
await getSubmission(val)
|
||||||
await nextTick()
|
// 等待 DOM 更新
|
||||||
await renderFlowchart(mermaidContainer.value, myMermaidCode.value)
|
await nextTick()
|
||||||
rendering.value = false
|
await renderFlowchart(mermaidContainer.value, myMermaidCode.value)
|
||||||
|
} catch (error) {
|
||||||
|
message.error("加载这次提交失败,请稍后重试")
|
||||||
|
console.error("加载流程图提交失败:", error)
|
||||||
|
} finally {
|
||||||
|
rendering.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 模态框相关函数 ====================
|
// ==================== 模态框相关函数 ====================
|
||||||
async function openDetailModal() {
|
async function openDetailModal() {
|
||||||
showDetailModal.value = true
|
showDetailModal.value = true
|
||||||
rendering.value = true
|
rendering.value = true
|
||||||
await getSubmission()
|
try {
|
||||||
page.value = submissionCount.value
|
await getSubmission()
|
||||||
// 等待 DOM 更新,确保弹框已经渲染
|
page.value = submissionCount.value
|
||||||
await nextTick()
|
// 等待 DOM 更新,确保弹框已经渲染
|
||||||
await renderFlowchart(mermaidContainer.value, myMermaidCode.value)
|
await nextTick()
|
||||||
rendering.value = false
|
await renderFlowchart(mermaidContainer.value, myMermaidCode.value)
|
||||||
|
} catch (error) {
|
||||||
|
message.error("加载评分详情失败,请稍后重试")
|
||||||
|
console.error("加载流程图评分详情失败:", error)
|
||||||
|
} finally {
|
||||||
|
rendering.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeModal() {
|
function closeModal() {
|
||||||
@@ -342,14 +356,18 @@ function closeModal() {
|
|||||||
|
|
||||||
function loadToEditor() {
|
function loadToEditor() {
|
||||||
if (myFlowchartZippedStr.value) {
|
if (myFlowchartZippedStr.value) {
|
||||||
const str = atou(myFlowchartZippedStr.value)
|
// 老提交的压缩数据可能是坏的(格式换过、存了一半),
|
||||||
const json = JSON.parse(str)
|
// 不兜住的话 atou/JSON.parse 直接抛,按钮点了毫无反应
|
||||||
const processedData = {
|
try {
|
||||||
nodes: json.nodes || [],
|
const json = JSON.parse(atou(myFlowchartZippedStr.value))
|
||||||
edges: json.edges || [],
|
flowchartEditorRef?.value?.setFlowchartData({
|
||||||
}
|
nodes: json.nodes || [],
|
||||||
if (flowchartEditorRef?.value) {
|
edges: json.edges || [],
|
||||||
flowchartEditorRef.value.setFlowchartData(processedData)
|
})
|
||||||
|
} catch (error) {
|
||||||
|
message.error("这份流程图数据已损坏,无法加载到编辑器")
|
||||||
|
console.error("解析流程图数据失败:", error)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
closeModal()
|
closeModal()
|
||||||
@@ -432,7 +450,11 @@ onUnmounted(() => {
|
|||||||
<n-alert v-if="renderError" type="error" title="流程图渲染失败">
|
<n-alert v-if="renderError" type="error" title="流程图渲染失败">
|
||||||
{{ renderError }}
|
{{ renderError }}
|
||||||
</n-alert>
|
</n-alert>
|
||||||
<div class="flowchart" v-else ref="mermaidContainer"></div>
|
<div
|
||||||
|
class="flowchart"
|
||||||
|
v-show="!renderError"
|
||||||
|
ref="mermaidContainer"
|
||||||
|
></div>
|
||||||
</n-spin>
|
</n-spin>
|
||||||
</div>
|
</div>
|
||||||
<!-- 加载到编辑器按钮 -->
|
<!-- 加载到编辑器按钮 -->
|
||||||
|
|||||||
@@ -20,8 +20,9 @@
|
|||||||
<n-alert v-if="renderError" type="error" title="流程图渲染失败">
|
<n-alert v-if="renderError" type="error" title="流程图渲染失败">
|
||||||
{{ renderError }}
|
{{ renderError }}
|
||||||
</n-alert>
|
</n-alert>
|
||||||
<Teleport v-else to="body" :disabled="!showLargeImage">
|
<Teleport to="body" :disabled="!showLargeImage">
|
||||||
<div
|
<div
|
||||||
|
v-show="!renderError"
|
||||||
:class="['flowchart', { 'flowchart-fullscreen': showLargeImage }]"
|
:class="['flowchart', { 'flowchart-fullscreen': showLargeImage }]"
|
||||||
ref="mermaidContainer"
|
ref="mermaidContainer"
|
||||||
></div>
|
></div>
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ onMounted(() => {
|
|||||||
nextTick(renderMermaid)
|
nextTick(renderMermaid)
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(modelValue, renderMermaid)
|
// 出题页是边敲边预览,不防抖的话每个字符都会触发一次完整的 mermaid 渲染,
|
||||||
|
// 而中间态几乎全是语法错误
|
||||||
|
watchDebounced(modelValue, renderMermaid, { debounce: 300, maxWait: 1000 })
|
||||||
|
|
||||||
const clearCode = () => {
|
const clearCode = () => {
|
||||||
modelValue.value = ""
|
modelValue.value = ""
|
||||||
|
|||||||
@@ -256,25 +256,41 @@ function getChromeVersion(): number {
|
|||||||
return match ? parseInt(match[1]) : Infinity
|
return match ? parseInt(match[1]) : Infinity
|
||||||
}
|
}
|
||||||
|
|
||||||
let mermaidInstance: any = null
|
let mermaidPromise: Promise<any> | null = null
|
||||||
let mermaidIsLegacy = false
|
let mermaidIsLegacy = false
|
||||||
|
|
||||||
async function loadMermaid() {
|
function loadMermaid(): Promise<any> {
|
||||||
if (!mermaidInstance) {
|
// 缓存 Promise 而不是实例:同一屏里两个组件一起挂载时,缓存实例会让两边都
|
||||||
if (getChromeVersion() < 94) {
|
// 落进 if (!mermaidInstance),import 两次、initialize 两次
|
||||||
mermaidInstance = (await import("mermaid-legacy")).default
|
if (!mermaidPromise) {
|
||||||
mermaidIsLegacy = true
|
mermaidPromise = (async () => {
|
||||||
} else {
|
let instance: any
|
||||||
mermaidInstance = (await import("mermaid")).default
|
if (getChromeVersion() < 94) {
|
||||||
}
|
instance = (await import("mermaid-legacy")).default
|
||||||
mermaidInstance.initialize({
|
mermaidIsLegacy = true
|
||||||
startOnLoad: false,
|
} else {
|
||||||
securityLevel: "strict",
|
instance = (await import("mermaid")).default
|
||||||
theme: "base",
|
}
|
||||||
themeVariables: mermaidThemeVariables,
|
instance.initialize({
|
||||||
|
startOnLoad: false,
|
||||||
|
securityLevel: "strict",
|
||||||
|
theme: "base",
|
||||||
|
// 解析失败时 mermaid 默认会先把一张「错误图」画进挂在 document.body 上的
|
||||||
|
// 临时容器,再抛异常,而清理临时容器的那行在 throw 之后,永远执行不到;
|
||||||
|
// 每次 render 用的又是新的随机 id,旧的也清不掉。于是每失败一次就往
|
||||||
|
// body 里留一个 div#dmermaid-xxx —— 出题页每敲一个字符渲染一次,一段
|
||||||
|
// 代码写下来能堆几十个。打开这个开关后 mermaid 会先清理再抛。
|
||||||
|
suppressErrorRendering: true,
|
||||||
|
themeVariables: mermaidThemeVariables,
|
||||||
|
})
|
||||||
|
return instance
|
||||||
|
})().catch((error) => {
|
||||||
|
// 失败的 Promise 不能留在缓存里,否则后面每次渲染都直接复用这个失败结果
|
||||||
|
mermaidPromise = null
|
||||||
|
throw error
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return mermaidInstance
|
return mermaidPromise
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useMermaid() {
|
export function useMermaid() {
|
||||||
|
|||||||
Reference in New Issue
Block a user