md2ui 1.0.18 → 1.0.19

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.
Files changed (74) hide show
  1. package/README.md +3 -55
  2. package/bin/build.js +82 -7
  3. package/bin/md2ui.js +80 -4
  4. package/package.json +23 -9
  5. package/public/docs/00-/345/277/253/351/200/237/345/274/200/345/247/213.md +48 -28
  6. package/public/docs/01-/345/212/237/350/203/275/347/211/271/346/200/247.md +55 -40
  7. package/public/docs/02-Markdown/346/270/262/346/237/223/00-/345/237/272/347/241/200/350/257/255/346/263/225.md +86 -0
  8. package/public/docs/02-Markdown/346/270/262/346/237/223/01-/344/273/243/347/240/201/345/235/227.md +91 -0
  9. package/public/docs/02-Markdown/346/270/262/346/237/223/02-/350/241/250/346/240/274.md +187 -0
  10. package/public/docs/02-Markdown/346/270/262/346/237/223/03-Mermaid/345/233/276/350/241/250.md +101 -0
  11. package/public/docs/02-Markdown/346/270/262/346/237/223/04-Frontmatter.md +32 -0
  12. package/public/docs/02-Markdown/346/270/262/346/237/223/05-/346/225/260/345/255/246/345/205/254/345/274/217.md +47 -0
  13. package/public/docs/03-/345/257/274/350/210/252/344/270/216/345/270/203/345/261/200/00-/344/270/211/346/240/217/345/270/203/345/261/200.md +33 -0
  14. package/public/docs/03-/345/257/274/350/210/252/344/270/216/345/270/203/345/261/200/01-/347/233/256/345/275/225/346/240/221/345/257/274/350/210/252.md +43 -0
  15. package/public/docs/03-/345/257/274/350/210/252/344/270/216/345/270/203/345/261/200/02-/346/226/207/346/241/243/345/244/247/347/272/262.md +51 -0
  16. package/public/docs/03-/345/257/274/350/210/252/344/270/216/345/270/203/345/261/200/03-/344/270/212/344/270/213/347/257/207/345/257/274/350/210/252.md +29 -0
  17. package/public/docs/03-/345/257/274/350/210/252/344/270/216/345/270/203/345/261/200/04-/347/253/231/345/206/205/351/223/276/346/216/245.md +39 -0
  18. package/public/docs/04-/346/220/234/347/264/242/345/212/237/350/203/275/00-/345/205/250/346/226/207/346/220/234/347/264/242.md +46 -0
  19. package/public/docs/05-/347/274/226/350/276/221/345/212/237/350/203/275/00-/347/274/226/350/276/221/345/231/250/345/237/272/347/241/200.md +65 -0
  20. package/public/docs/05-/347/274/226/350/276/221/345/212/237/350/203/275/01-/350/207/252/345/212/250/344/277/235/345/255/230.md +38 -0
  21. package/public/docs/06-/351/230/205/350/257/273/344/275/223/351/252/214/00-/351/230/205/350/257/273/350/277/233/345/272/246.md +43 -0
  22. package/public/docs/06-/351/230/205/350/257/273/344/275/223/351/252/214/01-/345/233/276/347/211/207/346/224/276/345/244/247.md +40 -0
  23. package/public/docs/06-/351/230/205/350/257/273/344/275/223/351/252/214/02-/350/277/224/345/233/236/351/241/266/351/203/250.md +38 -0
  24. package/public/docs/06-/351/230/205/350/257/273/344/275/223/351/252/214/assets/img-1777261394722.png +0 -0
  25. package/public/docs/07-/347/247/273/345/212/250/347/253/257/351/200/202/351/205/215/00-/345/223/215/345/272/224/345/274/217/345/270/203/345/261/200.md +37 -0
  26. package/public/docs/08-/346/226/207/346/241/243/347/256/241/347/220/206/00-/346/226/260/345/273/272/344/270/216/345/210/240/351/231/244.md +47 -0
  27. package/public/docs/09-/345/257/274/345/207/272/345/212/237/350/203/275/00-/345/257/274/345/207/272Word.md +77 -0
  28. package/public/docs/10-/351/203/250/347/275/262/344/270/216/351/205/215/347/275/256/00-CLI/345/267/245/345/205/267.md +52 -0
  29. package/public/docs/10-/351/203/250/347/275/262/344/270/216/351/205/215/347/275/256/01-SSG/351/235/231/346/200/201/346/236/204/345/273/272.md +44 -0
  30. package/public/docs/10-/351/203/250/347/275/262/344/270/216/351/205/215/347/275/256/02-/350/207/252/345/256/232/344/271/211/351/205/215/347/275/256.md +58 -0
  31. package/public/docs/11-/345/244/232/347/272/247/347/233/256/345/275/225/346/265/213/350/257/225/00-/344/270/200/347/272/247/346/226/207/346/241/243.md +20 -0
  32. package/public/docs/11-/345/244/232/347/272/247/347/233/256/345/275/225/346/265/213/350/257/225/01-/345/255/220/347/233/256/345/275/225/00-/344/272/214/347/272/247/346/226/207/346/241/243.md +13 -0
  33. package/public/docs/11-/345/244/232/347/272/247/347/233/256/345/275/225/346/265/213/350/257/225/01-/345/255/220/347/233/256/345/275/225/01-/346/267/261/345/261/202/345/265/214/345/245/227/00-/344/270/211/347/272/247/346/226/207/346/241/243.md +23 -0
  34. package/src/App.vue +130 -6
  35. package/src/components/AppSidebar.vue +181 -21
  36. package/src/components/CodeBlockNodeView.vue +72 -0
  37. package/src/components/DocContent.vue +25 -14
  38. package/src/components/EditorContent.vue +257 -0
  39. package/src/components/EditorToolbar.vue +264 -0
  40. package/src/components/ImageZoom.vue +199 -2
  41. package/src/components/MathBlockNodeView.vue +160 -0
  42. package/src/components/MathInlineNodeView.vue +145 -0
  43. package/src/components/MermaidNodeView.vue +149 -0
  44. package/src/components/TableBubbleMenu.vue +177 -0
  45. package/src/components/TableOfContents.vue +138 -32
  46. package/src/components/TopBar.vue +69 -4
  47. package/src/components/TreeNode.vue +232 -39
  48. package/src/components/WelcomePage.vue +2 -2
  49. package/src/composables/useDocHash.js +9 -1
  50. package/src/composables/useDocManager.js +325 -68
  51. package/src/composables/useDocTree.js +56 -1
  52. package/src/composables/useExportPdf.js +102 -0
  53. package/src/composables/useExportWord.js +73 -10
  54. package/src/composables/useFileWatcher.js +45 -0
  55. package/src/composables/useFrontmatter.js +2 -2
  56. package/src/composables/useMarkdown.js +529 -42
  57. package/src/composables/useScroll.js +47 -5
  58. package/src/config.js +1 -1
  59. package/src/extensions/CodeBlockCustom.js +113 -0
  60. package/src/extensions/MathBlock.js +107 -0
  61. package/src/extensions/MathInline.js +100 -0
  62. package/src/extensions/MermaidBlock.js +73 -0
  63. package/src/extensions/TableControls.js +670 -0
  64. package/src/services/DocService.js +184 -0
  65. package/src/style.css +2194 -39
  66. package/vite-plugin-doc-api.js +368 -0
  67. package/vite.config.js +2 -1
  68. package/public/docs/02-Mermaid/345/233/276/350/241/250.md +0 -102
  69. package/public/docs/03-/350/277/233/351/230/266/346/214/207/345/215/227/01-/347/233/256/345/275/225/347/273/223/346/236/204.md +0 -55
  70. package/public/docs/03-/350/277/233/351/230/266/346/214/207/345/215/227/02-/350/207/252/345/256/232/344/271/211/351/205/215/347/275/256.md +0 -63
  71. package/public/docs/03-/350/277/233/351/230/266/346/214/207/345/215/227/03-/351/203/250/347/275/262/346/226/271/346/241/210.md +0 -73
  72. package/public/docs/04-API/345/217/202/350/200/203/01-/347/273/204/344/273/266API.md +0 -80
  73. package/public/docs/04-API/345/217/202/350/200/203/02-Composables.md +0 -92
  74. package/src/api/docs.js +0 -106
