md2ui 1.0.7 → 1.0.9
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 +608 -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 +280 -0
- package/src/composables/useDocTree.js +96 -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
package/src/App.vue
CHANGED
|
@@ -1,286 +1,142 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div class="container">
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
2
|
+
<div class="container" :class="{ 'is-mobile': isMobile }">
|
|
3
|
+
<!-- 移动端顶栏 -->
|
|
4
|
+
<MobileHeader
|
|
5
|
+
v-if="isMobile"
|
|
6
|
+
@open-drawer="mobileDrawerOpen = true"
|
|
7
|
+
@go-home="goHome"
|
|
8
|
+
@open-search="openSearch"
|
|
9
|
+
/>
|
|
10
|
+
<!-- 桌面端顶栏 -->
|
|
11
|
+
<TopBar
|
|
12
|
+
v-if="!isMobile"
|
|
13
|
+
@select-search="handleSearchSelect"
|
|
14
|
+
@go-home="goHome"
|
|
15
|
+
/>
|
|
16
|
+
<!-- 移动端遮罩 -->
|
|
17
|
+
<transition name="fade">
|
|
18
|
+
<div v-if="isMobile && mobileDrawerOpen" class="drawer-overlay" @click="mobileDrawerOpen = false"></div>
|
|
19
|
+
</transition>
|
|
20
|
+
<!-- 主体区域(侧边栏 + 内容 + TOC) -->
|
|
21
|
+
<div class="main-body">
|
|
22
|
+
<!-- 侧边栏 -->
|
|
23
|
+
<AppSidebar
|
|
24
|
+
v-if="!isMobile ? !sidebarCollapsed : true"
|
|
25
|
+
:docsList="docsList"
|
|
26
|
+
:currentDoc="currentDoc"
|
|
27
|
+
:isMobile="isMobile"
|
|
28
|
+
:drawerOpen="mobileDrawerOpen"
|
|
29
|
+
:width="sidebarWidth"
|
|
30
|
+
@go-home="goHome"
|
|
31
|
+
@close-drawer="mobileDrawerOpen = false"
|
|
32
|
+
@collapse="sidebarCollapsed = true"
|
|
33
|
+
@expand-all="onExpandAll"
|
|
34
|
+
@collapse-all="onCollapseAll"
|
|
35
|
+
@toggle-folder="toggleFolder"
|
|
36
|
+
@select-doc="handleDocSelect"
|
|
37
|
+
/>
|
|
38
|
+
<!-- 左侧拖拽条 & 展开按钮(桌面端) -->
|
|
39
|
+
<div v-if="!isMobile && !sidebarCollapsed" class="resizer resizer-left" @mousedown="startResize('left', $event)"></div>
|
|
40
|
+
<button v-if="!isMobile && sidebarCollapsed" class="expand-btn expand-btn-left" @click="sidebarCollapsed = false" title="展开导航">
|
|
41
|
+
<ChevronRight :size="14" />
|
|
40
42
|
</button>
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
43
|
+
<!-- 内容区 -->
|
|
44
|
+
<DocContent
|
|
45
|
+
:showWelcome="showWelcome"
|
|
46
|
+
:htmlContent="htmlContent"
|
|
47
|
+
:prevDoc="prevDoc"
|
|
48
|
+
:nextDoc="nextDoc"
|
|
49
|
+
@scroll="handleScroll"
|
|
50
|
+
@content-click="onContentClick"
|
|
51
|
+
@start="loadFirstDoc"
|
|
52
|
+
@load-doc="loadDoc"
|
|
53
|
+
/>
|
|
54
|
+
<!-- 桌面端 TOC -->
|
|
55
|
+
<div v-if="!isMobile && !tocCollapsed && tocItems.length > 0" class="resizer resizer-right" @mousedown="startResize('right', $event)"></div>
|
|
56
|
+
<TableOfContents v-if="!isMobile" :tocItems="tocItems" :activeHeading="activeHeading" :collapsed="tocCollapsed" :width="tocWidth" @toggle="tocCollapsed = !tocCollapsed" @scroll-to="(id) => scrollToHeading(id, { push: true })" />
|
|
57
|
+
<button v-if="!isMobile && tocCollapsed && tocItems.length > 0" class="expand-btn expand-btn-right" @click="tocCollapsed = false" title="展开目录">
|
|
58
|
+
<ChevronLeft :size="14" />
|
|
48
59
|
</button>
|
|
60
|
+
</div>
|
|
61
|
+
<!-- 移动端 TOC -->
|
|
62
|
+
<MobileToc
|
|
63
|
+
v-if="isMobile"
|
|
64
|
+
:tocItems="tocItems"
|
|
65
|
+
:activeHeading="activeHeading"
|
|
66
|
+
:open="mobileTocOpen"
|
|
67
|
+
:showWelcome="showWelcome"
|
|
68
|
+
@toggle="mobileTocOpen = !mobileTocOpen"
|
|
69
|
+
@close="mobileTocOpen = false"
|
|
70
|
+
@scroll-to="(id) => { scrollToHeading(id, { push: true }); mobileTocOpen = false }"
|
|
71
|
+
/>
|
|
72
|
+
<!-- 返回顶部 -->
|
|
49
73
|
<transition name="fade">
|
|
50
74
|
<button v-if="showBackToTop" class="back-to-top" @click="scrollToTop" title="返回顶部">
|
|
51
75
|
<ArrowUp :size="20" />
|
|
52
76
|
<span class="progress-text">{{ scrollProgress }}%</span>
|
|
53
77
|
</button>
|
|
54
78
|
</transition>
|
|
79
|
+
<!-- 图片放大 -->
|
|
55
80
|
<ImageZoom :visible="zoomVisible" :imageContent="zoomContent" @close="zoomVisible = false" />
|
|
81
|
+
|
|
56
82
|
</div>
|
|
57
83
|
</template>
|
|
58
84
|
|
|
59
85
|
<script setup>
|
|
60
|
-
import { ref, onMounted
|
|
61
|
-
import {
|
|
62
|
-
import
|
|
63
|
-
import
|
|
64
|
-
import
|
|
86
|
+
import { ref, onMounted } from 'vue'
|
|
87
|
+
import { ArrowUp, ChevronRight, ChevronLeft } from 'lucide-vue-next'
|
|
88
|
+
import MobileHeader from './components/MobileHeader.vue'
|
|
89
|
+
import TopBar from './components/TopBar.vue'
|
|
90
|
+
import AppSidebar from './components/AppSidebar.vue'
|
|
91
|
+
import DocContent from './components/DocContent.vue'
|
|
65
92
|
import TableOfContents from './components/TableOfContents.vue'
|
|
93
|
+
import MobileToc from './components/MobileToc.vue'
|
|
66
94
|
import ImageZoom from './components/ImageZoom.vue'
|
|
67
|
-
import {
|
|
68
|
-
import { useMarkdown } from './composables/useMarkdown.js'
|
|
69
|
-
import { useScroll } from './composables/useScroll.js'
|
|
95
|
+
import { useDocManager } from './composables/useDocManager.js'
|
|
70
96
|
import { useResize } from './composables/useResize.js'
|
|
71
97
|
|
|
72
|
-
|
|
73
|
-
|
|
98
|
+
import { useSearch } from './composables/useSearch.js'
|
|
99
|
+
import { useMobile } from './composables/useMobile.js'
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
// UI 状态
|
|
74
103
|
const sidebarCollapsed = ref(false)
|
|
75
104
|
const tocCollapsed = ref(false)
|
|
76
105
|
const zoomVisible = ref(false)
|
|
77
106
|
const zoomContent = ref('')
|
|
78
107
|
|
|
79
|
-
|
|
80
|
-
const {
|
|
108
|
+
// composables
|
|
109
|
+
const {
|
|
110
|
+
docsList, currentDoc, showWelcome, htmlContent, tocItems,
|
|
111
|
+
scrollProgress, showBackToTop, activeHeading,
|
|
112
|
+
handleScroll, scrollToHeading, scrollToTop,
|
|
113
|
+
loadDocsList, loadFromUrl, goHome, loadDoc, loadFirstDoc,
|
|
114
|
+
handleDocSelect, handleContentClick, handleSearchSelect,
|
|
115
|
+
toggleFolder, onExpandAll, onCollapseAll,
|
|
116
|
+
prevDoc, nextDoc
|
|
117
|
+
} = useDocManager()
|
|
81
118
|
|
|
82
|
-
// 包装滚动处理,同步锚点到 URL
|
|
83
|
-
function handleScroll(e) {
|
|
84
|
-
_handleScroll(e)
|
|
85
|
-
// 滚动时更新 URL 锚点
|
|
86
|
-
if (activeHeading.value) {
|
|
87
|
-
updateHash(activeHeading.value)
|
|
88
|
-
} else {
|
|
89
|
-
updateHash('')
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// 包装目录点击,同步锚点到 URL
|
|
94
|
-
function scrollToHeading(id) {
|
|
95
|
-
_scrollToHeading(id)
|
|
96
|
-
updateHash(id)
|
|
97
|
-
}
|
|
98
119
|
const { sidebarWidth, tocWidth, startResize } = useResize()
|
|
99
120
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// 加载第一个文档
|
|
105
|
-
function loadFirstDoc() {
|
|
106
|
-
const firstDoc = findFirstDoc(docsList.value)
|
|
107
|
-
if (firstDoc) {
|
|
108
|
-
loadDoc(firstDoc.key)
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// 根据 key 生成 hash
|
|
113
|
-
function docHash(key) {
|
|
114
|
-
return MD5(key).toString()
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// 更新 URL hash(文档hash + 可选锚点)
|
|
118
|
-
function updateHash(anchor) {
|
|
119
|
-
if (!currentDoc.value) return
|
|
120
|
-
const base = docHash(currentDoc.value)
|
|
121
|
-
window.location.hash = anchor ? `${base}/${anchor}` : base
|
|
122
|
-
}
|
|
121
|
+
const { openSearch } = useSearch()
|
|
122
|
+
const { isMobile, mobileDrawerOpen, mobileTocOpen } = useMobile()
|
|
123
123
|
|
|
124
|
-
async function loadDoc(key) {
|
|
125
|
-
currentDoc.value = key
|
|
126
|
-
updateHash('')
|
|
127
|
-
const doc = findDoc(docsList.value, key)
|
|
128
|
-
if (!doc) return
|
|
129
|
-
try {
|
|
130
|
-
const response = await fetch(doc.path)
|
|
131
|
-
if (response.ok) {
|
|
132
|
-
const content = await response.text()
|
|
133
|
-
await renderMarkdown(content)
|
|
134
|
-
const contentEl = document.querySelector('.content')
|
|
135
|
-
if (contentEl) contentEl.scrollTop = 0
|
|
136
|
-
}
|
|
137
|
-
} catch (error) {
|
|
138
|
-
console.error('加载文档失败:', error)
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
124
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
125
|
+
// 内容区点击:委托给 docManager,图片放大回调在这里处理
|
|
126
|
+
function onContentClick(event) {
|
|
127
|
+
handleContentClick(event, {
|
|
128
|
+
onZoom(content) {
|
|
129
|
+
zoomContent.value = content
|
|
130
|
+
zoomVisible.value = true
|
|
148
131
|
}
|
|
149
|
-
}
|
|
150
|
-
return null
|
|
132
|
+
})
|
|
151
133
|
}
|
|
152
134
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
function expandAll() {
|
|
158
|
-
function expand(items) {
|
|
159
|
-
items.forEach(item => {
|
|
160
|
-
if (item.type === 'folder') {
|
|
161
|
-
item.expanded = true
|
|
162
|
-
if (item.children) expand(item.children)
|
|
163
|
-
}
|
|
164
|
-
})
|
|
165
|
-
}
|
|
166
|
-
expand(docsList.value)
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
function collapseAll() {
|
|
170
|
-
function collapse(items) {
|
|
171
|
-
items.forEach(item => {
|
|
172
|
-
if (item.type === 'folder') {
|
|
173
|
-
item.expanded = false
|
|
174
|
-
if (item.children) collapse(item.children)
|
|
175
|
-
}
|
|
176
|
-
})
|
|
177
|
-
}
|
|
178
|
-
collapse(docsList.value)
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
function handleContentClick(event) {
|
|
182
|
-
const target = event.target
|
|
183
|
-
if (target.tagName === 'IMG' && target.classList.contains('zoomable-image')) {
|
|
184
|
-
zoomContent.value = `<img src="${target.src}" alt="${target.alt || ''}" style="max-width: 100%; height: auto;" />`
|
|
185
|
-
zoomVisible.value = true
|
|
186
|
-
return
|
|
187
|
-
}
|
|
188
|
-
const mermaidEl = target.closest('.mermaid')
|
|
189
|
-
if (mermaidEl && mermaidEl.classList.contains('zoomable-image')) {
|
|
190
|
-
zoomContent.value = mermaidEl.innerHTML
|
|
191
|
-
zoomVisible.value = true
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
// 查找第一个文档
|
|
196
|
-
function findFirstDoc(items) {
|
|
197
|
-
for (const item of items) {
|
|
198
|
-
if (item.type === 'file') return item
|
|
199
|
-
if (item.type === 'folder' && item.children) {
|
|
200
|
-
const found = findFirstDoc(item.children)
|
|
201
|
-
if (found) return found
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
return null
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
// 显示无文档提示
|
|
208
|
-
function showEmptyMessage() {
|
|
209
|
-
renderMarkdown(`# 当前目录没有 Markdown 文档
|
|
210
|
-
|
|
211
|
-
请在当前目录下添加 \`.md\` 文件,然后刷新页面。
|
|
212
|
-
|
|
213
|
-
## 文档组织示例
|
|
214
|
-
|
|
215
|
-
\`\`\`
|
|
216
|
-
your-docs/
|
|
217
|
-
├── 00-快速开始.md
|
|
218
|
-
├── 01-功能特性.md
|
|
219
|
-
└── 02-进阶指南/
|
|
220
|
-
├── 01-目录结构.md
|
|
221
|
-
└── 02-自定义配置.md
|
|
222
|
-
\`\`\`
|
|
223
|
-
`)
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
// 根据 hash 查找文档
|
|
227
|
-
function findDocByHash(items, hash) {
|
|
228
|
-
for (const item of items) {
|
|
229
|
-
if (item.type === 'file' && docHash(item.key) === hash) return item
|
|
230
|
-
if (item.type === 'folder' && item.children) {
|
|
231
|
-
const found = findDocByHash(item.children, hash)
|
|
232
|
-
if (found) return found
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
return null
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
// 展开文档所在的所有父级文件夹
|
|
239
|
-
function expandParents(items, targetKey) {
|
|
240
|
-
for (const item of items) {
|
|
241
|
-
if (item.type === 'file' && item.key === targetKey) return true
|
|
242
|
-
if (item.type === 'folder' && item.children) {
|
|
243
|
-
if (expandParents(item.children, targetKey)) {
|
|
244
|
-
item.expanded = true
|
|
245
|
-
return true
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
return false
|
|
250
|
-
}
|
|
135
|
+
// 全局快捷键
|
|
136
|
+
window.addEventListener('popstate', () => loadFromUrl())
|
|
251
137
|
|
|
252
138
|
onMounted(async () => {
|
|
253
139
|
await loadDocsList()
|
|
254
|
-
|
|
255
|
-
const rawHash = window.location.hash.replace('#', '')
|
|
256
|
-
const [hash, anchor] = rawHash.includes('/')
|
|
257
|
-
? [rawHash.split('/')[0], rawHash.split('/').slice(1).join('/')]
|
|
258
|
-
: [rawHash, '']
|
|
259
|
-
|
|
260
|
-
if (hash) {
|
|
261
|
-
const doc = findDocByHash(docsList.value, hash)
|
|
262
|
-
if (doc) {
|
|
263
|
-
expandParents(docsList.value, doc.key)
|
|
264
|
-
await loadDoc(doc.key)
|
|
265
|
-
// 恢复锚点位置
|
|
266
|
-
if (anchor) {
|
|
267
|
-
await nextTick()
|
|
268
|
-
// 等待 DOM 渲染完成后再滚动
|
|
269
|
-
setTimeout(() => {
|
|
270
|
-
_scrollToHeading(anchor)
|
|
271
|
-
updateHash(anchor)
|
|
272
|
-
}, 100)
|
|
273
|
-
}
|
|
274
|
-
return
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
// 没有 hash 或找不到对应文档,加载第一个
|
|
279
|
-
const firstDoc = findFirstDoc(docsList.value)
|
|
280
|
-
if (firstDoc) {
|
|
281
|
-
await loadDoc(firstDoc.key)
|
|
282
|
-
} else {
|
|
283
|
-
showEmptyMessage()
|
|
284
|
-
}
|
|
140
|
+
await loadFromUrl()
|
|
285
141
|
})
|
|
286
142
|
</script>
|
package/src/api/docs.js
CHANGED
|
@@ -64,7 +64,8 @@ export async function getDocsList() {
|
|
|
64
64
|
|
|
65
65
|
// 开发模式:扫描 public/docs 目录
|
|
66
66
|
try {
|
|
67
|
-
|
|
67
|
+
// 排除隐藏目录和隐藏文件(以 . 开头的路径段)
|
|
68
|
+
const modules = import.meta.glob('/public/docs/**/*.md', { eager: false })
|
|
68
69
|
const files = []
|
|
69
70
|
|
|
70
71
|
for (const path in modules) {
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<aside
|
|
3
|
+
class="sidebar"
|
|
4
|
+
:class="{ 'sidebar-drawer': isMobile, 'drawer-open': drawerOpen }"
|
|
5
|
+
:style="!isMobile ? { width: width + 'px' } : undefined"
|
|
6
|
+
>
|
|
7
|
+
<!-- 移动端显示 Logo + 关闭按钮,桌面端只显示收起按钮 -->
|
|
8
|
+
<div class="sidebar-header">
|
|
9
|
+
<Logo v-if="isMobile" @go-home="$emit('go-home')" />
|
|
10
|
+
<div class="sidebar-header-actions">
|
|
11
|
+
<button v-if="isMobile" class="sidebar-toggle" @click="$emit('close-drawer')" title="关闭菜单">
|
|
12
|
+
<X :size="16" />
|
|
13
|
+
</button>
|
|
14
|
+
<button v-else class="sidebar-toggle" @click="$emit('collapse')" title="收起导航">
|
|
15
|
+
<ChevronLeft :size="14" />
|
|
16
|
+
</button>
|
|
17
|
+
</div>
|
|
18
|
+
</div>
|
|
19
|
+
<nav class="nav-menu">
|
|
20
|
+
<div class="nav-section">
|
|
21
|
+
<span>文档目录</span>
|
|
22
|
+
<div class="nav-actions">
|
|
23
|
+
<button class="action-btn" @click="$emit('expand-all')" title="全部展开">
|
|
24
|
+
<ChevronsDownUp :size="14" />
|
|
25
|
+
</button>
|
|
26
|
+
<button class="action-btn" @click="$emit('collapse-all')" title="全部收起">
|
|
27
|
+
<ChevronsUpDown :size="14" />
|
|
28
|
+
</button>
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
<!-- 过滤输入框 -->
|
|
32
|
+
<div class="nav-filter">
|
|
33
|
+
<Filter :size="12" class="nav-filter-icon" />
|
|
34
|
+
<input
|
|
35
|
+
v-model="filterText"
|
|
36
|
+
type="text"
|
|
37
|
+
class="nav-filter-input"
|
|
38
|
+
placeholder="过滤文档..."
|
|
39
|
+
/>
|
|
40
|
+
<button v-if="filterText" class="nav-filter-clear" @click="filterText = ''">
|
|
41
|
+
<X :size="12" />
|
|
42
|
+
</button>
|
|
43
|
+
</div>
|
|
44
|
+
<TreeNode
|
|
45
|
+
v-for="item in filteredDocs"
|
|
46
|
+
:key="item.key"
|
|
47
|
+
:item="item"
|
|
48
|
+
:currentDoc="currentDoc"
|
|
49
|
+
@toggle="$emit('toggle-folder', $event)"
|
|
50
|
+
@select="$emit('select-doc', $event)"
|
|
51
|
+
/>
|
|
52
|
+
<div v-if="filterText && filteredDocs.length === 0" class="nav-filter-empty">
|
|
53
|
+
没有匹配的文档
|
|
54
|
+
</div>
|
|
55
|
+
</nav>
|
|
56
|
+
</aside>
|
|
57
|
+
</template>
|
|
58
|
+
|
|
59
|
+
<script setup>
|
|
60
|
+
import { ref, computed } from 'vue'
|
|
61
|
+
import { ChevronLeft, X, ChevronsDownUp, ChevronsUpDown, Filter } from 'lucide-vue-next'
|
|
62
|
+
import Logo from './Logo.vue'
|
|
63
|
+
import TreeNode from './TreeNode.vue'
|
|
64
|
+
|
|
65
|
+
const props = defineProps({
|
|
66
|
+
docsList: { type: Array, default: () => [] },
|
|
67
|
+
currentDoc: { type: String, default: '' },
|
|
68
|
+
isMobile: { type: Boolean, default: false },
|
|
69
|
+
drawerOpen: { type: Boolean, default: false },
|
|
70
|
+
width: { type: Number, default: 320 }
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
defineEmits([
|
|
74
|
+
'go-home', 'close-drawer', 'collapse',
|
|
75
|
+
'expand-all', 'collapse-all',
|
|
76
|
+
'toggle-folder', 'select-doc'
|
|
77
|
+
])
|
|
78
|
+
|
|
79
|
+
const filterText = ref('')
|
|
80
|
+
|
|
81
|
+
// 递归过滤文档树:保留匹配的文件和包含匹配文件的文件夹
|
|
82
|
+
function filterTree(items, keyword) {
|
|
83
|
+
if (!keyword) return items
|
|
84
|
+
const lower = keyword.toLowerCase()
|
|
85
|
+
const result = []
|
|
86
|
+
for (const item of items) {
|
|
87
|
+
if (item.type === 'file') {
|
|
88
|
+
if (item.label.toLowerCase().includes(lower) || item.key.toLowerCase().includes(lower)) {
|
|
89
|
+
result.push(item)
|
|
90
|
+
}
|
|
91
|
+
} else if (item.type === 'folder' && item.children) {
|
|
92
|
+
const children = filterTree(item.children, keyword)
|
|
93
|
+
if (children.length > 0) {
|
|
94
|
+
result.push({ ...item, children, expanded: true })
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return result
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const filteredDocs = computed(() => filterTree(props.docsList, filterText.value))
|
|
102
|
+
</script>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<main class="content" @scroll="$emit('scroll', $event)" @click="$emit('content-click', $event)">
|
|
3
|
+
<WelcomePage v-if="showWelcome" @start="$emit('start')" />
|
|
4
|
+
<template v-else>
|
|
5
|
+
<article class="markdown-content" v-html="htmlContent"></article>
|
|
6
|
+
<nav v-if="prevDoc || nextDoc" class="doc-nav">
|
|
7
|
+
<a v-if="prevDoc" class="doc-nav-link prev" @click.prevent="$emit('load-doc', prevDoc.key)">
|
|
8
|
+
<ChevronLeft :size="16" />
|
|
9
|
+
<div class="doc-nav-text">
|
|
10
|
+
<span class="doc-nav-label">上一篇</span>
|
|
11
|
+
<span class="doc-nav-title">{{ prevDoc.label }}</span>
|
|
12
|
+
</div>
|
|
13
|
+
</a>
|
|
14
|
+
<div v-else></div>
|
|
15
|
+
<a v-if="nextDoc" class="doc-nav-link next" @click.prevent="$emit('load-doc', nextDoc.key)">
|
|
16
|
+
<div class="doc-nav-text">
|
|
17
|
+
<span class="doc-nav-label">下一篇</span>
|
|
18
|
+
<span class="doc-nav-title">{{ nextDoc.label }}</span>
|
|
19
|
+
</div>
|
|
20
|
+
<ChevronRight :size="16" />
|
|
21
|
+
</a>
|
|
22
|
+
</nav>
|
|
23
|
+
</template>
|
|
24
|
+
</main>
|
|
25
|
+
</template>
|
|
26
|
+
|
|
27
|
+
<script setup>
|
|
28
|
+
import { ChevronLeft, ChevronRight } from 'lucide-vue-next'
|
|
29
|
+
import WelcomePage from './WelcomePage.vue'
|
|
30
|
+
|
|
31
|
+
defineProps({
|
|
32
|
+
showWelcome: { type: Boolean, default: true },
|
|
33
|
+
htmlContent: { type: String, default: '' },
|
|
34
|
+
prevDoc: { type: Object, default: null },
|
|
35
|
+
nextDoc: { type: Object, default: null }
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
defineEmits(['scroll', 'content-click', 'start', 'load-doc'])
|
|
39
|
+
</script>
|
package/src/components/Logo.vue
CHANGED
|
@@ -18,18 +18,14 @@ defineEmits(['go-home'])
|
|
|
18
18
|
.logo-container {
|
|
19
19
|
display: flex;
|
|
20
20
|
align-items: center;
|
|
21
|
-
gap:
|
|
22
|
-
color:
|
|
21
|
+
gap: 8px;
|
|
22
|
+
color: var(--color-text);
|
|
23
23
|
cursor: pointer;
|
|
24
|
-
transition:
|
|
24
|
+
transition: color 0.15s;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
.logo-container:hover {
|
|
28
|
-
color:
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
.logo-container:active {
|
|
32
|
-
transform: scale(0.96);
|
|
28
|
+
color: var(--color-accent);
|
|
33
29
|
}
|
|
34
30
|
|
|
35
31
|
.logo-icon {
|
|
@@ -37,7 +33,7 @@ defineEmits(['go-home'])
|
|
|
37
33
|
}
|
|
38
34
|
|
|
39
35
|
.logo-text {
|
|
40
|
-
font-size:
|
|
36
|
+
font-size: 15px;
|
|
41
37
|
font-weight: 600;
|
|
42
38
|
letter-spacing: -0.02em;
|
|
43
39
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<header class="mobile-header">
|
|
3
|
+
<button class="mobile-menu-btn" @click="$emit('open-drawer')" title="打开菜单">
|
|
4
|
+
<Menu :size="20" />
|
|
5
|
+
</button>
|
|
6
|
+
<Logo @go-home="$emit('go-home')" />
|
|
7
|
+
<div class="mobile-header-actions">
|
|
8
|
+
<button class="mobile-action-btn" @click="$emit('open-search')" title="搜索">
|
|
9
|
+
<Search :size="18" />
|
|
10
|
+
</button>
|
|
11
|
+
</div>
|
|
12
|
+
</header>
|
|
13
|
+
</template>
|
|
14
|
+
|
|
15
|
+
<script setup>
|
|
16
|
+
import { Menu, Search } from 'lucide-vue-next'
|
|
17
|
+
import Logo from './Logo.vue'
|
|
18
|
+
|
|
19
|
+
defineEmits(['open-drawer', 'go-home', 'open-search'])
|
|
20
|
+
</script>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<!-- 浮动按钮 -->
|
|
3
|
+
<button v-if="tocItems.length > 0 && !showWelcome" class="mobile-toc-fab" @click="$emit('toggle')" title="文档目录">
|
|
4
|
+
<List :size="20" />
|
|
5
|
+
</button>
|
|
6
|
+
<!-- 弹出面板 -->
|
|
7
|
+
<transition name="slide-up">
|
|
8
|
+
<div v-if="open && tocItems.length > 0" class="mobile-toc-panel">
|
|
9
|
+
<div class="mobile-toc-header">
|
|
10
|
+
<span>目录</span>
|
|
11
|
+
<button class="mobile-toc-close" @click="$emit('close')">
|
|
12
|
+
<X :size="16" />
|
|
13
|
+
</button>
|
|
14
|
+
</div>
|
|
15
|
+
<nav class="mobile-toc-nav">
|
|
16
|
+
<a
|
|
17
|
+
v-for="item in tocItems"
|
|
18
|
+
:key="item.id"
|
|
19
|
+
:class="['toc-item', `toc-level-${item.level}`, { active: activeHeading === item.id }]"
|
|
20
|
+
@click.prevent="$emit('scroll-to', item.id)"
|
|
21
|
+
>
|
|
22
|
+
{{ item.text }}
|
|
23
|
+
</a>
|
|
24
|
+
</nav>
|
|
25
|
+
</div>
|
|
26
|
+
</transition>
|
|
27
|
+
</template>
|
|
28
|
+
|
|
29
|
+
<script setup>
|
|
30
|
+
import { List, X } from 'lucide-vue-next'
|
|
31
|
+
|
|
32
|
+
defineProps({
|
|
33
|
+
tocItems: { type: Array, default: () => [] },
|
|
34
|
+
activeHeading: { type: String, default: '' },
|
|
35
|
+
open: { type: Boolean, default: false },
|
|
36
|
+
showWelcome: { type: Boolean, default: true }
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
defineEmits(['toggle', 'close', 'scroll-to'])
|
|
40
|
+
</script>
|