md2ui 1.0.7 → 1.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +104 -38
- package/bin/build.js +609 -0
- package/bin/md2ui.js +112 -63
- package/index.html +43 -1
- package/package.json +6 -5
- package/src/App.vue +106 -250
- package/src/api/docs.js +2 -1
- package/src/components/AppSidebar.vue +102 -0
- package/src/components/DocContent.vue +39 -0
- package/src/components/Logo.vue +5 -9
- package/src/components/MobileHeader.vue +20 -0
- package/src/components/MobileToc.vue +40 -0
- package/src/components/SearchPanel.vue +90 -0
- package/src/components/TableOfContents.vue +2 -2
- package/src/components/TopBar.vue +144 -0
- package/src/components/WelcomePage.vue +251 -0
- package/src/composables/useDocHash.js +66 -0
- package/src/composables/useDocManager.js +239 -0
- package/src/composables/useDocTree.js +80 -0
- package/src/composables/useFrontmatter.js +41 -0
- package/src/composables/useMarkdown.js +316 -94
- package/src/composables/useMobile.js +29 -0
- package/src/composables/useScroll.js +9 -15
- package/src/composables/useSearch.js +133 -0
- package/src/hljs-theme.css +104 -0
- package/src/main.js +1 -0
- package/src/style.css +935 -213
|
@@ -1,128 +1,350 @@
|
|
|
1
1
|
import { ref, nextTick } from 'vue'
|
|
2
2
|
import { marked } from 'marked'
|
|
3
3
|
import mermaid from 'mermaid'
|
|
4
|
+
import GithubSlugger from 'github-slugger'
|
|
5
|
+
import hljs from 'highlight.js'
|
|
6
|
+
import { parseFrontmatter, calcReadingTime } from './useFrontmatter.js'
|
|
7
|
+
import { docHash, resolveDocKey, findDocInTree } from './useDocHash.js'
|
|
4
8
|
|
|
5
|
-
// 初始化 Mermaid
|
|
9
|
+
// 初始化 Mermaid — 基于 base 主题自定义柔和蓝紫色调
|
|
6
10
|
mermaid.initialize({
|
|
7
11
|
startOnLoad: false,
|
|
8
|
-
theme: '
|
|
9
|
-
|
|
12
|
+
theme: 'base',
|
|
13
|
+
// Mermaid 内置主题有这几个:default、neutral、dark、forest、base。
|
|
14
|
+
securityLevel: 'loose',
|
|
15
|
+
themeVariables: {
|
|
16
|
+
// 基础色调
|
|
17
|
+
primaryColor: '#e8eaf6',
|
|
18
|
+
primaryTextColor: '#37474f',
|
|
19
|
+
primaryBorderColor: '#7986cb',
|
|
20
|
+
// 线条与标签
|
|
21
|
+
lineColor: '#90a4ae',
|
|
22
|
+
textColor: '#455a64',
|
|
23
|
+
// 次要 / 第三色
|
|
24
|
+
secondaryColor: '#f3e5f5',
|
|
25
|
+
secondaryBorderColor: '#ba68c8',
|
|
26
|
+
secondaryTextColor: '#4a148c',
|
|
27
|
+
tertiaryColor: '#e0f7fa',
|
|
28
|
+
tertiaryBorderColor: '#4dd0e1',
|
|
29
|
+
tertiaryTextColor: '#006064',
|
|
30
|
+
// 字体
|
|
31
|
+
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
|
32
|
+
fontSize: '14px',
|
|
33
|
+
// 节点样式
|
|
34
|
+
nodeBorder: '#7986cb',
|
|
35
|
+
nodeTextColor: '#37474f',
|
|
36
|
+
// 序列图
|
|
37
|
+
actorBkg: '#e8eaf6',
|
|
38
|
+
actorBorder: '#7986cb',
|
|
39
|
+
actorTextColor: '#37474f',
|
|
40
|
+
signalColor: '#5c6bc0',
|
|
41
|
+
signalTextColor: '#37474f',
|
|
42
|
+
// 甘特图
|
|
43
|
+
sectionBkgColor: '#e8eaf6',
|
|
44
|
+
altSectionBkgColor: '#f3e5f5',
|
|
45
|
+
taskBkgColor: '#7986cb',
|
|
46
|
+
taskTextColor: '#ffffff',
|
|
47
|
+
activeTaskBkgColor: '#5c6bc0',
|
|
48
|
+
doneTaskBkgColor: '#9fa8da',
|
|
49
|
+
// 饼图
|
|
50
|
+
pie1: '#7986cb',
|
|
51
|
+
pie2: '#ba68c8',
|
|
52
|
+
pie3: '#4dd0e1',
|
|
53
|
+
pie4: '#ffb74d',
|
|
54
|
+
pie5: '#a1887f',
|
|
55
|
+
// 类图
|
|
56
|
+
classText: '#37474f',
|
|
57
|
+
// 状态图
|
|
58
|
+
labelColor: '#37474f',
|
|
59
|
+
// 背景
|
|
60
|
+
mainBkg: '#e8eaf6',
|
|
61
|
+
nodeBkg: '#e8eaf6',
|
|
62
|
+
background: '#ffffff',
|
|
63
|
+
}
|
|
10
64
|
})
|
|
11
65
|
|
|
12
|
-
//
|
|
13
|
-
|
|
14
|
-
const
|
|
66
|
+
// 创建自定义渲染器,处理链接、标题锚点和 Mermaid
|
|
67
|
+
function createRenderer(currentDocKey, docsList) {
|
|
68
|
+
const renderer = new marked.Renderer()
|
|
69
|
+
const slugger = new GithubSlugger()
|
|
15
70
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const id =
|
|
19
|
-
|
|
71
|
+
// 标题渲染:使用 github-slugger 生成语义化锚点 ID
|
|
72
|
+
renderer.heading = function(text, level) {
|
|
73
|
+
const id = slugger.slug(text)
|
|
74
|
+
const linkIcon = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>`
|
|
75
|
+
return `<h${level} id="${id}"><a class="heading-anchor" href="#${id}" data-anchor="${id}" aria-hidden="true">${linkIcon}</a>${text}</h${level}>\n`
|
|
20
76
|
}
|
|
21
|
-
return originalCodeRenderer(code, language)
|
|
22
|
-
}
|
|
23
77
|
|
|
24
|
-
|
|
25
|
-
renderer,
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
78
|
+
// 代码块渲染:Mermaid 图表 / 语法高亮 + 复制按钮
|
|
79
|
+
renderer.code = function(code, language) {
|
|
80
|
+
if (language === 'mermaid') {
|
|
81
|
+
const id = 'mermaid-' + Math.random().toString(36).substr(2, 9)
|
|
82
|
+
return `<div class="mermaid" id="${id}">${code}</div>`
|
|
83
|
+
}
|
|
84
|
+
// 高亮代码
|
|
85
|
+
let highlighted
|
|
86
|
+
if (language && hljs.getLanguage(language)) {
|
|
87
|
+
highlighted = hljs.highlight(code, { language }).value
|
|
88
|
+
} else {
|
|
89
|
+
highlighted = hljs.highlightAuto(code).value
|
|
90
|
+
}
|
|
31
91
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
92
|
+
// 生成行号(按换行拆分)
|
|
93
|
+
const lines = code.split('\n')
|
|
94
|
+
const lineCount = lines.length
|
|
95
|
+
const lineNums = lines.map((_, i) => `<span class="line-num">${i + 1}</span>`).join('')
|
|
35
96
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}
|
|
97
|
+
const langLabel = (language || '').toUpperCase()
|
|
98
|
+
// 切换行号按钮(列表图标)
|
|
99
|
+
const toggleLineNumIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>`
|
|
100
|
+
// 切换高亮按钮(</> 文本图标)
|
|
101
|
+
const toggleHighlightIcon = `<span class="code-icon-text"></></span>`
|
|
102
|
+
// 复制按钮图标
|
|
103
|
+
const copyIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>`
|
|
44
104
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
|
|
105
|
+
return `<div class="code-block-wrapper" data-raw-code="${encodeURIComponent(code)}" data-lang="${language || ''}">
|
|
106
|
+
<div class="code-block-header">
|
|
107
|
+
<span class="code-lang-label">${langLabel}</span>
|
|
108
|
+
<div class="code-block-actions">
|
|
109
|
+
<button class="code-action-btn toggle-line-num-btn active" data-tooltip="隐藏行号">${toggleLineNumIcon}</button>
|
|
110
|
+
<button class="code-action-btn toggle-highlight-btn active" data-tooltip="关闭高亮">${toggleHighlightIcon}</button>
|
|
111
|
+
<button class="code-action-btn copy-code-btn" data-tooltip="复制代码">${copyIcon}<span class="copy-text">复制</span></button>
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
<div class="code-block-body">
|
|
115
|
+
<div class="line-numbers" aria-hidden="true">${lineNums}</div>
|
|
116
|
+
<pre><code class="hljs${language ? ` language-${language}` : ''}">${highlighted}</code></pre>
|
|
117
|
+
</div>
|
|
118
|
+
</div>`
|
|
59
119
|
}
|
|
60
120
|
|
|
61
|
-
//
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
121
|
+
// 链接渲染:站内/站外分类处理
|
|
122
|
+
renderer.link = function(href, title, text) {
|
|
123
|
+
const decoded = decodeURIComponent(href || '')
|
|
124
|
+
const titleAttr = title ? ` title="${title}"` : ''
|
|
125
|
+
|
|
126
|
+
// 站外链接
|
|
127
|
+
if (/^(https?|mailto|tel):/.test(decoded)) {
|
|
128
|
+
return `<a href="${href}"${titleAttr} target="_blank" rel="noopener">${text}</a>`
|
|
129
|
+
}
|
|
130
|
+
// 站内纯锚点:# 后的内容已是 slug 格式,无需再次 slug(避免计数器追加后缀)
|
|
131
|
+
if (decoded.startsWith('#')) {
|
|
132
|
+
const anchor = decoded.slice(1)
|
|
133
|
+
return `<a href="javascript:void(0)" data-anchor="${anchor}"${titleAttr}>${text}</a>`
|
|
134
|
+
}
|
|
135
|
+
// 站内 .md 文档链接
|
|
136
|
+
if (decoded.endsWith('.md') || decoded.includes('.md#')) {
|
|
137
|
+
const [mdPath, anchor] = decoded.includes('#') ? decoded.split('#') : [decoded, '']
|
|
138
|
+
const targetKey = resolveDocKey(mdPath, currentDocKey)
|
|
139
|
+
const doc = findDocInTree(docsList, targetKey)
|
|
140
|
+
if (doc) {
|
|
141
|
+
const hash = docHash(doc.key)
|
|
142
|
+
// anchor 已是 slug 格式,无需再次 slug
|
|
143
|
+
const url = anchor ? `/${hash}#${anchor}` : `/${hash}`
|
|
144
|
+
return `<a href="${url}" data-doc-key="${doc.key}"${anchor ? ` data-anchor="${anchor}"` : ''}${titleAttr}>${text}</a>`
|
|
80
145
|
}
|
|
146
|
+
return `<a href="javascript:void(0)" class="broken-link" title="文档未找到: ${decoded}">${text}</a>`
|
|
81
147
|
}
|
|
148
|
+
// 其他相对链接
|
|
149
|
+
return `<a href="${href}"${titleAttr} target="_blank" rel="noopener">${text}</a>`
|
|
82
150
|
}
|
|
83
151
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
152
|
+
return renderer
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
// ---- 后处理器 ----
|
|
157
|
+
|
|
158
|
+
// 渲染 Mermaid 图表
|
|
159
|
+
async function renderMermaid() {
|
|
160
|
+
await nextTick()
|
|
161
|
+
const mermaidElements = document.querySelectorAll('.mermaid')
|
|
162
|
+
for (const element of mermaidElements) {
|
|
163
|
+
try {
|
|
164
|
+
const id = element.id
|
|
165
|
+
const code = element.textContent
|
|
166
|
+
const { svg } = await mermaid.render(id + '-svg', code)
|
|
167
|
+
element.innerHTML = svg
|
|
168
|
+
element.classList.add('zoomable-image')
|
|
169
|
+
element.style.cursor = 'zoom-in'
|
|
170
|
+
element.title = '点击放大查看'
|
|
171
|
+
} catch (error) {
|
|
172
|
+
console.error('Mermaid 渲染失败:', error)
|
|
173
|
+
// 清理 Mermaid 渲染失败时残留在 DOM 中的错误容器
|
|
174
|
+
const errorEl = document.getElementById(element.id + '-svg')
|
|
175
|
+
if (errorEl) errorEl.remove()
|
|
176
|
+
element.innerHTML = `<pre class="mermaid-error">图表渲染失败\n${error.message}</pre>`
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// 为表格添加滚动容器
|
|
182
|
+
function wrapTables() {
|
|
183
|
+
nextTick(() => {
|
|
184
|
+
const tables = document.querySelectorAll('.markdown-content table')
|
|
185
|
+
tables.forEach(table => {
|
|
186
|
+
if (!table.parentElement.classList.contains('table-wrapper')) {
|
|
187
|
+
const wrapper = document.createElement('div')
|
|
188
|
+
wrapper.className = 'table-wrapper'
|
|
189
|
+
table.parentNode.insertBefore(wrapper, table)
|
|
190
|
+
wrapper.appendChild(table)
|
|
191
|
+
}
|
|
192
|
+
})
|
|
193
|
+
})
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// 为图片添加放大功能
|
|
197
|
+
function addImageZoomHandlers() {
|
|
198
|
+
nextTick(() => {
|
|
199
|
+
const images = document.querySelectorAll('.markdown-content img')
|
|
200
|
+
images.forEach(img => {
|
|
201
|
+
img.classList.add('zoomable-image')
|
|
202
|
+
img.style.cursor = 'zoom-in'
|
|
203
|
+
img.title = '点击放大查看'
|
|
204
|
+
})
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// 为代码块添加交互事件(复制、切换行号、切换高亮)
|
|
209
|
+
function addCodeBlockHandlers() {
|
|
210
|
+
nextTick(() => {
|
|
211
|
+
// 复制按钮
|
|
212
|
+
document.querySelectorAll('.copy-code-btn').forEach(btn => {
|
|
213
|
+
btn.addEventListener('click', async () => {
|
|
214
|
+
const wrapper = btn.closest('.code-block-wrapper')
|
|
215
|
+
const code = wrapper.querySelector('code')
|
|
216
|
+
if (!code) return
|
|
217
|
+
try {
|
|
218
|
+
await navigator.clipboard.writeText(code.textContent)
|
|
219
|
+
const textEl = btn.querySelector('.copy-text')
|
|
220
|
+
textEl.textContent = '已复制'
|
|
221
|
+
btn.classList.add('copied')
|
|
222
|
+
setTimeout(() => {
|
|
223
|
+
textEl.textContent = '复制'
|
|
224
|
+
btn.classList.remove('copied')
|
|
225
|
+
}, 2000)
|
|
226
|
+
} catch {
|
|
227
|
+
const range = document.createRange()
|
|
228
|
+
range.selectNodeContents(code)
|
|
229
|
+
window.getSelection().removeAllRanges()
|
|
230
|
+
window.getSelection().addRange(range)
|
|
231
|
+
}
|
|
232
|
+
})
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
// 切换行号
|
|
236
|
+
document.querySelectorAll('.toggle-line-num-btn').forEach(btn => {
|
|
237
|
+
btn.addEventListener('click', () => {
|
|
238
|
+
const wrapper = btn.closest('.code-block-wrapper')
|
|
239
|
+
const lineNumbers = wrapper.querySelector('.line-numbers')
|
|
240
|
+
if (!lineNumbers) return
|
|
241
|
+
btn.classList.toggle('active')
|
|
242
|
+
lineNumbers.classList.toggle('hidden')
|
|
243
|
+
btn.dataset.tooltip = btn.classList.contains('active') ? '隐藏行号' : '显示行号'
|
|
93
244
|
})
|
|
94
245
|
})
|
|
95
|
-
}
|
|
96
246
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
247
|
+
// 切换语法高亮
|
|
248
|
+
document.querySelectorAll('.toggle-highlight-btn').forEach(btn => {
|
|
249
|
+
btn.addEventListener('click', () => {
|
|
250
|
+
const wrapper = btn.closest('.code-block-wrapper')
|
|
251
|
+
const codeEl = wrapper.querySelector('code')
|
|
252
|
+
if (!codeEl) return
|
|
253
|
+
const rawCode = decodeURIComponent(wrapper.dataset.rawCode || '')
|
|
254
|
+
const lang = wrapper.dataset.lang || ''
|
|
255
|
+
const isHighlighted = btn.classList.contains('active')
|
|
256
|
+
|
|
257
|
+
if (isHighlighted) {
|
|
258
|
+
// 关闭高亮:显示纯文本
|
|
259
|
+
codeEl.textContent = rawCode
|
|
260
|
+
codeEl.className = 'code-plain'
|
|
261
|
+
btn.classList.remove('active')
|
|
262
|
+
btn.dataset.tooltip = '开启高亮'
|
|
263
|
+
} else {
|
|
264
|
+
// 开启高亮:重新渲染
|
|
265
|
+
let highlighted
|
|
266
|
+
if (lang && hljs.getLanguage(lang)) {
|
|
267
|
+
highlighted = hljs.highlight(rawCode, { language: lang }).value
|
|
268
|
+
} else {
|
|
269
|
+
highlighted = hljs.highlightAuto(rawCode).value
|
|
270
|
+
}
|
|
271
|
+
codeEl.innerHTML = highlighted
|
|
272
|
+
codeEl.className = `hljs${lang ? ` language-${lang}` : ''}`
|
|
273
|
+
btn.classList.add('active')
|
|
274
|
+
btn.dataset.tooltip = '关闭高亮'
|
|
111
275
|
}
|
|
112
|
-
|
|
113
|
-
tocItems.value.push({
|
|
114
|
-
id,
|
|
115
|
-
text,
|
|
116
|
-
level
|
|
117
|
-
})
|
|
118
276
|
})
|
|
119
277
|
})
|
|
278
|
+
})
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// 提取文档大纲
|
|
282
|
+
function extractTOC(tocItems) {
|
|
283
|
+
tocItems.value = []
|
|
284
|
+
nextTick(() => {
|
|
285
|
+
const headings = document.querySelectorAll('.markdown-content h1, .markdown-content h2, .markdown-content h3, .markdown-content h4, .markdown-content h5, .markdown-content h6')
|
|
286
|
+
headings.forEach(heading => {
|
|
287
|
+
const level = parseInt(heading.tagName.substring(1))
|
|
288
|
+
const clone = heading.cloneNode(true)
|
|
289
|
+
clone.querySelectorAll('.heading-anchor').forEach(a => a.remove())
|
|
290
|
+
const text = clone.textContent.trim()
|
|
291
|
+
const id = heading.id
|
|
292
|
+
if (id) {
|
|
293
|
+
tocItems.value.push({ id, text, level })
|
|
294
|
+
}
|
|
295
|
+
})
|
|
296
|
+
})
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ---- 主 composable ----
|
|
300
|
+
|
|
301
|
+
export function useMarkdown() {
|
|
302
|
+
const htmlContent = ref('')
|
|
303
|
+
const tocItems = ref([])
|
|
304
|
+
|
|
305
|
+
// 渲染 Markdown,传入当前文档 key 和文档列表用于链接改写
|
|
306
|
+
async function renderMarkdown(markdown, currentDocKey = '', docsList = []) {
|
|
307
|
+
const { data: frontmatter, content } = parseFrontmatter(markdown)
|
|
308
|
+
const renderer = createRenderer(currentDocKey, docsList)
|
|
309
|
+
marked.setOptions({
|
|
310
|
+
renderer,
|
|
311
|
+
breaks: true,
|
|
312
|
+
gfm: true,
|
|
313
|
+
headerIds: false,
|
|
314
|
+
mangle: false
|
|
315
|
+
})
|
|
316
|
+
htmlContent.value = marked.parse(content)
|
|
317
|
+
|
|
318
|
+
// frontmatter.title 覆盖第一个 h1
|
|
319
|
+
if (frontmatter.title) {
|
|
320
|
+
htmlContent.value = htmlContent.value.replace(
|
|
321
|
+
/<h1[^>]*>[\s\S]*?<\/h1>/,
|
|
322
|
+
`<h1 id="${new GithubSlugger().slug(frontmatter.title)}">${frontmatter.title}</h1>`
|
|
323
|
+
)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// 在第一个 h1 后插入阅读元信息
|
|
327
|
+
const { totalChars, minutes } = calcReadingTime(content)
|
|
328
|
+
if (totalChars > 0) {
|
|
329
|
+
const metaParts = [`${totalChars} 字`, `约 ${minutes} 分钟`]
|
|
330
|
+
if (frontmatter.description) metaParts.push(frontmatter.description)
|
|
331
|
+
const metaHtml = `<div class="doc-meta">${metaParts.map(p => `<span class="doc-meta-item">${p}</span>`).join('<span class="doc-meta-sep">·</span>')}</div>`
|
|
332
|
+
htmlContent.value = htmlContent.value.replace(/(<\/h1>)/, `$1\n${metaHtml}`)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// 后处理
|
|
336
|
+
await renderMermaid()
|
|
337
|
+
wrapTables()
|
|
338
|
+
addImageZoomHandlers()
|
|
339
|
+
addCodeBlockHandlers()
|
|
340
|
+
extractTOC(tocItems)
|
|
120
341
|
}
|
|
121
342
|
|
|
122
343
|
return {
|
|
123
344
|
htmlContent,
|
|
124
345
|
tocItems,
|
|
125
346
|
renderMarkdown,
|
|
126
|
-
addImageZoomHandlers
|
|
347
|
+
addImageZoomHandlers,
|
|
348
|
+
docHash
|
|
127
349
|
}
|
|
128
350
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { ref } from 'vue'
|
|
2
|
+
|
|
3
|
+
const MOBILE_BREAKPOINT = 768
|
|
4
|
+
|
|
5
|
+
// 单例状态,确保多处调用共享同一份
|
|
6
|
+
const isMobile = ref(false)
|
|
7
|
+
const mobileDrawerOpen = ref(false)
|
|
8
|
+
const mobileTocOpen = ref(false)
|
|
9
|
+
let listenerAttached = false
|
|
10
|
+
|
|
11
|
+
// 移动端检测 & 抽屉/TOC 面板状态
|
|
12
|
+
export function useMobile() {
|
|
13
|
+
function checkMobile() {
|
|
14
|
+
isMobile.value = window.innerWidth <= MOBILE_BREAKPOINT
|
|
15
|
+
if (!isMobile.value) {
|
|
16
|
+
mobileDrawerOpen.value = false
|
|
17
|
+
mobileTocOpen.value = false
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// 只绑定一次事件监听
|
|
22
|
+
if (!listenerAttached && typeof window !== 'undefined') {
|
|
23
|
+
checkMobile()
|
|
24
|
+
window.addEventListener('resize', checkMobile)
|
|
25
|
+
listenerAttached = true
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return { isMobile, mobileDrawerOpen, mobileTocOpen }
|
|
29
|
+
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { ref } from 'vue'
|
|
2
2
|
|
|
3
|
+
// 单例状态,确保多处调用共享同一份
|
|
4
|
+
const scrollProgress = ref(0)
|
|
5
|
+
const showBackToTop = ref(false)
|
|
6
|
+
const activeHeading = ref('')
|
|
7
|
+
|
|
3
8
|
export function useScroll() {
|
|
4
|
-
const scrollProgress = ref(0)
|
|
5
|
-
const showBackToTop = ref(false)
|
|
6
|
-
const activeHeading = ref('')
|
|
7
9
|
|
|
8
10
|
// 监听滚动
|
|
9
11
|
function handleScroll(e) {
|
|
@@ -43,18 +45,10 @@ export function useScroll() {
|
|
|
43
45
|
|
|
44
46
|
// 滚动到指定标题
|
|
45
47
|
function scrollToHeading(id) {
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const contentRect = content.getBoundingClientRect()
|
|
51
|
-
const elementRect = element.getBoundingClientRect()
|
|
52
|
-
const offsetTop = elementRect.top - contentRect.top + content.scrollTop
|
|
53
|
-
|
|
54
|
-
content.scrollTo({
|
|
55
|
-
top: offsetTop - 20,
|
|
56
|
-
behavior: 'smooth'
|
|
57
|
-
})
|
|
48
|
+
const el = document.getElementById(id)
|
|
49
|
+
if (el) {
|
|
50
|
+
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
|
51
|
+
activeHeading.value = id
|
|
58
52
|
}
|
|
59
53
|
}
|
|
60
54
|
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { ref } from 'vue'
|
|
2
|
+
import MiniSearch from 'minisearch'
|
|
3
|
+
|
|
4
|
+
// 搜索索引实例
|
|
5
|
+
let searchIndex = null
|
|
6
|
+
// 缓存文档列表引用,用于懒加载
|
|
7
|
+
let pendingDocsList = null
|
|
8
|
+
|
|
9
|
+
// 扁平化文档树,提取所有文件节点
|
|
10
|
+
function flattenDocs(items, result = []) {
|
|
11
|
+
for (const item of items) {
|
|
12
|
+
if (item.type === 'file') result.push(item)
|
|
13
|
+
if (item.type === 'folder' && item.children) {
|
|
14
|
+
flattenDocs(item.children, result)
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return result
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// 单例状态,确保多处调用共享同一份
|
|
21
|
+
const searchVisible = ref(false)
|
|
22
|
+
const searchQuery = ref('')
|
|
23
|
+
const searchResults = ref([])
|
|
24
|
+
const searchReady = ref(false)
|
|
25
|
+
const indexBuilding = ref(false)
|
|
26
|
+
|
|
27
|
+
export function useSearch() {
|
|
28
|
+
|
|
29
|
+
// 注册文档列表(不立即构建索引,等用户打开搜索时再构建)
|
|
30
|
+
function buildIndex(docsList) {
|
|
31
|
+
pendingDocsList = docsList
|
|
32
|
+
// 如果索引已存在,标记需要重建
|
|
33
|
+
if (searchIndex) {
|
|
34
|
+
searchIndex = null
|
|
35
|
+
searchReady.value = false
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// 实际构建搜索索引
|
|
40
|
+
async function ensureIndex() {
|
|
41
|
+
if (searchReady.value || indexBuilding.value || !pendingDocsList) return
|
|
42
|
+
indexBuilding.value = true
|
|
43
|
+
|
|
44
|
+
const docs = flattenDocs(pendingDocsList)
|
|
45
|
+
const documents = []
|
|
46
|
+
|
|
47
|
+
for (const doc of docs) {
|
|
48
|
+
try {
|
|
49
|
+
const response = await fetch(doc.path)
|
|
50
|
+
if (response.ok) {
|
|
51
|
+
const content = await response.text()
|
|
52
|
+
documents.push({
|
|
53
|
+
id: doc.key,
|
|
54
|
+
title: doc.label,
|
|
55
|
+
content: content.replace(/^---[\s\S]*?---\n?/, ''), // 去掉 frontmatter
|
|
56
|
+
path: doc.path
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
} catch {
|
|
60
|
+
// 忽略加载失败的文档
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
searchIndex = new MiniSearch({
|
|
65
|
+
fields: ['title', 'content'],
|
|
66
|
+
storeFields: ['title'],
|
|
67
|
+
searchOptions: {
|
|
68
|
+
boost: { title: 3 },
|
|
69
|
+
fuzzy: 0.2,
|
|
70
|
+
prefix: true
|
|
71
|
+
},
|
|
72
|
+
// 中文分词:按标点、空格、换行分割
|
|
73
|
+
tokenize: (text) => {
|
|
74
|
+
const tokens = text.split(/[\s\n\r\t,.;:!?,。;:!?、()()【】\[\]{}""''""]+/)
|
|
75
|
+
.filter(t => t.length > 0)
|
|
76
|
+
// 对中文文本额外做 bigram 分词
|
|
77
|
+
const bigrams = []
|
|
78
|
+
for (const token of tokens) {
|
|
79
|
+
if (/[\u4e00-\u9fff]/.test(token) && token.length > 1) {
|
|
80
|
+
for (let i = 0; i < token.length - 1; i++) {
|
|
81
|
+
bigrams.push(token.slice(i, i + 2))
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return [...tokens, ...bigrams]
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
searchIndex.addAll(documents)
|
|
90
|
+
searchReady.value = true
|
|
91
|
+
indexBuilding.value = false
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 执行搜索
|
|
95
|
+
function doSearch(query) {
|
|
96
|
+
searchQuery.value = query
|
|
97
|
+
if (!query.trim() || !searchIndex) {
|
|
98
|
+
searchResults.value = []
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
const results = searchIndex.search(query, { limit: 20 })
|
|
102
|
+
searchResults.value = results.map(r => ({
|
|
103
|
+
key: r.id,
|
|
104
|
+
title: r.title,
|
|
105
|
+
score: r.score
|
|
106
|
+
}))
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// 打开搜索面板(触发懒加载索引构建)
|
|
110
|
+
async function openSearch() {
|
|
111
|
+
searchVisible.value = true
|
|
112
|
+
await ensureIndex()
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// 关闭搜索面板
|
|
116
|
+
function closeSearch() {
|
|
117
|
+
searchVisible.value = false
|
|
118
|
+
searchQuery.value = ''
|
|
119
|
+
searchResults.value = []
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
searchVisible,
|
|
124
|
+
searchQuery,
|
|
125
|
+
searchResults,
|
|
126
|
+
searchReady,
|
|
127
|
+
indexBuilding,
|
|
128
|
+
buildIndex,
|
|
129
|
+
doSearch,
|
|
130
|
+
openSearch,
|
|
131
|
+
closeSearch
|
|
132
|
+
}
|
|
133
|
+
}
|