@lemoncat7/dsh-knowledge 2.3.0 → 2.3.2

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 (52) hide show
  1. package/README.md +12 -4
  2. package/docs/architecture.md +1 -1
  3. package/lib/api.d.ts +2 -0
  4. package/lib/api.d.ts.map +1 -1
  5. package/lib/api.js +93 -0
  6. package/lib/api.js.map +1 -1
  7. package/lib/client.js +1 -1
  8. package/lib/client.js.map +1 -1
  9. package/lib/config.d.ts +2 -0
  10. package/lib/config.d.ts.map +1 -1
  11. package/lib/config.js +26 -0
  12. package/lib/config.js.map +1 -1
  13. package/lib/extraction.js +9 -2
  14. package/lib/extraction.js.map +1 -1
  15. package/lib/index.d.ts.map +1 -1
  16. package/lib/index.js +15 -1
  17. package/lib/index.js.map +1 -1
  18. package/lib/notes/domain.d.ts +8 -0
  19. package/lib/notes/domain.d.ts.map +1 -1
  20. package/lib/notes/domain.js.map +1 -1
  21. package/lib/notes/share-import.d.ts +50 -0
  22. package/lib/notes/share-import.d.ts.map +1 -0
  23. package/lib/notes/share-import.js +344 -0
  24. package/lib/notes/share-import.js.map +1 -0
  25. package/lib/notes/share-markdown.d.ts +12 -0
  26. package/lib/notes/share-markdown.d.ts.map +1 -0
  27. package/lib/notes/share-markdown.js +79 -0
  28. package/lib/notes/share-markdown.js.map +1 -0
  29. package/lib/notes/share-page.d.ts +12 -0
  30. package/lib/notes/share-page.d.ts.map +1 -0
  31. package/lib/notes/share-page.js +176 -0
  32. package/lib/notes/share-page.js.map +1 -0
  33. package/lib/notes/store.d.ts +15 -1
  34. package/lib/notes/store.d.ts.map +1 -1
  35. package/lib/notes/store.js +177 -6
  36. package/lib/notes/store.js.map +1 -1
  37. package/lib/web-note-editor-selection.d.ts.map +1 -1
  38. package/lib/web-note-editor-selection.js +23 -0
  39. package/lib/web-note-editor-selection.js.map +1 -1
  40. package/lib/web.d.ts.map +1 -1
  41. package/lib/web.js +7 -6
  42. package/lib/web.js.map +1 -1
  43. package/package.json +2 -2
  44. package/web/app.js +303 -29
  45. package/web/index.html +1 -2
  46. package/web/note-editor.js +64 -64
  47. package/web/styles.css +168 -291
  48. package/lib/web-workspace-effects.d.ts +0 -13
  49. package/lib/web-workspace-effects.d.ts.map +0 -1
  50. package/lib/web-workspace-effects.js +0 -366
  51. package/lib/web-workspace-effects.js.map +0 -1
  52. package/web/workspace-effects.js +0 -16
package/web/app.js CHANGED
@@ -90,7 +90,7 @@ const state = {
90
90
  children: new Map(), loadedFolders: new Set(), expandedFolders: new Set(),
91
91
  selectedId: '', selectedNode: null, currentFolderId: null, breadcrumbs: [],
92
92
  content: '', draft: '', dirty: false, assetUrl: '', query: '', searchResults: [],
93
- transfer: null, loadingNodeId: '',
93
+ transfer: null, loadingNodeId: '', shares: [], shareError: '',
94
94
  },
95
95
  service: { publicApiEnabled: false, publicApiPrefix: '/knowledge-api/v1', remote: false },
96
96
  scrollPositions: new Map(),
@@ -184,10 +184,6 @@ function restoreScrollPosition(view) {
184
184
  })
185
185
  }
186
186
 
187
- function refreshWorkspaceEffects(root = document) {
188
- window.DshKnowledgeEffects?.refresh(root)
189
- }
190
-
191
187
  const { api, binaryRequest, binaryUploadRequest } = createApiClient({
192
188
  apiBase: API_BASE,
193
189
  authMode: AUTH_MODE,
@@ -259,7 +255,6 @@ function renderLogin(message = '') {
259
255
  element('p', {}, '管理对话中沉淀的长期知识与 AI 提取候选。'),
260
256
  form,
261
257
  )))
262
- refreshWorkspaceEffects(app)
263
258
  }
264
259
 