@@ -1,10 +1,11 @@
1
- import { ref, computed, nextTick } from 'vue'
2
- import { getDocsList } from '../api/docs.js'
1
+ import { ref, computed, watch, nextTick } from 'vue'
2
+ import * as docService from '../services/DocService.js'
3
3
  import { useMarkdown } from './useMarkdown.js'
4
4
  import { useSearch } from './useSearch.js'
5
5
  import { useScroll } from './useScroll.js'
6
6
  import { useMobile } from './useMobile.js'
7
7
  import { findDoc, findFirstDoc, findReadmeDoc, findDocByHash, expandParents, flattenDocsList, expandAll, collapseAll } from './useDocTree.js'
8
+ import { stripOrderPrefix } from './useDocHash.js'
8
9
 
9
10
  // 等待内容区域图片加载完成
10
11
  async function waitForContentImages(timeoutMs = 3000) {
@@ -21,33 +22,39 @@ export function useDocManager() {
21
22
  // 文档状态
22
23
  const docsList = ref([])
23
24
  const currentDoc = ref('')
24
- // 如果 URL 有路径,说明是刷新已有文档页面,初始不显示欢迎页,避免闪烁
25
25
  const hasInitialPath = window.location.pathname.replace(/^\//, '') !== ''
26
26
  const showWelcome = ref(!hasInitialPath)
27
+ const lastModified = ref('')
28
+
29
+ // 编辑模式(从 sessionStorage 恢复)
30
+ const editMode = ref(sessionStorage.getItem('editMode') === 'true')
31
+ const rawMarkdown = ref('')
32
+ const currentDocFilePath = ref('')
27
33
 
28
34
  // composables
29
- const { htmlContent, tocItems, renderMarkdown, docHash } = useMarkdown()
35
+ const { htmlContent, tocItems, renderMarkdown, extractTOCFromMarkdown, docHash } = useMarkdown()
30
36
  const { buildIndex } = useSearch()
31
37
  const {
32
38
  scrollProgress, showBackToTop, activeHeading,
33
39
  handleScroll: _handleScroll,
34
40
  scrollToHeading: _scrollToHeading,
35
- scrollToTop
41
+ scrollToTop,
42
+ setTocItems
36
43
  } = useScroll()
37
44
  const { isMobile, mobileDrawerOpen } = useMobile()
38
45
 
39
- // 获取当前滚动位置
46
+ setTocItems(tocItems)
47
+
48
+ // ===== 滚动 & 历史 =====
40
49
  function getScrollTop() {
41
50
  const el = document.querySelector('.content')
42
51
  return el ? el.scrollTop : 0
43
52
  }
44
53
 
45
- // 构造 history state,保留滚动位置
46
54
  function makeState(scrollTop) {
47
55
  return { scrollTop: scrollTop ?? getScrollTop() }
48
56
  }
49
57
 
50
- // 滚动处理,同步锚点到 URL(replaceState 保留滚动位置)
51
58
  function handleScroll(e) {
52
59
  _handleScroll(e)
53
60
  if (activeHeading.value && currentDoc.value) {
@@ -55,10 +62,8 @@ export function useDocManager() {
55
62
  }
56
63
  }
57
64
 
58
- // push: true 表示用户主动点击锚点,产生可回退的历史条目
59
65
  function scrollToHeading(id, { push = false } = {}) {
60
66
  if (push && currentDoc.value) {
61
- // 先把当前滚动位置写入即将被覆盖的历史条目
62
67
  history.replaceState(makeState(), '', window.location.href)
63
68
  }
64
69
  _scrollToHeading(id)
@@ -72,37 +77,40 @@ export function useDocManager() {
72
77
  }
73
78
  }
74
79
 
75
- // 加载文档列表
80
+ // ===== 文档列表加载 =====
76
81
  async function loadDocsList() {
77
- docsList.value = await getDocsList()
82
+ docsList.value = await docService.fetchDocsList()
83
+ restoreExpandedState()
78
84
  buildIndex(docsList.value)
79
85
  }
80
86
 
81
- // 回到欢迎页(isPopstate: true 表示由浏览器回退触发,不操作 history)
87
+ // ===== 导航 =====
82
88
  function goHome({ isPopstate = false } = {}) {
83
89
  currentDoc.value = ''
84
90
  showWelcome.value = true
85
91
  htmlContent.value = ''
86
92
  tocItems.value = []
93
+ editMode.value = false
94
+ lastModified.value = ''
95
+ sessionStorage.setItem('editMode', 'false')
96
+ document.title = 'md2ui'
87
97
  if (!isPopstate) {
88
- // 用户主动点击,保存旧条目滚动位置后 push
89
98
  history.replaceState(makeState(), '', window.location.href)
90
99
  history.pushState(makeState(0), '', '/')
91
100
  }
92
101
  if (isMobile.value) mobileDrawerOpen.value = false
93
102
  }
94
103
 
95
- // 加载文档
96
104
  async function loadDoc(key, { replace = false, anchor = '', keepState = false } = {}) {
97
105
  currentDoc.value = key
98
106
  showWelcome.value = false
107
+ lastContentHash = ''
108
+ docService.resetContentEtag()
99
109
  const hash = docHash(key)
100
110
  const url = anchor ? `/${hash}#${anchor}` : `/${hash}`
101
111
  if (replace) {
102
- // keepState: popstate 回退时保留浏览器已有的 state(含 scrollTop)
103
112
  if (!keepState) history.replaceState(makeState(0), '', url)
104
113
  } else {
105
- // push 前先保存当前滚动位置到旧条目
106
114
  history.replaceState(makeState(), '', window.location.href)
107
115
  history.pushState(makeState(0), '', url)
108
116
  }
@@ -112,33 +120,75 @@ export function useDocManager() {
112
120
  const response = await fetch(doc.path)
113
121
  if (response.ok) {
114
122
  const content = await response.text()
115
- await renderMarkdown(content, key, docsList.value)
123
+ rawMarkdown.value = content
124
+ // 捕获最后修改时间
125
+ const lm = response.headers.get('x-last-modified')
126
+ lastModified.value = lm || ''
127
+ // 提取文件路径供轮询使用
128
+ currentDocFilePath.value = doc.path.replace(/^\/@user-docs\//, '')
129
+ if (editMode.value) {
130
+ extractTOCFromMarkdown(content, tocItems)
131
+ } else {
132
+ await renderMarkdown(content, key, docsList.value)
133
+ }
116
134
  const contentEl = document.querySelector('.content')
117
135
  if (contentEl) contentEl.scrollTop = 0
136
+ // 动态更新页面标题(SEO + 浏览器标签页)
137
+ const docTitle = findDoc(docsList.value, key)?.label || ''
138
+ document.title = docTitle ? `${docTitle} - md2ui` : 'md2ui'
118
139
  }
119
140
  } catch (error) {
120
141
  console.error('加载文档失败:', error)
121
142
  }
122
143
  }
123
144
 
124
- // 加载第一篇文档
125
145
  function loadFirstDoc() {
126
146
  const first = findFirstDoc(docsList.value)
127
147
  if (first) loadDoc(first.key)
128
148
  }
129
149
 
130
- // 选择文档(移动端自动关闭抽屉)
131
150
  function handleDocSelect(key) {
132
151
  loadDoc(key)
133
152
  if (isMobile.value) mobileDrawerOpen.value = false
134
153
  }
135
154
 
136
- // 文件夹操作
137
- function toggleFolder(item) { item.expanded = !item.expanded }
138
- function onExpandAll() { expandAll(docsList.value) }
139
- function onCollapseAll() { collapseAll(docsList.value) }
155
+ // ===== 文件夹操作 =====
156
+ function toggleFolder(item) { item.expanded = !item.expanded; saveExpandedState() }
157
+ function onExpandAll() { expandAll(docsList.value); saveExpandedState() }
158
+ function onCollapseAll() { collapseAll(docsList.value); saveExpandedState() }
140
159
 
141
- // 内容区点击处理(链接跳转 + 图片放大)
160
+ function saveExpandedState() {
161
+ const expanded = []
162
+ function collect(items) {
163
+ for (const item of items) {
164
+ if (item.type === 'folder') {
165
+ if (item.expanded) expanded.push(item.key)
166
+ if (item.children) collect(item.children)
167
+ }
168
+ }
169
+ }
170
+ collect(docsList.value)
171
+ sessionStorage.setItem('expandedFolders', JSON.stringify(expanded))
172
+ }
173
+
174
+ function restoreExpandedState() {
175
+ const raw = sessionStorage.getItem('expandedFolders')
176
+ if (!raw) return
177
+ try {
178
+ const expanded = new Set(JSON.parse(raw))
179
+ function apply(items) {
180
+ for (const item of items) {
181
+ if (item.type === 'folder') {
182
+ if (expanded.has(item.key)) item.expanded = true
183
+ if (item.children) apply(item.children)
184
+ }
185
+ }
186
+ }
187
+ apply(docsList.value)
188
+ } catch { /* ignore */ }
189
+ }
190
+
191
+ // ===== 内容区点击处理 =====
142
192
  function handleContentClick(event, { onZoom }) {
143
193
  const target = event.target
144
194
  const link = target.closest('a')
@@ -148,6 +198,7 @@ export function useDocManager() {
148
198
  event.preventDefault()
149
199
  const anchor = link.dataset.anchor || ''
150
200
  expandParents(docsList.value, docKey)
201
+ saveExpandedState()
151
202
  loadDoc(docKey).then(async () => {
152
203
  if (anchor) { await nextTick(); await waitForContentImages(); scrollToHeading(anchor) }
153
204
  })
@@ -160,20 +211,20 @@ export function useDocManager() {
160
211
  }
161
212
  return
162
213
  }
163
- // 图片/Mermaid 放大(收集所有可放大元素,支持左右切换)
164
214
  const isImg = target.tagName === 'IMG' && target.classList.contains('zoomable-image')
165
- const mermaidEl = target.closest('.mermaid')
215
+ const mermaidEl = target.closest('.mermaid') || target.closest('.mermaid-svg')
166
216
  const isMermaid = mermaidEl && mermaidEl.classList.contains('zoomable-image')
167
217
  if (isImg || isMermaid) {
168
218
  const container = document.querySelector('.markdown-content')
169
219
  if (!container) return
170
- // 收集所有可放大元素
171
220
  const allZoomable = [...container.querySelectorAll('.zoomable-image')]
172
221
  const images = allZoomable.map(el => {
173
222
  if (el.tagName === 'IMG') {
174
223
  return `<img src="${el.src}" alt="${el.alt || ''}" style="max-width: 100%; height: auto;" />`
175
224
  }
176
- return el.innerHTML
225
+ const clone = el.cloneNode(true)
226
+ clone.querySelectorAll('.mermaid-copy-btn, .image-copy-btn').forEach(btn => btn.remove())
227
+ return clone.innerHTML
177
228
  })
178
229
  const clickedEl = isImg ? target : mermaidEl
179
230
  const index = allZoomable.indexOf(clickedEl)
@@ -181,14 +232,13 @@ export function useDocManager() {
181
232
  }
182
233
  }
183
234
 
184
- // 当前文档标题
235
+ // ===== 计算属性 =====
185
236
  const currentDocTitle = computed(() => {
186
237
  if (!currentDoc.value) return '文档'
187
238
  const doc = findDoc(docsList.value, currentDoc.value)
188
239
  return doc?.label || '文档'
189
240
  })
190
241
 
191
- // 上一篇/下一篇
192
242
  const prevDoc = computed(() => {
193
243
  if (!currentDoc.value) return null
194
244
  const flat = flattenDocsList(docsList.value)
@@ -203,26 +253,139 @@ export function useDocManager() {
203
253
  return idx >= 0 && idx < flat.length - 1 ? flat[idx + 1] : null
204
254
  })
205
255
 
206
- // 搜索结果选中
207
256
  function handleSearchSelect(key) {
208
257
  expandParents(docsList.value, key)
258
+ saveExpandedState()
209
259
  loadDoc(key)
210
260
  }
211
261
 
212
- // URL 路由(popstate / 初始加载)
262
+ // ===== 编辑模式 =====
263
+ function toggleEditMode() {
264
+ editMode.value = !editMode.value
265
+ sessionStorage.setItem('editMode', editMode.value)
266
+ }
267
+
268
+ watch(editMode, async (newVal, oldVal) => {
269
+ if (oldVal && !newVal && rawMarkdown.value && currentDoc.value) {
270
+ await renderMarkdown(rawMarkdown.value, currentDoc.value, docsList.value)
271
+ }
272
+ if (newVal && rawMarkdown.value) {
273
+ extractTOCFromMarkdown(rawMarkdown.value, tocItems)
274
+ }
275
+ })
276
+
277
+ watch(rawMarkdown, (md) => {
278
+ if (editMode.value && md) {
279
+ extractTOCFromMarkdown(md, tocItems)
280
+ }
281
+ })
282
+
283
+ // ===== 轮询回调(供 useFileWatcher 调用) =====
284
+
285
+ // 树结构指纹,快速判断是否有变化
286
+ function treeFingerprint(items) {
287
+ const parts = []
288
+ for (const item of items) {
289
+ parts.push(item.key)
290
+ if (item.type === 'folder' && item.children) {
291
+ parts.push('{', treeFingerprint(item.children), '}')
292
+ }
293
+ }
294
+ return parts.join(',')
295
+ }
296
+
297
+ function getExpandedKeys(items) {
298
+ const keys = new Set()
299
+ for (const item of items) {
300
+ if (item.type === 'folder') {
301
+ if (item.expanded) keys.add(item.key)
302
+ if (item.children) for (const k of getExpandedKeys(item.children)) keys.add(k)
303
+ }
304
+ }
305
+ return keys
306
+ }
307
+
308
+ function applyExpandedState(items, expandedKeys) {
309
+ for (const item of items) {
310
+ if (item.type === 'folder') {
311
+ if (expandedKeys.has(item.key)) item.expanded = true
312
+ if (item.children) applyExpandedState(item.children, expandedKeys)
313
+ }
314
+ }
315
+ }
316
+
317
+ // 刷新文档列表(轮询回调 或 手动调用)
318
+ async function reloadDocsList(newTree) {
319
+ if (!newTree) {
320
+ // 手动调用(创建/删除/重命名后),强制拉取最新
321
+ docService.resetListEtag()
322
+ newTree = await docService.fetchDocsList()
323
+ }
324
+ // 结构没变就跳过
325
+ if (docsList.value.length > 0 && treeFingerprint(docsList.value) === treeFingerprint(newTree)) {
326
+ return
327
+ }
328
+ // 保存展开状态 → 替换 → 恢复
329
+ const expandedKeys = getExpandedKeys(docsList.value)
330
+ docsList.value = newTree
331
+ applyExpandedState(docsList.value, expandedKeys)
332
+ if (currentDoc.value) expandParents(docsList.value, currentDoc.value)
333
+ buildIndex(docsList.value)
334
+ saveExpandedState()
335
+ }
336
+
337
+ // 刷新当前文档内容(轮询回调)
338
+ let lastContentHash = ''
339
+ async function reloadCurrentDoc(content) {
340
+ if (!currentDoc.value) return
341
+ // 哈希比对,内容没变就跳过
342
+ let hash = 0
343
+ for (let i = 0; i < content.length; i++) {
344
+ hash = ((hash << 5) - hash + content.charCodeAt(i)) | 0
345
+ }
346
+ const hashStr = String(hash)
347
+ if (hashStr === lastContentHash) return
348
+ lastContentHash = hashStr
349
+ rawMarkdown.value = content
350
+ // 编辑模式下只更新 rawMarkdown(编辑器组件会自行比对内容决定是否刷新)
351
+ if (editMode.value) return
352
+ await renderMarkdown(content, currentDoc.value, docsList.value)
353
+ }
354
+
355
+ // ===== 写操作 =====
356
+ async function saveDocContent({ path: filePath, content }) {
357
+ try {
358
+ const ok = await docService.saveDoc(filePath, content)
359
+ if (ok) {
360
+ rawMarkdown.value = content
361
+ if (!editMode.value) {
362
+ await renderMarkdown(content, currentDoc.value, docsList.value)
363
+ }
364
+ return true
365
+ }
366
+ return false
367
+ } catch (e) {
368
+ console.error('保存失败:', e)
369
+ return false
370
+ }
371
+ }
372
+
373
+ function getCurrentDocPath() {
374
+ return currentDocFilePath.value
375
+ }
376
+
377
+ // ===== URL 路由 =====
213
378
  async function loadFromUrl() {
214
379
  const pathname = window.location.pathname.replace(/^\//, '')
215
380
  const anchor = window.location.hash.replace('#', '')
216
381
  const savedScroll = history.state?.scrollTop
217
382
  if (!pathname) {
218
383
  if (currentDoc.value) {
219
- // 浏览器回退到首页,不操作 history
220
384
  goHome({ isPopstate: true })
221
385
  } else if (docsList.value.length === 0) {
222
386
  showWelcome.value = false
223
387
  renderMarkdown('# 当前目录没有 Markdown 文档\n\n请在当前目录下添加 `.md` 文件,然后刷新页面。')
224
388
  } else {
225
- // 优先定位到 README,没有则定位到第一篇文档
226
389
  const readme = findReadmeDoc(docsList.value)
227
390
  const target = readme || findFirstDoc(docsList.value)
228
391
  if (target) {
@@ -234,11 +397,9 @@ export function useDocManager() {
234
397
  }
235
398
  const doc = findDocByHash(docsList.value, pathname, docHash)
236
399
  if (!doc) return
237
- // 同一文档内的锚点变化(含回退)
238
400
  if (doc.key === currentDoc.value) {
239
401
  await nextTick()
240
402
  if (savedScroll != null) {
241
- // 有保存的滚动位置,直接恢复(回退场景)
242
403
  const contentEl = document.querySelector('.content')
243
404
  if (contentEl) contentEl.scrollTo({ top: savedScroll, behavior: 'smooth' })
244
405
  } else if (anchor) {
@@ -250,6 +411,7 @@ export function useDocManager() {
250
411
  return
251
412
  }
252
413
  expandParents(docsList.value, doc.key)
414
+ saveExpandedState()
253
415
  await loadDoc(doc.key, { replace: true, keepState: savedScroll != null, anchor: anchor ? decodeURIComponent(anchor) : '' })
254
416
  if (savedScroll != null) {
255
417
  await nextTick()
@@ -260,38 +422,133 @@ export function useDocManager() {
260
422
  }
261
423
  }
262
424
 
425
+ // ===== 文档管理操作 =====
426
+ async function createDoc({ parentKey, name, type }) {
427
+ const relativePath = parentKey ? `${parentKey}/${name}` : name
428
+ const apiPath = type === 'file' ? `${relativePath}.md` : relativePath
429
+ try {
430
+ await docService.createDoc(apiPath, type)
431
+ await reloadDocsList()
432
+ if (parentKey) expandParents(docsList.value, relativePath)
433
+ if (type === 'file') await loadDoc(relativePath)
434
+ return { ok: true }
435
+ } catch (e) {
436
+ return { ok: false, message: e.message }
437
+ }
438
+ }
439
+
440
+ async function renameDoc(item, newName) {
441
+ const parts = item.key.split('/')
442
+ parts[parts.length - 1] = newName
443
+ const newKey = parts.join('/')
444
+ const oldPath = item.type === 'file' ? `${item.key}.md` : item.key
445
+ const newPath = item.type === 'file' ? `${newKey}.md` : newKey
446
+ try {
447
+ await docService.renameDoc(oldPath, newPath)
448
+ const wasCurrentDoc = item.type === 'file' && item.key === currentDoc.value
449
+ const wasInFolder = item.type === 'folder' && currentDoc.value.startsWith(item.key + '/')
450
+ await reloadDocsList()
451
+ if (wasCurrentDoc) {
452
+ expandParents(docsList.value, newKey)
453
+ await loadDoc(newKey, { replace: true })
454
+ }
455
+ if (wasInFolder) {
456
+ const newDocKey = currentDoc.value.replace(item.key + '/', newKey + '/')
457
+ expandParents(docsList.value, newDocKey)
458
+ await loadDoc(newDocKey, { replace: true })
459
+ }
460
+ return { ok: true }
461
+ } catch (e) {
462
+ return { ok: false, message: e.message }
463
+ }
464
+ }
465
+
466
+ async function deleteDoc(item) {
467
+ const apiPath = item.type === 'file' ? `${item.key}.md` : item.key
468
+ try {
469
+ await docService.deleteDoc(apiPath)
470
+ if (item.type === 'file' && item.key === currentDoc.value) goHome()
471
+ if (item.type === 'folder' && currentDoc.value.startsWith(item.key + '/')) goHome()
472
+ await reloadDocsList()
473
+ return { ok: true }
474
+ } catch (e) {
475
+ return { ok: false, message: e.message }
476
+ }
477
+ }
478
+
479
+ // ===== 拖拽排序 =====
480
+ function calcPadWidth(count) { return count < 100 ? 2 : String(count - 1).length }
481
+ function orderPrefix(index, padWidth) { return String(index).padStart(padWidth, '0') }
482
+ function stripPrefix(name) { return name.replace(/^\d+-/, '') }
483
+
484
+ function collectReorderItems(items, parentPath = '') {
485
+ const result = []
486
+ const padWidth = calcPadWidth(items.length)
487
+ items.forEach((item, index) => {
488
+ const prefix = orderPrefix(index, padWidth)
489
+ const pureName = stripPrefix(item.label || item.key.split('/').pop())
490
+ const newName = `${prefix}-${pureName}`
491
+ const oldName = item.key.split('/').pop()
492
+ const oldFsName = item.type === 'file' ? `${oldName}.md` : oldName
493
+ const newFsName = item.type === 'file' ? `${newName}.md` : newName
494
+ const oldPath = parentPath ? `${parentPath}/${oldFsName}` : oldFsName
495
+ const newPath = parentPath ? `${parentPath}/${newFsName}` : newFsName
496
+ if (oldPath !== newPath) result.push({ oldPath, newPath })
497
+ })
498
+ return result
499
+ }
500
+
501
+ function collectAllReorderLevels(items, parentPath = '') {
502
+ const levels = []
503
+ items.forEach((item) => {
504
+ if (item.type === 'folder' && item.children) {
505
+ const folderPath = parentPath ? `${parentPath}/${item.key.split('/').pop()}` : item.key.split('/').pop()
506
+ levels.push(...collectAllReorderLevels(item.children, folderPath))
507
+ }
508
+ })
509
+ const currentLevelItems = collectReorderItems(items, parentPath)
510
+ if (currentLevelItems.length > 0) levels.push(currentLevelItems)
511
+ return levels
512
+ }
513
+
514
+ async function reorderDocs() {
515
+ const levels = collectAllReorderLevels(docsList.value)
516
+ if (levels.length === 0) return { ok: true }
517
+ const currentPureName = currentDoc.value ? stripOrderPrefix(currentDoc.value) : ''
518
+ try {
519
+ for (const levelItems of levels) {
520
+ if (levelItems.length === 0) continue
521
+ await docService.reorderDocs(levelItems)
522
+ }
523
+ await reloadDocsList()
524
+ if (currentPureName) {
525
+ const flat = flattenDocsList(docsList.value)
526
+ const match = flat.find(d => stripOrderPrefix(d.key) === currentPureName)
527
+ if (match) {
528
+ expandParents(docsList.value, match.key)
529
+ await loadDoc(match.key, { replace: true })
530
+ }
531
+ }
532
+ return { ok: true }
533
+ } catch (e) {
534
+ console.error('重编号失败:', e)
535
+ return { ok: false, message: e.message }
536
+ }
537
+ }
538
+
539
+ // ===== 导出 =====
263
540
  return {
264
- // 状态
265
- docsList,
266
- currentDoc,
267
- currentDocTitle,
268
- showWelcome,
269
- htmlContent,
270
- tocItems,
271
- // 滚动
272
- scrollProgress,
273
- showBackToTop,
274
- activeHeading,
275
- handleScroll,
276
- scrollToHeading,
277
- scrollToTop,
278
- // 文档操作
279
- loadDocsList,
280
- loadFromUrl,
281
- goHome,
282
- loadDoc,
283
- loadFirstDoc,
284
- handleDocSelect,
285
- handleContentClick,
286
- handleSearchSelect,
287
- // 文件夹操作
288
- toggleFolder,
289
- onExpandAll,
290
- onCollapseAll,
291
- // 导航
292
- prevDoc,
293
- nextDoc,
294
- // 工具
541
+ docsList, currentDoc, currentDocTitle, showWelcome, htmlContent, tocItems,
542
+ editMode, rawMarkdown, currentDocFilePath, lastModified,
543
+ scrollProgress, showBackToTop, activeHeading,
544
+ handleScroll, scrollToHeading, scrollToTop,
545
+ loadDocsList, loadFromUrl, goHome, loadDoc, loadFirstDoc,
546
+ handleDocSelect, handleContentClick, handleSearchSelect,
547
+ toggleEditMode, reloadDocsList, reloadCurrentDoc,
548
+ saveDoc: saveDocContent, getCurrentDocPath,
549
+ toggleFolder, onExpandAll, onCollapseAll,
550
+ prevDoc, nextDoc,
551
+ createDoc, deleteDoc, renameDoc, reorderDocs,
295
552
  docHash
296
553
  }
297
554
  }
@@ -55,7 +55,11 @@ export function findDocByHash(items, hash, docHash) {
55
55
  // 展开文档所在的所有父级文件夹
56
56
  export function expandParents(items, targetKey) {
57
57
  for (const item of items) {
58
- if (item.type === 'file' && item.key === targetKey) return true
58
+ if (item.key === targetKey) {
59
+ // 如果目标本身是文件夹,也展开它
60
+ if (item.type === 'folder') item.expanded = true
61
+ return true
62
+ }
59
63
  if (item.type === 'folder' && item.children) {
60
64
  if (expandParents(item.children, targetKey)) {
61
65
  item.expanded = true
@@ -94,3 +98,54 @@ export function collapseAll(items) {
94
98
  }
95
99
  })
96
100
  }
101
+
102
+ // 查找节点的父级列表(用于插入/删除操作)
103
+ // 返回 { parent: 父节点children数组, index: 节点在数组中的索引 }
104
+ export function findParent(items, targetKey) {
105
+ for (let i = 0; i < items.length; i++) {
106
+ if (items[i].key === targetKey) {
107
+ return { parent: items, index: i }
108
+ }
109
+ if (items[i].type === 'folder' && items[i].children) {
110
+ const found = findParent(items[i].children, targetKey)
111
+ if (found) return found
112
+ }
113
+ }
114
+ return null
115
+ }
116
+
117
+ // 查找文件夹节点
118
+ export function findFolder(items, key) {
119
+ for (const item of items) {
120
+ if (item.type === 'folder' && item.key === key) return item
121
+ if (item.type === 'folder' && item.children) {
122
+ const found = findFolder(item.children, key)
123
+ if (found) return found
124
+ }
125
+ }
126
+ return null
127
+ }
128
+
129
+ // 收集所有已展开文件夹的 key
130
+ export function collectExpandedKeys(items) {
131
+ const keys = new Set()
132
+ for (const item of items) {
133
+ if (item.type === 'folder') {
134
+ if (item.expanded) keys.add(item.key)
135
+ if (item.children) {
136
+ for (const k of collectExpandedKeys(item.children)) keys.add(k)
137
+ }
138
+ }
139
+ }
140
+ return keys
141
+ }
142
+
143
+ // 根据 key 集合恢复展开状态
144
+ export function restoreExpandedKeys(items, keys) {
145
+ for (const item of items) {
146
+ if (item.type === 'folder') {
147
+ if (keys.has(item.key)) item.expanded = true
148
+ if (item.children) restoreExpandedKeys(item.children, keys)
149
+ }
150
+ }
151
+ }