11<template >
2- <div class =" md-content" v-html =" renderedHtml" />
2+ <div class =" md-content" v-html =" renderedHtml" @click = " onCodeAction " />
33</template >
44
55<script setup lang="ts">
@@ -10,6 +10,7 @@ import 'highlight.js/styles/github.css'
1010import katex from ' katex'
1111import ' katex/dist/katex.min.css'
1212import mermaid from ' mermaid'
13+ import * as echarts from ' echarts'
1314
1415const props = defineProps <{ content: string }>()
1516const renderedHtml = ref (' ' )
@@ -33,47 +34,98 @@ function protectFormulas(text: string): { text: string; blocks: string[] } {
3334 return { text , blocks }
3435}
3536
36- /** 渲染 markdown:代码高亮 + mermaid 图表 + 公式占位恢复 */
37+ /** 渲染 markdown:代码高亮(HTML 带复制/预览/下载) + mermaid/echart 图表 + 公式占位恢复 */
3738function renderMarkdown(raw : string ): string {
38- const { text, blocks } = protectFormulas (raw )
39- // mermaid 代码块 → 占位符
39+ let { text, blocks } = protectFormulas (raw )
40+ // echart 代码块 → 图表占位符
41+ const chartBlocks: string [] = []
42+ text = text .replace (/ ```echart\s * \n ([\s\S ] +? )```/ g , (_m , code : string ) => {
43+ chartBlocks .push (code .trim ())
44+ return ` @@CHART${chartBlocks .length - 1 }@@ `
45+ })
46+ // mermaid 代码块 → 收集(渲染后追加)
4047 const mermaidBlocks: string [] = []
4148 text .replace (/ ```mermaid\s * \n ([\s\S ] +? )```/ g , (_m , code : string ) => {
4249 mermaidBlocks .push (code .trim ())
4350 return ' '
4451 })
45- // marked 渲染(自定义 code renderer 加高亮与复制按钮 )
52+ // marked 渲染(自定义 code renderer 加高亮与操作按钮 )
4653 const renderer = new marked .Renderer ()
4754 renderer .code = ({ text : code , lang }) => {
4855 const langName = (lang || ' ' ).split (' ' )[0 ]
4956 let highlighted = ' '
5057 try { highlighted = langName && hljs .getLanguage (langName ) ? hljs .highlight (code , { language: langName }).value : hljs .highlightAuto (code ).value }
5158 catch { highlighted = code .replace (/ &/ g , ' &' ).replace (/ </ g , ' <' ).replace (/ >/ g , ' >' ) }
52- return ` <div class="code-block"><div class="code-head"><span>${langName || ' code' }</span><button class="copy-btn" data-code="${encodeURIComponent (code )}" onclick="navigator.clipboard.writeText(decodeURIComponent(this.dataset.code));this.textContent='已复制';setTimeout(()=>this.textContent='复制',1500)">复制</button></div><pre><code class="hljs">${highlighted }</code></pre></div> `
59+ const enc = encodeURIComponent (code )
60+ // HTML 代码块:复制/预览/下载;其他语言:仅复制
61+ const actions = langName .toLowerCase () === ' html'
62+ ? ` <button class="copy-btn" data-action="copy" data-code="${enc }">复制</button><button class="copy-btn" data-action="preview" data-code="${enc }">预览</button><button class="copy-btn" data-action="download" data-code="${enc }">下载</button> `
63+ : ` <button class="copy-btn" data-action="copy" data-code="${enc }">复制</button> `
64+ return ` <div class="code-block"><div class="code-head"><span>${langName || ' code' }</span><div class="code-actions">${actions }</div></div><pre><code class="hljs">${highlighted }</code></pre></div> `
5365 }
5466 marked .use ({ renderer , gfm: true , breaks: true })
5567 let html = marked .parse (text ) as string
5668 // 恢复公式占位
5769 blocks .forEach ((b , i ) => { html = html .replace (` @@BLOCK${i }@@ ` , b ) })
58- // mermaid 图表
70+ // 恢复 echart 图表占位
71+ chartBlocks .forEach ((code , i ) => {
72+ html = html .replace (` @@CHART${i }@@ ` , ` <div class="echart-box" data-option="${encodeURIComponent (code )}"></div> ` )
73+ })
74+ // mermaid 图表(追加到末尾渲染)
5975 mermaidBlocks .forEach ((code , i ) => {
6076 const id = ` mermaid_${Date .now ()}_${i } `
6177 html += ` <div class="mermaid" id="${id }">${code .replace (/ &/ g , ' &' ).replace (/ </ g , ' <' ).replace (/ >/ g , ' >' )}</div> `
6278 })
6379 return html
6480}
6581
82+ /** 代码块操作按钮(复制/预览/下载,事件委托) */
83+ function onCodeAction(e : MouseEvent ) {
84+ const btn = (e .target as HTMLElement ).closest ?.(' [data-action]' ) as HTMLElement | null
85+ if (! btn ) return
86+ const code = decodeURIComponent (btn .dataset .code || ' ' )
87+ const action = btn .dataset .action
88+ if (action === ' copy' ) {
89+ navigator .clipboard ?.writeText (code ).then (
90+ () => { btn .textContent = ' 已复制' ; setTimeout (() => { btn .textContent = ' 复制' }, 1500 ) },
91+ () => { /* 剪贴板不可用则忽略 */ }
92+ )
93+ } else if (action === ' preview' ) {
94+ const w = window .open (' ' , ' _blank' )
95+ if (! w ) return
96+ w .document .write (code )
97+ w .document .close ()
98+ } else if (action === ' download' ) {
99+ const blob = new Blob ([code ], { type: ' text/html' })
100+ const a = document .createElement (' a' )
101+ a .href = URL .createObjectURL (blob )
102+ a .download = ' preview.html'
103+ a .click ()
104+ URL .revokeObjectURL (a .href )
105+ }
106+ }
107+
66108async function render() {
67109 renderedHtml .value = renderMarkdown (props .content || ' ' )
68110 await nextTick ()
69111 // mermaid 渲染
70- const els = document .querySelectorAll (' .mermaid:not([data-processed])' )
71- for (const el of Array .from (els )) {
112+ const mermaidEls = document .querySelectorAll (' .mermaid:not([data-processed])' )
113+ for (const el of Array .from (mermaidEls )) {
72114 try {
73115 await mermaid .run ({ nodes: [el as HTMLElement ] })
74116 el .setAttribute (' data-processed' , ' 1' )
75117 } catch { el .innerHTML = ' <span style="color:#f56c6c">图表渲染失败</span>' }
76118 }
119+ // echart 图表渲染(```echart 代码块内容为 ECharts option JSON)
120+ const chartEls = document .querySelectorAll (' .echart-box:not([data-processed])' )
121+ for (const el of Array .from (chartEls )) {
122+ try {
123+ const optionJson = decodeURIComponent ((el as HTMLElement ).dataset .option || ' {}' )
124+ const chart = echarts .init (el as HTMLElement )
125+ chart .setOption (JSON .parse (optionJson ))
126+ el .setAttribute (' data-processed' , ' 1' )
127+ } catch { el .innerHTML = ' <span style="color:#f56c6c;padding:8px">图表配置解析失败(```echart 内应为 ECharts option JSON)</span>' }
128+ }
77129}
78130
79131watch (() => props .content , render )
@@ -92,10 +144,12 @@ onMounted(render)
92144.md-content :deep(a ) { color : #409eff ; }
93145.code-block { margin : 6px 0 ; border : 1px solid #e4e7ed ; border-radius : 6px ; overflow : hidden ; }
94146.code-head { display : flex ; justify-content : space-between ; align-items : center ; padding : 4px 10px ; background : #f6f8fa ; font-size : 11px ; color : #909399 ; }
147+ .code-actions { display : flex ; gap : 6px ; }
95148.copy-btn { border : none ; background : #ecf5ff ; color : #409eff ; font-size : 11px ; border-radius : 4px ; padding : 2px 8px ; cursor : pointer ; }
96149.code-block pre { margin : 0 ; padding : 8px 10px ; overflow-x : auto ; background : #fafafa ; font-size : 12px ; }
97150.code-block code { font-family : Consolas, Monaco, ' Courier New' , monospace ; }
98151.katex-block { overflow-x : auto ; padding : 6px 0 ; }
99152.md-content :deep(.mermaid ) { margin : 8px 0 ; text-align : center ; }
100153.md-content :deep(.mermaid svg ) { max-width : 100% ; }
154+ .md-content :deep(.echart-box ) { width : 100% ; height : 320px ; margin : 8px 0 ; border : 1px solid #f0f2f5 ; border-radius : 6px ; }
101155 </style >
0 commit comments