265
260
  function signOut() {
@@ -268,7 +263,7 @@ function signOut() {
268
263
  releaseNoteAsset()
269
264
  sessionStorage.removeItem(TOKEN_KEY)
270
265
  Object.assign(state, { token: '', stats: null, overview: null, knowledgeBases: [], mounts: [], resolvedMounts: [], entries: [], documents: [], candidates: [], candidateTargets: new Map(), settings: { writebackPolicy: 'conservative', updatedAt: '' }, tokens: [] })
271
- state.notes = { children: new Map(), loadedFolders: new Set(), expandedFolders: new Set(), selectedId: '', selectedNode: null, currentFolderId: null, breadcrumbs: [], content: '', draft: '', dirty: false, assetUrl: '', query: '', searchResults: [], transfer: null, loadingNodeId: '' }
266
+ state.notes = { children: new Map(), loadedFolders: new Set(), expandedFolders: new Set(), selectedId: '', selectedNode: null, currentFolderId: null, breadcrumbs: [], content: '', draft: '', dirty: false, assetUrl: '', query: '', searchResults: [], transfer: null, loadingNodeId: '', shares: [], shareError: '' }
272
267
  if (AUTH_MODE === 'same-origin') void boot()
273
268
  else renderLogin()
274
269
  }
@@ -298,6 +293,7 @@ async function navigate(view) {
298
293
  if (view === 'entries') await loadDocuments(controller.signal, (label, progress) => updateLoadingPhase(request, label, progress))
299
294
  if (view === 'candidates') await loadCandidates(controller.signal)
300
295
  if (view === 'notes') await loadNotes(controller.signal, (label, progress) => updateLoadingPhase(request, label, progress))
296
+ if (view === 'shares') await loadNoteShares(controller.signal)
301
297
  if (view === 'tokens') await loadTokens(controller.signal)
302
298
  } catch (error) {
303
299
  if (controller.signal.aborted) return
@@ -329,7 +325,7 @@ function cancelPendingSearches() {
329
325
  function loadingPhaseForView(view) {
330
326
  return ({
331
327
  overview: '正在汇总知识活动', bases: '正在读取知识库配置', entries: '正在读取知识目录',
332
- notes: '正在读取笔记目录', candidates: '正在准备审核队列', tokens: '正在读取访问权限',
328
+ notes: '正在读取笔记目录', shares: '正在读取分享列表', candidates: '正在准备审核队列', tokens: '正在读取访问权限',
333
329
  })[view] || '正在准备工作区'
334
330
  }
335
331
 
@@ -476,7 +472,11 @@ async function loadDocuments(signal, onPhase = () => {}) {
476
472
 
477
473
  async function loadNotes(signal, onPhase = () => {}) {
478
474
  onPhase('正在读取顶层目录', .36)
479
- state.notes.children.set('root', await api('notes?limit=500', { signal }))
475
+ const [rootNodes] = await Promise.all([
476
+ api('notes?limit=500', { signal }),
477
+ loadNoteShares(signal),
478
+ ])
479
+ state.notes.children.set('root', rootNodes)
480
480
  state.notes.loadedFolders.add('root')
481
481
  if (state.notes.selectedId) {
482
482
  onPhase('正在恢复上次打开的笔记', .74)
@@ -944,11 +944,12 @@ function renderShell() {
944
944
  bases: ['知识库与挂载', '管理知识目录,并限定项目与会话的召回和写入范围'],
945
945
  entries: ['知识文档', '在知识目录中阅读、整理和维护 Markdown 文档'],
946
946
  notes: ['笔记文档', '像本地目录一样整理笔记和资料,并按需关联到知识文档'],
947
+ shares: ['已分享', '管理只读分享链接,也可以导入别人分享的笔记和目录'],
947
948
  candidates: ['待审核', '确认 AI 提取结果后再写入知识文档'],
948
949
  tokens: ['访问管理', '管理其他客户端连接中央知识库的权限'],
949
950
  }
950
951
  const [title, subtitle] = titles[state.view]
951
- const viewIndexes = { overview: '00', notes: '01', entries: '02', candidates: '03', bases: '04', tokens: '05' }
952
+ const viewIndexes = { overview: '00', notes: '01', shares: '02', entries: '03', candidates: '04', bases: '05', tokens: '06' }
952
953
  const shell = element('div', {
953
954
  class: 'app-shell', 'data-menu-open': String(state.menuOpen),
954
955
  'data-view': state.view, 'data-loading': String(state.loading),
@@ -993,7 +994,6 @@ function renderShell() {
993
994
  )
994
995
  app.replaceChildren(shell)
995
996
  applySidebarVisibility(shell, state.documentView.sidebarHidden)
996
- refreshWorkspaceEffects(shell)
997
997
  restoreScrollPosition(currentScrollState())
998
998
  }
999
999
 
@@ -1095,7 +1095,7 @@ function applySidebarVisibility(shell, hidden) {
1095
1095
  function renderSidebar() {
1096
1096
  const pending = state.stats?.candidates.pending
1097
1097
  const navGroups = [
1098
- ['笔记工作区', [['notes', '笔记文档']]],
1098
+ ['笔记工作区', [['notes', '笔记文档'], ['shares', '已分享']]],
1099
1099
  ['知识工作区', [['entries', '知识文档'], ['candidates', '待审核'], ['bases', '知识库与挂载']]],
1100
1100
  ['连接', [['tokens', '访问管理']].filter(([id]) => id !== 'tokens' || !state.service.remote)],
1101
1101
  ].filter(([, items]) => items.length)
@@ -1126,6 +1126,7 @@ function renderCurrentView() {
1126
1126
  if (state.view === 'bases') return renderKnowledgeBases()
1127
1127
  if (state.view === 'entries') return renderEntries()
1128
1128
  if (state.view === 'notes') return renderNotes()
1129
+ if (state.view === 'shares') return renderShares()
1129
1130
  if (state.view === 'candidates') return renderCandidates()
1130
1131
  return renderTokens()
1131
1132
  }
@@ -1379,7 +1380,6 @@ function renderKnowledgeBaseCard(base) {
1379
1380
  const hiddenTagCount = Math.max(0, base.defaultTags.length - visibleTags.length)
1380
1381
  return element('article', {
1381
1382
  class: `base-card${archived ? ' is-archived' : ''}`,
1382
- 'data-knowledge-motion-key': `knowledge-library:${base.id}`,
1383
1383
  },
1384
1384
  element('div', { class: 'base-card-header' },
1385
1385
  element('div', { class: 'base-card-identity' },
@@ -1515,7 +1515,6 @@ function renderMountListRow(base, view, targetKind, targetId) {
1515
1515
  return element('article', {
1516
1516
  class: `mount-list-row${selected ? ' is-selected' : ''}`,
1517
1517
  role: 'listitem',
1518
- 'data-knowledge-motion-key': `knowledge-mount:${targetKind}:${base.id}`,
1519
1518
  },
1520
1519
  element('label', { class: 'mount-select' }, checkbox, element('span', { class: 'visually-hidden' }, `选择 ${base.name}`)),
1521
1520
  element('div', { class: 'mount-list-main' },
@@ -1598,7 +1597,6 @@ function renderDocumentWorkspace(workspace, options = {}) {
1598
1597
  },
1599
1598
  element('button', {
1600
1599
  type: 'button', class: 'note-tree-base', 'aria-expanded': String(expanded),
1601
- 'data-knowledge-motion-key': `knowledge-base:${workspace.kind}:${base.id}`,
1602
1600
  onClick: () => {
1603
1601
  if (query) { view.knowledgeBaseId = base.id; return renderShell() }
1604
1602
  void toggleDocumentBase(workspace, base.id)
@@ -1619,7 +1617,6 @@ function renderDocumentWorkspace(workspace, options = {}) {
1619
1617
  'aria-busy': movingDocumentId === document.id ? 'true' : undefined,
1620
1618
  title: canOrganize ? `${document.title} · 拖到其他知识库以移动` : document.title,
1621
1619
  'data-document-id': document.id,
1622
- 'data-knowledge-motion-key': `knowledge-document:${workspace.kind}:${document.id}`,
1623
1620
  onDragStart: event => {
1624
1621
  knowledgeDocumentDrag = { documentId: document.id, sourceBaseId: base.id, workspaceKind: workspace.kind }
1625
1622
  event.dataTransfer.effectAllowed = 'move'
@@ -1972,9 +1969,7 @@ function renderNotes() {
1972
1969
  onChange: event => { void uploadNoteFiles([...event.target.files], state.notes.currentFolderId); event.target.value = '' },
1973
1970
  })
1974
1971
  const rootNodes = state.notes.children.get('root') || []
1975
- const tree = state.notes.query.trim()
1976
- ? renderNoteSearchResults()
1977
- : renderNoteTreeBranch(null)
1972
+ const tree = state.notes.query.trim() ? renderNoteSearchResults() : renderNoteTreeBranch(null)
1978
1973
  const workspace = element('section', {
1979
1974
  class: 'notes-workspace', 'data-drop-active': 'false',
1980
1975
  onDragEnter: noteWorkspaceDragEnter, onDragOver: noteWorkspaceDragEnter,
@@ -2045,12 +2040,213 @@ function openNoteAgentGuide() {
2045
2040
  element('h3', {}, '可以直接这样说'),
2046
2041
  element('ul', {}, examples)),
2047
2042
  element('p', { class: 'notes-agent-guide-note' }, '只说“新建 Markdown”或“创建本地目录”仍不会获得笔记权限;在当前消息中带上“笔记文档”即可。'))
2048
- return openSheet({
2043
+ return openModal({
2049
2044
  title: '让会话整理笔记',
2050
2045
  description: '会话可以操作笔记工作区,但每次写入都需要当前用户消息明确授权。',
2051
2046
  body,
2052
2047
  cancelLabel: '知道了',
2048
+ className: 'notes-agent-guide-dialog',
2049
+ })
2050
+ }
2051
+
2052
+ async function loadNoteShares(signal) {
2053
+ try {
2054
+ state.notes.shares = await api('notes/shares', { signal })
2055
+ state.notes.shareError = ''
2056
+ } catch (error) {
2057
+ if (error.name === 'AbortError') throw error
2058
+ state.notes.shareError = friendlyError(error)
2059
+ }
2060
+ return state.notes.shares
2061
+ }
2062
+
2063
+ async function refreshNoteShares() {
2064
+ await loadNoteShares()
2065
+ renderShell()
2066
+ if (!state.notes.shareError) showToast('分享列表已刷新。')
2067
+ }
2068
+
2069
+ function renderShares() {
2070
+ return element('section', { class: 'shares-page', 'aria-label': '已分享笔记' },
2071
+ element('div', { class: 'section-heading shares-heading' },
2072
+ element('div', {}, element('h2', {}, '分享空间'), element('p', {}, '管理本机生成的只读链接,或将别人分享的内容复制到你的笔记工作区。')),
2073
+ element('div', { class: 'shares-heading-actions' },
2074
+ actionButton('从分享导入', openImportNoteShare, 'primary small'),
2075
+ actionButton('刷新', () => { void refreshNoteShares() }, 'ghost small'),
2076
+ ),
2077
+ ),
2078
+ element('section', { class: 'panel shares-panel' },
2079
+ element('header', { class: 'panel-header shares-panel-header' },
2080
+ element('div', {}, element('h3', {}, '本机已分享'), element('p', {}, `${state.notes.shares.length} 项 · 原内容更新后分享链接自动同步`)),
2081
+ ),
2082
+ element('div', { class: 'shares-panel-body' },
2083
+ state.notes.shareError
2084
+ ? element('div', { class: 'notes-share-error', role: 'alert' }, element('strong', {}, '分享列表加载失败'), element('p', {}, state.notes.shareError), actionButton('重试', () => { void refreshNoteShares() }, 'small'))
2085
+ : state.notes.shares.length
2086
+ ? element('div', { class: 'notes-share-list' }, state.notes.shares.map(renderNoteShareRow))
2087
+ : element('div', { class: 'notes-folder-empty notes-share-empty' },
2088
+ element('span', { class: 'notes-share-empty-mark', 'aria-hidden': 'true' }),
2089
+ element('h3', {}, '还没有分享内容'),
2090
+ element('p', {}, '打开一篇笔记或目录,选择“分享”即可生成只读链接。'),
2091
+ element('div', {}, actionButton('打开笔记文档', () => { void navigate('notes') }, 'primary small')),
2092
+ ),
2093
+ ),
2094
+ ),
2095
+ )
2096
+ }
2097
+
2098
+ function renderNoteShareRow(share) {
2099
+ const url = noteShareUrl(share)
2100
+ return element('article', { class: 'notes-share-row' },
2101
+ element('button', { type: 'button', class: 'notes-share-main', onClick: () => { void openSharedNoteOriginal(share) } },
2102
+ renderNoteIcon(share.node, true),
2103
+ element('span', {}, element('strong', {}, share.node.name), element('small', {}, share.node.kind === 'folder' ? '共享目录及其子项' : noteKindLabel(share.node))),
2104
+ ),
2105
+ element('div', { class: 'notes-share-meta' },
2106
+ element('span', {}, '分享于', element('time', { datetime: share.createdAt }, formatDate(share.createdAt))),
2107
+ element('span', {}, '原内容更新后链接自动同步'),
2108
+ ),
2109
+ element('div', { class: 'notes-share-actions' },
2110
+ actionButton('打开原文', () => { void openSharedNoteOriginal(share) }, 'ghost small'),
2111
+ actionButton('复制链接', () => copyText(url, '分享链接已复制。'), 'small'),
2112
+ actionButton('管理', () => showNoteShareDialog(share), 'ghost small'),
2113
+ ),
2114
+ )
2115
+ }
2116
+
2117
+ function openImportNoteShare() {
2118
+ const urlField = formField('分享链接', 'input', '', {
2119
+ type: 'url', required: true, inputmode: 'url', autocomplete: 'off',
2120
+ placeholder: 'https://example.com/knowledge-api/v1/shared/share_…',
2121
+ })
2122
+ const destination = selectField('导入到', [{ value: '', label: '笔记文档(根目录)' }], '')
2123
+ destination.input.disabled = true
2124
+ const inspectButton = actionButton('读取分享', () => { void inspect() }, 'small')
2125
+ const fieldError = element('p', { class: 'share-import-field-error', role: 'alert', hidden: true })
2126
+ const preview = element('div', { class: 'share-import-preview', 'data-state': 'empty', 'aria-live': 'polite' },
2127
+ element('span', { class: 'share-import-preview-mark', 'aria-hidden': 'true' }),
2128
+ element('div', {}, element('strong', {}, '等待读取分享'), element('p', {}, '读取后会显示类型、文件数量与大小;导入前不会写入任何内容。')),
2129
+ )
2130
+ const destinationHint = element('span', { class: 'field-hint' }, '正在读取你的笔记目录…')
2131
+ destination.wrapper.append(destinationHint)
2132
+ const inputRow = element('div', { class: 'share-import-url-row' }, urlField.input, inspectButton)
2133
+ urlField.wrapper.replaceChildren(element('label', {}, '分享链接'), inputRow, fieldError)
2134
+ const form = element('form', { class: 'share-import-form', onSubmit: event => { event.preventDefault(); void inspect() } },
2135
+ urlField.wrapper,
2136
+ preview,
2137
+ destination.wrapper,
2138
+ element('p', { class: 'share-import-note' }, '导入会创建一份独立副本;之后对方更新或停止分享,都不会改动你已经导入的内容。'),
2139
+ )
2140
+ let inspectedUrl = ''
2141
+
2142
+ const showFieldError = message => {
2143
+ fieldError.textContent = message
2144
+ fieldError.hidden = !message
2145
+ urlField.input.setAttribute('aria-invalid', String(Boolean(message)))
2146
+ }
2147
+ const renderPreview = manifest => {
2148
+ const kind = manifest.share.kind === 'folder' ? '目录' : manifest.share.kind === 'document' ? 'Markdown 文档' : '文件'
2149
+ preview.dataset.state = 'ready'
2150
+ preview.replaceChildren(
2151
+ renderNoteIcon({ kind: manifest.share.kind, name: manifest.share.name, mediaType: null }, true),
2152
+ element('div', {},
2153
+ element('strong', {}, manifest.share.name),
2154
+ element('p', {}, `${kind} · ${manifest.share.fileCount} 个文件 · ${formatBytes(manifest.share.totalSize)}`),
2155
+ ),
2156
+ manifest.truncated ? badge('超过导入上限', 'danger') : badge('可以导入', 'success'),
2157
+ )
2158
+ }
2159
+ const inspect = async () => {
2160
+ if (!urlField.input.reportValidity()) return false
2161
+ inspectButton.disabled = true
2162
+ inspectButton.setAttribute('aria-busy', 'true')
2163
+ inspectButton.textContent = '正在读取…'
2164
+ showFieldError('')
2165
+ preview.dataset.state = 'loading'
2166
+ try {
2167
+ const result = await api('notes/import-share/inspect', { method: 'POST', body: { url: urlField.input.value.trim() } })
2168
+ inspectedUrl = urlField.input.value.trim()
2169
+ renderPreview(result.manifest)
2170
+ return !result.manifest.truncated
2171
+ } catch (error) {
2172
+ inspectedUrl = ''
2173
+ preview.dataset.state = 'error'
2174
+ preview.replaceChildren(
2175
+ element('span', { class: 'share-import-preview-mark', 'aria-hidden': 'true' }),
2176
+ element('div', {}, element('strong', {}, '无法读取这个分享'), element('p', {}, '请检查链接是否完整、分享是否仍有效。')),
2177
+ )
2178
+ showFieldError(friendlyError(error))
2179
+ return false
2180
+ } finally {
2181
+ inspectButton.disabled = false
2182
+ inspectButton.removeAttribute('aria-busy')
2183
+ inspectButton.textContent = '读取分享'
2184
+ }
2185
+ }
2186
+ urlField.input.addEventListener('input', () => {
2187
+ if (urlField.input.value.trim() !== inspectedUrl) {
2188
+ inspectedUrl = ''
2189
+ preview.dataset.state = 'empty'
2190
+ }
2191
+ showFieldError('')
2192
+ })
2193
+
2194
+ openModal({
2195
+ title: '从分享导入',
2196
+ description: '把只读分享复制到你的笔记工作区',
2197
+ body: form,
2198
+ primaryLabel: '导入笔记',
2199
+ className: 'share-import-dialog',
2200
+ onPrimary: async () => {
2201
+ const requestedUrl = urlField.input.value.trim()
2202
+ if (!requestedUrl || inspectedUrl !== requestedUrl) {
2203
+ showFieldError('请先读取并确认分享内容。')
2204
+ urlField.input.focus()
2205
+ return false
2206
+ }
2207
+ showFieldError('')
2208
+ try {
2209
+ const result = await api('notes/import-share', {
2210
+ method: 'POST', body: { url: requestedUrl, parentId: destination.input.value || null },
2211
+ })
2212
+ state.notes.children.clear()
2213
+ state.notes.loadedFolders.clear()
2214
+ showToast(`已导入“${result.root.name}”,共 ${result.importedNodes} 项。`)
2215
+ return true
2216
+ } catch (error) {
2217
+ showFieldError(friendlyError(error))
2218
+ return false
2219
+ }
2220
+ },
2053
2221
  })
2222
+
2223
+ void loadShareImportDestinations().then(options => {
2224
+ destination.input.replaceChildren(...options.map(option => element('option', { value: option.value }, option.label)))
2225
+ destination.input.disabled = false
2226
+ destinationHint.textContent = options.length > 1 ? `可选择 ${options.length - 1} 个已有目录` : '当前会导入到笔记根目录'
2227
+ }).catch(error => {
2228
+ destination.input.disabled = false
2229
+ destinationHint.textContent = `目录读取失败,将导入到根目录:${friendlyError(error)}`
2230
+ })
2231
+ }
2232
+
2233
+ async function loadShareImportDestinations() {
2234
+ const options = [{ value: '', label: '笔记文档(根目录)' }]
2235
+ const queue = [{ id: null, path: '' }]
2236
+ while (queue.length && options.length <= 500) {
2237
+ const batch = queue.splice(0, 4)
2238
+ const groups = await Promise.all(batch.map(async parent => ({ parent, nodes: await loadNoteChildren(parent.id) })))
2239
+ for (const { parent, nodes } of groups) {
2240
+ for (const node of nodes) {
2241
+ if (node.kind !== 'folder') continue
2242
+ const path = parent.path ? `${parent.path}/${node.name}` : node.name
2243
+ options.push({ value: node.id, label: path })
2244
+ queue.push({ id: node.id, path })
2245
+ if (options.length > 500) break
2246
+ }
2247
+ }
2248
+ }
2249
+ return options
2054
2250
  }
2055
2251
 
2056
2252
  function renderNoteTransfer() {
@@ -2091,7 +2287,6 @@ function renderNoteTreeBranch(parentId, depth = 0) {
2091
2287
  const row = element('div', {
2092
2288
  class: 'notes-tree-item', 'data-kind': node.kind, 'data-selected': String(selected),
2093
2289
  'data-note-id': node.id,
2094
- 'data-knowledge-motion-key': `note:${node.id}`,
2095
2290
  draggable: 'true',
2096
2291
  onDragStart: event => {
2097
2292
  event.dataTransfer.effectAllowed = 'move'
@@ -2131,7 +2326,7 @@ function renderNoteTreeBranch(parentId, depth = 0) {
2131
2326
  function renderNoteSearchResults() {
2132
2327
  if (!state.notes.searchResults.length) return element('div', { class: 'notes-tree-empty' }, '没有匹配的笔记文档。')
2133
2328
  return element('div', { class: 'notes-search-results' }, state.notes.searchResults.map(node => element('button', {
2134
- type: 'button', class: 'notes-search-row', 'data-knowledge-motion-key': `note-search:${node.id}`,
2329
+ type: 'button', class: 'notes-search-row',
2135
2330
  onClick: () => { void selectNoteNode(node) },
2136
2331
  }, renderNoteIcon(node), element('span', {}, element('strong', {}, node.name), element('small', {}, noteKindLabel(node))))))
2137
2332
  }
@@ -2168,7 +2363,10 @@ function renderNoteFolderContent(folder) {
2168
2363
  },
2169
2364
  renderNoteContentHeader(folder),
2170
2365
  children.length
2171
- ? element('div', { class: 'notes-file-list', role: 'list' }, children.map(node => element('div', {
2366
+ ? element('div', { class: 'notes-file-table' },
2367
+ element('div', { class: 'notes-file-columns', 'aria-hidden': 'true' },
2368
+ element('span', {}, '名称'), element('span', {}, '更新时间'), element('span', {}, '大小'), element('span', {}, '操作')),
2369
+ element('div', { class: 'notes-file-list', role: 'list' }, children.map(node => element('div', {
2172
2370
  class: 'notes-file-row', role: 'listitem', draggable: 'true',
2173
2371
  onDragStart: event => { event.dataTransfer.effectAllowed = 'move'; event.dataTransfer.setData('application/x-dsh-note-id', node.id) },
2174
2372
  onDblClick: () => { void selectNoteNode(node, { toggleFolder: node.kind === 'folder' }) },
@@ -2181,11 +2379,12 @@ function renderNoteFolderContent(folder) {
2181
2379
  element('span', { class: 'notes-file-size', 'data-label': node.kind === 'folder' ? '类型' : '大小' }, node.kind === 'folder' ? '目录' : formatBytes(node.size)),
2182
2380
  element('div', { class: 'notes-file-actions' },
2183
2381
  node.kind !== 'folder' ? noteDownloadButton(node, 'ghost tiny') : null,
2382
+ actionButton(noteShareForNode(node.id) ? '分享中' : '分享', () => { void openNoteShare(node) }, 'ghost tiny'),
2184
2383
  actionButton('重命名', () => openRenameNoteNode(node), 'ghost tiny'),
2185
2384
  actionButton('复制', () => { void copyNoteNode(node) }, 'ghost tiny'),
2186
2385
  actionButton('删除', () => { void confirmDeleteNoteNode(node) }, 'ghost tiny danger-text'),
2187
2386
  ),
2188
- )))
2387
+ ))))
2189
2388
  : element('div', { class: 'notes-folder-empty' },
2190
2389
  element('span', { class: 'notes-empty-folder-mark', 'aria-hidden': 'true' }),
2191
2390
  element('h3', {}, '这个目录还是空的'),
@@ -2205,6 +2404,8 @@ function renderNoteContentHeader(folder) {
2205
2404
  element('div', { class: 'notes-content-title' },
2206
2405
  element('div', {}, element('h2', {}, folder?.name || '全部笔记'), element('p', {}, folder ? `${(state.notes.children.get(folder.id) || []).length} 个项目` : '你的独立笔记与资料目录')),
2207
2406
  element('div', { class: 'notes-content-actions' },
2407
+ folder ? actionButton(noteShareForNode(folder.id) ? '分享中' : '分享目录', () => { void openNoteShare(folder) }, 'ghost small') : null,
2408
+ actionButton('已分享', () => { void navigate('shares') }, 'ghost small'),
2208
2409
  actionButton('新建文档', () => openCreateNoteDocument(folder?.id || null), 'primary small'),
2209
2410
  actionButton('新建目录', () => openCreateNoteFolder(folder?.id || null), 'small'),
2210
2411
  ),
@@ -2454,6 +2655,7 @@ function renderNoteFileToolbar(node, options = {}) {
2454
2655
  ], () => markdownEditorHandle?.toggleOutline(), 'ghost small notes-outline-action', { 'data-note-outline': '', 'aria-label': '打开标题大纲', 'aria-controls': 'dsh-note-outline', 'aria-expanded': 'false', 'aria-pressed': 'false' }) : null,
2455
2656
  options.editable ? actionButton('历史', () => { void openNoteHistory(node) }, 'ghost small', { 'data-note-history': '', 'data-note-mobile-overflow': '' }) : null,
2456
2657
  noteDownloadButton(node, 'ghost small', '下载', { 'data-note-mobile-overflow': '' }),
2658
+ actionButton(noteShareForNode(node.id) ? '分享中' : '分享', () => { void openNoteShare(node) }, 'ghost small', { 'data-note-mobile-overflow': '' }),
2457
2659
  actionButton('复制引用', () => { void copyNoteReference(node) }, 'ghost small', { 'data-note-mobile-overflow': '' }),
2458
2660
  actionButton('重命名', () => openRenameNoteNode(node), 'ghost small', { 'data-note-mobile-overflow': '' }),
2459
2661
  options.editable ? actionButton('保存', () => { void saveNoteDocument() }, 'primary small', { 'data-note-save': '', 'data-note-mobile-overflow': '', disabled: !state.notes.dirty }) : null,
@@ -2500,8 +2702,11 @@ function renderNoteFileOverflowMenu(node, options) {
2500
2702
  if (options.enhanced || options.editable) menuItems.push(noteMenuDivider())
2501
2703
  menuItems.push(
2502
2704
  noteMenuAction('下载', 'download', closeThen(event => { void downloadNoteFile(node, event.currentTarget) })),
2705
+ noteMenuAction(noteShareForNode(node.id) ? '管理分享' : '创建分享', 'link', closeThen(() => { void openNoteShare(node) })),
2503
2706
  noteMenuAction('复制引用', 'link', closeThen(() => { void copyNoteReference(node) })),
2504
2707
  noteMenuAction('重命名', 'rename', closeThen(() => openRenameNoteNode(node))),
2708
+ noteMenuDivider(),
2709
+ noteMenuAction('查看已分享', 'outline', closeThen(() => { void navigate('shares') })),
2505
2710
  )
2506
2711
  const menu = element('div', { class: 'notes-document-more-menu', role: 'menu', 'aria-label': `${node.name} 的更多操作` }, menuItems)
2507
2712
  summary.setAttribute('aria-expanded', 'false')
@@ -2524,7 +2729,7 @@ function renderNoteFileStatusbar(node) {
2524
2729
  return element('footer', { class: 'notes-document-statusbar', 'aria-label': '文档信息' },
2525
2730
  element('div', { class: 'notes-file-info' },
2526
2731
  noteInfoItem('编号', shortNoteId(node.id), node.id, 'id'),
2527
- noteInfoItem('类型', node.kind === 'document' ? 'Markdown' : node.mediaType || '未知'),
2732
+ noteInfoItem('格式', node.kind === 'document' ? 'Markdown' : node.mediaType || '未知'),
2528
2733
  noteInfoItem('大小', formatBytes(node.size), '', 'size'),
2529
2734
  noteInfoItem('更新', formatDate(node.updatedAt), '', 'updated'),
2530
2735
  ),
@@ -3187,6 +3392,76 @@ async function copyNoteReference(node) {
3187
3392
  catch { showToast('复制失败,请手动复制文档编号。', 'error') }
3188
3393
  }
3189
3394
 
3395
+ function noteShareForNode(noteId) {
3396
+ return state.notes.shares.find(share => share.noteId === noteId) || null
3397
+ }
3398
+
3399
+ function noteShareUrl(share) {
3400
+ const apiBase = state.service.publicApiPrefix || API_BASE
3401
+ return new URL(`${apiBase.replace(/\/$/, '')}/shared/${encodeURIComponent(share.token)}`, location.href).href
3402
+ }
3403
+
3404
+ async function openNoteShare(node) {
3405
+ if (state.notes.selectedNode?.id === node.id && state.notes.dirty && !await saveNoteDocument()) return
3406
+ try {
3407
+ let share = noteShareForNode(node.id)
3408
+ if (!share) {
3409
+ share = await api(`notes/${encodeURIComponent(node.id)}/share`, { method: 'POST' })
3410
+ state.notes.shares = [share, ...state.notes.shares.filter(item => item.noteId !== node.id)]
3411
+ }
3412
+ showNoteShareDialog(share)
3413
+ } catch (error) {
3414
+ showToast(`创建分享失败:${friendlyError(error)}`, 'error')
3415
+ }
3416
+ }
3417
+
3418
+ function showNoteShareDialog(share) {
3419
+ const url = noteShareUrl(share)
3420
+ const input = Object.assign(element('input', {
3421
+ class: 'input notes-share-link-input', readonly: true, value: url,
3422
+ 'aria-label': `${share.node.name} 的分享链接`, onFocus: event => event.currentTarget.select(),
3423
+ }), { value: url })
3424
+ let modal
3425
+ const body = element('div', { class: 'notes-share-dialog-body' },
3426
+ element('div', { class: 'notes-share-dialog-kind' }, renderNoteIcon(share.node, true), element('span', {}, element('strong', {}, share.node.name), element('small', {}, share.node.kind === 'folder' ? '目录与当前子项均可只读访问' : '只读文档链接'))),
3427
+ element('label', { class: 'notes-share-link-label' }, element('span', {}, '分享链接'), element('div', { class: 'notes-share-link-row' }, input, actionButton('复制', () => copyText(url, '分享链接已复制。'), 'primary small'))),
3428
+ element('p', { class: 'notes-share-dialog-note' }, '链接中的内容会随原笔记更新。任何获得链接的人都可以访问;停止分享后链接立即失效。'),
3429
+ element('div', { class: 'notes-share-dialog-danger' },
3430
+ element('span', {}, element('strong', {}, '停止分享'), element('small', {}, '不会删除原文档或目录。')),
3431
+ actionButton('停止分享', () => confirmRemoveNoteShare(share, () => modal?.close(true)), 'danger small'),
3432
+ ),
3433
+ )
3434
+ modal = openModal({
3435
+ title: '分享笔记',
3436
+ description: '生成独立的只读访问链接',
3437
+ body,
3438
+ cancelLabel: '完成',
3439
+ className: 'notes-share-dialog',
3440
+ })
3441
+ }
3442
+
3443
+ function confirmRemoveNoteShare(share, afterRemove) {
3444
+ openConfirm({
3445
+ title: `停止分享“${share.node.name}”?`,
3446
+ message: '现有分享链接会立即失效,原笔记内容不会被删除。',
3447
+ confirmLabel: '停止分享',
3448
+ danger: true,
3449
+ onConfirm: async () => {
3450
+ await api(`notes/${encodeURIComponent(share.noteId)}/share`, { method: 'DELETE' })
3451
+ state.notes.shares = state.notes.shares.filter(item => item.noteId !== share.noteId)
3452
+ afterRemove?.()
3453
+ renderShell()
3454
+ showToast('已停止分享。')
3455
+ },
3456
+ })
3457
+ }
3458
+
3459
+ async function openSharedNoteOriginal(share) {
3460
+ if (state.notes.dirty && !await saveNoteDocument()) return
3461
+ state.view = 'notes'
3462
+ await selectNoteNode(share.node)
3463
+ }
3464
+
3190
3465
  async function confirmDeleteNoteNode(node) {
3191
3466
  try {
3192
3467
  const references = await api(`notes/${encodeURIComponent(node.id)}/references`)
@@ -3204,6 +3479,7 @@ async function confirmDeleteNoteNode(node) {
3204
3479
  confirmLabel: '永久删除', danger: true,
3205
3480
  onConfirm: async () => {
3206
3481
  await api(`notes/${encodeURIComponent(node.id)}`, { method: 'DELETE' })
3482
+ await loadNoteShares()
3207
3483
  await loadNoteChildren(node.parentId, true)
3208
3484
  if (state.notes.selectedId === node.id || state.notes.breadcrumbs.some(parent => parent.id === node.id)) clearNoteSelection()
3209
3485
  renderShell(); showToast('已删除。')
@@ -4148,11 +4424,11 @@ function openSheet(options) {
4148
4424
  return openModal({ ...options, presentation: 'sheet' })
4149
4425
  }
4150
4426
 
4151
- function openModal({ title, description = '', body, primaryLabel, primaryVariant = 'primary', onPrimary, cancelLabel = '取消', presentation = 'modal', onClose }) {
4427
+ function openModal({ title, description = '', body, primaryLabel, primaryVariant = 'primary', onPrimary, cancelLabel = '取消', presentation = 'modal', className = '', onClose }) {
4152
4428
  const previouslyFocused = document.activeElement
4153
4429
  const isSheet = presentation === 'sheet'
4154
4430
  const backdrop = element('div', { class: `dialog-backdrop${isSheet ? ' sheet-backdrop' : ''}` })
4155
- const dialog = element('section', { class: `dialog${isSheet ? ' sheet' : ''} ${primaryLabel ? '' : 'narrow'}`.trim(), role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'dialog-title' })
4431
+ const dialog = element('section', { class: `dialog${isSheet ? ' sheet' : ''} ${primaryLabel ? '' : 'narrow'} ${className}`.trim(), role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'dialog-title' })
4156
4432
  let busy = false
4157
4433
  let formDirty = false
4158
4434
  let closed = false
@@ -4165,7 +4441,6 @@ function openModal({ title, description = '', body, primaryLabel, primaryVariant
4165
4441
  document.removeEventListener('keydown', onKeyDown)
4166
4442
  closed = true
4167
4443
  backdrop.remove()
4168
- refreshWorkspaceEffects()
4169
4444
  onClose?.()
4170
4445
  if (previouslyFocused instanceof HTMLElement) previouslyFocused.focus()
4171
4446
  }
@@ -4207,7 +4482,6 @@ function openModal({ title, description = '', body, primaryLabel, primaryVariant
4207
4482
  body.addEventListener('change', () => { formDirty = true })
4208
4483
  }
4209
4484
  document.body.append(backdrop)
4210
- refreshWorkspaceEffects(backdrop)
4211
4485
  document.addEventListener('keydown', onKeyDown)
4212
4486
  window.setTimeout(() => (dialog.querySelector('input, textarea, select, button') || dialog).focus(), 0)
4213
4487
  return { close, dialog }
package/web/index.html CHANGED
@@ -1,5 +1,5 @@
1
1
  <!doctype html>
2
- <html lang="zh-CN">
2
+ <html lang="zh-CN" data-dsh-embed-mode="__DSH_KNOWLEDGE_EMBED_MODE__">
3
3
  <head>
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
@@ -14,7 +14,6 @@
14
14
  <script src="__DSH_KNOWLEDGE_WEB_PATH__/markdown-preview.js?v=__DSH_KNOWLEDGE_ASSET_VERSION__"></script>
15
15
  <script src="__DSH_KNOWLEDGE_WEB_PATH__/change-review.js?v=__DSH_KNOWLEDGE_ASSET_VERSION__"></script>
16
16
  <script src="__DSH_KNOWLEDGE_WEB_PATH__/note-history.js?v=__DSH_KNOWLEDGE_ASSET_VERSION__"></script>
17
- <script src="__DSH_KNOWLEDGE_WEB_PATH__/workspace-effects.js?v=__DSH_KNOWLEDGE_ASSET_VERSION__"></script>
18
17
  <script type="module" src="__DSH_KNOWLEDGE_WEB_PATH__/app.js?v=__DSH_KNOWLEDGE_ASSET_VERSION__"></script>
19
18
  </head>
20
19
  <body>