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/src/App.vue CHANGED
@@ -1,286 +1,142 @@
1
1
  <template>
2
- <div class="container">
3
- <aside v-if="!sidebarCollapsed" class="sidebar" :style="{ width: sidebarWidth + 'px' }">
4
- <div class="logo">
5
- <div class="logo-group">
6
- <Logo @go-home="loadFirstDoc" />
7
- <a href="https://github.com/devsneed/md2ui" target="_blank" class="github-link" title="GitHub">
8
- <Github :size="14" />
9
- </a>
10
- </div>
11
- <button class="sidebar-toggle" @click="sidebarCollapsed = true" title="收起导航">
12
- <PanelLeftClose :size="16" />
13
- </button>
14
- </div>
15
- <nav class="nav-menu">
16
- <div class="nav-section">
17
- <span>文档目录</span>
18
- <div class="nav-actions">
19
- <button class="action-btn" @click="expandAll" title="全部展开">
20
- <ChevronsDownUp :size="14" />
21
- </button>
22
- <button class="action-btn" @click="collapseAll" title="全部收起">
23
- <ChevronsUpDown :size="14" />
24
- </button>
25
- </div>
26
- </div>
27
- <TreeNode
28
- v-for="item in docsList"
29
- :key="item.key"
30
- :item="item"
31
- :currentDoc="currentDoc"
32
- @toggle="toggleFolder"
33
- @select="loadDoc"
34
- />
35
- </nav>
36
- </aside>
37
- <div v-if="!sidebarCollapsed" class="resizer resizer-left" @mousedown="startResize('left', $event)"></div>
38
- <button v-if="sidebarCollapsed" class="expand-btn expand-btn-left" @click="sidebarCollapsed = false" title="展开导航">
39
- <PanelLeftOpen :size="16" />
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
- <main class="content" @scroll="handleScroll" @click="handleContentClick">
42
- <article class="markdown-content" v-html="htmlContent"></article>
43
- </main>
44
- <div v-if="!tocCollapsed && tocItems.length > 0" class="resizer resizer-right" @mousedown="startResize('right', $event)"></div>
45
- <TableOfContents :tocItems="tocItems" :activeHeading="activeHeading" :collapsed="tocCollapsed" :width="tocWidth" @toggle="tocCollapsed = !tocCollapsed" @scroll-to="scrollToHeading" />
46
- <button v-if="tocCollapsed && tocItems.length > 0" class="expand-btn expand-btn-right" @click="tocCollapsed = false" title="展开目录">
47
- <PanelRightOpen :size="16" />
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, nextTick } from 'vue'
61
- import { ChevronsDownUp, ChevronsUpDown, ArrowUp, PanelLeftOpen, PanelLeftClose, PanelRightOpen, Github } from 'lucide-vue-next'
62
- import MD5 from 'crypto-js/md5'
63
- import Logo from './components/Logo.vue'
64
- import TreeNode from './components/TreeNode.vue'
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 { getDocsList } from './api/docs.js'
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
- const docsList = ref([])
73
- const currentDoc = ref('')
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
- const { htmlContent, tocItems, renderMarkdown } = useMarkdown()
80
- const { scrollProgress, showBackToTop, activeHeading, handleScroll: _handleScroll, scrollToHeading: _scrollToHeading, scrollToTop } = useScroll()
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
- async function loadDocsList() {
101
- docsList.value = await getDocsList()
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
- function findDoc(items, key) {
143
- for (const item of items) {
144
- if (item.type === 'file' && item.key === key) return item
145
- if (item.type === 'folder' && item.children) {
146
- const found = findDoc(item.children, key)
147
- if (found) return found
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
- function toggleFolder(item) {
154
- item.expanded = !item.expanded
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
- const modules = import.meta.glob('/public/docs/**/*.md')
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>
@@ -18,18 +18,14 @@ defineEmits(['go-home'])
18
18
  .logo-container {
19
19
  display: flex;
20
20
  align-items: center;
21
- gap: 10px;
22
- color: #111827;
21
+ gap: 8px;
22
+ color: var(--color-text);
23
23
  cursor: pointer;
24
- transition: all 0.15s;
24
+ transition: color 0.15s;
25
25
  }
26
26
 
27
27
  .logo-container:hover {
28
- color: #3eaf7c;
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: 16px;
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>