@lemoncat7/dsh-knowledge 0.6.0 → 0.7.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.
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,
@@ -51,12 +63,17 @@ const state = {
51
63
  },
52
64
  candidates: [],
53
65
  candidateStatus: 'pending',
66
+ settings: { writebackPolicy: 'conservative', updatedAt: '' },
67
+ settingsSaving: false,
54
68
  tokens: [],
55
69
  service: { publicApiEnabled: false, publicApiPrefix: '/knowledge-api/v1', remote: false },
70
+ scrollPositions: new Map(),
56
71
  loading: false,
57
72
  error: '',
58
73
  }
59
74
 
75
+ let scrollRestoreFrame = 0
76
+
60
77
  function readDocumentLayout() {
61
78
  const fallback = {
62
79
  sidebarHidden: false, libraryHidden: false, documentListHidden: false,
@@ -93,6 +110,37 @@ function clampNumber(value, minimum, maximum, fallback) {
93
110
  return Number.isFinite(number) ? Math.min(maximum, Math.max(minimum, Math.round(number))) : fallback
94
111
  }
95
112
 
113
+ function captureScrollPosition() {
114
+ const shell = app.querySelector('.app-shell[data-view]')
115
+ if (!shell || shell.dataset.loading === 'true') return
116
+ const regions = {}
117
+ shell.querySelectorAll('[data-scroll-key]').forEach(node => {
118
+ const key = node.getAttribute('data-scroll-key')
119
+ if (key) regions[key] = { left: node.scrollLeft, top: node.scrollTop }
120
+ })
121
+ state.scrollPositions.set(shell.dataset.view, {
122
+ window: { left: window.scrollX, top: window.scrollY },
123
+ regions,
124
+ })
125
+ }
126
+
127
+ function restoreScrollPosition(view) {
128
+ if (state.loading) return
129
+ const saved = state.scrollPositions.get(view)
130
+ if (!saved) return
131
+ if (scrollRestoreFrame) window.cancelAnimationFrame(scrollRestoreFrame)
132
+ scrollRestoreFrame = window.requestAnimationFrame(() => {
133
+ window.scrollTo(saved.window.left, saved.window.top)
134
+ app.querySelectorAll('[data-scroll-key]').forEach(node => {
135
+ const position = saved.regions[node.getAttribute('data-scroll-key')]
136
+ if (!position) return
137
+ node.scrollLeft = position.left
138
+ node.scrollTop = position.top
139
+ })
140
+ scrollRestoreFrame = 0
141
+ })
142
+ }
143
+
96
144
  function element(tag, attributes = {}, ...children) {
97
145
  const node = document.createElement(tag)
98
146
  for (const [key, value] of Object.entries(attributes)) {
@@ -122,6 +170,16 @@ function paneToggleButton(pane, visible, onClick, label) {
122
170
  }, element('span', { class: `pane-icon pane-icon-${pane}`, 'aria-hidden': 'true' }))
123
171
  }
124
172
 
173
+ function interfaceIcon(name, className = 'interface-icon') {
174
+ const paths = {
175
+ 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',
176
+ }
177
+ return element('svg', {
178
+ class: className, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor',
179
+ 'stroke-width': '1.8', 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'aria-hidden': 'true',
180
+ }, element('path', { d: paths[name] }))
181
+ }
182
+
125
183
  function badge(label, variant = '') {
126
184
  return element('span', { class: `badge ${variant}`.trim() }, label)
127
185
  }
@@ -221,7 +279,7 @@ function renderLogin(message = '') {
221
279
 
222
280
  function signOut() {
223
281
  sessionStorage.removeItem(TOKEN_KEY)
224
- Object.assign(state, { token: '', stats: null, overview: null, knowledgeBases: [], mounts: [], resolvedMounts: [], entries: [], documents: [], candidates: [], tokens: [] })
282
+ Object.assign(state, { token: '', stats: null, overview: null, knowledgeBases: [], mounts: [], resolvedMounts: [], entries: [], documents: [], candidates: [], settings: { writebackPolicy: 'conservative', updatedAt: '' }, tokens: [] })
225
283
  if (AUTH_MODE === 'same-origin') void boot()
226
284
  else renderLogin()
227
285
  }
@@ -257,15 +315,16 @@ async function ensureKnowledgeBases(force = false) {
257
315
  }
258
316
 
259
317
  async function loadKnowledgeBasesPage() {
260
- const requests = [api('knowledge-bases'), api('mounts')]
318
+ const requests = [api('knowledge-bases'), api('mounts'), api('settings')]
261
319
  if (state.mountContext.sessionId) {
262
320
  const params = new URLSearchParams({ sessionId: state.mountContext.sessionId })
263
321
  if (state.mountContext.projectId) params.set('projectId', state.mountContext.projectId)
264
322
  requests.push(api(`mounts/resolve?${params}`))
265
323
  }
266
- const [bases, mounts, resolved = []] = await Promise.all(requests)
324
+ const [bases, mounts, settings, resolved = []] = await Promise.all(requests)
267
325
  state.knowledgeBases = bases
268
326
  state.mounts = mounts
327
+ state.settings = settings
269
328
  state.resolvedMounts = resolved
270
329
  await refreshStats()
271
330
  }
@@ -346,6 +405,7 @@ async function loadTokens() {
346
405
  }
347
406
 
348
407
  function renderShell() {
408
+ captureScrollPosition()
349
409
  const titles = {
350
410
  overview: ['概览', '知识库运行状态与最近活动'],
351
411
  bases: ['知识库', '创建知识库,并限定项目与会话的召回和写入范围'],
@@ -356,6 +416,7 @@ function renderShell() {
356
416
  const [title, subtitle] = titles[state.view]
357
417
  const shell = element('div', {
358
418
  class: 'app-shell', 'data-menu-open': String(state.menuOpen),
419
+ 'data-view': state.view, 'data-loading': String(state.loading),
359
420
  'data-sidebar-hidden': String(state.documentView.sidebarHidden),
360
421
  style: `--sidebar-width: ${state.documentView.sidebarWidth}px`,
361
422
  },
@@ -376,6 +437,7 @@ function renderShell() {
376
437
  if (event.target === shell) { state.menuOpen = false; renderShell() }
377
438
  })
378
439
  app.replaceChildren(shell)
440
+ restoreScrollPosition(state.view)
379
441
  }
380
442
 
381
443
  function renderAppSidebarResizer() {
@@ -445,19 +507,21 @@ function setSidebarHidden(hidden) {
445
507
 
446
508
  function renderSidebar() {
447
509
  const pending = state.stats?.candidates.pending
448
- const navItems = [
449
- ['overview', '概览', ''], ['bases', '知识库', '▦'], ['entries', '文档', '◇'], ['candidates', '审核', '✓'], ['tokens', '访问管理', '⌁'],
450
- ].filter(([id]) => id !== 'tokens' || !state.service.remote)
510
+ const navGroups = [
511
+ ['工作区', [['overview', '概览'], ['bases', '知识库'], ['entries', '文档'], ['candidates', '审核']]],
512
+ ['服务', [['tokens', '访问管理']].filter(([id]) => id !== 'tokens' || !state.service.remote)],
513
+ ].filter(([, items]) => items.length)
451
514
  return element('aside', { class: 'sidebar', 'aria-label': '知识库导航' },
452
515
  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', {}, '管理控制台')),
516
+ element('div', { class: 'brand-copy' }, element('span', {}, 'DSH Knowledge'), element('strong', {}, '知识库')),
455
517
  ),
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))),
518
+ element('nav', { class: 'nav' }, navGroups.map(([group, items]) => element('div', { class: 'nav-group' },
519
+ element('div', { class: 'nav-group-label' }, group),
520
+ items.map(([id, label]) => element('button', {
521
+ type: 'button', class: 'nav-button', 'aria-current': state.view === id ? 'page' : undefined,
522
+ onClick: () => navigate(id),
523
+ }, element('span', { class: 'nav-label' }, label),
524
+ id === 'candidates' && pending ? element('span', { class: 'nav-count', 'aria-label': `${pending} 条待审核` }, pending) : null))))),
461
525
  element('div', { class: 'sidebar-footer' },
462
526
  element('div', { class: 'connection' }, element('span', { class: 'status-dot', 'aria-hidden': 'true' }), state.service.remote ? '中央知识库已连接' : '本地知识库已连接'),
463
527
  AUTH_MODE === 'bearer' ? actionButton('退出当前会话', signOut, 'ghost small') : null,
@@ -476,7 +540,16 @@ function renderCurrentView() {
476
540
  }
477
541
 
478
542
  function loadingView() {
479
- return element('div', { class: 'loading', role: 'status' }, element('div', {}, element('div', { class: 'spinner', 'aria-hidden': 'true' }), element('span', { class: 'visually-hidden' }, '正在加载')))
543
+ return element('div', { class: 'loading-skeleton', role: 'status', 'aria-label': '正在加载' },
544
+ element('span', { class: 'visually-hidden' }, '正在加载'),
545
+ element('div', { class: 'skeleton-line skeleton-title', 'aria-hidden': 'true' }),
546
+ element('div', { class: 'skeleton-line skeleton-copy', 'aria-hidden': 'true' }),
547
+ element('div', { class: 'skeleton-grid', 'aria-hidden': 'true' },
548
+ element('div', { class: 'skeleton-block' }),
549
+ element('div', { class: 'skeleton-block' }),
550
+ element('div', { class: 'skeleton-block' }),
551
+ ),
552
+ )
480
553
  }
481
554
 
482
555
  function errorView(message, retry) {
@@ -516,24 +589,60 @@ function renderKnowledgeBases() {
516
589
  const activeBases = state.knowledgeBases.filter(base => base.status === 'active')
517
590
  const archivedBases = state.knowledgeBases.filter(base => base.status === 'archived')
518
591
  const contextAvailable = Boolean(state.mountContext.projectId || state.mountContext.sessionId)
592
+ const query = state.knowledgeBaseQuery.trim().toLocaleLowerCase()
593
+ const matchesQuery = base => !query || [base.name, base.description, base.defaultTags.join(' '), base.writebackProvider, base.writebackModel]
594
+ .some(value => String(value || '').toLocaleLowerCase().includes(query))
595
+ const visibleActiveBases = activeBases.filter(matchesQuery)
596
+ const visibleArchivedBases = archivedBases.filter(matchesQuery)
597
+ const switcher = element('div', { class: 'workspace-switcher' },
598
+ element('div', { class: 'workspace-switcher-leading' },
599
+ element('div', { class: 'tabs workspace-tabs', role: 'tablist', 'aria-label': '知识库管理范围' }, [
600
+ ['libraries', '知识库', activeBases.length],
601
+ ['mounts', '项目与会话挂载', state.mounts.filter(mount => mount.enabled).length],
602
+ ].map(([id, label, count]) => element('button', {
603
+ type: 'button', role: 'tab', class: 'tab', 'aria-selected': String(state.knowledgeBaseView === id),
604
+ onClick: () => { state.knowledgeBaseView = id; renderShell() },
605
+ }, element('span', {}, label), element('span', { class: 'tab-count' }, count)))),
606
+ renderWritebackPolicyControl(),
607
+ ),
608
+ element('p', {}, state.settings.writebackPolicy === 'conservative'
609
+ ? '严谨模式只保留明确或已验证的长期知识,宁可漏记也不堆积噪声。'
610
+ : '主动模式会更积极地沉淀可复用内容,仍会排除敏感信息和临时输出。'),
611
+ )
519
612
  return element('div', { class: 'bases-page' },
520
- element('section', { 'aria-labelledby': 'bases-heading' },
613
+ switcher,
614
+ state.knowledgeBaseView === 'libraries' ? element('section', { class: 'library-management', 'aria-labelledby': 'bases-heading' },
521
615
  element('div', { class: 'section-heading' },
522
- element('div', {}, element('h2', { id: 'bases-heading' }, `知识库 · ${activeBases.length}`), element('p', {}, '每个知识库拥有独立的默认标签和提取规则。')),
616
+ element('div', {}, element('h2', { id: 'bases-heading' }, '我的知识库'), element('p', {}, '名称和描述帮助 AI 判断知识应该写到哪里。')),
523
617
  actionButton('+ 创建知识库', () => openKnowledgeBaseEditor(), 'primary'),
524
618
  ),
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)),
619
+ element('div', { class: 'knowledge-base-toolbar' },
620
+ element('div', { class: 'search-box base-search' }, interfaceIcon('search', 'search-symbol'), element('input', {
621
+ class: 'input', type: 'search', value: state.knowledgeBaseQuery,
622
+ placeholder: '搜索名称、描述、标签或模型', 'aria-label': '搜索知识库',
623
+ onInput: event => {
624
+ state.knowledgeBaseQuery = event.target.value
625
+ renderShell()
626
+ document.querySelector('.base-search input')?.focus()
627
+ },
628
+ })),
629
+ element('div', { class: 'base-result-summary', 'aria-live': 'polite' }, query
630
+ ? `找到 ${visibleActiveBases.length + visibleArchivedBases.length} 个知识库`
631
+ : `${activeBases.length} 个可用 · ${archivedBases.length} 个已归档`),
632
+ ),
633
+ visibleActiveBases.length
634
+ ? element('div', { class: 'base-grid' }, visibleActiveBases.map(renderKnowledgeBaseCard))
635
+ : query && visibleArchivedBases.length === 0
636
+ ? emptyState('没有匹配的知识库', '尝试搜索名称、描述、标签或回写模型。')
637
+ : !query ? emptyState('还没有可用知识库', '使用右上角按钮创建第一个知识库。') : null,
638
+ visibleArchivedBases.length ? element('details', { class: 'archived-bases', open: Boolean(query) },
639
+ element('summary', {}, element('span', {}, '已归档知识库'), element('span', { class: 'summary-count' }, visibleArchivedBases.length)),
640
+ element('div', { class: 'base-grid' }, visibleArchivedBases.map(renderKnowledgeBaseCard)),
531
641
  ) : null,
532
- ),
533
- element('section', { class: 'mount-section', 'aria-labelledby': 'mounts-heading' },
642
+ ) : element('section', { class: 'mount-section', 'aria-labelledby': 'mounts-heading' },
534
643
  element('div', { class: 'section-heading' }, element('div', {},
535
- element('h2', { id: 'mounts-heading' }, '当前项目与会话挂载'),
536
- element('p', {}, '会话默认继承项目设置;创建会话覆盖后,可独立调整或关闭。'),
644
+ element('h2', { id: 'mounts-heading' }, '挂载范围'),
645
+ element('p', {}, '会话默认继承项目;只有需要差异时才创建会话覆盖。'),
537
646
  )),
538
647
  contextAvailable
539
648
  ? element('div', { class: 'mount-context' },
@@ -548,27 +657,67 @@ function renderKnowledgeBases() {
548
657
  )
549
658
  }
550
659
 
660
+ function renderWritebackPolicyControl() {
661
+ const policies = [
662
+ ['conservative', '严谨'],
663
+ ['proactive', '主动'],
664
+ ]
665
+ return element('div', {
666
+ class: 'writeback-policy-control',
667
+ 'aria-label': '全局回写策略',
668
+ 'aria-busy': String(state.settingsSaving),
669
+ },
670
+ element('span', { class: 'writeback-policy-label' }, '回写策略'),
671
+ element('div', { class: 'writeback-policy-segments', role: 'radiogroup', 'aria-label': '选择全局回写策略' }, policies.map(([value, label]) => element('button', {
672
+ type: 'button', role: 'radio', class: 'writeback-policy-option',
673
+ 'aria-checked': String(state.settings.writebackPolicy === value),
674
+ disabled: state.settingsSaving,
675
+ onClick: () => void updateWritebackPolicy(value),
676
+ }, label))))
677
+ }
678
+
679
+ async function updateWritebackPolicy(writebackPolicy) {
680
+ if (state.settingsSaving || state.settings.writebackPolicy === writebackPolicy) return
681
+ const previous = state.settings
682
+ state.settings = { ...state.settings, writebackPolicy }
683
+ state.settingsSaving = true
684
+ renderShell()
685
+ try {
686
+ state.settings = await api('settings', { method: 'PUT', body: { patch: { writebackPolicy } } })
687
+ showToast(`全局回写策略已切换为${writebackPolicy === 'conservative' ? '严谨' : '主动'}模式`)
688
+ } catch (error) {
689
+ state.settings = previous
690
+ showToast(friendlyError(error), 'error')
691
+ } finally {
692
+ state.settingsSaving = false
693
+ renderShell()
694
+ }
695
+ }
696
+
551
697
  function contextPill(label, value) {
552
698
  return element('div', { class: 'context-pill' }, element('strong', {}, label), element('span', { title: value }, value))
553
699
  }
554
700
 
555
701
  function renderKnowledgeBaseCard(base) {
556
702
  const archived = base.status === 'archived'
703
+ const visibleTags = base.defaultTags.slice(0, 4)
704
+ const hiddenTagCount = Math.max(0, base.defaultTags.length - visibleTags.length)
557
705
  return element('article', { class: `base-card${archived ? ' is-archived' : ''}` },
558
706
  element('div', { class: 'base-card-header' },
559
- element('div', {}, element('h3', {}, base.name), element('small', {}, base.id === 'default' ? '系统默认库' : `ID · ${base.id}`)),
707
+ element('div', { class: 'base-card-identity' },
708
+ element('span', { class: 'base-symbol', 'aria-hidden': 'true' }, base.name.trim().slice(0, 1).toLocaleUpperCase() || 'K'),
709
+ element('div', {}, element('h3', {}, base.name), element('small', { title: base.id }, base.id === 'default' ? '系统默认库' : `ID · ${base.id}`)),
710
+ ),
560
711
  badge(archived ? '已归档' : '可用', archived ? '' : 'success'),
561
712
  ),
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}` : '跟随当前会话'),
713
+ element('p', { class: 'base-description' }, base.description || '通用知识库,尚未设置匹配描述。'),
714
+ element('div', { class: 'base-card-tags' }, visibleTags.length
715
+ ? visibleTags.map(tag => element('span', { class: 'tag' }, `#${tag}`))
716
+ : element('span', { class: 'tag is-empty' }, '无默认标签'),
717
+ hiddenTagCount ? element('span', { class: 'tag' }, `+${hiddenTagCount}`) : null),
718
+ element('div', { class: 'base-card-meta' },
719
+ element('span', {}, element('strong', {}, '回写模型'), base.writebackProvider && base.writebackModel ? `${base.writebackProvider} / ${base.writebackModel}` : '跟随当前会话'),
720
+ base.extractionInstructions ? element('span', {}, element('strong', {}, '提取规则'), '已设置') : null,
572
721
  ),
573
722
  element('div', { class: 'base-card-actions' },
574
723
  actionButton('查看知识', () => {
@@ -657,7 +806,7 @@ function renderMountManager(activeBases) {
657
806
  renderShell()
658
807
  },
659
808
  }, label))),
660
- element('div', { class: 'search-box mount-search' }, element('span', { class: 'search-symbol', 'aria-hidden': 'true' }, '⌕'), searchInput),
809
+ element('div', { class: 'search-box mount-search' }, interfaceIcon('search', 'search-symbol'), searchInput),
661
810
  filter,
662
811
  ),
663
812
  element('div', { class: 'mount-target-line' },
@@ -666,7 +815,7 @@ function renderMountManager(activeBases) {
666
815
  element('span', { class: 'field-hint' }, `显示 ${visibleRows.length} / ${activeBases.length}`),
667
816
  ),
668
817
  visibleRows.length
669
- ? element('div', { class: 'mount-table', role: 'list', 'aria-label': '知识库挂载列表' }, visibleRows.map(({ base, view }) => renderMountListRow(base, view, manager.targetKind, targetId)))
818
+ ? 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
819
  : emptyState('没有匹配的知识库', '调整搜索词或筛选条件。'),
671
820
  element('div', { class: 'mount-bulk-bar', 'aria-live': 'polite' },
672
821
  element('strong', {}, `已选择 ${selectedCount} 个`),
@@ -760,19 +909,16 @@ function renderEntries() {
760
909
 
761
910
  return element('section', { class: 'document-page', 'aria-labelledby': 'documents-heading' },
762
911
  element('div', { class: 'document-page-toolbar' },
763
- element('div', { class: 'search-box document-global-search' }, element('span', { class: 'search-symbol', 'aria-hidden': 'true' }, '⌕'), search),
912
+ renderDocumentModeTabs(),
913
+ element('div', { class: 'search-box document-global-search' }, interfaceIcon('search', 'search-symbol'), search),
764
914
  element('select', {
765
915
  class: 'select compact-library-picker', 'aria-label': '选择知识库', value: view.knowledgeBaseId,
766
916
  onChange: (event) => { view.knowledgeBaseId = event.target.value; view.documentId = ''; selectDefaultDocument(); renderShell() },
767
917
  }, 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), '文档列表栏'),
918
+ element('div', { class: 'document-toolbar-actions' },
919
+ renderDocumentColumnControls(),
920
+ actionButton('+ 新建知识', () => openEntryEditor(), 'primary'),
773
921
  ),
774
- actionButton('调整栏宽', openLayoutEditor, 'ghost small', { 'aria-label': '调整主导航、知识库和文档栏宽度' }),
775
- actionButton('+ 新建知识', () => openEntryEditor(), 'primary'),
776
922
  ),
777
923
  element('div', {
778
924
  class: 'document-browser',
@@ -780,11 +926,11 @@ function renderEntries() {
780
926
  'data-document-list-hidden': String(view.documentListHidden),
781
927
  style: `--library-width:${view.libraryWidth}px;--document-list-width:${view.documentListWidth}px`,
782
928
  },
783
- element('aside', { class: 'knowledge-library-column', 'aria-label': '知识库列表' },
929
+ element('aside', { id: 'knowledge-library-column', class: 'knowledge-library-column', 'aria-label': '知识库列表' },
784
930
  element('header', { class: 'column-header' },
785
931
  element('div', {}, element('h2', { id: 'documents-heading' }, '知识库'), element('span', {}, `${activeBases.length} 个可用`)),
786
932
  ),
787
- activeBases.length ? element('div', { class: 'library-list', role: 'listbox', tabindex: '0', onKeyDown: event => moveDocumentSelection(event, 'base') },
933
+ activeBases.length ? element('div', { class: 'library-list', role: 'listbox', tabindex: '0', 'data-scroll-key': 'library-list', onKeyDown: event => moveDocumentSelection(event, 'base') },
788
934
  activeBases.map(base => {
789
935
  const documentCount = state.documents.filter(document => document.knowledgeBaseId === base.id).length
790
936
  const selected = base.id === view.knowledgeBaseId
@@ -798,11 +944,11 @@ function renderEntries() {
798
944
  element('footer', { class: 'column-footer' }, actionButton('+ 新建知识库', () => openKnowledgeBaseEditor(), 'ghost small')),
799
945
  ),
800
946
  renderColumnResizer('library', '调整知识库栏宽度'),
801
- element('aside', { class: 'document-list-column', 'aria-label': '文档列表' },
947
+ element('aside', { id: 'document-list-column', class: 'document-list-column', 'aria-label': '文档列表' },
802
948
  element('header', { class: 'column-header' },
803
949
  element('div', {}, element('h2', {}, selectedBase?.name || '文档'), element('span', {}, query ? `找到 ${visibleDocuments.length} 篇` : `${allBaseDocuments.length} 篇文档`)),
804
950
  ),
805
- visibleDocuments.length ? element('div', { class: 'document-list', role: 'listbox', tabindex: '0', onKeyDown: event => moveDocumentSelection(event, 'document') },
951
+ visibleDocuments.length ? element('div', { class: 'document-list', role: 'listbox', tabindex: '0', 'data-scroll-key': 'document-list', onKeyDown: event => moveDocumentSelection(event, 'document') },
806
952
  visibleDocuments.map(document => element('button', {
807
953
  type: 'button', class: 'document-row', role: 'option', 'aria-selected': String(document.id === view.documentId),
808
954
  onClick: () => { view.documentId = document.id; renderShell() },
@@ -824,104 +970,56 @@ function setDocumentColumnHidden(column, hidden) {
824
970
  renderShell()
825
971
  }
826
972
 
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
- ),
973
+ function renderDocumentColumnControls() {
974
+ return element('div', { class: 'document-column-controls', role: 'group', 'aria-label': '显示文档栏位' },
975
+ element('span', { class: 'document-column-controls-label', 'aria-hidden': 'true' }, '显示'),
976
+ documentColumnControl('library', '知识库栏'),
977
+ documentColumnControl('documentList', '文档栏'),
865
978
  )
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
979
  }
885
980
 
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
- }
981
+ function documentColumnControl(column, label) {
982
+ const layout = DOCUMENT_COLUMN_LAYOUTS[column]
983
+ const visible = !state.documentView[layout.hiddenKey]
984
+ const action = `${visible ? '收起' : '展开'}${label}`
985
+ return element('button', {
986
+ type: 'button', class: 'document-column-control', 'data-column': column,
987
+ 'aria-label': action, 'aria-pressed': String(visible), title: action,
988
+ onClick: () => setDocumentColumnHidden(column, visible),
989
+ }, label)
892
990
  }
893
991
 
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
- }
992
+ function renderColumnResizer(column, label) {
993
+ const layout = DOCUMENT_COLUMN_LAYOUTS[column]
994
+ const hidden = state.documentView[layout.hiddenKey]
995
+ if (hidden) return null
996
+ const value = state.documentView[layout.widthKey]
997
+ return element('div', {
998
+ class: 'column-resizer', 'data-column': column, role: 'separator', tabindex: '0',
999
+ title: `${label};可拖动或使用左右方向键`,
1000
+ 'aria-label': label, 'aria-controls': layout.controls, 'aria-orientation': 'vertical',
1001
+ 'aria-valuemin': layout.minimum, 'aria-valuemax': layout.maximum, 'aria-valuenow': value,
1002
+ 'aria-valuetext': `${value} 像素`,
1003
+ onPointerDown: event => startColumnResize(event, column),
1004
+ onKeyDown: event => resizeColumnWithKeyboard(event, column),
1005
+ }, element('span', { 'aria-hidden': 'true' }, '⋮'))
909
1006
  }
910
1007
 
911
- function startColumnResize(event, column, minimum, maximum) {
1008
+ function startColumnResize(event, column) {
912
1009
  if (event.button !== 0) return
913
1010
  event.preventDefault()
1011
+ const layout = DOCUMENT_COLUMN_LAYOUTS[column]
914
1012
  const handle = event.currentTarget
915
1013
  const browser = handle.closest('.document-browser')
916
1014
  if (!browser) return
917
1015
  const startX = event.clientX
918
- const startWidth = column === 'library' ? state.documentView.libraryWidth : state.documentView.documentListWidth
1016
+ const startWidth = state.documentView[layout.widthKey]
919
1017
  handle.setPointerCapture?.(event.pointerId)
920
1018
  handle.classList.add('is-dragging')
921
1019
  document.body.classList.add('is-resizing-columns')
922
1020
  const move = moveEvent => {
923
- const width = clampNumber(startWidth + moveEvent.clientX - startX, minimum, maximum, startWidth)
924
- setColumnWidth(column, width, browser, handle)
1021
+ const width = clampNumber(startWidth + moveEvent.clientX - startX, layout.minimum, layout.maximum, startWidth)
1022
+ setDocumentColumnWidth(column, width, browser, handle)
925
1023
  }
926
1024
  const finish = () => {
927
1025
  handle.classList.remove('is-dragging')
@@ -936,27 +1034,32 @@ function startColumnResize(event, column, minimum, maximum) {
936
1034
  handle.addEventListener('pointercancel', finish)
937
1035
  }
938
1036
 
939
- function resizeColumnWithKeyboard(event, column, minimum, maximum) {
1037
+ function resizeColumnWithKeyboard(event, column) {
940
1038
  if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return
941
1039
  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)
1040
+ const layout = DOCUMENT_COLUMN_LAYOUTS[column]
1041
+ const current = state.documentView[layout.widthKey]
946
1042
  const browser = event.currentTarget.closest('.document-browser')
947
- setColumnWidth(column, width, browser, event.currentTarget)
1043
+ const width = event.key === 'Home' ? layout.minimum
1044
+ : event.key === 'End' ? layout.maximum
1045
+ : clampNumber(current + (event.key === 'ArrowRight' ? 16 : -16), layout.minimum, layout.maximum, current)
1046
+ setDocumentColumnWidth(column, width, browser, event.currentTarget)
948
1047
  saveDocumentLayout()
949
1048
  }
950
1049
 
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
- }
1050
+ function setDocumentColumnWidth(column, width, browser, handle) {
1051
+ const layout = DOCUMENT_COLUMN_LAYOUTS[column]
1052
+ state.documentView[layout.widthKey] = width
1053
+ browser?.style.setProperty(layout.cssVariable, `${width}px`)
959
1054
  handle?.setAttribute('aria-valuenow', String(width))
1055
+ handle?.setAttribute('aria-valuetext', `${width} 像素`)
1056
+ }
1057
+
1058
+ function renderDocumentModeTabs() {
1059
+ return element('div', { class: 'tabs', role: 'tablist', 'aria-label': '知识视图' },
1060
+ documentViewTab('文档', 'documents'),
1061
+ documentViewTab('条目管理', 'entries'),
1062
+ )
960
1063
  }
961
1064
 
962
1065
  function documentViewTab(label, mode) {
@@ -1002,7 +1105,7 @@ function renderDocumentReader(document, base) {
1002
1105
  .map(item => element('option', { value: item.id, selected: item.id === document.id }, item.relPath))),
1003
1106
  element('div', { class: 'reader-actions' }, badge(`${document.entryCount} 条知识`), actionButton('添加内容', () => openEntryEditor(), 'small')),
1004
1107
  ),
1005
- element('article', { class: 'markdown-document' }, renderMarkdown(document.content)),
1108
+ element('article', { class: 'markdown-document', 'data-scroll-key': 'document-reader' }, renderMarkdown(document.content)),
1006
1109
  element('footer', { class: 'reader-footer' },
1007
1110
  element('span', {}, `自动整理 · ${formatDate(document.updatedAt)}`),
1008
1111
  element('span', {}, `SHA-256 · ${document.contentHash.slice(0, 8)}`),
@@ -1077,7 +1180,11 @@ function renderLegacyEntries() {
1077
1180
  projectInput,
1078
1181
  actionButton('搜索', () => {}, 'primary', { type: 'submit' }),
1079
1182
  )
1080
- return element('section', { 'aria-labelledby': 'entries-heading' },
1183
+ return element('section', { class: 'document-page legacy-entries-page', 'aria-labelledby': 'entries-heading' },
1184
+ element('div', { class: 'document-page-toolbar legacy-entries-toolbar' },
1185
+ renderDocumentModeTabs(),
1186
+ element('div', { class: 'document-toolbar-actions' }, actionButton('+ 新建知识', () => openEntryEditor(), 'primary')),
1187
+ ),
1081
1188
  element('div', { class: 'section-heading' }, element('div', {}, element('h2', { id: 'entries-heading' }, `${STATUS_LABELS[state.entryFilters.status]} · ${state.entries.length}`), element('p', {}, '搜索范围会包含全局知识和指定项目知识'))),
1082
1189
  toolbar,
1083
1190
  state.entries.length