Files
OJ2/apps/web/src/shared/composables/useMermaid.ts
yuetsh 5f0fe713dc 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>
2026-08-27 06:14:40 -06:00

351 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { getRandomId } from "utils/functions"
const mermaidThemeVariables = {
primaryColor: "#e0f2fe",
primaryTextColor: "#0f172a",
primaryBorderColor: "#0284c7",
lineColor: "#64748b",
arrowheadColor: "#64748b",
secondaryColor: "#f5f3ff",
tertiaryColor: "#ecfdf5",
background: "#ffffff",
mainBkg: "#f8fafc",
secondBkg: "#eef2ff",
tertiaryBkg: "#f0fdfa",
nodeBorder: "#2563eb",
clusterBkg: "#f8fafc",
clusterBorder: "#cbd5e1",
edgeLabelBackground: "#ffffff",
fontFamily:
'Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
}
const semanticNodeClasses = [
"startNode",
"endNode",
"startEnd",
"input",
"output",
"process",
"decision",
"loop",
]
const displayStyleId = "oj-mermaid-display-style"
const mermaidDisplayStyle = `
.oj-mermaid-flowchart {
max-width: 100%;
height: auto;
}
.oj-mermaid-flowchart g.node rect,
.oj-mermaid-flowchart g.node polygon,
.oj-mermaid-flowchart g.node ellipse,
.oj-mermaid-flowchart g.node circle,
.oj-mermaid-flowchart g.node path {
stroke-width: 2px !important;
filter: drop-shadow(0 6px 12px rgba(15, 23, 42, 0.12));
}
.oj-mermaid-flowchart g.node.startNode rect,
.oj-mermaid-flowchart g.node.startNode polygon,
.oj-mermaid-flowchart g.node.startNode ellipse,
.oj-mermaid-flowchart g.node.startNode circle,
.oj-mermaid-flowchart g.node.startNode path,
.oj-mermaid-flowchart g.node.startEnd rect,
.oj-mermaid-flowchart g.node.startEnd polygon,
.oj-mermaid-flowchart g.node.startEnd ellipse,
.oj-mermaid-flowchart g.node.startEnd circle,
.oj-mermaid-flowchart g.node.startEnd path {
fill: #dcfce7 !important;
stroke: #16a34a !important;
}
.oj-mermaid-flowchart g.node.endNode rect,
.oj-mermaid-flowchart g.node.endNode polygon,
.oj-mermaid-flowchart g.node.endNode ellipse,
.oj-mermaid-flowchart g.node.endNode circle,
.oj-mermaid-flowchart g.node.endNode path {
fill: #fee2e2 !important;
stroke: #dc2626 !important;
}
.oj-mermaid-flowchart g.node.input rect,
.oj-mermaid-flowchart g.node.input polygon,
.oj-mermaid-flowchart g.node.input ellipse,
.oj-mermaid-flowchart g.node.input circle,
.oj-mermaid-flowchart g.node.input path {
fill: #dbeafe !important;
stroke: #2563eb !important;
}
.oj-mermaid-flowchart g.node.output rect,
.oj-mermaid-flowchart g.node.output polygon,
.oj-mermaid-flowchart g.node.output ellipse,
.oj-mermaid-flowchart g.node.output circle,
.oj-mermaid-flowchart g.node.output path {
fill: #ede9fe !important;
stroke: #7c3aed !important;
}
.oj-mermaid-flowchart g.node.process rect,
.oj-mermaid-flowchart g.node.process polygon,
.oj-mermaid-flowchart g.node.process ellipse,
.oj-mermaid-flowchart g.node.process circle,
.oj-mermaid-flowchart g.node.process path {
fill: #f0f9ff !important;
stroke: #0284c7 !important;
}
.oj-mermaid-flowchart g.node.decision rect,
.oj-mermaid-flowchart g.node.decision polygon,
.oj-mermaid-flowchart g.node.decision ellipse,
.oj-mermaid-flowchart g.node.decision circle,
.oj-mermaid-flowchart g.node.decision path {
fill: #fef3c7 !important;
stroke: #d97706 !important;
}
.oj-mermaid-flowchart g.node.loop rect,
.oj-mermaid-flowchart g.node.loop polygon,
.oj-mermaid-flowchart g.node.loop ellipse,
.oj-mermaid-flowchart g.node.loop circle,
.oj-mermaid-flowchart g.node.loop path {
fill: #fae8ff !important;
stroke: #c026d3 !important;
}
.oj-mermaid-flowchart g.node.oj-node-palette-0 rect,
.oj-mermaid-flowchart g.node.oj-node-palette-0 polygon,
.oj-mermaid-flowchart g.node.oj-node-palette-0 ellipse,
.oj-mermaid-flowchart g.node.oj-node-palette-0 circle,
.oj-mermaid-flowchart g.node.oj-node-palette-0 path {
fill: #dbeafe !important;
stroke: #2563eb !important;
}
.oj-mermaid-flowchart g.node.oj-node-palette-1 rect,
.oj-mermaid-flowchart g.node.oj-node-palette-1 polygon,
.oj-mermaid-flowchart g.node.oj-node-palette-1 ellipse,
.oj-mermaid-flowchart g.node.oj-node-palette-1 circle,
.oj-mermaid-flowchart g.node.oj-node-palette-1 path {
fill: #ccfbf1 !important;
stroke: #0d9488 !important;
}
.oj-mermaid-flowchart g.node.oj-node-palette-2 rect,
.oj-mermaid-flowchart g.node.oj-node-palette-2 polygon,
.oj-mermaid-flowchart g.node.oj-node-palette-2 ellipse,
.oj-mermaid-flowchart g.node.oj-node-palette-2 circle,
.oj-mermaid-flowchart g.node.oj-node-palette-2 path {
fill: #ede9fe !important;
stroke: #7c3aed !important;
}
.oj-mermaid-flowchart g.node.oj-node-palette-3 rect,
.oj-mermaid-flowchart g.node.oj-node-palette-3 polygon,
.oj-mermaid-flowchart g.node.oj-node-palette-3 ellipse,
.oj-mermaid-flowchart g.node.oj-node-palette-3 circle,
.oj-mermaid-flowchart g.node.oj-node-palette-3 path {
fill: #ffe4e6 !important;
stroke: #e11d48 !important;
}
.oj-mermaid-flowchart g.node.oj-node-palette-4 rect,
.oj-mermaid-flowchart g.node.oj-node-palette-4 polygon,
.oj-mermaid-flowchart g.node.oj-node-palette-4 ellipse,
.oj-mermaid-flowchart g.node.oj-node-palette-4 circle,
.oj-mermaid-flowchart g.node.oj-node-palette-4 path {
fill: #fef3c7 !important;
stroke: #d97706 !important;
}
.oj-mermaid-flowchart g.node.oj-node-palette-5 rect,
.oj-mermaid-flowchart g.node.oj-node-palette-5 polygon,
.oj-mermaid-flowchart g.node.oj-node-palette-5 ellipse,
.oj-mermaid-flowchart g.node.oj-node-palette-5 circle,
.oj-mermaid-flowchart g.node.oj-node-palette-5 path {
fill: #dcfce7 !important;
stroke: #16a34a !important;
}
.oj-mermaid-flowchart g.node .label,
.oj-mermaid-flowchart g.node .nodeLabel,
.oj-mermaid-flowchart g.node .nodeLabel p,
.oj-mermaid-flowchart g.node .label span {
color: #0f172a !important;
fill: #0f172a !important;
font-weight: 650 !important;
}
.oj-mermaid-flowchart .edgePaths path.path,
.oj-mermaid-flowchart .flowchart-link {
stroke: #64748b !important;
stroke-width: 2.4px !important;
}
.oj-mermaid-flowchart marker path,
.oj-mermaid-flowchart .marker {
fill: #64748b !important;
stroke: #64748b !important;
}
.oj-mermaid-flowchart .edgeLabel rect,
.oj-mermaid-flowchart .edgeLabel .labelBkg {
fill: rgba(255, 255, 255, 0.94) !important;
stroke: #cbd5e1 !important;
}
.oj-mermaid-flowchart .edgeLabel,
.oj-mermaid-flowchart .edgeLabel span,
.oj-mermaid-flowchart .edgeLabel p {
color: #334155 !important;
font-weight: 600 !important;
}
`
const svgNamespace = "http://www.w3.org/2000/svg"
function getNodeLabel(node: SVGGElement): string {
const el =
node.querySelector(".nodeLabel p") ||
node.querySelector(".nodeLabel") ||
node.querySelector(".label span") ||
node.querySelector(".label")
return el?.textContent?.trim() ?? ""
}
function applyFlowchartDisplayStyle(container: HTMLElement) {
container.classList.add("oj-mermaid-surface")
const svg = container.querySelector("svg")
if (!svg) return
svg.classList.add("oj-mermaid-flowchart")
const nodes = Array.from(svg.querySelectorAll<SVGGElement>("g.node"))
// Assign palette indices by label so same label → same color, different labels → different colors
const labelPaletteMap = new Map<string, number>()
let paletteCounter = 0
nodes.forEach((node) => {
const hasSemanticClass = semanticNodeClasses.some((className) =>
node.classList.contains(className),
)
if (!hasSemanticClass) {
const label = getNodeLabel(node)
if (!labelPaletteMap.has(label)) {
labelPaletteMap.set(label, paletteCounter % 6)
paletteCounter++
}
node.classList.add(`oj-node-palette-${labelPaletteMap.get(label)}`)
}
})
svg.querySelector(`#${displayStyleId}`)?.remove()
const style = document.createElementNS(svgNamespace, "style")
style.setAttribute("id", displayStyleId)
style.textContent = mermaidDisplayStyle
svg.insertBefore(style, svg.firstChild)
}
function getChromeVersion(): number {
const match = navigator.userAgent.match(/Chrome\/(\d+)/)
return match ? parseInt(match[1]) : Infinity
}
let mermaidPromise: Promise<any> | null = null
let mermaidIsLegacy = false
function loadMermaid(): Promise<any> {
// 缓存 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 mermaidPromise
}
export function useMermaid() {
const renderError = ref<string | null>(null)
const renderSuccess = ref(false)
let renderGeneration = 0
const renderFlowchart = async (
container: HTMLElement | null,
mermaidCode: string,
) => {
renderError.value = null
renderSuccess.value = false
if (container) container.innerHTML = ""
if (!container || !mermaidCode?.trim()) return
const gen = ++renderGeneration
try {
const m = await loadMermaid()
const id = `mermaid-${getRandomId()}`
// v9 (mermaid-legacy): callback-based render(id, code, cb)
// v10+: Promise-based render(id, code) → { svg }
const svg = mermaidIsLegacy
? await new Promise<string>((resolve, reject) => {
try {
m.render(id, mermaidCode, resolve)
} catch (e) {
reject(e)
}
})
: (await m.render(id, mermaidCode)).svg
if (gen !== renderGeneration) return
container.innerHTML = svg
applyFlowchartDisplayStyle(container)
renderSuccess.value = true
} catch (error) {
if (gen !== renderGeneration) return
renderError.value =
error instanceof Error
? error.message
: "流程图渲染失败,请检查代码格式"
}
}
const clearError = () => {
renderError.value = null
}
return {
renderError: readonly(renderError),
renderSuccess: readonly(renderSuccess),
renderFlowchart,
clearError,
}
}