From 5f0fe713dc3fd8c953fa8748ed8c0c2466b7932a Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Thu, 27 Aug 2026 06:14:40 -0600 Subject: [PATCH] =?UTF-8?q?fix(=E6=B5=81=E7=A8=8B=E5=9B=BE=E6=B8=B2?= =?UTF-8?q?=E6=9F=93):=20=E5=87=BA=E9=94=99=E5=90=8E=E5=86=8D=E4=B9=9F?= =?UTF-8?q?=E7=94=BB=E4=B8=8D=E5=87=BA=E6=9D=A5=E3=80=81=E6=AF=8F=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E4=B8=80=E6=AC=A1=E5=BE=80=20body=20=E6=BC=8F?= =?UTF-8?q?=E4=B8=80=E4=B8=AA=20div?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 渲染失败后永久空白 四个渲染点都是同一个写法:
一旦渲染出错,容器被 `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 --- .../oj/problem/components/MyFlowchartTab.vue | 6 +- .../problem/components/ProblemFlowchart.vue | 4 +- .../oj/problem/components/SubmitFlowchart.vue | 62 +++++++++++++------ .../components/FlowchartScoreDetail.vue | 3 +- .../src/shared/components/MermaidEditor.vue | 4 +- apps/web/src/shared/composables/useMermaid.ts | 46 +++++++++----- 6 files changed, 86 insertions(+), 39 deletions(-) diff --git a/apps/web/src/oj/problem/components/MyFlowchartTab.vue b/apps/web/src/oj/problem/components/MyFlowchartTab.vue index 10987f4..f2389c9 100644 --- a/apps/web/src/oj/problem/components/MyFlowchartTab.vue +++ b/apps/web/src/oj/problem/components/MyFlowchartTab.vue @@ -22,7 +22,11 @@ watch( {{ renderError }} -
+
diff --git a/apps/web/src/oj/problem/components/ProblemFlowchart.vue b/apps/web/src/oj/problem/components/ProblemFlowchart.vue index c61f7a8..6ab9451 100644 --- a/apps/web/src/oj/problem/components/ProblemFlowchart.vue +++ b/apps/web/src/oj/problem/components/ProblemFlowchart.vue @@ -27,7 +27,9 @@ watch(() => problem.value?.mermaidCode, renderProblemFlowchart) {{ renderError }}
-
+ +
diff --git a/apps/web/src/oj/problem/components/SubmitFlowchart.vue b/apps/web/src/oj/problem/components/SubmitFlowchart.vue index a816897..6bbcda4 100644 --- a/apps/web/src/oj/problem/components/SubmitFlowchart.vue +++ b/apps/web/src/oj/problem/components/SubmitFlowchart.vue @@ -314,26 +314,40 @@ async function getSubmission(submissionPage = 0) { } } +// 请求失败时 rendering 必须复位:拦截器对普通业务错误是静默 reject 的, +// 少了这个 finally,弹框就会永远停在转圈状态,也没有任何提示 async function updatePage(val: number) { page.value = val rendering.value = true - await getSubmission(val) - // 等待 DOM 更新 - await nextTick() - await renderFlowchart(mermaidContainer.value, myMermaidCode.value) - rendering.value = false + try { + await getSubmission(val) + // 等待 DOM 更新 + await nextTick() + await renderFlowchart(mermaidContainer.value, myMermaidCode.value) + } catch (error) { + message.error("加载这次提交失败,请稍后重试") + console.error("加载流程图提交失败:", error) + } finally { + rendering.value = false + } } // ==================== 模态框相关函数 ==================== async function openDetailModal() { showDetailModal.value = true rendering.value = true - await getSubmission() - page.value = submissionCount.value - // 等待 DOM 更新,确保弹框已经渲染 - await nextTick() - await renderFlowchart(mermaidContainer.value, myMermaidCode.value) - rendering.value = false + try { + await getSubmission() + page.value = submissionCount.value + // 等待 DOM 更新,确保弹框已经渲染 + await nextTick() + await renderFlowchart(mermaidContainer.value, myMermaidCode.value) + } catch (error) { + message.error("加载评分详情失败,请稍后重试") + console.error("加载流程图评分详情失败:", error) + } finally { + rendering.value = false + } } function closeModal() { @@ -342,14 +356,18 @@ function closeModal() { function loadToEditor() { if (myFlowchartZippedStr.value) { - const str = atou(myFlowchartZippedStr.value) - const json = JSON.parse(str) - const processedData = { - nodes: json.nodes || [], - edges: json.edges || [], - } - if (flowchartEditorRef?.value) { - flowchartEditorRef.value.setFlowchartData(processedData) + // 老提交的压缩数据可能是坏的(格式换过、存了一半), + // 不兜住的话 atou/JSON.parse 直接抛,按钮点了毫无反应 + try { + const json = JSON.parse(atou(myFlowchartZippedStr.value)) + flowchartEditorRef?.value?.setFlowchartData({ + nodes: json.nodes || [], + edges: json.edges || [], + }) + } catch (error) { + message.error("这份流程图数据已损坏,无法加载到编辑器") + console.error("解析流程图数据失败:", error) + return } } closeModal() @@ -432,7 +450,11 @@ onUnmounted(() => { {{ renderError }} -
+
diff --git a/apps/web/src/oj/submission/components/FlowchartScoreDetail.vue b/apps/web/src/oj/submission/components/FlowchartScoreDetail.vue index 6e242c9..ed90cbc 100644 --- a/apps/web/src/oj/submission/components/FlowchartScoreDetail.vue +++ b/apps/web/src/oj/submission/components/FlowchartScoreDetail.vue @@ -20,8 +20,9 @@ {{ renderError }} - +
diff --git a/apps/web/src/shared/components/MermaidEditor.vue b/apps/web/src/shared/components/MermaidEditor.vue index 238e5ac..7c042a3 100644 --- a/apps/web/src/shared/components/MermaidEditor.vue +++ b/apps/web/src/shared/components/MermaidEditor.vue @@ -20,7 +20,9 @@ onMounted(() => { nextTick(renderMermaid) }) -watch(modelValue, renderMermaid) +// 出题页是边敲边预览,不防抖的话每个字符都会触发一次完整的 mermaid 渲染, +// 而中间态几乎全是语法错误 +watchDebounced(modelValue, renderMermaid, { debounce: 300, maxWait: 1000 }) const clearCode = () => { modelValue.value = "" diff --git a/apps/web/src/shared/composables/useMermaid.ts b/apps/web/src/shared/composables/useMermaid.ts index 0925627..942c462 100644 --- a/apps/web/src/shared/composables/useMermaid.ts +++ b/apps/web/src/shared/composables/useMermaid.ts @@ -256,25 +256,41 @@ function getChromeVersion(): number { return match ? parseInt(match[1]) : Infinity } -let mermaidInstance: any = null +let mermaidPromise: Promise | null = null let mermaidIsLegacy = false -async function loadMermaid() { - if (!mermaidInstance) { - if (getChromeVersion() < 94) { - mermaidInstance = (await import("mermaid-legacy")).default - mermaidIsLegacy = true - } else { - mermaidInstance = (await import("mermaid")).default - } - mermaidInstance.initialize({ - startOnLoad: false, - securityLevel: "strict", - theme: "base", - themeVariables: mermaidThemeVariables, +function loadMermaid(): Promise { + // 缓存 Promise 而不是实例:同一屏里两个组件一起挂载时,缓存实例会让两边都 + // 落进 if (!mermaidInstance),import 两次、initialize 两次 + if (!mermaidPromise) { + mermaidPromise = (async () => { + let instance: any + if (getChromeVersion() < 94) { + instance = (await import("mermaid-legacy")).default + mermaidIsLegacy = true + } else { + instance = (await import("mermaid")).default + } + 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() {