@lemoncat7/dsh-knowledge 0.6.0 → 0.7.0

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/web/app.js CHANGED
@@ -11,6 +11,16 @@ const TYPE_DOCUMENTS = {
11
11
  preference: 'preferences.md', fact: 'facts.md', decision: 'decisions.md', procedure: 'procedures.md', lesson: 'lessons.md',
12
12
  }
13
13
  const DOCUMENT_LAYOUT_KEY = 'dsh-knowledge.document-layout'
14
+ const DOCUMENT_COLUMN_LAYOUTS = Object.freeze({
15
+ library: {
16
+ widthKey: 'libraryWidth', hiddenKey: 'libraryHidden', cssVariable: '--library-width',
17
+ minimum: 170, maximum: 360, controls: 'knowledge-library-column',
18
+ },
19
+ documentList: {
20
+ widthKey: 'documentListWidth', hiddenKey: 'documentListHidden', cssVariable: '--document-list-width',
21
+ minimum: 210, maximum: 480, controls: 'document-list-column',
22
+ },
23
+ })
14
24
  const pageParams = new URLSearchParams(location.search)
15
25
  const mountContext = {
16
26
  sessionId: pageParams.get('sessionId')?.trim() || '',
@@ -27,6 +37,8 @@ const state = {
27
37
  stats: null,
28
38
  overview: null,
29
39
  knowledgeBases: [],
40
+ knowledgeBaseView: 'libraries',
41
+ knowledgeBaseQuery: '',
30
42
  mounts: [],
31
43
  resolvedMounts: [],
32
44
  mountContext,
@@ -53,10 +65,13 @@ const state = {
53
65
  candidateStatus: 'pending',
54
66
  tokens: [],
55
67
  service: { publicApiEnabled: false, publicApiPrefix: '/knowledge-api/v1', remote: false },
68
+ scrollPositions: new Map(),
56
69
  loading: false,
57
70
  error: '',
58
71
  }
59
72
 
73
+ let scrollRestoreFrame = 0
74
+
60
75
  function readDocumentLayout() {
61
76
  const fallback = {
62
77
  sidebarHidden: false, libraryHidden: false, documentListHidden: false,
@@ -93,6 +108,37 @@ function clampNumber(value, minimum, maximum, fallback) {
93
108
  return Number.isFinite(number) ? Math.min(maximum, Math.max(minimum, Math.round(number))) : fallback
94
109
  }
95
110
 
111
+ function captureScrollPosition() {
112
+ const shell = app.querySelector('.app-shell[data-view]')
113
+ if (!shell || shell.dataset.loading === 'true') return
114
+ const regions = {}
115
+ shell.querySelectorAll('[data-scroll-key]').forEach(node => {
116
+ const key = node.getAttribute('data-scroll-key')
117
+ if (key) regions[key] = { left: node.scrollLeft, top: node.scrollTop }
118
+ })
119
+ state.scrollPositions.set(shell.dataset.view, {
120
+ window: { left: window.scrollX, top: window.scrollY },
121
+ regions,
122
+ })
123
+ }
124
+
125
+ function restoreScrollPosition(view) {
126
+ if (state.loading) return
127
+ const saved = state.scrollPositions.get(view)
128
+ if (!saved) return
129
+ if (scrollRestoreFrame) window.cancelAnimationFrame(scrollRestoreFrame)
130
+ scrollRestoreFrame = window.requestAnimationFrame(() => {
131
+ window.scrollTo(saved.window.left, saved.window.top)
132
+ app.querySelectorAll('[data-scroll-key]').forEach(node => {
133
+ const position = saved.regions[node.getAttribute('data-scroll-key')]
134
+ if (!position) return
135
+ node.scrollLeft = position.left
136
+ node.scrollTop = position.top
137
+ })
138
+ scrollRestoreFrame = 0
139
+ })
140
+ }
141
+
96
142
  function element(tag, attributes = {}, ...children) {
97
143
  const node = document.createElement(tag)
98
144
  for (const [key, value] of Object.entries(attributes)) {
@@ -122,6 +168,16 @@ function paneToggleButton(pane, visible, onClick, label) {
122
168
  }, element('span', { class: `pane-icon pane-icon-${pane}`, 'aria-hidden': 'true' }))
123
169
  }
124
170
 
171
+ function interfaceIcon(name, className = 'interface-icon') {
172
+ const paths = {
173
+ search: 'M10.8 4.5a6.3 6.3 0 1 0 0 12.6 6.3 6.3 0 0 0 0-12.6Zm4.6 11 4.1 4',
174
+ }
175
+ return element('svg', {
176
+ class: className, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor',
177
+ 'stroke-width': '1.8', 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'aria-hidden': 'true',
178
+ }, element('path', { d: paths[name] }))
179
+ }
180
+
125
181
  function badge(label, variant = '') {
126
182
  return element('span', { class: `badge ${variant}`.trim() }, label)
127
183
  }
@@ -346,6 +402,7 @@ async function loadTokens() {
346
402
  }
347
403
 
348
404
  function renderShell() {
405
+ captureScrollPosition()
349
406
  const titles = {
350
407
  overview: ['概览', '知识库运行状态与最近活动'],
351
408
  bases: ['知识库', '创建知识库,并限定项目与会话的召回和写入范围'],
@@ -356,6 +413,7 @@ function renderShell() {
356
413
  const [title, subtitle] = titles[state.view]
357
414
  const shell = element('div', {
358
415
  class: 'app-shell', 'data-menu-open': String(state.menuOpen),
416
+ 'data-view': state.view, 'data-loading': String(state.loading),
359
417
  'data-sidebar-hidden': String(state.documentView.sidebarHidden),
360
418
  style: `--sidebar-width: ${state.documentView.sidebarWidth}px`,
361
419
  },
@@ -376,6 +434,7 @@ function renderShell() {
376
434
  if (event.target === shell) { state.menuOpen = false; renderShell() }
377
435
  })
378
436
  app.replaceChildren(shell)
437
+ restoreScrollPosition(state.view)
379
438
  }
380
439
 
381
440
  function renderAppSidebarResizer() {
@@ -445,19 +504,21 @@ function setSidebarHidden(hidden) {
445
504
 
446
505
  function renderSidebar() {
447
506
  const pending = state.stats?.candidates.pending
448
- const navItems = [
449
- ['overview', '概览', ''], ['bases', '知识库', '▦'], ['entries', '文档', '◇'], ['candidates', '审核', '✓'], ['tokens', '访问管理', '⌁'],
450
- ].filter(([id]) => id !== 'tokens' || !state.service.remote)
507
+ const navGroups = [
508
+ ['工作区', [['overview', '概览'], ['bases', '知识库'], ['entries', '文档'], ['candidates', '审核']]],
509
+ ['服务', [['tokens', '访问管理']].filter(([id]) => id !== 'tokens' || !state.service.remote)],
510
+ ].filter(([, items]) => items.length)
451
511
  return element('aside', { class: 'sidebar', 'aria-label': '知识库导航' },
452
512
  element('div', { class: 'brand' },
453
- element('div', { class: 'brand-mark', 'aria-hidden': 'true' }, 'K'),
454
- element('div', { class: 'brand-copy' }, element('strong', {}, 'DSH Knowledge'), element('span', {}, '管理控制台')),
513
+ element('div', { class: 'brand-copy' }, element('span', {}, 'DSH Knowledge'), element('strong', {}, '知识库')),
455
514
  ),
456
- element('nav', { class: 'nav' }, navItems.map(([id, label, icon]) => element('button', {
457
- type: 'button', class: 'nav-button', 'aria-current': state.view === id ? 'page' : undefined,
458
- onClick: () => navigate(id),
459
- }, element('span', { class: 'nav-icon', 'aria-hidden': 'true' }, icon), label,
460
- id === 'candidates' && pending ? element('span', { class: 'nav-count', 'aria-label': `${pending} 条待审核` }, pending) : null))),
515
+ element('nav', { class: 'nav' }, navGroups.map(([group, items]) => element('div', { class: 'nav-group' },
516
+ element('div', { class: 'nav-group-label' }, group),
517
+ items.map(([id, label]) => element('button', {
518
+ type: 'button', class: 'nav-button', 'aria-current': state.view === id ? 'page' : undefined,
519
+ onClick: () => navigate(id),
520
+ }, element('span', { class: 'nav-label' }, label),
521
+ id === 'candidates' && pending ? element('span', { class: 'nav-count', 'aria-label': `${pending} 条待审核` }, pending) : null))))),
461
522
  element('div', { class: 'sidebar-footer' },
462
523
  element('div', { class: 'connection' }, element('span', { class: 'status-dot', 'aria-hidden': 'true' }), state.service.remote ? '中央知识库已连接' : '本地知识库已连接'),
463
524
  AUTH_MODE === 'bearer' ? actionButton('退出当前会话', signOut, 'ghost small') : null,
@@ -476,7 +537,16 @@ function renderCurrentView() {
476
537
  }
477
538
 
478
539
  function loadingView() {
479
- return element('div', { class: 'loading', role: 'status' }, element('div', {}, element('div', { class: 'spinner', 'aria-hidden': 'true' }), element('span', { class: 'visually-hidden' }, '正在加载')))
540
+ return element('div', { class: 'loading-skeleton', role: 'status', 'aria-label': '正在加载' },
541
+ element('span', { class: 'visually-hidden' }, '正在加载'),
542
+ element('div', { class: 'skeleton-line skeleton-title', 'aria-hidden': 'true' }),
543
+ element('div', { class: 'skeleton-line skeleton-copy', 'aria-hidden': 'true' }),
544
+ element('div', { class: 'skeleton-grid', 'aria-hidden': 'true' },
545
+ element('div', { class: 'skeleton-block' }),
546
+ element('div', { class: 'skeleton-block' }),
547
+ element('div', { class: 'skeleton-block' }),
548
+ ),
549
+ )
480
550
  }
481
551
 
482
552
  function errorView(message, retry) {
@@ -516,24 +586,57 @@ function renderKnowledgeBases() {
516
586
  const activeBases = state.knowledgeBases.filter(base => base.status === 'active')
517
587
  const archivedBases = state.knowledgeBases.filter(base => base.status === 'archived')
518
588
  const contextAvailable = Boolean(state.mountContext.projectId || state.mountContext.sessionId)
589
+ const query = state.knowledgeBaseQuery.trim().toLocaleLowerCase()
590
+ const matchesQuery = base => !query || [base.name, base.description, base.defaultTags.join(' '), base.writebackProvider, base.writebackModel]
591
+ .some(value => String(value || '').toLocaleLowerCase().includes(query))
592
+ const visibleActiveBases = activeBases.filter(matchesQuery)
593
+ const visibleArchivedBases = archivedBases.filter(matchesQuery)
594
+ const switcher = element('div', { class: 'workspace-switcher' },
595
+ element('div', { class: 'tabs workspace-tabs', role: 'tablist', 'aria-label': '知识库管理范围' }, [
596
+ ['libraries', '知识库', activeBases.length],
597
+ ['mounts', '项目与会话挂载', state.mounts.filter(mount => mount.enabled).length],
598
+ ].map(([id, label, count]) => element('button', {
599
+ type: 'button', role: 'tab', class: 'tab', 'aria-selected': String(state.knowledgeBaseView === id),
600
+ onClick: () => { state.knowledgeBaseView = id; renderShell() },
601
+ }, element('span', {}, label), element('span', { class: 'tab-count' }, count)))),
602
+ element('p', {}, state.knowledgeBaseView === 'libraries'
603
+ ? '管理知识库本身、标签和回写规则'
604
+ : '决定当前项目或会话可以召回、审核或直接写入哪些知识库'),
605
+ )
519
606
  return element('div', { class: 'bases-page' },
520
- element('section', { 'aria-labelledby': 'bases-heading' },
607
+ switcher,
608
+ state.knowledgeBaseView === 'libraries' ? element('section', { class: 'library-management', 'aria-labelledby': 'bases-heading' },
521
609
  element('div', { class: 'section-heading' },
522
- element('div', {}, element('h2', { id: 'bases-heading' }, `知识库 · ${activeBases.length}`), element('p', {}, '每个知识库拥有独立的默认标签和提取规则。')),
610
+ element('div', {}, element('h2', { id: 'bases-heading' }, '我的知识库'), element('p', {}, '名称和描述帮助 AI 判断知识应该写到哪里。')),
523
611
  actionButton('+ 创建知识库', () => openKnowledgeBaseEditor(), 'primary'),
524
612
  ),
525
- activeBases.length
526
- ? element('div', { class: 'base-grid' }, activeBases.map(renderKnowledgeBaseCard))
527
- : emptyState('还没有可用知识库', '先创建一个知识库,再挂载到项目或会话。', '创建知识库', () => openKnowledgeBaseEditor()),
528
- archivedBases.length ? element('details', { class: 'archived-bases' },
529
- element('summary', {}, `已归档知识库 · ${archivedBases.length}`),
530
- element('div', { class: 'base-grid' }, archivedBases.map(renderKnowledgeBaseCard)),
613
+ element('div', { class: 'knowledge-base-toolbar' },
614
+ element('div', { class: 'search-box base-search' }, interfaceIcon('search', 'search-symbol'), element('input', {
615
+ class: 'input', type: 'search', value: state.knowledgeBaseQuery,
616
+ placeholder: '搜索名称、描述、标签或模型', 'aria-label': '搜索知识库',
617
+ onInput: event => {
618
+ state.knowledgeBaseQuery = event.target.value
619
+ renderShell()
620
+ document.querySelector('.base-search input')?.focus()
621
+ },
622
+ })),
623
+ element('div', { class: 'base-result-summary', 'aria-live': 'polite' }, query
624
+ ? `找到 ${visibleActiveBases.length + visibleArchivedBases.length} 个知识库`
625
+ : `${activeBases.length} 个可用 · ${archivedBases.length} 个已归档`),
626
+ ),
627
+ visibleActiveBases.length
628
+ ? element('div', { class: 'base-grid' }, visibleActiveBases.map(renderKnowledgeBaseCard))
629
+ : query && visibleArchivedBases.length === 0
630
+ ? emptyState('没有匹配的知识库', '尝试搜索名称、描述、标签或回写模型。')
631
+ : !query ? emptyState('还没有可用知识库', '使用右上角按钮创建第一个知识库。') : null,
632
+ visibleArchivedBases.length ? element('details', { class: 'archived-bases', open: Boolean(query) },
633
+ element('summary', {}, element('span', {}, '已归档知识库'), element('span', { class: 'summary-count' }, visibleArchivedBases.length)),
634
+ element('div', { class: 'base-grid' }, visibleArchivedBases.map(renderKnowledgeBaseCard)),
531
635
  ) : null,
532
- ),
533
- element('section', { class: 'mount-section', 'aria-labelledby': 'mounts-heading' },
636
+ ) : element('section', { class: 'mount-section', 'aria-labelledby': 'mounts-heading' },
534
637
  element('div', { class: 'section-heading' }, element('div', {},
535
- element('h2', { id: 'mounts-heading' }, '当前项目与会话挂载'),
536
- element('p', {}, '会话默认继承项目设置;创建会话覆盖后,可独立调整或关闭。'),
638
+ element('h2', { id: 'mounts-heading' }, '挂载范围'),
639
+ element('p', {}, '会话默认继承项目;只有需要差异时才创建会话覆盖。'),
537
640
  )),
538
641
  contextAvailable
539
642
  ? element('div', { class: 'mount-context' },
@@ -554,21 +657,24 @@ function contextPill(label, value) {
554
657
 
555
658
  function renderKnowledgeBaseCard(base) {
556
659
  const archived = base.status === 'archived'
660
+ const visibleTags = base.defaultTags.slice(0, 4)
661
+ const hiddenTagCount = Math.max(0, base.defaultTags.length - visibleTags.length)
557
662
  return element('article', { class: `base-card${archived ? ' is-archived' : ''}` },
558
663
  element('div', { class: 'base-card-header' },
559
- element('div', {}, element('h3', {}, base.name), element('small', {}, base.id === 'default' ? '系统默认库' : `ID · ${base.id}`)),
664
+ element('div', { class: 'base-card-identity' },
665
+ element('span', { class: 'base-symbol', 'aria-hidden': 'true' }, base.name.trim().slice(0, 1).toLocaleUpperCase() || 'K'),
666
+ element('div', {}, element('h3', {}, base.name), element('small', { title: base.id }, base.id === 'default' ? '系统默认库' : `ID · ${base.id}`)),
667
+ ),
560
668
  badge(archived ? '已归档' : '可用', archived ? '' : 'success'),
561
669
  ),
562
- element('div', { class: 'base-description' }, element('strong', {}, '回写匹配描述'), element('p', {}, base.description || '未设置:按通用知识库处理')),
563
- base.defaultTags.length
564
- ? element('div', { class: 'tag-row' }, base.defaultTags.map(tag => element('span', { class: 'tag' }, `#${tag}`)))
565
- : element('span', { class: 'field-hint' }, '无默认标签'),
566
- base.extractionInstructions
567
- ? element('div', { class: 'base-instructions' }, element('strong', {}, '提取要求'), element('span', {}, base.extractionInstructions))
568
- : null,
569
- element('div', { class: 'base-model' },
570
- element('strong', {}, '回写模型'),
571
- badge(base.writebackProvider && base.writebackModel ? `${base.writebackProvider} / ${base.writebackModel}` : '跟随当前会话'),
670
+ element('p', { class: 'base-description' }, base.description || '通用知识库,尚未设置匹配描述。'),
671
+ element('div', { class: 'base-card-tags' }, visibleTags.length
672
+ ? visibleTags.map(tag => element('span', { class: 'tag' }, `#${tag}`))
673
+ : element('span', { class: 'tag is-empty' }, '无默认标签'),
674
+ hiddenTagCount ? element('span', { class: 'tag' }, `+${hiddenTagCount}`) : null),
675
+ element('div', { class: 'base-card-meta' },
676
+ element('span', {}, element('strong', {}, '回写模型'), base.writebackProvider && base.writebackModel ? `${base.writebackProvider} / ${base.writebackModel}` : '跟随当前会话'),
677
+ base.extractionInstructions ? element('span', {}, element('strong', {}, '提取规则'), '已设置') : null,
572
678
  ),
573
679
  element('div', { class: 'base-card-actions' },
574
680
  actionButton('查看知识', () => {
@@ -657,7 +763,7 @@ function renderMountManager(activeBases) {
657
763
  renderShell()
658
764
  },
659
765
  }, label))),
660
- element('div', { class: 'search-box mount-search' }, element('span', { class: 'search-symbol', 'aria-hidden': 'true' }, '⌕'), searchInput),
766
+ element('div', { class: 'search-box mount-search' }, interfaceIcon('search', 'search-symbol'), searchInput),
661
767
  filter,
662
768
  ),
663
769
  element('div', { class: 'mount-target-line' },
@@ -666,7 +772,7 @@ function renderMountManager(activeBases) {
666
772
  element('span', { class: 'field-hint' }, `显示 ${visibleRows.length} / ${activeBases.length}`),
667
773
  ),
668
774
  visibleRows.length
669
- ? element('div', { class: 'mount-table', role: 'list', 'aria-label': '知识库挂载列表' }, visibleRows.map(({ base, view }) => renderMountListRow(base, view, manager.targetKind, targetId)))
775
+ ? element('div', { class: 'mount-table', role: 'list', 'aria-label': '知识库挂载列表', 'data-scroll-key': 'mount-table' }, visibleRows.map(({ base, view }) => renderMountListRow(base, view, manager.targetKind, targetId)))
670
776
  : emptyState('没有匹配的知识库', '调整搜索词或筛选条件。'),
671
777
  element('div', { class: 'mount-bulk-bar', 'aria-live': 'polite' },
672
778
  element('strong', {}, `已选择 ${selectedCount} 个`),
@@ -760,19 +866,16 @@ function renderEntries() {
760
866
 
761
867
  return element('section', { class: 'document-page', 'aria-labelledby': 'documents-heading' },
762
868
  element('div', { class: 'document-page-toolbar' },
763
- element('div', { class: 'search-box document-global-search' }, element('span', { class: 'search-symbol', 'aria-hidden': 'true' }, '⌕'), search),
869
+ renderDocumentModeTabs(),
870
+ element('div', { class: 'search-box document-global-search' }, interfaceIcon('search', 'search-symbol'), search),
764
871
  element('select', {
765
872
  class: 'select compact-library-picker', 'aria-label': '选择知识库', value: view.knowledgeBaseId,
766
873
  onChange: (event) => { view.knowledgeBaseId = event.target.value; view.documentId = ''; selectDefaultDocument(); renderShell() },
767
874
  }, activeBases.map(base => element('option', { value: base.id, selected: base.id === view.knowledgeBaseId }, base.name))),
768
- element('div', { class: 'tabs', role: 'tablist', 'aria-label': '知识视图' },
769
- documentViewTab('文档', 'documents'), documentViewTab('条目管理', 'entries')),
770
- element('div', { class: 'pane-toggle-group document-pane-toggles', role: 'group', 'aria-label': '文档边栏显示' },
771
- paneToggleButton('library', !view.libraryHidden, () => setDocumentColumnHidden('library', !view.libraryHidden), '知识库二级栏'),
772
- paneToggleButton('document', !view.documentListHidden, () => setDocumentColumnHidden('documentList', !view.documentListHidden), '文档列表栏'),
875
+ element('div', { class: 'document-toolbar-actions' },
876
+ renderDocumentColumnControls(),
877
+ actionButton('+ 新建知识', () => openEntryEditor(), 'primary'),
773
878
  ),
774
- actionButton('调整栏宽', openLayoutEditor, 'ghost small', { 'aria-label': '调整主导航、知识库和文档栏宽度' }),
775
- actionButton('+ 新建知识', () => openEntryEditor(), 'primary'),
776
879
  ),
777
880
  element('div', {
778
881
  class: 'document-browser',
@@ -780,11 +883,11 @@ function renderEntries() {
780
883
  'data-document-list-hidden': String(view.documentListHidden),
781
884
  style: `--library-width:${view.libraryWidth}px;--document-list-width:${view.documentListWidth}px`,
782
885
  },
783
- element('aside', { class: 'knowledge-library-column', 'aria-label': '知识库列表' },
886
+ element('aside', { id: 'knowledge-library-column', class: 'knowledge-library-column', 'aria-label': '知识库列表' },
784
887
  element('header', { class: 'column-header' },
785
888
  element('div', {}, element('h2', { id: 'documents-heading' }, '知识库'), element('span', {}, `${activeBases.length} 个可用`)),
786
889
  ),
787
- activeBases.length ? element('div', { class: 'library-list', role: 'listbox', tabindex: '0', onKeyDown: event => moveDocumentSelection(event, 'base') },
890
+ activeBases.length ? element('div', { class: 'library-list', role: 'listbox', tabindex: '0', 'data-scroll-key': 'library-list', onKeyDown: event => moveDocumentSelection(event, 'base') },
788
891
  activeBases.map(base => {
789
892
  const documentCount = state.documents.filter(document => document.knowledgeBaseId === base.id).length
790
893
  const selected = base.id === view.knowledgeBaseId
@@ -798,11 +901,11 @@ function renderEntries() {
798
901
  element('footer', { class: 'column-footer' }, actionButton('+ 新建知识库', () => openKnowledgeBaseEditor(), 'ghost small')),
799
902
  ),
800
903
  renderColumnResizer('library', '调整知识库栏宽度'),
801
- element('aside', { class: 'document-list-column', 'aria-label': '文档列表' },
904
+ element('aside', { id: 'document-list-column', class: 'document-list-column', 'aria-label': '文档列表' },
802
905
  element('header', { class: 'column-header' },
803
906
  element('div', {}, element('h2', {}, selectedBase?.name || '文档'), element('span', {}, query ? `找到 ${visibleDocuments.length} 篇` : `${allBaseDocuments.length} 篇文档`)),
804
907
  ),
805
- visibleDocuments.length ? element('div', { class: 'document-list', role: 'listbox', tabindex: '0', onKeyDown: event => moveDocumentSelection(event, 'document') },
908
+ visibleDocuments.length ? element('div', { class: 'document-list', role: 'listbox', tabindex: '0', 'data-scroll-key': 'document-list', onKeyDown: event => moveDocumentSelection(event, 'document') },
806
909
  visibleDocuments.map(document => element('button', {
807
910
  type: 'button', class: 'document-row', role: 'option', 'aria-selected': String(document.id === view.documentId),
808
911
  onClick: () => { view.documentId = document.id; renderShell() },
@@ -824,104 +927,56 @@ function setDocumentColumnHidden(column, hidden) {
824
927
  renderShell()
825
928
  }
826
929
 
827
- function renderColumnResizer(column, label) {
828
- const isLibrary = column === 'library'
829
- const minimum = isLibrary ? 170 : 210
830
- const maximum = isLibrary ? 360 : 480
831
- const value = isLibrary ? state.documentView.libraryWidth : state.documentView.documentListWidth
832
- return element('div', {
833
- class: 'column-resizer', 'data-column': column, role: 'separator', tabindex: '0',
834
- title: `${label};可拖动或使用左右方向键`,
835
- 'aria-label': label, 'aria-orientation': 'vertical',
836
- 'aria-valuemin': minimum, 'aria-valuemax': maximum, 'aria-valuenow': value,
837
- onPointerDown: event => startColumnResize(event, column, minimum, maximum),
838
- onKeyDown: event => resizeColumnWithKeyboard(event, column, minimum, maximum),
839
- }, element('span', { 'aria-hidden': 'true' }, '⋮'))
840
- }
841
-
842
- function openLayoutEditor() {
843
- const fields = [
844
- layoutRangeField('主导航栏', state.documentView.sidebarWidth, 190, 340),
845
- layoutRangeField('知识库二级栏', state.documentView.libraryWidth, 170, 360),
846
- layoutRangeField('文档列表栏', state.documentView.documentListWidth, 210, 480),
847
- ]
848
- const visibility = [
849
- layoutVisibilityOption('显示主导航栏', !state.documentView.sidebarHidden),
850
- layoutVisibilityOption('显示知识库二级栏', !state.documentView.libraryHidden),
851
- layoutVisibilityOption('显示文档列表栏', !state.documentView.documentListHidden),
852
- ]
853
- const reset = actionButton('恢复默认布局', () => {
854
- const defaults = [236, 220, 280]
855
- fields.forEach((field, index) => field.setValue(defaults[index]))
856
- visibility.forEach(option => { option.input.checked = true })
857
- }, 'ghost small')
858
- const body = element('div', { class: 'layout-editor' },
859
- element('div', { class: 'layout-visibility', 'aria-label': '边栏显示设置' }, visibility.map(option => option.wrapper)),
860
- fields.map(field => field.wrapper),
861
- element('div', { class: 'layout-editor-note' },
862
- element('span', {}, '也可以直接拖动栏与栏之间的三点分隔线。窗口过窄时会自动切换为紧凑布局。'),
863
- reset,
864
- ),
930
+ function renderDocumentColumnControls() {
931
+ return element('div', { class: 'document-column-controls', role: 'group', 'aria-label': '显示文档栏位' },
932
+ element('span', { class: 'document-column-controls-label', 'aria-hidden': 'true' }, '显示'),
933
+ documentColumnControl('library', '知识库栏'),
934
+ documentColumnControl('documentList', '文档栏'),
865
935
  )
866
- openModal({
867
- title: '调整边栏宽度',
868
- description: '分别设置管理导航、知识库二级栏和文档列表栏。',
869
- body,
870
- primaryLabel: '应用布局',
871
- onPrimary: async () => {
872
- state.documentView.sidebarHidden = !visibility[0].input.checked
873
- state.documentView.libraryHidden = !visibility[1].input.checked
874
- state.documentView.documentListHidden = !visibility[2].input.checked
875
- state.documentView.sidebarWidth = fields[0].value()
876
- state.documentView.libraryWidth = fields[1].value()
877
- state.documentView.documentListWidth = fields[2].value()
878
- saveDocumentLayout()
879
- renderShell()
880
- showToast('边栏宽度已保存。')
881
- return true
882
- },
883
- })
884
936
  }
885
937
 
886
- function layoutVisibilityOption(label, checked) {
887
- const input = element('input', { type: 'checkbox', checked })
888
- return {
889
- input,
890
- wrapper: element('label', { class: 'check-option' }, input, element('span', {}, element('strong', {}, label))),
891
- }
938
+ function documentColumnControl(column, label) {
939
+ const layout = DOCUMENT_COLUMN_LAYOUTS[column]
940
+ const visible = !state.documentView[layout.hiddenKey]
941
+ const action = `${visible ? '收起' : '展开'}${label}`
942
+ return element('button', {
943
+ type: 'button', class: 'document-column-control', 'data-column': column,
944
+ 'aria-label': action, 'aria-pressed': String(visible), title: action,
945
+ onClick: () => setDocumentColumnHidden(column, visible),
946
+ }, label)
892
947
  }
893
948
 
894
- function layoutRangeField(label, value, minimum, maximum) {
895
- const output = element('output', { class: 'range-value' }, `${value}px`)
896
- const input = element('input', {
897
- type: 'range', min: minimum, max: maximum, step: '10', value,
898
- 'aria-label': label,
899
- onInput: event => { output.textContent = `${event.target.value}px` },
900
- })
901
- return {
902
- wrapper: element('div', { class: 'field' },
903
- element('div', { class: 'layout-range-label' }, element('label', {}, label), output),
904
- element('div', { class: 'range-row' }, input),
905
- ),
906
- value: () => Number(input.value),
907
- setValue: next => { input.value = String(next); output.textContent = `${next}px` },
908
- }
949
+ function renderColumnResizer(column, label) {
950
+ const layout = DOCUMENT_COLUMN_LAYOUTS[column]
951
+ const hidden = state.documentView[layout.hiddenKey]
952
+ if (hidden) return null
953
+ const value = state.documentView[layout.widthKey]
954
+ return element('div', {
955
+ class: 'column-resizer', 'data-column': column, role: 'separator', tabindex: '0',
956
+ title: `${label};可拖动或使用左右方向键`,
957
+ 'aria-label': label, 'aria-controls': layout.controls, 'aria-orientation': 'vertical',
958
+ 'aria-valuemin': layout.minimum, 'aria-valuemax': layout.maximum, 'aria-valuenow': value,
959
+ 'aria-valuetext': `${value} 像素`,
960
+ onPointerDown: event => startColumnResize(event, column),
961
+ onKeyDown: event => resizeColumnWithKeyboard(event, column),
962
+ }, element('span', { 'aria-hidden': 'true' }, '⋮'))
909
963
  }
910
964
 
911
- function startColumnResize(event, column, minimum, maximum) {
965
+ function startColumnResize(event, column) {
912
966
  if (event.button !== 0) return
913
967
  event.preventDefault()
968
+ const layout = DOCUMENT_COLUMN_LAYOUTS[column]
914
969
  const handle = event.currentTarget
915
970
  const browser = handle.closest('.document-browser')
916
971
  if (!browser) return
917
972
  const startX = event.clientX
918
- const startWidth = column === 'library' ? state.documentView.libraryWidth : state.documentView.documentListWidth
973
+ const startWidth = state.documentView[layout.widthKey]
919
974
  handle.setPointerCapture?.(event.pointerId)
920
975
  handle.classList.add('is-dragging')
921
976
  document.body.classList.add('is-resizing-columns')
922
977
  const move = moveEvent => {
923
- const width = clampNumber(startWidth + moveEvent.clientX - startX, minimum, maximum, startWidth)
924
- setColumnWidth(column, width, browser, handle)
978
+ const width = clampNumber(startWidth + moveEvent.clientX - startX, layout.minimum, layout.maximum, startWidth)
979
+ setDocumentColumnWidth(column, width, browser, handle)
925
980
  }
926
981
  const finish = () => {
927
982
  handle.classList.remove('is-dragging')
@@ -936,27 +991,32 @@ function startColumnResize(event, column, minimum, maximum) {
936
991
  handle.addEventListener('pointercancel', finish)
937
992
  }
938
993
 
939
- function resizeColumnWithKeyboard(event, column, minimum, maximum) {
994
+ function resizeColumnWithKeyboard(event, column) {
940
995
  if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return
941
996
  event.preventDefault()
942
- const current = column === 'library' ? state.documentView.libraryWidth : state.documentView.documentListWidth
943
- const width = event.key === 'Home' ? minimum
944
- : event.key === 'End' ? maximum
945
- : clampNumber(current + (event.key === 'ArrowRight' ? 16 : -16), minimum, maximum, current)
997
+ const layout = DOCUMENT_COLUMN_LAYOUTS[column]
998
+ const current = state.documentView[layout.widthKey]
946
999
  const browser = event.currentTarget.closest('.document-browser')
947
- setColumnWidth(column, width, browser, event.currentTarget)
1000
+ const width = event.key === 'Home' ? layout.minimum
1001
+ : event.key === 'End' ? layout.maximum
1002
+ : clampNumber(current + (event.key === 'ArrowRight' ? 16 : -16), layout.minimum, layout.maximum, current)
1003
+ setDocumentColumnWidth(column, width, browser, event.currentTarget)
948
1004
  saveDocumentLayout()
949
1005
  }
950
1006
 
951
- function setColumnWidth(column, width, browser, handle) {
952
- if (column === 'library') {
953
- state.documentView.libraryWidth = width
954
- browser?.style.setProperty('--library-width', `${width}px`)
955
- } else {
956
- state.documentView.documentListWidth = width
957
- browser?.style.setProperty('--document-list-width', `${width}px`)
958
- }
1007
+ function setDocumentColumnWidth(column, width, browser, handle) {
1008
+ const layout = DOCUMENT_COLUMN_LAYOUTS[column]
1009
+ state.documentView[layout.widthKey] = width
1010
+ browser?.style.setProperty(layout.cssVariable, `${width}px`)
959
1011
  handle?.setAttribute('aria-valuenow', String(width))
1012
+ handle?.setAttribute('aria-valuetext', `${width} 像素`)
1013
+ }
1014
+
1015
+ function renderDocumentModeTabs() {
1016
+ return element('div', { class: 'tabs', role: 'tablist', 'aria-label': '知识视图' },
1017
+ documentViewTab('文档', 'documents'),
1018
+ documentViewTab('条目管理', 'entries'),
1019
+ )
960
1020
  }
961
1021
 
962
1022
  function documentViewTab(label, mode) {
@@ -1002,7 +1062,7 @@ function renderDocumentReader(document, base) {
1002
1062
  .map(item => element('option', { value: item.id, selected: item.id === document.id }, item.relPath))),
1003
1063
  element('div', { class: 'reader-actions' }, badge(`${document.entryCount} 条知识`), actionButton('添加内容', () => openEntryEditor(), 'small')),
1004
1064
  ),
1005
- element('article', { class: 'markdown-document' }, renderMarkdown(document.content)),
1065
+ element('article', { class: 'markdown-document', 'data-scroll-key': 'document-reader' }, renderMarkdown(document.content)),
1006
1066
  element('footer', { class: 'reader-footer' },
1007
1067
  element('span', {}, `自动整理 · ${formatDate(document.updatedAt)}`),
1008
1068
  element('span', {}, `SHA-256 · ${document.contentHash.slice(0, 8)}`),
@@ -1077,7 +1137,11 @@ function renderLegacyEntries() {
1077
1137
  projectInput,
1078
1138
  actionButton('搜索', () => {}, 'primary', { type: 'submit' }),
1079
1139
  )
1080
- return element('section', { 'aria-labelledby': 'entries-heading' },
1140
+ return element('section', { class: 'document-page legacy-entries-page', 'aria-labelledby': 'entries-heading' },
1141
+ element('div', { class: 'document-page-toolbar legacy-entries-toolbar' },
1142
+ renderDocumentModeTabs(),
1143
+ element('div', { class: 'document-toolbar-actions' }, actionButton('+ 新建知识', () => openEntryEditor(), 'primary')),
1144
+ ),
1081
1145
  element('div', { class: 'section-heading' }, element('div', {}, element('h2', { id: 'entries-heading' }, `${STATUS_LABELS[state.entryFilters.status]} · ${state.entries.length}`), element('p', {}, '搜索范围会包含全局知识和指定项目知识'))),
1082
1146
  toolbar,
1083
1147
  state.entries.length