@lemoncat7/dsh-knowledge 1.2.0 → 2.1.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/README.md +22 -2
- package/docs/architecture.md +30 -4
- package/docs/requirements.md +10 -0
- package/lib/api.d.ts.map +1 -1
- package/lib/api.js +194 -1
- package/lib/api.js.map +1 -1
- package/lib/atomic-file.d.ts +1 -1
- package/lib/atomic-file.d.ts.map +1 -1
- package/lib/atomic-file.js +0 -1
- package/lib/atomic-file.js.map +1 -1
- package/lib/client.d.ts.map +1 -1
- package/lib/client.js +48 -82
- package/lib/client.js.map +2 -2
- package/lib/content-safety.d.ts +12 -0
- package/lib/content-safety.d.ts.map +1 -0
- package/lib/content-safety.js +38 -0
- package/lib/content-safety.js.map +1 -0
- package/lib/domain.d.ts +13 -0
- package/lib/domain.d.ts.map +1 -1
- package/lib/domain.js.map +1 -1
- package/lib/extraction.d.ts.map +1 -1
- package/lib/extraction.js +8 -2
- package/lib/extraction.js.map +1 -1
- package/lib/index.d.ts +1 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +6 -1
- package/lib/index.js.map +1 -1
- package/lib/local-provider.d.ts +24 -1
- package/lib/local-provider.d.ts.map +1 -1
- package/lib/local-provider.js +302 -2
- package/lib/local-provider.js.map +1 -1
- package/lib/management-proxy.d.ts.map +1 -1
- package/lib/management-proxy.js +22 -13
- package/lib/management-proxy.js.map +1 -1
- package/lib/note-reference-handle.d.ts +15 -0
- package/lib/note-reference-handle.d.ts.map +1 -0
- package/lib/note-reference-handle.js +54 -0
- package/lib/note-reference-handle.js.map +1 -0
- package/lib/note-reference-tools.d.ts +6 -0
- package/lib/note-reference-tools.d.ts.map +1 -0
- package/lib/note-reference-tools.js +119 -0
- package/lib/note-reference-tools.js.map +1 -0
- package/lib/note-tools.d.ts +6 -0
- package/lib/note-tools.d.ts.map +1 -0
- package/lib/note-tools.js +283 -0
- package/lib/note-tools.js.map +1 -0
- package/lib/notes/domain.d.ts +44 -0
- package/lib/notes/domain.d.ts.map +1 -0
- package/lib/notes/domain.js +38 -0
- package/lib/notes/domain.js.map +1 -0
- package/lib/notes/store.d.ts +39 -0
- package/lib/notes/store.d.ts.map +1 -0
- package/lib/notes/store.js +407 -0
- package/lib/notes/store.js.map +1 -0
- package/lib/provider.d.ts +17 -0
- package/lib/provider.d.ts.map +1 -1
- package/lib/remote-provider.d.ts +19 -0
- package/lib/remote-provider.d.ts.map +1 -1
- package/lib/remote-provider.js +139 -13
- package/lib/remote-provider.js.map +1 -1
- package/lib/runtime.d.ts +1 -0
- package/lib/runtime.d.ts.map +1 -1
- package/lib/runtime.js.map +1 -1
- package/lib/theme-bridge.d.ts +2 -2
- package/lib/theme-bridge.d.ts.map +1 -1
- package/lib/theme-bridge.js +24 -57
- package/lib/theme-bridge.js.map +1 -1
- package/lib/tool-authorization.d.ts +14 -0
- package/lib/tool-authorization.d.ts.map +1 -0
- package/lib/tool-authorization.js +124 -0
- package/lib/tool-authorization.js.map +1 -0
- package/lib/tool-input.d.ts +6 -0
- package/lib/tool-input.d.ts.map +1 -0
- package/lib/tool-input.js +27 -0
- package/lib/tool-input.js.map +1 -0
- package/lib/tools.d.ts +2 -1
- package/lib/tools.d.ts.map +1 -1
- package/lib/tools.js +29 -179
- package/lib/tools.js.map +1 -1
- package/lib/tracking.d.ts +29 -0
- package/lib/tracking.d.ts.map +1 -0
- package/lib/tracking.js +141 -0
- package/lib/tracking.js.map +1 -0
- package/lib/web-markdown-preview.d.ts.map +1 -1
- package/lib/web-markdown-preview.js +10 -0
- package/lib/web-markdown-preview.js.map +1 -1
- package/lib/web-note-editor.d.ts +44 -0
- package/lib/web-note-editor.d.ts.map +1 -0
- package/lib/web-note-editor.js +118 -0
- package/lib/web-note-editor.js.map +1 -0
- package/lib/web-workspace-effects.d.ts +13 -0
- package/lib/web-workspace-effects.d.ts.map +1 -0
- package/lib/web-workspace-effects.js +340 -0
- package/lib/web-workspace-effects.js.map +1 -0
- package/lib/web.d.ts.map +1 -1
- package/lib/web.js +3 -1
- package/lib/web.js.map +1 -1
- package/package.json +12 -1
- package/web/app.js +1670 -93
- package/web/index.html +2 -0
- package/web/markdown-preview.js +8 -8
- package/web/note-editor.js +220 -0
- package/web/styles.css +845 -182
- package/web/workspace-effects.js +16 -0
package/web/app.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
const API_BASE = document.querySelector('meta[name="dsh-knowledge-api"]')?.content || '/knowledge-api/v1'
|
|
2
2
|
const AUTH_MODE = document.querySelector('meta[name="dsh-knowledge-auth-mode"]')?.content || 'bearer'
|
|
3
|
+
const WEB_PATH = document.querySelector('meta[name="dsh-knowledge-web"]')?.content || '/knowledge'
|
|
4
|
+
const ASSET_VERSION = document.querySelector('meta[name="dsh-knowledge-asset-version"]')?.content || ''
|
|
3
5
|
const HOST_THEME_MESSAGE = '@lemoncat7/dsh-knowledge/host-theme'
|
|
4
6
|
const HOST_THEME_READY_MESSAGE = '@lemoncat7/dsh-knowledge/host-theme-ready'
|
|
5
7
|
const HOST_THEME_PROTOCOL_VERSION = 1
|
|
@@ -20,6 +22,7 @@ const WRITE_MODE_LABELS = { none: '仅召回', audit: '审核写入', direct: '
|
|
|
20
22
|
const EVIDENCE_LABELS = { explicit: '用户明确', verified: '结果已验证', inferred: '模型推断' }
|
|
21
23
|
const DOCUMENT_STATE_LABELS = { open: '进行中', resolved: '已解决', complete: '已收集完成' }
|
|
22
24
|
const DOCUMENT_LAYOUT_KEY = 'dsh-knowledge.document-layout'
|
|
25
|
+
const NOTE_MAX_FILE_SIZE = 64 * 1024 * 1024
|
|
23
26
|
const pageParams = new URLSearchParams(location.search)
|
|
24
27
|
const mountContext = {
|
|
25
28
|
sessionId: pageParams.get('sessionId')?.trim() || '',
|
|
@@ -32,7 +35,9 @@ const savedDocumentLayout = readDocumentLayout()
|
|
|
32
35
|
function createDocumentViewState(overrides = {}) {
|
|
33
36
|
return {
|
|
34
37
|
knowledgeBaseId: '', documentId: '', query: '', mode: 'preview', treeOpen: false,
|
|
35
|
-
expandedBases: new Set(),
|
|
38
|
+
expandedBases: new Set(), documentPages: new Map(),
|
|
39
|
+
searchResults: [], searchNextCursor: '', searchTotal: 0, searchLoading: false, searchError: '', searchRequest: 0,
|
|
40
|
+
editor: null, editorLoading: false, loadingDocumentId: '', ...overrides,
|
|
36
41
|
}
|
|
37
42
|
}
|
|
38
43
|
|
|
@@ -75,9 +80,17 @@ const state = {
|
|
|
75
80
|
modelCatalog: null,
|
|
76
81
|
settingsSaving: false,
|
|
77
82
|
tokens: [],
|
|
83
|
+
notes: {
|
|
84
|
+
children: new Map(), loadedFolders: new Set(), expandedFolders: new Set(),
|
|
85
|
+
selectedId: '', selectedNode: null, currentFolderId: null, breadcrumbs: [],
|
|
86
|
+
content: '', draft: '', dirty: false, assetUrl: '', query: '', searchResults: [],
|
|
87
|
+
transfer: null, loadingNodeId: '',
|
|
88
|
+
},
|
|
78
89
|
service: { publicApiEnabled: false, publicApiPrefix: '/knowledge-api/v1', remote: false },
|
|
79
90
|
scrollPositions: new Map(),
|
|
80
91
|
loading: false,
|
|
92
|
+
loadingPhase: '',
|
|
93
|
+
loadingProgress: 0,
|
|
81
94
|
error: '',
|
|
82
95
|
}
|
|
83
96
|
|
|
@@ -86,6 +99,16 @@ let navigationController = null
|
|
|
86
99
|
let navigationRequest = 0
|
|
87
100
|
let navigationSave = Promise.resolve(true)
|
|
88
101
|
let libraryDetailRequest = 0
|
|
102
|
+
let documentSearchTimer = 0
|
|
103
|
+
let noteSearchTimer = 0
|
|
104
|
+
let markdownEditorHandle = null
|
|
105
|
+
let noteEditorLoader = null
|
|
106
|
+
let markdownEditorMountRequest = 0
|
|
107
|
+
let plainTextEditorHandle = null
|
|
108
|
+
let plainTextEditorMountRequest = 0
|
|
109
|
+
let noteTransferFrame = 0
|
|
110
|
+
let noteTransferSequence = 0
|
|
111
|
+
let noteSelectionRequest = 0
|
|
89
112
|
|
|
90
113
|
function installHostThemeBridge() {
|
|
91
114
|
if (window.parent === window) return Promise.resolve()
|
|
@@ -219,6 +242,10 @@ function element(tag, attributes = {}, ...children) {
|
|
|
219
242
|
return node
|
|
220
243
|
}
|
|
221
244
|
|
|
245
|
+
function refreshWorkspaceEffects(root = document) {
|
|
246
|
+
window.DshKnowledgeEffects?.refresh(root)
|
|
247
|
+
}
|
|
248
|
+
|
|
222
249
|
function actionButton(label, onClick, variant = '', attributes = {}) {
|
|
223
250
|
return element('button', { type: 'button', class: `button ${variant}`.trim(), onClick, ...attributes }, label)
|
|
224
251
|
}
|
|
@@ -277,11 +304,83 @@ async function api(path, options = {}) {
|
|
|
277
304
|
return payload
|
|
278
305
|
}
|
|
279
306
|
|
|
307
|
+
async function binaryRequest(path, options = {}) {
|
|
308
|
+
const headers = { accept: options.accept || 'application/json' }
|
|
309
|
+
if (AUTH_MODE === 'same-origin') headers['x-dsh-knowledge-client'] = 'management-web'
|
|
310
|
+
if (state.token) headers.authorization = `Bearer ${state.token}`
|
|
311
|
+
if (options.contentType) headers['content-type'] = options.contentType
|
|
312
|
+
const response = await fetch(`${API_BASE}/${path.replace(/^\/+/, '')}`, {
|
|
313
|
+
method: options.method || 'GET',
|
|
314
|
+
headers,
|
|
315
|
+
body: options.body,
|
|
316
|
+
signal: options.signal,
|
|
317
|
+
})
|
|
318
|
+
if (options.responseType === 'blob') {
|
|
319
|
+
if (!response.ok) throw await responseError(response)
|
|
320
|
+
return response.blob()
|
|
321
|
+
}
|
|
322
|
+
const text = await response.text()
|
|
323
|
+
let payload
|
|
324
|
+
if (text) {
|
|
325
|
+
try { payload = JSON.parse(text) } catch { throw new Error('服务返回了无法识别的数据') }
|
|
326
|
+
}
|
|
327
|
+
if (!response.ok) {
|
|
328
|
+
const error = new Error(payload?.error || `请求失败(HTTP ${response.status})`)
|
|
329
|
+
error.status = response.status
|
|
330
|
+
throw error
|
|
331
|
+
}
|
|
332
|
+
return payload
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function binaryUploadRequest(path, body, options = {}) {
|
|
336
|
+
return new Promise((resolve, reject) => {
|
|
337
|
+
const request = new XMLHttpRequest()
|
|
338
|
+
request.open(options.method || 'POST', `${API_BASE}/${path.replace(/^\/+/, '')}`)
|
|
339
|
+
request.responseType = 'text'
|
|
340
|
+
request.setRequestHeader('accept', 'application/json')
|
|
341
|
+
if (AUTH_MODE === 'same-origin') request.setRequestHeader('x-dsh-knowledge-client', 'management-web')
|
|
342
|
+
if (state.token) request.setRequestHeader('authorization', `Bearer ${state.token}`)
|
|
343
|
+
if (options.contentType) request.setRequestHeader('content-type', options.contentType)
|
|
344
|
+
request.upload.addEventListener('progress', event => {
|
|
345
|
+
if (event.lengthComputable) options.onProgress?.(event.loaded, event.total)
|
|
346
|
+
})
|
|
347
|
+
request.addEventListener('load', () => {
|
|
348
|
+
let payload
|
|
349
|
+
if (request.responseText) {
|
|
350
|
+
try { payload = JSON.parse(request.responseText) }
|
|
351
|
+
catch { return reject(new Error('服务返回了无法识别的数据')) }
|
|
352
|
+
}
|
|
353
|
+
if (request.status < 200 || request.status >= 300) {
|
|
354
|
+
const error = new Error(payload?.error || `请求失败(HTTP ${request.status})`)
|
|
355
|
+
error.status = request.status
|
|
356
|
+
reject(error)
|
|
357
|
+
return
|
|
358
|
+
}
|
|
359
|
+
resolve(payload)
|
|
360
|
+
})
|
|
361
|
+
request.addEventListener('error', () => reject(new Error('上传连接中断,请检查网络后重试')))
|
|
362
|
+
request.addEventListener('abort', () => reject(new DOMException('上传已取消', 'AbortError')))
|
|
363
|
+
request.send(body)
|
|
364
|
+
})
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async function responseError(response) {
|
|
368
|
+
let message = `请求失败(HTTP ${response.status})`
|
|
369
|
+
try {
|
|
370
|
+
const payload = await response.json()
|
|
371
|
+
if (payload?.error) message = payload.error
|
|
372
|
+
} catch {}
|
|
373
|
+
return Object.assign(new Error(message), { status: response.status })
|
|
374
|
+
}
|
|
375
|
+
|
|
280
376
|
async function boot() {
|
|
281
377
|
if (AUTH_MODE === 'same-origin') {
|
|
282
378
|
state.token = ''
|
|
283
|
-
|
|
284
|
-
|
|
379
|
+
await Promise.all([
|
|
380
|
+
api('service').then(service => { state.service = service }).catch(() => {}),
|
|
381
|
+
navigate('entries'),
|
|
382
|
+
])
|
|
383
|
+
renderShell()
|
|
285
384
|
return
|
|
286
385
|
}
|
|
287
386
|
if (!state.token) {
|
|
@@ -339,18 +438,22 @@ function renderLogin(message = '') {
|
|
|
339
438
|
element('p', {}, '管理对话中沉淀的长期知识与 AI 提取候选。'),
|
|
340
439
|
form,
|
|
341
440
|
)))
|
|
441
|
+
refreshWorkspaceEffects(app)
|
|
342
442
|
}
|
|
343
443
|
|
|
344
444
|
function signOut() {
|
|
445
|
+
releaseNoteEditors()
|
|
446
|
+
releaseNoteAsset()
|
|
345
447
|
sessionStorage.removeItem(TOKEN_KEY)
|
|
346
448
|
Object.assign(state, { token: '', stats: null, overview: null, knowledgeBases: [], mounts: [], resolvedMounts: [], entries: [], documents: [], candidates: [], candidateTargets: new Map(), settings: { writebackPolicy: 'conservative', updatedAt: '' }, tokens: [] })
|
|
449
|
+
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: '' }
|
|
347
450
|
if (AUTH_MODE === 'same-origin') void boot()
|
|
348
451
|
else renderLogin()
|
|
349
452
|
}
|
|
350
453
|
|
|
351
454
|
async function navigate(view) {
|
|
352
455
|
const request = ++navigationRequest
|
|
353
|
-
navigationSave = navigationSave.then(() =>
|
|
456
|
+
navigationSave = navigationSave.then(() => saveBeforeNavigation(), () => saveBeforeNavigation())
|
|
354
457
|
if (!await navigationSave || request !== navigationRequest) return
|
|
355
458
|
navigationController?.abort()
|
|
356
459
|
const controller = new AbortController()
|
|
@@ -362,13 +465,16 @@ async function navigate(view) {
|
|
|
362
465
|
state.view = view
|
|
363
466
|
state.menuOpen = false
|
|
364
467
|
state.loading = true
|
|
468
|
+
state.loadingPhase = loadingPhaseForView(view)
|
|
469
|
+
state.loadingProgress = .12
|
|
365
470
|
state.error = ''
|
|
366
471
|
renderShell()
|
|
367
472
|
try {
|
|
368
473
|
if (view === 'overview') await loadOverview(controller.signal)
|
|
369
474
|
if (view === 'bases') await loadKnowledgeBasesPage(controller.signal)
|
|
370
|
-
if (view === 'entries') await loadDocuments(controller.signal)
|
|
475
|
+
if (view === 'entries') await loadDocuments(controller.signal, (label, progress) => updateLoadingPhase(request, label, progress))
|
|
371
476
|
if (view === 'candidates') await loadCandidates(controller.signal)
|
|
477
|
+
if (view === 'notes') await loadNotes(controller.signal, (label, progress) => updateLoadingPhase(request, label, progress))
|
|
372
478
|
if (view === 'tokens') await loadTokens(controller.signal)
|
|
373
479
|
} catch (error) {
|
|
374
480
|
if (controller.signal.aborted) return
|
|
@@ -377,10 +483,31 @@ async function navigate(view) {
|
|
|
377
483
|
} finally {
|
|
378
484
|
if (navigationController !== controller) return
|
|
379
485
|
state.loading = false
|
|
486
|
+
state.loadingPhase = ''
|
|
487
|
+
state.loadingProgress = 1
|
|
380
488
|
renderShell()
|
|
381
489
|
}
|
|
382
490
|
}
|
|
383
491
|
|
|
492
|
+
function loadingPhaseForView(view) {
|
|
493
|
+
return ({
|
|
494
|
+
overview: '正在汇总知识活动', bases: '正在读取知识库配置', entries: '正在读取知识目录',
|
|
495
|
+
notes: '正在读取笔记目录', candidates: '正在准备审核队列', tokens: '正在读取访问权限',
|
|
496
|
+
})[view] || '正在准备工作区'
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function updateLoadingPhase(request, label, progress) {
|
|
500
|
+
if (request !== navigationRequest || !state.loading) return
|
|
501
|
+
state.loadingPhase = label
|
|
502
|
+
state.loadingProgress = Math.max(0, Math.min(1, progress))
|
|
503
|
+
renderShell()
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
async function saveBeforeNavigation() {
|
|
507
|
+
if (state.view === 'notes' && state.notes.dirty) return saveNoteDocument()
|
|
508
|
+
return saveBeforeLeavingDocument()
|
|
509
|
+
}
|
|
510
|
+
|
|
384
511
|
async function saveBeforeLeavingDocument() {
|
|
385
512
|
const workspace = activeDocumentWorkspace()
|
|
386
513
|
const editor = workspace?.view.editor
|
|
@@ -457,19 +584,26 @@ async function loadEntries(cursor = '') {
|
|
|
457
584
|
if (!state.stats) await refreshStats()
|
|
458
585
|
}
|
|
459
586
|
|
|
460
|
-
async function loadDocuments(signal) {
|
|
587
|
+
async function loadDocuments(signal, onPhase = () => {}) {
|
|
588
|
+
onPhase('正在读取可用知识库', .24)
|
|
461
589
|
const options = { signal }
|
|
462
|
-
const requests = [api('knowledge-bases', options)
|
|
590
|
+
const requests = [api('knowledge-bases', options)]
|
|
463
591
|
if (state.mountContext.sessionId) {
|
|
464
592
|
const params = new URLSearchParams({ sessionId: state.mountContext.sessionId })
|
|
465
593
|
if (state.mountContext.projectId) params.set('projectId', state.mountContext.projectId)
|
|
466
594
|
requests.push(api(`mounts/resolve?${params}`, options))
|
|
467
595
|
}
|
|
468
|
-
const [
|
|
596
|
+
const [results, stats] = await Promise.all([
|
|
597
|
+
Promise.all(requests),
|
|
598
|
+
state.stats ? Promise.resolve(state.stats) : api('stats', options),
|
|
599
|
+
])
|
|
600
|
+
const [bases, resolved = []] = results
|
|
469
601
|
state.knowledgeBases = bases
|
|
470
602
|
state.resolvedMounts = resolved
|
|
603
|
+
state.stats = stats
|
|
604
|
+
onPhase('正在建立文档索引', .56)
|
|
471
605
|
const visibleBaseIds = new Set(documentKnowledgeBases(bases).map(base => base.id))
|
|
472
|
-
state.documents = documents.filter(document => visibleBaseIds.has(document.knowledgeBaseId))
|
|
606
|
+
state.documents = state.documents.filter(document => visibleBaseIds.has(document.knowledgeBaseId))
|
|
473
607
|
const workspace = sessionDocumentWorkspace()
|
|
474
608
|
const view = workspace.view
|
|
475
609
|
const availableBaseIds = visibleBaseIds
|
|
@@ -478,10 +612,30 @@ async function loadDocuments(signal) {
|
|
|
478
612
|
? state.entryFilters.knowledgeBaseId
|
|
479
613
|
: documentKnowledgeBases(bases)[0]?.id || ''
|
|
480
614
|
}
|
|
615
|
+
if (view.knowledgeBaseId) {
|
|
616
|
+
view.expandedBases.add(view.knowledgeBaseId)
|
|
617
|
+
await loadDocumentPage(workspace, view.knowledgeBaseId, { signal })
|
|
618
|
+
}
|
|
481
619
|
selectDefaultDocument(workspace)
|
|
482
|
-
if (view.documentId)
|
|
620
|
+
if (view.documentId) {
|
|
621
|
+
onPhase('正在打开最近文档', .82)
|
|
622
|
+
await loadDocumentEditor(workspace, view.documentId, signal)
|
|
623
|
+
}
|
|
483
624
|
else view.editor = null
|
|
484
|
-
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
async function loadNotes(signal, onPhase = () => {}) {
|
|
628
|
+
onPhase('正在读取顶层目录', .36)
|
|
629
|
+
state.notes.children.set('root', await api('notes?limit=500', { signal }))
|
|
630
|
+
state.notes.loadedFolders.add('root')
|
|
631
|
+
if (state.notes.selectedId) {
|
|
632
|
+
onPhase('正在恢复上次打开的笔记', .74)
|
|
633
|
+
const selected = state.notes.selectedNode?.id === state.notes.selectedId
|
|
634
|
+
? state.notes.selectedNode
|
|
635
|
+
: await api(`notes/${encodeURIComponent(state.notes.selectedId)}`, { signal }).catch(() => null)
|
|
636
|
+
if (!selected) clearNoteSelection()
|
|
637
|
+
else state.notes.selectedNode = selected
|
|
638
|
+
}
|
|
485
639
|
}
|
|
486
640
|
|
|
487
641
|
function documentKnowledgeBases(bases = state.knowledgeBases) {
|
|
@@ -514,6 +668,139 @@ function setDocumentWorkspaceDocuments(workspace, documents) {
|
|
|
514
668
|
else state.documents = documents
|
|
515
669
|
}
|
|
516
670
|
|
|
671
|
+
function documentPageState(workspace, knowledgeBaseId) {
|
|
672
|
+
const pages = workspace.view.documentPages
|
|
673
|
+
if (!pages.has(knowledgeBaseId)) {
|
|
674
|
+
pages.set(knowledgeBaseId, { loaded: false, loading: false, nextCursor: '', total: 0, error: '' })
|
|
675
|
+
}
|
|
676
|
+
return pages.get(knowledgeBaseId)
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function mergeDocumentSummaries(workspace, items, knowledgeBaseId, reset) {
|
|
680
|
+
const current = documentWorkspaceDocuments(workspace)
|
|
681
|
+
const retained = reset ? current.filter(document => document.knowledgeBaseId !== knowledgeBaseId) : current
|
|
682
|
+
const merged = new Map(retained.map(document => [document.id, document]))
|
|
683
|
+
for (const item of items) merged.set(item.id, item)
|
|
684
|
+
setDocumentWorkspaceDocuments(workspace, [...merged.values()])
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
async function loadDocumentPage(workspace, knowledgeBaseId, options = {}) {
|
|
688
|
+
if (!knowledgeBaseId) return
|
|
689
|
+
const page = documentPageState(workspace, knowledgeBaseId)
|
|
690
|
+
if (page.loading || (!options.reset && !options.append && page.loaded)) return
|
|
691
|
+
if (options.append && !page.nextCursor) return
|
|
692
|
+
page.loading = true
|
|
693
|
+
page.error = ''
|
|
694
|
+
const cursor = options.append ? page.nextCursor : ''
|
|
695
|
+
try {
|
|
696
|
+
const params = new URLSearchParams({ knowledgeBaseId, limit: '60' })
|
|
697
|
+
if (cursor) params.set('cursor', cursor)
|
|
698
|
+
const result = await api(`document-index?${params}`, { signal: options.signal })
|
|
699
|
+
mergeDocumentSummaries(workspace, result.items, knowledgeBaseId, !options.append)
|
|
700
|
+
page.loaded = true
|
|
701
|
+
page.nextCursor = result.nextCursor || ''
|
|
702
|
+
page.total = result.total
|
|
703
|
+
} catch (error) {
|
|
704
|
+
if (error.name === 'AbortError') throw error
|
|
705
|
+
page.error = friendlyError(error)
|
|
706
|
+
if (options.throwOnError) throw error
|
|
707
|
+
} finally {
|
|
708
|
+
page.loading = false
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
async function toggleDocumentBase(workspace, baseId) {
|
|
713
|
+
const view = workspace.view
|
|
714
|
+
view.knowledgeBaseId = baseId
|
|
715
|
+
if (view.expandedBases.has(baseId)) {
|
|
716
|
+
view.expandedBases.delete(baseId)
|
|
717
|
+
renderShell()
|
|
718
|
+
return
|
|
719
|
+
}
|
|
720
|
+
view.expandedBases.add(baseId)
|
|
721
|
+
const page = documentPageState(workspace, baseId)
|
|
722
|
+
if (!page.loaded) page.loading = true
|
|
723
|
+
renderShell()
|
|
724
|
+
if (!page.loaded) {
|
|
725
|
+
page.loading = false
|
|
726
|
+
await loadDocumentPage(workspace, baseId)
|
|
727
|
+
renderShell()
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
function resetDocumentSearch(view) {
|
|
732
|
+
view.searchRequest += 1
|
|
733
|
+
view.searchResults = []
|
|
734
|
+
view.searchNextCursor = ''
|
|
735
|
+
view.searchTotal = 0
|
|
736
|
+
view.searchLoading = false
|
|
737
|
+
view.searchError = ''
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function scheduleDocumentSearch(workspace) {
|
|
741
|
+
window.clearTimeout(documentSearchTimer)
|
|
742
|
+
const view = workspace.view
|
|
743
|
+
if (!view.query.trim()) {
|
|
744
|
+
resetDocumentSearch(view)
|
|
745
|
+
renderShell()
|
|
746
|
+
return
|
|
747
|
+
}
|
|
748
|
+
view.searchLoading = true
|
|
749
|
+
view.searchError = ''
|
|
750
|
+
renderShell()
|
|
751
|
+
documentSearchTimer = window.setTimeout(() => { void loadDocumentSearch(workspace, true) }, 220)
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function renderDocumentSearchState(workspace) {
|
|
755
|
+
renderShell()
|
|
756
|
+
window.requestAnimationFrame(() => {
|
|
757
|
+
const input = document.querySelector(`.note-tree-search[data-document-scope="${workspace.kind}"]`)
|
|
758
|
+
input?.focus()
|
|
759
|
+
input?.setSelectionRange(input.value.length, input.value.length)
|
|
760
|
+
})
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
async function loadDocumentSearch(workspace, reset = false) {
|
|
764
|
+
const view = workspace.view
|
|
765
|
+
const query = view.query.trim()
|
|
766
|
+
if (!query) return resetDocumentSearch(view)
|
|
767
|
+
const request = ++view.searchRequest
|
|
768
|
+
view.searchLoading = true
|
|
769
|
+
view.searchError = ''
|
|
770
|
+
const params = new URLSearchParams({ q: query, limit: '80' })
|
|
771
|
+
const bases = documentWorkspaceBases(workspace)
|
|
772
|
+
if (bases.length === 0) {
|
|
773
|
+
resetDocumentSearch(view)
|
|
774
|
+
return renderShell()
|
|
775
|
+
}
|
|
776
|
+
if (workspace.kind === 'library') {
|
|
777
|
+
params.append('knowledgeBaseId', state.libraryDetail.knowledgeBaseId)
|
|
778
|
+
} else if (state.mountContext.sessionId) {
|
|
779
|
+
params.set('sessionId', state.mountContext.sessionId)
|
|
780
|
+
if (state.mountContext.projectId) params.set('projectId', state.mountContext.projectId)
|
|
781
|
+
} else {
|
|
782
|
+
params.set('active', '1')
|
|
783
|
+
}
|
|
784
|
+
if (!reset && view.searchNextCursor) params.set('cursor', view.searchNextCursor)
|
|
785
|
+
try {
|
|
786
|
+
const result = await api(`document-index?${params}`)
|
|
787
|
+
if (request !== view.searchRequest || query !== view.query.trim()) return
|
|
788
|
+
const merged = new Map((reset ? [] : view.searchResults).map(document => [document.id, document]))
|
|
789
|
+
for (const item of result.items) merged.set(item.id, item)
|
|
790
|
+
view.searchResults = [...merged.values()]
|
|
791
|
+
view.searchNextCursor = result.nextCursor || ''
|
|
792
|
+
view.searchTotal = result.total
|
|
793
|
+
} catch (error) {
|
|
794
|
+
if (request !== view.searchRequest) return
|
|
795
|
+
view.searchError = friendlyError(error)
|
|
796
|
+
} finally {
|
|
797
|
+
if (request === view.searchRequest) {
|
|
798
|
+
view.searchLoading = false
|
|
799
|
+
renderDocumentSearchState(workspace)
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
517
804
|
function documentWorkspaceBases(workspace) {
|
|
518
805
|
if (workspace.kind === 'library') {
|
|
519
806
|
const base = state.knowledgeBases.find(item => item.id === state.libraryDetail.knowledgeBaseId)
|
|
@@ -527,21 +814,23 @@ function documentWorkspaceReadOnly(workspace) {
|
|
|
527
814
|
}
|
|
528
815
|
|
|
529
816
|
async function reloadDocumentWorkspace(workspace) {
|
|
530
|
-
const
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
else {
|
|
539
|
-
const visibleBaseIds = new Set(documentKnowledgeBases().map(base => base.id))
|
|
540
|
-
setDocumentWorkspaceDocuments(workspace, documents.filter(document => visibleBaseIds.has(document.knowledgeBaseId)))
|
|
817
|
+
const baseId = workspace.kind === 'library' ? state.libraryDetail.knowledgeBaseId : workspace.view.knowledgeBaseId
|
|
818
|
+
if (!baseId) return true
|
|
819
|
+
await loadDocumentPage(workspace, baseId, { reset: true, throwOnError: true })
|
|
820
|
+
if (workspace.kind === 'library' && state.libraryDetail.knowledgeBaseId !== baseId) return false
|
|
821
|
+
const selectedId = workspace.view.documentId
|
|
822
|
+
if (selectedId && !documentWorkspaceDocuments(workspace).some(document => document.id === selectedId)) {
|
|
823
|
+
const document = await api(`documents/${encodeURIComponent(selectedId)}`)
|
|
824
|
+
mergeDocumentSummaries(workspace, [documentSummary(document)], baseId, false)
|
|
541
825
|
}
|
|
542
826
|
return true
|
|
543
827
|
}
|
|
544
828
|
|
|
829
|
+
function documentSummary(document) {
|
|
830
|
+
const { content: _content, ...summary } = document
|
|
831
|
+
return summary
|
|
832
|
+
}
|
|
833
|
+
|
|
545
834
|
function selectDefaultDocument(workspace) {
|
|
546
835
|
const view = workspace.view
|
|
547
836
|
const documents = documentWorkspaceDocuments(workspace).filter(document => document.knowledgeBaseId === view.knowledgeBaseId)
|
|
@@ -554,11 +843,15 @@ function selectDefaultDocument(workspace) {
|
|
|
554
843
|
async function loadDocumentEditor(workspace, id, signal) {
|
|
555
844
|
const revision = (workspace.view.loadRevision || 0) + 1
|
|
556
845
|
workspace.view.loadRevision = revision
|
|
557
|
-
const entry = await
|
|
846
|
+
const [entry, noteReferences] = await Promise.all([
|
|
847
|
+
api(`entries/${encodeURIComponent(id)}`, { signal }),
|
|
848
|
+
api(`entries/${encodeURIComponent(id)}/note-references`, { signal }),
|
|
849
|
+
])
|
|
558
850
|
if (workspace.view.loadRevision !== revision || workspace.view.documentId !== id) return false
|
|
559
851
|
workspace.view.mode = 'preview'
|
|
560
852
|
workspace.view.editor = {
|
|
561
853
|
...entry,
|
|
854
|
+
noteReferences,
|
|
562
855
|
tagsText: entry.tags.join(', '),
|
|
563
856
|
dirty: false,
|
|
564
857
|
isNew: false,
|
|
@@ -578,7 +871,7 @@ function createBlankDocument(workspace, baseId) {
|
|
|
578
871
|
view.expandedBases.add(base.id)
|
|
579
872
|
view.editor = {
|
|
580
873
|
id: '', knowledgeBaseId: base.id, title: '', body: '', type: 'fact', tags: [], tagsText: '',
|
|
581
|
-
scope: { kind: 'global' }, confidence: .8, dirty: true, isNew: true, saveState: '新文档',
|
|
874
|
+
scope: { kind: 'global' }, confidence: .8, noteReferences: [], dirty: true, isNew: true, saveState: '新文档',
|
|
582
875
|
}
|
|
583
876
|
renderShell()
|
|
584
877
|
document.querySelector('.note-title-input')?.focus()
|
|
@@ -597,7 +890,21 @@ async function selectDocument(workspace, id) {
|
|
|
597
890
|
if (editor?.dirty && !emptyDraft && !await saveDocumentEditor(workspace)) return
|
|
598
891
|
workspace.view.documentId = id
|
|
599
892
|
workspace.view.treeOpen = false
|
|
600
|
-
|
|
893
|
+
workspace.view.editor = null
|
|
894
|
+
workspace.view.editorLoading = true
|
|
895
|
+
workspace.view.loadingDocumentId = id
|
|
896
|
+
renderShell()
|
|
897
|
+
try {
|
|
898
|
+
await loadDocumentEditor(workspace, id)
|
|
899
|
+
} catch (error) {
|
|
900
|
+
if (workspace.view.documentId === id) showToast(friendlyError(error), 'error')
|
|
901
|
+
} finally {
|
|
902
|
+
if (workspace.view.loadingDocumentId === id) {
|
|
903
|
+
workspace.view.editorLoading = false
|
|
904
|
+
workspace.view.loadingDocumentId = ''
|
|
905
|
+
renderShell()
|
|
906
|
+
}
|
|
907
|
+
}
|
|
601
908
|
}
|
|
602
909
|
|
|
603
910
|
function editorDraft(editor) {
|
|
@@ -680,15 +987,18 @@ async function loadTokens(signal) {
|
|
|
680
987
|
}
|
|
681
988
|
|
|
682
989
|
function renderShell() {
|
|
990
|
+
releaseNoteEditors()
|
|
683
991
|
captureScrollPosition()
|
|
684
992
|
const titles = {
|
|
685
993
|
overview: ['概览', '知识库运行状态与最近活动'],
|
|
686
994
|
bases: ['知识库与挂载', '管理知识目录,并限定项目与会话的召回和写入范围'],
|
|
687
995
|
entries: ['知识文档', '在知识目录中阅读、整理和维护 Markdown 文档'],
|
|
996
|
+
notes: ['笔记文档', '像本地目录一样整理笔记和资料,并按需关联到知识文档'],
|
|
688
997
|
candidates: ['待审核', '确认 AI 提取结果后再写入知识文档'],
|
|
689
998
|
tokens: ['访问管理', '管理其他客户端连接中央知识库的权限'],
|
|
690
999
|
}
|
|
691
1000
|
const [title, subtitle] = titles[state.view]
|
|
1001
|
+
const viewIndexes = { overview: '00', notes: '01', entries: '02', candidates: '03', bases: '04', tokens: '05' }
|
|
692
1002
|
const shell = element('div', {
|
|
693
1003
|
class: 'app-shell', 'data-menu-open': String(state.menuOpen),
|
|
694
1004
|
'data-view': state.view, 'data-loading': String(state.loading),
|
|
@@ -713,16 +1023,25 @@ function renderShell() {
|
|
|
713
1023
|
workspace.view.treeOpen = !workspace.view.treeOpen
|
|
714
1024
|
renderShell()
|
|
715
1025
|
}, '知识目录') : null,
|
|
716
|
-
element('div', {
|
|
1026
|
+
element('div', { class: 'topbar-heading' },
|
|
1027
|
+
element('span', { class: 'topbar-kicker' }, `KNOWLEDGE / ${viewIndexes[state.view] || '00'}`),
|
|
1028
|
+
element('h1', {}, title),
|
|
1029
|
+
element('p', {}, subtitle)),
|
|
717
1030
|
),
|
|
1031
|
+
state.loading ? element('div', {
|
|
1032
|
+
class: 'route-progress', role: 'progressbar', 'aria-label': state.loadingPhase || '正在加载',
|
|
1033
|
+
'aria-valuemin': '0', 'aria-valuemax': '100', 'aria-valuenow': String(Math.round(state.loadingProgress * 100)),
|
|
1034
|
+
}, element('span', { style: `--route-progress: ${state.loadingProgress}` })) : null,
|
|
718
1035
|
),
|
|
719
|
-
element('div', { class: 'page' }, renderCurrentView()),
|
|
1036
|
+
element('div', { class: 'page' }, element('div', { class: 'view-stage', 'data-view-stage': state.view }, renderCurrentView())),
|
|
720
1037
|
),
|
|
721
1038
|
)
|
|
722
1039
|
if (state.menuOpen) shell.addEventListener('click', (event) => {
|
|
723
1040
|
if (event.target === shell) { state.menuOpen = false; renderShell() }
|
|
724
1041
|
})
|
|
725
1042
|
app.replaceChildren(shell)
|
|
1043
|
+
applySidebarVisibility(shell, state.documentView.sidebarHidden)
|
|
1044
|
+
refreshWorkspaceEffects(shell)
|
|
726
1045
|
restoreScrollPosition(currentScrollState())
|
|
727
1046
|
}
|
|
728
1047
|
|
|
@@ -788,17 +1107,46 @@ function setSidebarWidth(width, shell, handle) {
|
|
|
788
1107
|
function setSidebarHidden(hidden) {
|
|
789
1108
|
state.documentView.sidebarHidden = hidden
|
|
790
1109
|
saveDocumentLayout()
|
|
791
|
-
|
|
1110
|
+
const shell = app.querySelector('.app-shell')
|
|
1111
|
+
if (shell) applySidebarVisibility(shell, hidden)
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
function applySidebarVisibility(shell, hidden) {
|
|
1115
|
+
shell.dataset.sidebarHidden = String(hidden)
|
|
1116
|
+
const sidebar = shell.querySelector(':scope > .sidebar')
|
|
1117
|
+
const resizer = shell.querySelector(':scope > .app-sidebar-resizer')
|
|
1118
|
+
const toggle = shell.querySelector('.pane-toggle-button[data-pane="main"]')
|
|
1119
|
+
sidebar?.toggleAttribute('inert', hidden)
|
|
1120
|
+
resizer?.toggleAttribute('inert', hidden)
|
|
1121
|
+
if (sidebar) {
|
|
1122
|
+
if (hidden) sidebar.setAttribute('aria-hidden', 'true')
|
|
1123
|
+
else sidebar.removeAttribute('aria-hidden')
|
|
1124
|
+
}
|
|
1125
|
+
if (resizer) {
|
|
1126
|
+
resizer.tabIndex = hidden ? -1 : 0
|
|
1127
|
+
if (hidden) resizer.setAttribute('aria-hidden', 'true')
|
|
1128
|
+
else resizer.removeAttribute('aria-hidden')
|
|
1129
|
+
}
|
|
1130
|
+
if (toggle) {
|
|
1131
|
+
const visible = !hidden
|
|
1132
|
+
const action = `${visible ? '隐藏' : '显示'}主导航栏`
|
|
1133
|
+
toggle.setAttribute('aria-pressed', String(visible))
|
|
1134
|
+
toggle.setAttribute('aria-label', action)
|
|
1135
|
+
toggle.title = action
|
|
1136
|
+
}
|
|
792
1137
|
}
|
|
793
1138
|
|
|
794
1139
|
function renderSidebar() {
|
|
795
1140
|
const pending = state.stats?.candidates.pending
|
|
796
1141
|
const navGroups = [
|
|
797
|
-
['
|
|
1142
|
+
['笔记工作区', [['notes', '笔记文档']]],
|
|
1143
|
+
['知识工作区', [['entries', '知识文档'], ['candidates', '待审核'], ['bases', '知识库与挂载']]],
|
|
798
1144
|
['连接', [['tokens', '访问管理']].filter(([id]) => id !== 'tokens' || !state.service.remote)],
|
|
799
1145
|
].filter(([, items]) => items.length)
|
|
1146
|
+
let navIndex = 0
|
|
800
1147
|
return element('aside', { class: 'sidebar', 'aria-label': '知识库导航' },
|
|
801
1148
|
element('div', { class: 'brand' },
|
|
1149
|
+
element('span', { class: 'brand-emblem', 'aria-hidden': 'true' }, element('span', {})),
|
|
802
1150
|
element('div', { class: 'brand-copy' }, element('span', {}, 'DSH Knowledge'), element('strong', {}, '知识库')),
|
|
803
1151
|
),
|
|
804
1152
|
element('nav', { class: 'nav' }, navGroups.map(([group, items]) => element('div', { class: 'nav-group' },
|
|
@@ -806,7 +1154,7 @@ function renderSidebar() {
|
|
|
806
1154
|
items.map(([id, label]) => element('button', {
|
|
807
1155
|
type: 'button', class: 'nav-button', 'aria-current': state.view === id ? 'page' : undefined,
|
|
808
1156
|
onClick: () => navigate(id),
|
|
809
|
-
}, element('span', { class: 'nav-label' }, label),
|
|
1157
|
+
}, element('span', { class: 'nav-index', 'aria-hidden': 'true' }, String(++navIndex).padStart(2, '0')), element('span', { class: 'nav-label' }, label),
|
|
810
1158
|
id === 'candidates' && pending ? element('span', { class: 'nav-count', 'aria-label': `${pending} 条待审核` }, pending) : null))))),
|
|
811
1159
|
element('div', { class: 'sidebar-footer' },
|
|
812
1160
|
element('div', { class: 'connection' }, element('span', { class: 'status-dot', 'aria-hidden': 'true' }), state.service.remote ? '中央知识库已连接' : '本地知识库已连接'),
|
|
@@ -816,26 +1164,38 @@ function renderSidebar() {
|
|
|
816
1164
|
}
|
|
817
1165
|
|
|
818
1166
|
function renderCurrentView() {
|
|
819
|
-
if (state.loading) return loadingView()
|
|
1167
|
+
if (state.loading) return loadingView(state.view, state.loadingPhase, state.loadingProgress)
|
|
820
1168
|
if (state.error) return errorView(state.error, () => navigate(state.view))
|
|
821
1169
|
if (state.view === 'overview') return renderOverview()
|
|
822
1170
|
if (state.view === 'bases') return renderKnowledgeBases()
|
|
823
1171
|
if (state.view === 'entries') return renderEntries()
|
|
1172
|
+
if (state.view === 'notes') return renderNotes()
|
|
824
1173
|
if (state.view === 'candidates') return renderCandidates()
|
|
825
1174
|
return renderTokens()
|
|
826
1175
|
}
|
|
827
1176
|
|
|
828
|
-
function loadingView() {
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
element('div', { class: 'skeleton-line skeleton-title', 'aria-hidden': 'true' }),
|
|
832
|
-
element('
|
|
833
|
-
element('div', { class: 'skeleton-grid', 'aria-hidden': 'true' },
|
|
834
|
-
element('div', { class: 'skeleton-block' }),
|
|
835
|
-
element('div', { class: 'skeleton-block' }),
|
|
836
|
-
element('div', { class: 'skeleton-block' }),
|
|
837
|
-
),
|
|
1177
|
+
function loadingView(view = state.view, phase = '正在加载', progress = 0) {
|
|
1178
|
+
const label = phase || loadingPhaseForView(view)
|
|
1179
|
+
const header = element('div', { class: 'skeleton-heading' },
|
|
1180
|
+
element('div', {}, element('div', { class: 'skeleton-line skeleton-title', 'aria-hidden': 'true' }), element('div', { class: 'skeleton-line skeleton-copy', 'aria-hidden': 'true' })),
|
|
1181
|
+
element('span', { class: 'skeleton-phase' }, label),
|
|
838
1182
|
)
|
|
1183
|
+
const content = view === 'entries'
|
|
1184
|
+
? element('div', { class: 'skeleton-workspace skeleton-workspace-documents', 'aria-hidden': 'true' },
|
|
1185
|
+
element('div', { class: 'skeleton-pane skeleton-pane-tree' }, ...Array.from({ length: 7 }, (_, index) => element('div', { class: `skeleton-line skeleton-tree-row is-${index % 3}` }))),
|
|
1186
|
+
element('div', { class: 'skeleton-pane skeleton-pane-paper' }, element('div', { class: 'skeleton-line skeleton-paper-title' }), ...Array.from({ length: 8 }, (_, index) => element('div', { class: `skeleton-line skeleton-paper-row is-${index % 4}` }))),
|
|
1187
|
+
)
|
|
1188
|
+
: view === 'notes'
|
|
1189
|
+
? element('div', { class: 'skeleton-workspace skeleton-workspace-notes', 'aria-hidden': 'true' },
|
|
1190
|
+
element('div', { class: 'skeleton-pane skeleton-pane-tree' }, ...Array.from({ length: 8 }, (_, index) => element('div', { class: `skeleton-line skeleton-tree-row is-${index % 3}` }))),
|
|
1191
|
+
element('div', { class: 'skeleton-pane skeleton-pane-list' }, ...Array.from({ length: 6 }, () => element('div', { class: 'skeleton-list-row' }, element('span', { class: 'skeleton-block' }), element('span', { class: 'skeleton-line' })))),
|
|
1192
|
+
)
|
|
1193
|
+
: element('div', { class: 'skeleton-grid', 'aria-hidden': 'true' },
|
|
1194
|
+
element('div', { class: 'skeleton-block' }), element('div', { class: 'skeleton-block' }), element('div', { class: 'skeleton-block' }))
|
|
1195
|
+
return element('div', {
|
|
1196
|
+
class: `loading-skeleton loading-skeleton--${view}`, role: 'status', 'aria-label': label,
|
|
1197
|
+
style: `--loading-progress: ${progress}`,
|
|
1198
|
+
}, element('span', { class: 'visually-hidden' }, label), header, content)
|
|
839
1199
|
}
|
|
840
1200
|
|
|
841
1201
|
function errorView(message, retry) {
|
|
@@ -1061,7 +1421,10 @@ function renderKnowledgeBaseCard(base) {
|
|
|
1061
1421
|
const archived = base.status === 'archived'
|
|
1062
1422
|
const visibleTags = base.defaultTags.slice(0, 4)
|
|
1063
1423
|
const hiddenTagCount = Math.max(0, base.defaultTags.length - visibleTags.length)
|
|
1064
|
-
return element('article', {
|
|
1424
|
+
return element('article', {
|
|
1425
|
+
class: `base-card${archived ? ' is-archived' : ''}`,
|
|
1426
|
+
'data-knowledge-motion-key': `knowledge-library:${base.id}`,
|
|
1427
|
+
},
|
|
1065
1428
|
element('div', { class: 'base-card-header' },
|
|
1066
1429
|
element('div', { class: 'base-card-identity' },
|
|
1067
1430
|
element('span', { class: 'base-symbol', 'aria-hidden': 'true' }, base.name.trim().slice(0, 1).toLocaleUpperCase() || 'K'),
|
|
@@ -1193,7 +1556,11 @@ function renderMountListRow(base, view, targetKind, targetId) {
|
|
|
1193
1556
|
},
|
|
1194
1557
|
})
|
|
1195
1558
|
const modelLabel = writebackRouteLabel(base)
|
|
1196
|
-
return element('article', {
|
|
1559
|
+
return element('article', {
|
|
1560
|
+
class: `mount-list-row${selected ? ' is-selected' : ''}`,
|
|
1561
|
+
role: 'listitem',
|
|
1562
|
+
'data-knowledge-motion-key': `knowledge-mount:${targetKind}:${base.id}`,
|
|
1563
|
+
},
|
|
1197
1564
|
element('label', { class: 'mount-select' }, checkbox, element('span', { class: 'visually-hidden' }, `选择 ${base.name}`)),
|
|
1198
1565
|
element('div', { class: 'mount-list-main' },
|
|
1199
1566
|
element('div', { class: 'mount-list-title' }, element('strong', {}, base.name), badge(view.statusLabel, view.statusVariant), badge(modelLabel)),
|
|
@@ -1249,53 +1616,82 @@ function renderDocumentWorkspace(workspace, options = {}) {
|
|
|
1249
1616
|
'data-document-scope': workspace.kind,
|
|
1250
1617
|
onInput: (event) => {
|
|
1251
1618
|
view.query = event.target.value
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1619
|
+
scheduleDocumentSearch(workspace)
|
|
1620
|
+
window.requestAnimationFrame(() => {
|
|
1621
|
+
const input = document.querySelector(`.note-tree-search[data-document-scope="${workspace.kind}"]`)
|
|
1622
|
+
input?.focus()
|
|
1623
|
+
input?.setSelectionRange(input.value.length, input.value.length)
|
|
1624
|
+
})
|
|
1256
1625
|
},
|
|
1257
1626
|
})
|
|
1258
|
-
const
|
|
1627
|
+
const visibleBases = query
|
|
1628
|
+
? activeBases.filter(base => view.searchResults.some(document => document.knowledgeBaseId === base.id))
|
|
1629
|
+
: activeBases
|
|
1630
|
+
const tree = visibleBases.map(base => {
|
|
1259
1631
|
const expanded = view.expandedBases.has(base.id) || Boolean(query)
|
|
1260
|
-
const
|
|
1261
|
-
|
|
1262
|
-
return element('section', {
|
|
1632
|
+
const page = documentPageState(workspace, base.id)
|
|
1633
|
+
const documents = (query ? view.searchResults : workspaceDocuments).filter(document => document.knowledgeBaseId === base.id)
|
|
1634
|
+
return element('section', {
|
|
1635
|
+
class: 'note-tree-group', 'data-expanded': String(expanded),
|
|
1636
|
+
},
|
|
1263
1637
|
element('button', {
|
|
1264
1638
|
type: 'button', class: 'note-tree-base', 'aria-expanded': String(expanded),
|
|
1639
|
+
'data-knowledge-motion-key': `knowledge-base:${workspace.kind}:${base.id}`,
|
|
1265
1640
|
onClick: () => {
|
|
1266
|
-
view.knowledgeBaseId = base.id
|
|
1267
|
-
|
|
1268
|
-
else view.expandedBases.add(base.id)
|
|
1269
|
-
renderShell()
|
|
1641
|
+
if (query) { view.knowledgeBaseId = base.id; return renderShell() }
|
|
1642
|
+
void toggleDocumentBase(workspace, base.id)
|
|
1270
1643
|
},
|
|
1271
1644
|
},
|
|
1272
1645
|
element('span', { class: 'tree-disclosure', 'aria-hidden': 'true' }),
|
|
1273
1646
|
element('span', { class: 'tree-folder-icon', 'aria-hidden': 'true' }),
|
|
1274
1647
|
element('span', { class: 'tree-base-name' }, base.name),
|
|
1275
|
-
element('span', { class: 'tree-count' }, documents.length)),
|
|
1648
|
+
element('span', { class: 'tree-count' }, query ? documents.length : page.loaded ? page.total : '—')),
|
|
1276
1649
|
expanded ? element('div', { class: 'note-tree-documents', role: 'group', 'aria-label': `${base.name}文档` },
|
|
1650
|
+
!query && page.loading && documents.length === 0 ? element('div', { class: 'note-tree-status', role: 'status' }, '正在读取目录…') : null,
|
|
1651
|
+
!query && page.error ? element('div', { class: 'note-tree-status is-error' },
|
|
1652
|
+
element('span', {}, page.error),
|
|
1653
|
+
actionButton('重试', () => { void loadDocumentPage(workspace, base.id, { reset: true }).then(renderShell) }, 'ghost small')) : null,
|
|
1277
1654
|
documents.map(document => element('button', {
|
|
1278
1655
|
type: 'button', class: 'note-tree-document', 'aria-current': document.id === view.documentId ? 'page' : undefined,
|
|
1279
|
-
|
|
1656
|
+
'data-document-id': document.id,
|
|
1657
|
+
'data-knowledge-motion-key': `knowledge-document:${workspace.kind}:${document.id}`,
|
|
1658
|
+
onClick: () => { view.knowledgeBaseId = base.id; void selectDocument(workspace, document.id) },
|
|
1280
1659
|
}, element('span', { class: 'tree-document-icon', 'aria-hidden': 'true' }), element('span', { class: 'tree-document-copy' },
|
|
1281
1660
|
element('strong', {}, document.title), element('small', {}, document.relPath)),
|
|
1282
1661
|
document.documentState !== 'open' ? badge(DOCUMENT_STATE_LABELS[document.documentState] || '已结束', 'success') : null)),
|
|
1662
|
+
!query && page.nextCursor ? element('button', {
|
|
1663
|
+
type: 'button', class: 'note-tree-more', disabled: page.loading,
|
|
1664
|
+
onClick: () => { void loadDocumentPage(workspace, base.id, { append: true }).then(renderShell) },
|
|
1665
|
+
}, page.loading ? '正在加载…' : `继续加载(已显示 ${documents.length} / ${page.total})`) : null,
|
|
1666
|
+
!query && page.loaded && documents.length === 0 ? element('div', { class: 'note-tree-status' }, '这个知识库还没有文档。') : null,
|
|
1283
1667
|
!query && !readOnly ? element('button', { type: 'button', class: 'note-tree-new', onClick: () => { void startBlankDocument(workspace, base.id) } },
|
|
1284
1668
|
element('span', { 'aria-hidden': 'true' }, '+'), '新建文档') : null,
|
|
1285
1669
|
) : null,
|
|
1286
1670
|
)
|
|
1287
1671
|
})
|
|
1672
|
+
const treeContent = query
|
|
1673
|
+
? [
|
|
1674
|
+
view.searchLoading && view.searchResults.length === 0 ? element('div', { class: 'note-tree-status', role: 'status' }, '正在搜索文档…') : null,
|
|
1675
|
+
view.searchError ? element('div', { class: 'note-tree-status is-error' }, view.searchError) : null,
|
|
1676
|
+
...tree,
|
|
1677
|
+
!view.searchLoading && !view.searchError && view.searchResults.length === 0 ? element('div', { class: 'note-tree-empty' }, '没有匹配的文档') : null,
|
|
1678
|
+
view.searchNextCursor ? element('button', {
|
|
1679
|
+
type: 'button', class: 'note-tree-more search-more', disabled: view.searchLoading,
|
|
1680
|
+
onClick: () => { void loadDocumentSearch(workspace, false) },
|
|
1681
|
+
}, view.searchLoading ? '正在加载…' : `继续加载搜索结果(${view.searchResults.length} / ${view.searchTotal})`) : null,
|
|
1682
|
+
]
|
|
1683
|
+
: tree.length ? tree : [element('div', { class: 'note-tree-empty' }, workspace.kind === 'session' && state.mountContext.sessionId ? '当前会话未挂载知识库' : '还没有知识库')]
|
|
1288
1684
|
return element('section', {
|
|
1289
1685
|
class: `note-workspace note-workspace--${workspace.kind}`, 'aria-labelledby': `${workspace.kind}-documents-heading`,
|
|
1290
1686
|
'data-tree-open': String(view.treeOpen),
|
|
1291
1687
|
},
|
|
1292
1688
|
element('aside', { class: 'note-tree-panel', 'aria-label': '知识目录' },
|
|
1293
1689
|
element('header', { class: 'note-tree-header' },
|
|
1294
|
-
element('div', {}, element('h2', { id: `${workspace.kind}-documents-heading` }, options.heading || '知识目录'), element('span', {}, `${workspaceDocuments.length}
|
|
1690
|
+
element('div', {}, element('h2', { id: `${workspace.kind}-documents-heading` }, options.heading || '知识目录'), element('span', {}, query ? `${view.searchTotal} 条搜索结果` : `${workspaceDocuments.length} 篇已加载`)),
|
|
1295
1691
|
!readOnly ? actionButton('+', () => { void startBlankDocument(workspace, view.knowledgeBaseId || activeBases[0]?.id) }, 'ghost note-add-button', { 'aria-label': '新建文档', title: '新建文档' }) : null,
|
|
1296
1692
|
),
|
|
1297
1693
|
element('div', { class: 'note-tree-search-wrap' }, interfaceIcon('search', 'search-symbol'), search),
|
|
1298
|
-
element('nav', { class: 'note-tree', 'data-scroll-key': `${workspace.kind}-note-tree` },
|
|
1694
|
+
element('nav', { class: 'note-tree', 'data-scroll-key': `${workspace.kind}-note-tree` }, treeContent),
|
|
1299
1695
|
workspace.kind === 'session' ? element('footer', { class: 'note-tree-footer' }, actionButton('新建知识库', () => openKnowledgeBaseEditor(), 'ghost small')) : null,
|
|
1300
1696
|
),
|
|
1301
1697
|
view.treeOpen ? element('button', {
|
|
@@ -1309,6 +1705,7 @@ function renderDocumentWorkspace(workspace, options = {}) {
|
|
|
1309
1705
|
function renderNoteEditor(workspace, editor, base) {
|
|
1310
1706
|
const view = workspace.view
|
|
1311
1707
|
const readOnly = documentWorkspaceReadOnly(workspace)
|
|
1708
|
+
if (view.editorLoading) return renderDocumentEditorLoading(base)
|
|
1312
1709
|
if (!editor) return element('main', { class: 'note-editor note-editor-empty' },
|
|
1313
1710
|
element('div', { class: 'note-empty-content' },
|
|
1314
1711
|
element('span', { class: 'empty-document-mark', 'aria-hidden': 'true' }),
|
|
@@ -1332,28 +1729,29 @@ function renderNoteEditor(workspace, editor, base) {
|
|
|
1332
1729
|
class: 'note-title-input', value: editor.title, maxlength: 200, placeholder: '无标题文档', 'aria-label': '文档标题',
|
|
1333
1730
|
onInput: event => update('title', event.target.value), onKeyDown: saveShortcut,
|
|
1334
1731
|
})
|
|
1335
|
-
const body = element('textarea', {
|
|
1336
|
-
class: 'note-body-editor', maxlength: 50000, placeholder: '从这里开始记录…', 'aria-label': '文档正文',
|
|
1337
|
-
onInput: event => { update('body', event.target.value); resizeDocumentEditor(event.target) }, onKeyDown: saveShortcut,
|
|
1338
|
-
})
|
|
1339
|
-
body.value = editor.body
|
|
1340
1732
|
const finalized = !editor.isNew && editor.documentState !== 'open'
|
|
1341
|
-
const
|
|
1342
|
-
|
|
1733
|
+
const editable = !finalized && !readOnly
|
|
1734
|
+
const body = editable
|
|
1735
|
+
? element('div', { class: 'notes-live-editor knowledge-live-editor', role: 'status', 'aria-label': '正在打开知识文档正文' },
|
|
1736
|
+
element('div', { class: 'notes-editor-loading' }, '正在打开文档…'))
|
|
1737
|
+
: renderMarkdownPreview(editor.body)
|
|
1738
|
+
if (editable) mountMarkdownEditor(body, {
|
|
1739
|
+
markdown: editor.body,
|
|
1740
|
+
label: '编辑知识文档正文',
|
|
1741
|
+
onChange: value => update('body', value),
|
|
1742
|
+
onSave: () => { void saveDocumentEditor(workspace).then(saved => { if (saved) renderShell() }) },
|
|
1743
|
+
})
|
|
1343
1744
|
return element('main', { class: 'note-editor', 'aria-label': '文档编辑器' },
|
|
1344
1745
|
element('header', { class: 'note-editor-toolbar' },
|
|
1345
1746
|
element('div', { class: 'note-breadcrumb' },
|
|
1346
1747
|
element('span', {}, base?.name || '知识库'),
|
|
1347
1748
|
element('span', { 'aria-hidden': 'true' }, '/'),
|
|
1348
|
-
element('strong', {}, editor.isNew ? '新文档' : documentWorkspaceDocuments(workspace).find(item => item.id === editor.id)?.relPath || editor.title)
|
|
1749
|
+
element('strong', {}, editor.isNew ? '新文档' : documentWorkspaceDocuments(workspace).find(item => item.id === editor.id)?.relPath || editor.title),
|
|
1750
|
+
element('span', { class: 'note-toolbar-meta' },
|
|
1751
|
+
element('span', {}, editor.isNew ? '尚未保存' : `更新于 ${formatDate(editor.updatedAt)}`),
|
|
1752
|
+
element('span', {}, editor.scope.kind === 'global' ? '全局知识' : `项目 ${editor.scope.id}`))),
|
|
1349
1753
|
element('div', { class: 'note-editor-actions' },
|
|
1350
|
-
finalized ? badge(DOCUMENT_STATE_LABELS[editor.documentState] || '已结束', 'success') : readOnly ? badge('只读') :
|
|
1351
|
-
['edit', '编辑'],
|
|
1352
|
-
['preview', '预览'],
|
|
1353
|
-
].map(([value, label]) => element('button', {
|
|
1354
|
-
type: 'button', role: 'tab', 'aria-selected': String(mode === value),
|
|
1355
|
-
onClick: () => switchDocumentMode(workspace, value),
|
|
1356
|
-
}, label))),
|
|
1754
|
+
finalized ? badge(DOCUMENT_STATE_LABELS[editor.documentState] || '已结束', 'success') : readOnly ? badge('只读') : null,
|
|
1357
1755
|
element('span', { class: 'editor-save-status', role: 'status' }, editor.saveState),
|
|
1358
1756
|
finalized && !readOnly ? actionButton('重新打开', () => reopenDocument(workspace, editor), 'small') : null,
|
|
1359
1757
|
!readOnly && !editor.isNew && !finalized ? actionButton('标记结束', () => openFinalizeDocument(workspace, editor), 'small') : null,
|
|
@@ -1370,14 +1768,12 @@ function renderNoteEditor(workspace, editor, base) {
|
|
|
1370
1768
|
editor.finalizationNote ? element('p', {}, editor.finalizationNote) : null,
|
|
1371
1769
|
editor.finalizedAt ? element('small', {}, `结束于 ${formatDate(editor.finalizedAt)}`) : null,
|
|
1372
1770
|
) : null,
|
|
1373
|
-
element('article', { class: `note-paper is-${
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
element('span', {}, editor.isNew ? '尚未保存' : `更新于 ${formatDate(editor.updatedAt)}`),
|
|
1377
|
-
element('span', {}, editor.scope.kind === 'global' ? '全局知识' : `项目 · ${editor.scope.id}`)),
|
|
1378
|
-
mode === 'edit' ? body : renderMarkdownPreview(editor.body),
|
|
1771
|
+
element('article', { class: `note-paper is-${editable ? 'edit' : 'preview'}`, 'data-document-mode': editable ? 'edit' : 'preview' },
|
|
1772
|
+
editable ? title : element('h1', { class: 'note-preview-title' }, editor.title || '无标题文档'),
|
|
1773
|
+
body,
|
|
1379
1774
|
),
|
|
1380
1775
|
),
|
|
1776
|
+
renderDocumentReferenceBar(workspace, editor, editable),
|
|
1381
1777
|
element('footer', { class: 'note-inspector' },
|
|
1382
1778
|
finalized || readOnly ? element('span', { class: 'note-format-hint' }, readOnly ? '知识库已归档 · 只读' : '只读封存 · 重新打开后才能编辑') : element('label', {}, element('span', {}, '类型'), element('select', {
|
|
1383
1779
|
class: 'note-meta-select', onChange: event => update('type', event.target.value),
|
|
@@ -1385,16 +1781,109 @@ function renderNoteEditor(workspace, editor, base) {
|
|
|
1385
1781
|
finalized || readOnly ? null : element('label', { class: 'note-tags-field' }, element('span', {}, '标签'), element('input', {
|
|
1386
1782
|
value: editor.tagsText, placeholder: '用逗号分隔', onInput: event => update('tagsText', event.target.value),
|
|
1387
1783
|
})),
|
|
1388
|
-
|
|
1784
|
+
editable ? element('span', { class: 'note-format-hint' }, 'Markdown · Ctrl/⌘ S 保存') : null,
|
|
1389
1785
|
),
|
|
1390
1786
|
)
|
|
1391
1787
|
}
|
|
1392
1788
|
|
|
1393
|
-
function
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1789
|
+
function renderDocumentEditorLoading(base) {
|
|
1790
|
+
return element('main', { class: 'note-editor note-editor-loading', role: 'status', 'aria-label': '正在打开知识文档' },
|
|
1791
|
+
element('header', { class: 'note-editor-toolbar' },
|
|
1792
|
+
element('div', { class: 'note-breadcrumb' }, element('span', {}, base?.name || '知识库'), element('span', { 'aria-hidden': 'true' }, '/'), element('strong', {}, '正在打开文档')),
|
|
1793
|
+
element('span', { class: 'editor-load-label' }, '按需加载正文与关联笔记'),
|
|
1794
|
+
),
|
|
1795
|
+
element('div', { class: 'note-editor-scroll' }, element('article', { class: 'note-paper editor-paper-skeleton', 'aria-hidden': 'true' },
|
|
1796
|
+
element('div', { class: 'skeleton-line skeleton-paper-title' }),
|
|
1797
|
+
...Array.from({ length: 9 }, (_, index) => element('div', { class: `skeleton-line skeleton-paper-row is-${index % 4}` })),
|
|
1798
|
+
)),
|
|
1799
|
+
)
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
function renderDocumentReferenceBar(workspace, editor, editable) {
|
|
1803
|
+
const references = editor.noteReferences || []
|
|
1804
|
+
return element('section', { class: 'document-reference-bar', 'aria-label': '关联笔记' },
|
|
1805
|
+
element('div', { class: 'document-reference-heading' },
|
|
1806
|
+
element('span', { class: 'document-reference-mark', 'aria-hidden': 'true' }),
|
|
1807
|
+
element('strong', {}, '关联笔记'),
|
|
1808
|
+
element('small', {}, references.length ? `${references.length} 项` : '未关联')),
|
|
1809
|
+
element('div', { class: 'document-reference-list' }, references.length
|
|
1810
|
+
? references.map(reference => element('span', { class: 'document-reference-chip' },
|
|
1811
|
+
element('button', {
|
|
1812
|
+
type: 'button', class: 'document-reference-open', title: `打开 ${reference.note.name}`,
|
|
1813
|
+
onClick: () => { void openNoteReference(reference.note.id) },
|
|
1814
|
+
}, renderNoteIcon(reference.note), element('span', {}, reference.note.name)),
|
|
1815
|
+
editable ? element('button', {
|
|
1816
|
+
type: 'button', class: 'document-reference-remove', 'aria-label': `移除 ${reference.note.name} 的关联`, title: '移除关联',
|
|
1817
|
+
onClick: () => { void removeDocumentNoteReference(workspace, editor, reference) },
|
|
1818
|
+
}, '×') : null,
|
|
1819
|
+
))
|
|
1820
|
+
: element('span', { class: 'document-reference-empty' }, editor.isNew ? '保存文档后可以关联笔记或资料文件' : '这篇知识还没有关联笔记')),
|
|
1821
|
+
editable && !editor.isNew ? actionButton('+ 添加', () => { void openDocumentNoteReferencePicker(workspace, editor) }, 'ghost small document-reference-add') : null,
|
|
1822
|
+
)
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
async function openDocumentNoteReferencePicker(workspace, editor) {
|
|
1826
|
+
const search = element('input', {
|
|
1827
|
+
class: 'input', type: 'search', placeholder: '搜索笔记文档或文件', 'aria-label': '搜索可关联的笔记',
|
|
1828
|
+
})
|
|
1829
|
+
const results = element('div', { class: 'note-picker-results', 'aria-live': 'polite' })
|
|
1830
|
+
let modal
|
|
1831
|
+
let request = 0
|
|
1832
|
+
const paint = async () => {
|
|
1833
|
+
const current = ++request
|
|
1834
|
+
const query = search.value.trim()
|
|
1835
|
+
if (!query) {
|
|
1836
|
+
results.replaceChildren(element('div', { class: 'note-picker-empty' }, '输入名称以搜索可关联的笔记文档和文件。'))
|
|
1837
|
+
return
|
|
1838
|
+
}
|
|
1839
|
+
results.replaceChildren(element('div', { class: 'note-picker-empty' }, '正在搜索…'))
|
|
1840
|
+
try {
|
|
1841
|
+
const linked = new Set((editor.noteReferences || []).map(reference => reference.note.id))
|
|
1842
|
+
const visible = (await api(`notes?q=${encodeURIComponent(query)}&limit=100`)).filter(node => node.kind !== 'folder' && !linked.has(node.id))
|
|
1843
|
+
if (current !== request) return
|
|
1844
|
+
results.replaceChildren(...(visible.length
|
|
1845
|
+
? visible.map(node => element('button', {
|
|
1846
|
+
type: 'button', class: 'note-picker-row',
|
|
1847
|
+
onClick: async event => {
|
|
1848
|
+
event.currentTarget.disabled = true
|
|
1849
|
+
try {
|
|
1850
|
+
const reference = await api(`entries/${encodeURIComponent(editor.id)}/note-references`, { method: 'POST', body: { noteId: node.id } })
|
|
1851
|
+
editor.noteReferences = [...(editor.noteReferences || []), reference]
|
|
1852
|
+
modal.close(true)
|
|
1853
|
+
renderShell()
|
|
1854
|
+
showToast('已关联笔记。')
|
|
1855
|
+
} catch (error) {
|
|
1856
|
+
event.currentTarget.disabled = false
|
|
1857
|
+
showToast(friendlyError(error), 'error')
|
|
1858
|
+
}
|
|
1859
|
+
},
|
|
1860
|
+
},
|
|
1861
|
+
renderNoteIcon(node),
|
|
1862
|
+
element('span', {}, element('strong', {}, node.name), element('small', {}, `${node.kind === 'document' ? '笔记文档' : formatBytes(node.size)} ${shortNoteId(node.id)}`))))
|
|
1863
|
+
: [element('div', { class: 'note-picker-empty' }, '没有匹配的笔记文档或文件。')]))
|
|
1864
|
+
} catch (error) {
|
|
1865
|
+
if (current !== request) return
|
|
1866
|
+
results.replaceChildren(element('div', { class: 'note-picker-empty is-error' }, friendlyError(error)))
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
let timer = 0
|
|
1870
|
+
search.addEventListener('input', () => { window.clearTimeout(timer); timer = window.setTimeout(() => void paint(), 180) })
|
|
1871
|
+
modal = openSheet({
|
|
1872
|
+
title: '添加关联笔记',
|
|
1873
|
+
description: '关联保存在文档正文之外;笔记移动或改名后仍然有效。',
|
|
1874
|
+
body: element('div', { class: 'note-picker' }, search, results),
|
|
1875
|
+
cancelLabel: '取消',
|
|
1876
|
+
})
|
|
1877
|
+
search.focus()
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
async function removeDocumentNoteReference(workspace, editor, reference) {
|
|
1881
|
+
try {
|
|
1882
|
+
await api(`entries/${encodeURIComponent(editor.id)}/note-references/${encodeURIComponent(reference.note.id)}`, { method: 'DELETE' })
|
|
1883
|
+
editor.noteReferences = (editor.noteReferences || []).filter(item => item.note.id !== reference.note.id)
|
|
1884
|
+
if (workspace.view.editor === editor) renderShell()
|
|
1885
|
+
showToast('已移除关联。')
|
|
1886
|
+
} catch (error) { showToast(friendlyError(error), 'error') }
|
|
1398
1887
|
}
|
|
1399
1888
|
|
|
1400
1889
|
function renderMarkdownPreview(markdown) {
|
|
@@ -1409,12 +1898,30 @@ function renderMarkdownPreview(markdown) {
|
|
|
1409
1898
|
table.replaceWith(scroller)
|
|
1410
1899
|
scroller.append(table)
|
|
1411
1900
|
})
|
|
1901
|
+
preview.querySelectorAll('[data-note-id]').forEach(link => {
|
|
1902
|
+
link.setAttribute('role', 'button')
|
|
1903
|
+
link.setAttribute('tabindex', '0')
|
|
1904
|
+
link.setAttribute('title', '打开笔记文档')
|
|
1905
|
+
const open = event => {
|
|
1906
|
+
if (event.type === 'keydown' && event.key !== 'Enter' && event.key !== ' ') return
|
|
1907
|
+
event.preventDefault()
|
|
1908
|
+
void openNoteReference(link.dataset.noteId)
|
|
1909
|
+
}
|
|
1910
|
+
link.addEventListener('click', open)
|
|
1911
|
+
link.addEventListener('keydown', open)
|
|
1912
|
+
})
|
|
1412
1913
|
return preview
|
|
1413
1914
|
}
|
|
1414
1915
|
|
|
1415
|
-
function
|
|
1416
|
-
|
|
1417
|
-
|
|
1916
|
+
async function openNoteReference(id) {
|
|
1917
|
+
try {
|
|
1918
|
+
if (!await saveBeforeLeavingDocument()) return
|
|
1919
|
+
const node = await api(`notes/${encodeURIComponent(id)}`)
|
|
1920
|
+
state.view = 'notes'
|
|
1921
|
+
state.loading = false
|
|
1922
|
+
await selectNoteNode(node)
|
|
1923
|
+
renderShell()
|
|
1924
|
+
} catch (error) { showToast(friendlyError(error), 'error') }
|
|
1418
1925
|
}
|
|
1419
1926
|
|
|
1420
1927
|
function openFinalizeDocument(workspace, editor) {
|
|
@@ -1484,6 +1991,1070 @@ function confirmDeleteDocument(workspace, editor) {
|
|
|
1484
1991
|
})
|
|
1485
1992
|
}
|
|
1486
1993
|
|
|
1994
|
+
function renderNotes() {
|
|
1995
|
+
const fileInput = element('input', {
|
|
1996
|
+
class: 'visually-hidden', type: 'file', multiple: true, tabindex: '-1',
|
|
1997
|
+
onChange: event => { void uploadNoteFiles([...event.target.files], state.notes.currentFolderId); event.target.value = '' },
|
|
1998
|
+
})
|
|
1999
|
+
const rootNodes = state.notes.children.get('root') || []
|
|
2000
|
+
const tree = state.notes.query.trim()
|
|
2001
|
+
? renderNoteSearchResults()
|
|
2002
|
+
: renderNoteTreeBranch(null)
|
|
2003
|
+
const workspace = element('section', {
|
|
2004
|
+
class: 'notes-workspace', 'data-drop-active': 'false',
|
|
2005
|
+
onDragEnter: noteWorkspaceDragEnter, onDragOver: noteWorkspaceDragEnter,
|
|
2006
|
+
onDragLeave: noteWorkspaceDragLeave,
|
|
2007
|
+
onDrop: event => {
|
|
2008
|
+
clearNoteDragState()
|
|
2009
|
+
if (!hasDragType(event, 'Files')) return
|
|
2010
|
+
event.preventDefault()
|
|
2011
|
+
void importDroppedNotes(event.dataTransfer, state.notes.currentFolderId)
|
|
2012
|
+
},
|
|
2013
|
+
},
|
|
2014
|
+
element('aside', { class: 'notes-browser', 'aria-label': '笔记目录' },
|
|
2015
|
+
element('header', { class: 'notes-browser-header' },
|
|
2016
|
+
element('div', {}, element('h2', {}, '目录'), element('span', {}, `${rootNodes.length} 个根目录项目`)),
|
|
2017
|
+
element('div', { class: 'notes-browser-actions' },
|
|
2018
|
+
actionButton('新建', () => openCreateNoteDocument(), 'primary small'),
|
|
2019
|
+
actionButton('目录', () => openCreateNoteFolder(), 'small'),
|
|
2020
|
+
actionButton('导入', () => fileInput.click(), 'ghost small'),
|
|
2021
|
+
fileInput,
|
|
2022
|
+
),
|
|
2023
|
+
),
|
|
2024
|
+
element('div', { class: 'notes-search' }, element('input', {
|
|
2025
|
+
class: 'input', type: 'search', value: state.notes.query, placeholder: '搜索笔记文档', 'aria-label': '搜索笔记文档',
|
|
2026
|
+
onInput: event => scheduleNoteSearch(event.target.value),
|
|
2027
|
+
})),
|
|
2028
|
+
element('div', {
|
|
2029
|
+
class: 'notes-tree', role: 'tree', 'data-scroll-key': 'notes-tree',
|
|
2030
|
+
onDragOver: event => {
|
|
2031
|
+
if (hasDragType(event, 'application/x-dsh-note-id') || hasDragType(event, 'Files')) event.preventDefault()
|
|
2032
|
+
},
|
|
2033
|
+
onDrop: event => { if (event.target === event.currentTarget) void dropNoteNode(event, null) },
|
|
2034
|
+
}, tree),
|
|
2035
|
+
),
|
|
2036
|
+
renderNoteContent(),
|
|
2037
|
+
element('div', { class: 'notes-drop-overlay', 'aria-hidden': 'true' },
|
|
2038
|
+
element('strong', {}, '放开以导入当前目录'),
|
|
2039
|
+
element('span', {}, '支持文件和完整目录,原有目录层级会被保留。')),
|
|
2040
|
+
renderNoteTransfer(),
|
|
2041
|
+
)
|
|
2042
|
+
return element('section', { class: 'notes-page', 'aria-label': '笔记工作区' }, workspace)
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
function renderNoteTransfer() {
|
|
2046
|
+
const transfer = state.notes.transfer
|
|
2047
|
+
if (!transfer) return null
|
|
2048
|
+
const dismissible = transfer.phase === 'complete' || transfer.phase === 'error'
|
|
2049
|
+
return element('section', {
|
|
2050
|
+
class: 'notes-transfer', 'data-note-transfer': transfer.id, 'data-state': transfer.phase,
|
|
2051
|
+
role: transfer.phase === 'error' ? 'alert' : 'status', 'aria-live': 'polite',
|
|
2052
|
+
},
|
|
2053
|
+
element('div', { class: 'notes-transfer-heading' },
|
|
2054
|
+
element('span', { class: 'notes-transfer-mark', 'aria-hidden': 'true' }),
|
|
2055
|
+
element('strong', { class: 'notes-transfer-title' }, noteTransferTitle(transfer)),
|
|
2056
|
+
dismissible ? actionButton('关闭', dismissNoteTransfer, 'ghost tiny notes-transfer-close', { 'aria-label': '关闭导入进度' }) : null,
|
|
2057
|
+
),
|
|
2058
|
+
element('div', { class: 'notes-transfer-detail', title: transfer.currentName || '' }, noteTransferDetail(transfer)),
|
|
2059
|
+
element('div', {
|
|
2060
|
+
class: 'notes-transfer-track', role: 'progressbar',
|
|
2061
|
+
'aria-label': '笔记导入进度', 'aria-valuemin': '0', 'aria-valuemax': '100',
|
|
2062
|
+
'aria-valuenow': transfer.phase === 'scanning' ? undefined : String(noteTransferPercent(transfer)),
|
|
2063
|
+
}, element('span', { class: 'notes-transfer-fill' })),
|
|
2064
|
+
element('div', { class: 'notes-transfer-meta' },
|
|
2065
|
+
element('span', { class: 'notes-transfer-count' }, noteTransferCount(transfer)),
|
|
2066
|
+
element('span', { class: 'notes-transfer-percent' }, transfer.phase === 'scanning' ? '正在整理' : `${noteTransferPercent(transfer)}%`),
|
|
2067
|
+
),
|
|
2068
|
+
)
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2071
|
+
function renderNoteTreeBranch(parentId, depth = 0) {
|
|
2072
|
+
const key = parentId || 'root'
|
|
2073
|
+
const nodes = state.notes.children.get(key) || []
|
|
2074
|
+
if (!nodes.length && depth === 0) {
|
|
2075
|
+
return element('div', { class: 'notes-tree-empty' }, element('strong', {}, '还没有笔记'), element('span', {}, '新建目录或文档开始整理。'))
|
|
2076
|
+
}
|
|
2077
|
+
return element('div', { class: 'notes-tree-branch', role: depth ? 'group' : undefined }, nodes.map(node => {
|
|
2078
|
+
const selected = state.notes.selectedId === node.id
|
|
2079
|
+
const expanded = node.kind === 'folder' && state.notes.expandedFolders.has(node.id)
|
|
2080
|
+
const row = element('div', {
|
|
2081
|
+
class: 'notes-tree-item', 'data-kind': node.kind, 'data-selected': String(selected),
|
|
2082
|
+
'data-note-id': node.id,
|
|
2083
|
+
'data-knowledge-motion-key': `note:${node.id}`,
|
|
2084
|
+
draggable: 'true',
|
|
2085
|
+
onDragStart: event => {
|
|
2086
|
+
event.dataTransfer.effectAllowed = 'move'
|
|
2087
|
+
event.dataTransfer.setData('application/x-dsh-note-id', node.id)
|
|
2088
|
+
event.currentTarget.dataset.dragging = 'true'
|
|
2089
|
+
},
|
|
2090
|
+
onDragEnd: () => clearNoteDragState(),
|
|
2091
|
+
onDragEnter: event => activateNoteFolderDropTarget(event, node),
|
|
2092
|
+
onDragOver: event => activateNoteFolderDropTarget(event, node),
|
|
2093
|
+
onDragLeave: event => { if (!event.currentTarget.contains(event.relatedTarget)) event.currentTarget.dataset.dropTarget = 'false' },
|
|
2094
|
+
onDrop: event => { if (node.kind === 'folder') void dropNoteNode(event, node.id) },
|
|
2095
|
+
},
|
|
2096
|
+
element('button', {
|
|
2097
|
+
type: 'button', class: 'notes-tree-row', role: 'treeitem',
|
|
2098
|
+
'aria-selected': String(selected), 'aria-expanded': node.kind === 'folder' ? String(expanded) : undefined,
|
|
2099
|
+
onClick: () => { void selectNoteNode(node, { toggleFolder: node.kind === 'folder' }) },
|
|
2100
|
+
},
|
|
2101
|
+
node.kind === 'folder' ? element('span', { class: 'notes-tree-chevron', 'aria-hidden': 'true' }) : element('span', { class: 'notes-tree-spacer' }),
|
|
2102
|
+
renderNoteIcon(node),
|
|
2103
|
+
element('span', { class: 'notes-tree-name', title: node.name }, node.name),
|
|
2104
|
+
),
|
|
2105
|
+
element('div', { class: 'notes-row-actions' },
|
|
2106
|
+
actionButton('重命名', event => { event.stopPropagation(); openRenameNoteNode(node) }, 'ghost tiny'),
|
|
2107
|
+
actionButton('复制', event => { event.stopPropagation(); void copyNoteNode(node) }, 'ghost tiny'),
|
|
2108
|
+
actionButton('删除', event => { event.stopPropagation(); void confirmDeleteNoteNode(node) }, 'ghost tiny danger-text'),
|
|
2109
|
+
),
|
|
2110
|
+
)
|
|
2111
|
+
const children = expanded
|
|
2112
|
+
? state.notes.loadedFolders.has(node.id)
|
|
2113
|
+
? renderNoteTreeBranch(node.id, depth + 1)
|
|
2114
|
+
: element('div', { class: 'notes-tree-loading', role: 'status' }, '正在读取…')
|
|
2115
|
+
: null
|
|
2116
|
+
return element('div', { class: 'notes-tree-node' }, row, children)
|
|
2117
|
+
}))
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
function renderNoteSearchResults() {
|
|
2121
|
+
if (!state.notes.searchResults.length) return element('div', { class: 'notes-tree-empty' }, '没有匹配的笔记文档。')
|
|
2122
|
+
return element('div', { class: 'notes-search-results' }, state.notes.searchResults.map(node => element('button', {
|
|
2123
|
+
type: 'button', class: 'notes-search-row', 'data-knowledge-motion-key': `note-search:${node.id}`,
|
|
2124
|
+
onClick: () => { void selectNoteNode(node) },
|
|
2125
|
+
}, renderNoteIcon(node), element('span', {}, element('strong', {}, node.name), element('small', {}, noteKindLabel(node))))))
|
|
2126
|
+
}
|
|
2127
|
+
|
|
2128
|
+
function renderNoteContent() {
|
|
2129
|
+
const node = state.notes.selectedNode
|
|
2130
|
+
if (!node) return renderNoteFolderContent(null)
|
|
2131
|
+
if (state.notes.loadingNodeId === node.id) return renderNoteSelectionLoading(node)
|
|
2132
|
+
if (node.kind === 'folder') return renderNoteFolderContent(node)
|
|
2133
|
+
if (node.kind === 'document') return renderNoteDocument(node)
|
|
2134
|
+
return renderNoteFile(node)
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2137
|
+
function renderNoteSelectionLoading(node) {
|
|
2138
|
+
return element('main', { class: `notes-content notes-selection-loading is-${node.kind}`, role: 'status', 'aria-label': `正在打开 ${node.name}` },
|
|
2139
|
+
element('header', { class: 'notes-content-header' },
|
|
2140
|
+
element('div', { class: 'notes-breadcrumb' }, element('span', {}, '笔记文档'), element('span', { 'aria-hidden': 'true' }, '/'), element('strong', {}, node.name)),
|
|
2141
|
+
element('div', { class: 'notes-content-title' }, element('div', {}, element('h2', {}, node.name), element('p', {}, node.kind === 'folder' ? '正在读取目录内容' : '正在按需读取文档内容'))),
|
|
2142
|
+
),
|
|
2143
|
+
element('div', { class: 'notes-selection-skeleton', 'aria-hidden': 'true' },
|
|
2144
|
+
element('div', { class: 'skeleton-line skeleton-paper-title' }),
|
|
2145
|
+
...Array.from({ length: node.kind === 'folder' ? 6 : 9 }, (_, index) => element('div', { class: `skeleton-line skeleton-paper-row is-${index % 4}` })),
|
|
2146
|
+
),
|
|
2147
|
+
)
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
function renderNoteFolderContent(folder) {
|
|
2151
|
+
const parentId = folder?.id || null
|
|
2152
|
+
const children = state.notes.children.get(parentId || 'root') || []
|
|
2153
|
+
return element('main', {
|
|
2154
|
+
class: 'notes-content is-folder',
|
|
2155
|
+
onDragOver: event => event.preventDefault(),
|
|
2156
|
+
onDrop: event => void dropNoteNode(event, parentId),
|
|
2157
|
+
},
|
|
2158
|
+
renderNoteContentHeader(folder),
|
|
2159
|
+
children.length
|
|
2160
|
+
? element('div', { class: 'notes-file-list', role: 'list' }, children.map(node => element('div', {
|
|
2161
|
+
class: 'notes-file-row', role: 'listitem', draggable: 'true',
|
|
2162
|
+
onDragStart: event => { event.dataTransfer.effectAllowed = 'move'; event.dataTransfer.setData('application/x-dsh-note-id', node.id) },
|
|
2163
|
+
onDblClick: () => { void selectNoteNode(node, { toggleFolder: node.kind === 'folder' }) },
|
|
2164
|
+
},
|
|
2165
|
+
element('button', { type: 'button', class: 'notes-file-main', onClick: () => { void selectNoteNode(node) } },
|
|
2166
|
+
renderNoteIcon(node, true),
|
|
2167
|
+
element('span', {}, element('strong', {}, node.name), element('small', {}, noteKindLabel(node))),
|
|
2168
|
+
),
|
|
2169
|
+
element('time', { datetime: node.updatedAt }, formatDate(node.updatedAt)),
|
|
2170
|
+
element('span', { class: 'notes-file-size' }, node.kind === 'folder' ? '' : formatBytes(node.size)),
|
|
2171
|
+
element('div', { class: 'notes-file-actions' },
|
|
2172
|
+
actionButton('重命名', () => openRenameNoteNode(node), 'ghost tiny'),
|
|
2173
|
+
actionButton('复制', () => { void copyNoteNode(node) }, 'ghost tiny'),
|
|
2174
|
+
actionButton('删除', () => { void confirmDeleteNoteNode(node) }, 'ghost tiny danger-text'),
|
|
2175
|
+
),
|
|
2176
|
+
)))
|
|
2177
|
+
: element('div', { class: 'notes-folder-empty' },
|
|
2178
|
+
element('span', { class: 'notes-empty-folder-mark', 'aria-hidden': 'true' }),
|
|
2179
|
+
element('h3', {}, '这个目录还是空的'),
|
|
2180
|
+
element('p', {}, '可以新建笔记文档、建立子目录,或把本地文件拖到这里。'),
|
|
2181
|
+
element('div', {}, actionButton('新建文档', () => openCreateNoteDocument(parentId), 'primary small'), actionButton('新建目录', () => openCreateNoteFolder(parentId), 'small')),
|
|
2182
|
+
),
|
|
2183
|
+
)
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
function renderNoteContentHeader(folder) {
|
|
2187
|
+
const path = folder ? [...state.notes.breadcrumbs] : []
|
|
2188
|
+
return element('header', { class: 'notes-content-header' },
|
|
2189
|
+
element('nav', { class: 'notes-breadcrumb', 'aria-label': '当前位置' },
|
|
2190
|
+
element('button', { type: 'button', onClick: () => { void openNoteRoot() } }, '笔记文档'),
|
|
2191
|
+
path.map(node => element('span', {}, element('span', { 'aria-hidden': 'true' }, '/'), element('button', { type: 'button', onClick: () => { void selectNoteNode(node) } }, node.name))),
|
|
2192
|
+
),
|
|
2193
|
+
element('div', { class: 'notes-content-title' },
|
|
2194
|
+
element('div', {}, element('h2', {}, folder?.name || '全部笔记'), element('p', {}, folder ? `${(state.notes.children.get(folder.id) || []).length} 个项目` : '你的独立笔记与资料目录')),
|
|
2195
|
+
element('div', { class: 'notes-content-actions' },
|
|
2196
|
+
actionButton('新建文档', () => openCreateNoteDocument(folder?.id || null), 'primary small'),
|
|
2197
|
+
actionButton('新建目录', () => openCreateNoteFolder(folder?.id || null), 'small'),
|
|
2198
|
+
),
|
|
2199
|
+
),
|
|
2200
|
+
)
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
function renderNoteDocument(node) {
|
|
2204
|
+
return renderEditableNote(node)
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
function renderEditableNote(node) {
|
|
2208
|
+
const markdown = isMarkdownNote(node)
|
|
2209
|
+
const title = editableNoteTitle(node)
|
|
2210
|
+
const editor = markdown
|
|
2211
|
+
? element('div', { class: 'notes-live-editor', role: 'status', 'aria-label': `正在打开 ${node.name}` },
|
|
2212
|
+
element('div', { class: 'notes-editor-loading' }, '正在打开文档…'))
|
|
2213
|
+
: createPlainTextNoteEditor(node)
|
|
2214
|
+
if (markdown) mountMarkdownNoteEditor(editor, node)
|
|
2215
|
+
else mountPlainTextNoteEditor(editor, node)
|
|
2216
|
+
return element('main', { class: `notes-content is-document${markdown ? '' : ' has-line-numbers'}` },
|
|
2217
|
+
renderNoteFileToolbar(node, { editable: true }),
|
|
2218
|
+
element('h1', {
|
|
2219
|
+
class: 'notes-document-title', contenteditable: 'plaintext-only', spellcheck: 'false',
|
|
2220
|
+
'aria-label': `修改 ${node.name} 的标题`, title: '点击修改标题',
|
|
2221
|
+
onBlur: event => { void saveEditableNoteTitle(event.currentTarget, node) },
|
|
2222
|
+
onKeyDown: event => {
|
|
2223
|
+
if (event.key === 'Enter') { event.preventDefault(); event.currentTarget.blur() }
|
|
2224
|
+
if (event.key === 'Escape') { event.preventDefault(); event.currentTarget.textContent = title; event.currentTarget.blur() }
|
|
2225
|
+
},
|
|
2226
|
+
}, title),
|
|
2227
|
+
editor,
|
|
2228
|
+
)
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
function editableNoteTitle(node) {
|
|
2232
|
+
return node.kind === 'document' ? node.name.replace(/\.md$/i, '') : node.name
|
|
2233
|
+
}
|
|
2234
|
+
|
|
2235
|
+
async function saveEditableNoteTitle(editor, node) {
|
|
2236
|
+
const title = readPlainTextEditor(editor).replace(/\s+/g, ' ').trim()
|
|
2237
|
+
const name = node.kind === 'document' && title ? `${title.replace(/\.md$/i, '')}.md` : title
|
|
2238
|
+
if (!name) {
|
|
2239
|
+
editor.textContent = editableNoteTitle(node)
|
|
2240
|
+
showToast('标题不能为空。', 'error')
|
|
2241
|
+
return
|
|
2242
|
+
}
|
|
2243
|
+
if (name === node.name) {
|
|
2244
|
+
editor.textContent = editableNoteTitle(node)
|
|
2245
|
+
return
|
|
2246
|
+
}
|
|
2247
|
+
editor.setAttribute('contenteditable', 'false')
|
|
2248
|
+
try {
|
|
2249
|
+
const updated = await api(`notes/${encodeURIComponent(node.id)}`, { method: 'PATCH', body: { name } })
|
|
2250
|
+
if (state.notes.selectedNode?.id === node.id) state.notes.selectedNode = updated
|
|
2251
|
+
editor.textContent = editableNoteTitle(updated)
|
|
2252
|
+
const breadcrumb = document.querySelector('.notes-document-toolbar .notes-breadcrumb strong')
|
|
2253
|
+
if (breadcrumb) breadcrumb.textContent = updated.name
|
|
2254
|
+
await loadNoteChildren(updated.parentId, true).catch(() => {})
|
|
2255
|
+
const treeName = document.querySelector(`.notes-tree-item[data-note-id="${updated.id}"] .notes-tree-name`)
|
|
2256
|
+
if (treeName) treeName.textContent = updated.name
|
|
2257
|
+
showToast('标题已更新。')
|
|
2258
|
+
} catch (error) {
|
|
2259
|
+
editor.textContent = editableNoteTitle(node)
|
|
2260
|
+
showToast(friendlyError(error), 'error')
|
|
2261
|
+
} finally {
|
|
2262
|
+
if (editor.isConnected) editor.setAttribute('contenteditable', 'plaintext-only')
|
|
2263
|
+
}
|
|
2264
|
+
}
|
|
2265
|
+
|
|
2266
|
+
function createPlainTextNoteEditor(node) {
|
|
2267
|
+
return element('div', { class: 'notes-plain-editor', role: 'status', 'aria-label': `正在打开 ${node.name}` },
|
|
2268
|
+
element('div', { class: 'notes-editor-loading' }, '正在打开文档…'))
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2271
|
+
function readPlainTextEditor(editor) {
|
|
2272
|
+
return editor.innerText.replace(/\r\n?/g, '\n')
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
function updateNoteDraft(value) {
|
|
2276
|
+
state.notes.draft = value
|
|
2277
|
+
state.notes.dirty = state.notes.draft !== state.notes.content
|
|
2278
|
+
syncNoteEditorChrome()
|
|
2279
|
+
}
|
|
2280
|
+
|
|
2281
|
+
function mountMarkdownNoteEditor(host, node) {
|
|
2282
|
+
mountMarkdownEditor(host, {
|
|
2283
|
+
markdown: state.notes.draft,
|
|
2284
|
+
label: `编辑 ${node.name}`,
|
|
2285
|
+
isCurrent: () => state.notes.selectedNode?.id === node.id,
|
|
2286
|
+
onChange: updateNoteDraft,
|
|
2287
|
+
onSave: () => { void saveNoteDocument() },
|
|
2288
|
+
})
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2291
|
+
function mountPlainTextNoteEditor(host, node) {
|
|
2292
|
+
const request = ++plainTextEditorMountRequest
|
|
2293
|
+
window.requestAnimationFrame(async () => {
|
|
2294
|
+
if (!host.isConnected || state.notes.selectedNode?.id !== node.id || request !== plainTextEditorMountRequest) return
|
|
2295
|
+
try {
|
|
2296
|
+
const runtime = await loadMarkdownNoteEditor()
|
|
2297
|
+
if (!host.isConnected || state.notes.selectedNode?.id !== node.id || request !== plainTextEditorMountRequest) return
|
|
2298
|
+
host.replaceChildren()
|
|
2299
|
+
host.removeAttribute('role')
|
|
2300
|
+
plainTextEditorHandle = runtime.createPlainTextEditor({
|
|
2301
|
+
host,
|
|
2302
|
+
text: state.notes.draft,
|
|
2303
|
+
label: `编辑 ${node.name}`,
|
|
2304
|
+
onChange: updateNoteDraft,
|
|
2305
|
+
onSave: () => { void saveNoteDocument() },
|
|
2306
|
+
})
|
|
2307
|
+
} catch (error) {
|
|
2308
|
+
if (!host.isConnected || request !== plainTextEditorMountRequest) return
|
|
2309
|
+
host.replaceChildren(element('div', { class: 'notes-editor-error', role: 'alert' },
|
|
2310
|
+
element('strong', {}, '无法打开纯文本编辑器'),
|
|
2311
|
+
element('span', {}, friendlyError(error)),
|
|
2312
|
+
actionButton('重试', () => { noteEditorLoader = null; host.replaceChildren(); mountPlainTextNoteEditor(host, node) }, 'small')))
|
|
2313
|
+
}
|
|
2314
|
+
})
|
|
2315
|
+
}
|
|
2316
|
+
|
|
2317
|
+
function mountMarkdownEditor(host, options) {
|
|
2318
|
+
const request = ++markdownEditorMountRequest
|
|
2319
|
+
window.requestAnimationFrame(async () => {
|
|
2320
|
+
if (!host.isConnected || options.isCurrent?.() === false || request !== markdownEditorMountRequest) return
|
|
2321
|
+
try {
|
|
2322
|
+
const runtime = await loadMarkdownNoteEditor()
|
|
2323
|
+
if (!host.isConnected || options.isCurrent?.() === false || request !== markdownEditorMountRequest) return
|
|
2324
|
+
host.replaceChildren()
|
|
2325
|
+
host.removeAttribute('role')
|
|
2326
|
+
markdownEditorHandle = runtime.createMarkdownEditor({
|
|
2327
|
+
host,
|
|
2328
|
+
markdown: options.markdown,
|
|
2329
|
+
label: options.label,
|
|
2330
|
+
onChange: options.onChange,
|
|
2331
|
+
onSave: options.onSave,
|
|
2332
|
+
})
|
|
2333
|
+
} catch (error) {
|
|
2334
|
+
if (!host.isConnected || request !== markdownEditorMountRequest) return
|
|
2335
|
+
host.replaceChildren(element('div', { class: 'notes-editor-error', role: 'alert' },
|
|
2336
|
+
element('strong', {}, '无法打开 Markdown 编辑器'),
|
|
2337
|
+
element('span', {}, friendlyError(error)),
|
|
2338
|
+
actionButton('重试', () => { noteEditorLoader = null; host.replaceChildren(); mountMarkdownEditor(host, options) }, 'small')))
|
|
2339
|
+
}
|
|
2340
|
+
})
|
|
2341
|
+
}
|
|
2342
|
+
|
|
2343
|
+
function loadMarkdownNoteEditor() {
|
|
2344
|
+
if (window.DshKnowledgeNoteEditor?.createMarkdownEditor) return Promise.resolve(window.DshKnowledgeNoteEditor)
|
|
2345
|
+
if (noteEditorLoader) return noteEditorLoader
|
|
2346
|
+
noteEditorLoader = new Promise((resolve, reject) => {
|
|
2347
|
+
const script = document.createElement('script')
|
|
2348
|
+
const suffix = ASSET_VERSION ? `?v=${encodeURIComponent(ASSET_VERSION)}` : ''
|
|
2349
|
+
script.src = `${WEB_PATH.replace(/\/$/, '')}/note-editor.js${suffix}`
|
|
2350
|
+
script.async = true
|
|
2351
|
+
script.onload = () => window.DshKnowledgeNoteEditor?.createMarkdownEditor
|
|
2352
|
+
? resolve(window.DshKnowledgeNoteEditor)
|
|
2353
|
+
: reject(new Error('Markdown 编辑器模块无效'))
|
|
2354
|
+
script.onerror = () => { script.remove(); reject(new Error('Markdown 编辑器加载失败')) }
|
|
2355
|
+
document.head.append(script)
|
|
2356
|
+
}).catch(error => {
|
|
2357
|
+
noteEditorLoader = null
|
|
2358
|
+
throw error
|
|
2359
|
+
})
|
|
2360
|
+
return noteEditorLoader
|
|
2361
|
+
}
|
|
2362
|
+
|
|
2363
|
+
function releaseNoteEditors() {
|
|
2364
|
+
markdownEditorMountRequest += 1
|
|
2365
|
+
plainTextEditorMountRequest += 1
|
|
2366
|
+
markdownEditorHandle?.destroy()
|
|
2367
|
+
plainTextEditorHandle?.destroy()
|
|
2368
|
+
markdownEditorHandle = null
|
|
2369
|
+
plainTextEditorHandle = null
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2372
|
+
function syncNoteEditorChrome() {
|
|
2373
|
+
const status = document.querySelector('[data-note-save-state]')
|
|
2374
|
+
if (status) {
|
|
2375
|
+
status.textContent = state.notes.dirty ? '未保存' : '已保存'
|
|
2376
|
+
status.dataset.dirty = String(state.notes.dirty)
|
|
2377
|
+
}
|
|
2378
|
+
const save = document.querySelector('[data-note-save]')
|
|
2379
|
+
if (save) save.disabled = !state.notes.dirty
|
|
2380
|
+
}
|
|
2381
|
+
|
|
2382
|
+
function renderNoteDocumentBreadcrumb(node) {
|
|
2383
|
+
return element('nav', { class: 'notes-breadcrumb', 'aria-label': '当前位置' },
|
|
2384
|
+
element('button', { type: 'button', onClick: () => { void openNoteRoot() } }, '笔记文档'),
|
|
2385
|
+
state.notes.breadcrumbs.map(parent => element('span', {}, element('span', { 'aria-hidden': 'true' }, '/'), element('button', { type: 'button', onClick: () => { void selectNoteNode(parent) } }, parent.name))),
|
|
2386
|
+
element('span', {}, element('span', { 'aria-hidden': 'true' }, '/'), element('strong', {}, node.name)),
|
|
2387
|
+
)
|
|
2388
|
+
}
|
|
2389
|
+
|
|
2390
|
+
function renderNoteFile(node) {
|
|
2391
|
+
if (node.editable) return renderEditableNote(node)
|
|
2392
|
+
const previewKind = noteFilePreviewKind(node)
|
|
2393
|
+
let preview
|
|
2394
|
+
if (previewKind === 'image' && state.notes.assetUrl) {
|
|
2395
|
+
preview = element('img', { class: 'notes-inline-image', src: state.notes.assetUrl, alt: node.name })
|
|
2396
|
+
} else if (previewKind === 'pdf' && state.notes.assetUrl) {
|
|
2397
|
+
preview = element('iframe', { class: 'notes-inline-frame', src: state.notes.assetUrl, title: node.name })
|
|
2398
|
+
} else {
|
|
2399
|
+
preview = element('div', { class: 'notes-inline-unavailable' },
|
|
2400
|
+
renderNoteIcon(node, true),
|
|
2401
|
+
element('h2', {}, node.name),
|
|
2402
|
+
element('p', {}, '该格式不能在浏览器中直接编辑,可以下载后使用本地应用打开。'),
|
|
2403
|
+
actionButton('下载文件', () => { void downloadNoteFile(node) }, 'primary small'),
|
|
2404
|
+
)
|
|
2405
|
+
}
|
|
2406
|
+
return element('main', { class: 'notes-content is-file' },
|
|
2407
|
+
renderNoteFileToolbar(node),
|
|
2408
|
+
element('section', { class: `notes-inline-viewer is-${previewKind}`, 'aria-label': `${node.name}内容` }, preview),
|
|
2409
|
+
)
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
function renderNoteFileToolbar(node, options = {}) {
|
|
2413
|
+
return element('header', { class: 'notes-document-toolbar' },
|
|
2414
|
+
element('div', { class: 'notes-toolbar-leading' },
|
|
2415
|
+
renderNoteDocumentBreadcrumb(node),
|
|
2416
|
+
element('div', { class: 'notes-file-info', 'aria-label': '文件信息' },
|
|
2417
|
+
noteInfoItem('编号', shortNoteId(node.id), node.id),
|
|
2418
|
+
noteInfoItem('类型', node.kind === 'document' ? 'Markdown' : node.mediaType || '未知'),
|
|
2419
|
+
noteInfoItem('大小', formatBytes(node.size), '', 'size'),
|
|
2420
|
+
noteInfoItem('更新', formatDate(node.updatedAt), '', 'updated'),
|
|
2421
|
+
),
|
|
2422
|
+
),
|
|
2423
|
+
element('div', { class: 'notes-document-actions' },
|
|
2424
|
+
options.editable ? element('span', { class: 'notes-save-state', role: 'status', 'data-note-save-state': '', 'data-dirty': String(state.notes.dirty) }, state.notes.dirty ? '未保存' : '已保存') : null,
|
|
2425
|
+
node.kind === 'file' ? actionButton('下载', () => { void downloadNoteFile(node) }, 'ghost small') : null,
|
|
2426
|
+
actionButton('复制引用', () => { void copyNoteReference(node) }, 'ghost small'),
|
|
2427
|
+
actionButton('重命名', () => openRenameNoteNode(node), 'ghost small'),
|
|
2428
|
+
options.editable ? actionButton('保存', () => { void saveNoteDocument() }, 'primary small', { 'data-note-save': '', disabled: !state.notes.dirty }) : null,
|
|
2429
|
+
),
|
|
2430
|
+
)
|
|
2431
|
+
}
|
|
2432
|
+
|
|
2433
|
+
function noteInfoItem(label, value, title = '', key = '') {
|
|
2434
|
+
return element('span', { class: 'notes-file-info-item', title: title || value, ...(key ? { 'data-note-info': key } : {}) }, element('strong', {}, label), element('span', {}, value))
|
|
2435
|
+
}
|
|
2436
|
+
|
|
2437
|
+
function renderNoteIcon(node, large = false) {
|
|
2438
|
+
const label = node.kind === 'folder' ? '' : noteExtension(node.name)
|
|
2439
|
+
return element('span', { class: `note-node-icon is-${node.kind}${large ? ' is-large' : ''}`, 'data-media-kind': noteMediaKind(node), 'aria-hidden': 'true' }, label)
|
|
2440
|
+
}
|
|
2441
|
+
|
|
2442
|
+
function noteKindLabel(node) {
|
|
2443
|
+
if (node.kind === 'folder') return '目录'
|
|
2444
|
+
if (node.kind === 'document') return 'Markdown 笔记'
|
|
2445
|
+
return `${node.mediaType || '文件'} ${formatBytes(node.size)}`
|
|
2446
|
+
}
|
|
2447
|
+
|
|
2448
|
+
function isMarkdownNote(node) {
|
|
2449
|
+
return node.kind === 'document' || node.mediaType === 'text/markdown' || /\.(md|markdown)$/i.test(node.name)
|
|
2450
|
+
}
|
|
2451
|
+
|
|
2452
|
+
function noteFilePreviewKind(node) {
|
|
2453
|
+
if (node.mediaType?.startsWith('image/')) return 'image'
|
|
2454
|
+
if (node.mediaType === 'application/pdf' || /\.pdf$/i.test(node.name)) return 'pdf'
|
|
2455
|
+
return 'unavailable'
|
|
2456
|
+
}
|
|
2457
|
+
|
|
2458
|
+
async function selectNoteNode(node, options = {}) {
|
|
2459
|
+
if (state.notes.dirty && !await saveNoteDocument()) return
|
|
2460
|
+
const request = ++noteSelectionRequest
|
|
2461
|
+
releaseNoteAsset()
|
|
2462
|
+
state.notes.selectedId = node.id
|
|
2463
|
+
state.notes.selectedNode = node
|
|
2464
|
+
Object.assign(state.notes, { content: '', draft: '', dirty: false })
|
|
2465
|
+
state.notes.loadingNodeId = node.id
|
|
2466
|
+
renderShell()
|
|
2467
|
+
try {
|
|
2468
|
+
const breadcrumbs = await loadNoteBreadcrumbs(node)
|
|
2469
|
+
if (request !== noteSelectionRequest || state.notes.selectedId !== node.id) return
|
|
2470
|
+
state.notes.breadcrumbs = breadcrumbs
|
|
2471
|
+
if (node.kind === 'folder') {
|
|
2472
|
+
Object.assign(state.notes, { content: '', draft: '', dirty: false })
|
|
2473
|
+
state.notes.currentFolderId = node.id
|
|
2474
|
+
if (options.toggleFolder) {
|
|
2475
|
+
if (state.notes.expandedFolders.has(node.id)) state.notes.expandedFolders.delete(node.id)
|
|
2476
|
+
else state.notes.expandedFolders.add(node.id)
|
|
2477
|
+
}
|
|
2478
|
+
await loadNoteChildren(node.id)
|
|
2479
|
+
} else if (node.editable) {
|
|
2480
|
+
const blob = await binaryRequest(`notes/${encodeURIComponent(node.id)}/content`, { responseType: 'blob', accept: node.mediaType || 'text/plain' })
|
|
2481
|
+
const content = await blob.text()
|
|
2482
|
+
if (request !== noteSelectionRequest || state.notes.selectedId !== node.id) return
|
|
2483
|
+
state.notes.content = content
|
|
2484
|
+
state.notes.draft = content
|
|
2485
|
+
state.notes.dirty = false
|
|
2486
|
+
state.notes.currentFolderId = node.parentId
|
|
2487
|
+
} else {
|
|
2488
|
+
state.notes.content = ''
|
|
2489
|
+
state.notes.draft = ''
|
|
2490
|
+
state.notes.dirty = false
|
|
2491
|
+
state.notes.currentFolderId = node.parentId
|
|
2492
|
+
if (noteFilePreviewKind(node) !== 'unavailable') {
|
|
2493
|
+
const blob = await binaryRequest(`notes/${encodeURIComponent(node.id)}/content`, { responseType: 'blob', accept: node.mediaType || 'application/octet-stream' })
|
|
2494
|
+
if (request !== noteSelectionRequest || state.notes.selectedId !== node.id) return
|
|
2495
|
+
state.notes.assetUrl = URL.createObjectURL(blob)
|
|
2496
|
+
}
|
|
2497
|
+
}
|
|
2498
|
+
} catch (error) {
|
|
2499
|
+
if (request === noteSelectionRequest) showToast(friendlyError(error), 'error')
|
|
2500
|
+
} finally {
|
|
2501
|
+
if (request === noteSelectionRequest && state.notes.loadingNodeId === node.id) {
|
|
2502
|
+
state.notes.loadingNodeId = ''
|
|
2503
|
+
renderShell()
|
|
2504
|
+
}
|
|
2505
|
+
}
|
|
2506
|
+
}
|
|
2507
|
+
|
|
2508
|
+
async function loadNoteChildren(parentId, force = false) {
|
|
2509
|
+
const key = parentId || 'root'
|
|
2510
|
+
if (!force && state.notes.loadedFolders.has(key)) return state.notes.children.get(key) || []
|
|
2511
|
+
const params = new URLSearchParams({ limit: '500' })
|
|
2512
|
+
if (parentId) params.set('parentId', parentId)
|
|
2513
|
+
const nodes = await api(`notes?${params}`)
|
|
2514
|
+
state.notes.children.set(key, nodes)
|
|
2515
|
+
state.notes.loadedFolders.add(key)
|
|
2516
|
+
return nodes
|
|
2517
|
+
}
|
|
2518
|
+
|
|
2519
|
+
async function loadNoteBreadcrumbs(node) {
|
|
2520
|
+
const parents = []
|
|
2521
|
+
let parentId = node.parentId
|
|
2522
|
+
while (parentId) {
|
|
2523
|
+
const parent = findCachedNote(parentId) || await api(`notes/${encodeURIComponent(parentId)}`)
|
|
2524
|
+
parents.unshift(parent)
|
|
2525
|
+
parentId = parent.parentId
|
|
2526
|
+
}
|
|
2527
|
+
return node.kind === 'folder' ? [...parents, node] : parents
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2530
|
+
function findCachedNote(id) {
|
|
2531
|
+
for (const nodes of state.notes.children.values()) {
|
|
2532
|
+
const found = nodes.find(node => node.id === id)
|
|
2533
|
+
if (found) return found
|
|
2534
|
+
}
|
|
2535
|
+
return state.notes.selectedNode?.id === id ? state.notes.selectedNode : null
|
|
2536
|
+
}
|
|
2537
|
+
|
|
2538
|
+
async function openNoteRoot() {
|
|
2539
|
+
if (state.notes.dirty && !await saveNoteDocument()) return
|
|
2540
|
+
noteSelectionRequest += 1
|
|
2541
|
+
clearNoteSelection()
|
|
2542
|
+
await loadNoteChildren(null)
|
|
2543
|
+
renderShell()
|
|
2544
|
+
}
|
|
2545
|
+
|
|
2546
|
+
function clearNoteSelection() {
|
|
2547
|
+
releaseNoteAsset()
|
|
2548
|
+
Object.assign(state.notes, { selectedId: '', selectedNode: null, currentFolderId: null, breadcrumbs: [], content: '', draft: '', dirty: false, loadingNodeId: '' })
|
|
2549
|
+
}
|
|
2550
|
+
|
|
2551
|
+
function releaseNoteAsset() {
|
|
2552
|
+
if (!state.notes?.assetUrl) return
|
|
2553
|
+
URL.revokeObjectURL(state.notes.assetUrl)
|
|
2554
|
+
state.notes.assetUrl = ''
|
|
2555
|
+
}
|
|
2556
|
+
|
|
2557
|
+
function scheduleNoteSearch(value) {
|
|
2558
|
+
state.notes.query = value
|
|
2559
|
+
window.clearTimeout(noteSearchTimer)
|
|
2560
|
+
noteSearchTimer = window.setTimeout(async () => {
|
|
2561
|
+
try {
|
|
2562
|
+
state.notes.searchResults = value.trim() ? await api(`notes?q=${encodeURIComponent(value.trim())}&limit=200`) : []
|
|
2563
|
+
renderShell()
|
|
2564
|
+
} catch (error) { showToast(friendlyError(error), 'error') }
|
|
2565
|
+
}, 180)
|
|
2566
|
+
}
|
|
2567
|
+
|
|
2568
|
+
function openCreateNoteFolder(parentId = state.notes.currentFolderId) {
|
|
2569
|
+
const field = formField('目录名称', 'text', '', { maxlength: 255, placeholder: '例如:项目资料' })
|
|
2570
|
+
const form = element('form', { class: 'form-grid' }, field.wrapper)
|
|
2571
|
+
openSheet({
|
|
2572
|
+
title: '新建目录', description: '目录可以继续包含子目录、笔记文档和任意文件。', body: form,
|
|
2573
|
+
primaryLabel: '创建目录', onPrimary: async () => {
|
|
2574
|
+
const node = await api('notes/folders', { method: 'POST', body: { name: field.input.value, parentId } })
|
|
2575
|
+
await loadNoteChildren(parentId, true)
|
|
2576
|
+
if (parentId) state.notes.expandedFolders.add(parentId)
|
|
2577
|
+
await selectNoteNode(node)
|
|
2578
|
+
return true
|
|
2579
|
+
},
|
|
2580
|
+
})
|
|
2581
|
+
field.input.focus()
|
|
2582
|
+
}
|
|
2583
|
+
|
|
2584
|
+
function openCreateNoteDocument(parentId = state.notes.currentFolderId) {
|
|
2585
|
+
const field = formField('文档名称', 'text', '', { maxlength: 255, placeholder: '例如:部署记录' })
|
|
2586
|
+
const form = element('form', { class: 'form-grid' }, field.wrapper)
|
|
2587
|
+
openSheet({
|
|
2588
|
+
title: '新建笔记文档', description: '文档使用 Markdown 编辑,默认不会被 AI 检索或回写。', body: form,
|
|
2589
|
+
primaryLabel: '创建并编辑', onPrimary: async () => {
|
|
2590
|
+
const node = await api('notes/documents', { method: 'POST', body: { name: field.input.value, parentId } })
|
|
2591
|
+
await loadNoteChildren(parentId, true)
|
|
2592
|
+
if (parentId) state.notes.expandedFolders.add(parentId)
|
|
2593
|
+
await selectNoteNode(node)
|
|
2594
|
+
return true
|
|
2595
|
+
},
|
|
2596
|
+
})
|
|
2597
|
+
field.input.focus()
|
|
2598
|
+
}
|
|
2599
|
+
|
|
2600
|
+
function openRenameNoteNode(node) {
|
|
2601
|
+
const field = formField('名称', 'text', node.name, { maxlength: 255 })
|
|
2602
|
+
openSheet({
|
|
2603
|
+
title: '重命名', description: '知识文档中的 @ 引用使用稳定编号,不会因改名失效。', body: element('form', { class: 'form-grid' }, field.wrapper),
|
|
2604
|
+
primaryLabel: '保存名称', onPrimary: async () => {
|
|
2605
|
+
const updated = await api(`notes/${encodeURIComponent(node.id)}`, { method: 'PATCH', body: { name: field.input.value } })
|
|
2606
|
+
await loadNoteChildren(node.parentId, true)
|
|
2607
|
+
if (state.notes.selectedId === node.id) await selectNoteNode(updated)
|
|
2608
|
+
else renderShell()
|
|
2609
|
+
showToast('名称已更新。')
|
|
2610
|
+
return true
|
|
2611
|
+
},
|
|
2612
|
+
})
|
|
2613
|
+
field.input.select()
|
|
2614
|
+
}
|
|
2615
|
+
|
|
2616
|
+
async function copyNoteNode(node) {
|
|
2617
|
+
try {
|
|
2618
|
+
const copy = await api(`notes/${encodeURIComponent(node.id)}/copy`, { method: 'POST', body: {} })
|
|
2619
|
+
await loadNoteChildren(node.parentId, true)
|
|
2620
|
+
renderShell()
|
|
2621
|
+
showToast(`已创建“${copy.name}”。`)
|
|
2622
|
+
} catch (error) { showToast(friendlyError(error), 'error') }
|
|
2623
|
+
}
|
|
2624
|
+
|
|
2625
|
+
async function dropNoteNode(event, parentId) {
|
|
2626
|
+
event.preventDefault()
|
|
2627
|
+
event.stopPropagation()
|
|
2628
|
+
clearNoteDragState()
|
|
2629
|
+
const id = event.dataTransfer.getData('application/x-dsh-note-id')
|
|
2630
|
+
if (!id) {
|
|
2631
|
+
if (hasDragType(event, 'Files')) await importDroppedNotes(event.dataTransfer, parentId)
|
|
2632
|
+
return
|
|
2633
|
+
}
|
|
2634
|
+
const node = findCachedNote(id)
|
|
2635
|
+
if (!node || id === parentId || node.parentId === parentId) return
|
|
2636
|
+
try {
|
|
2637
|
+
await api(`notes/${encodeURIComponent(id)}`, { method: 'PATCH', body: { parentId } })
|
|
2638
|
+
await Promise.all([loadNoteChildren(node.parentId, true), loadNoteChildren(parentId, true)])
|
|
2639
|
+
if (state.notes.selectedId === id) state.notes.selectedNode = await api(`notes/${encodeURIComponent(id)}`)
|
|
2640
|
+
renderShell()
|
|
2641
|
+
showToast('已移动到目标目录。')
|
|
2642
|
+
} catch (error) { showToast(friendlyError(error), 'error') }
|
|
2643
|
+
}
|
|
2644
|
+
|
|
2645
|
+
async function uploadNoteFiles(files, parentId) {
|
|
2646
|
+
const summary = createNoteImportSummary()
|
|
2647
|
+
const plans = files.flatMap(file => createNoteFilePlan(file, summary))
|
|
2648
|
+
if (!plans.length) return showToast('没有可上传的文件,单文件上限为 64 MiB。', 'error')
|
|
2649
|
+
const transfer = beginNoteTransfer('uploading')
|
|
2650
|
+
if (!transfer) return
|
|
2651
|
+
prepareNoteTransfer(transfer, summary)
|
|
2652
|
+
try {
|
|
2653
|
+
await importNotePlans(plans, parentId, transfer)
|
|
2654
|
+
await loadNoteChildren(parentId, true)
|
|
2655
|
+
renderShell()
|
|
2656
|
+
completeNoteTransfer(transfer)
|
|
2657
|
+
showNoteImportResult(summary)
|
|
2658
|
+
} catch (error) {
|
|
2659
|
+
if (transfer.completedItems) await loadNoteChildren(parentId, true).catch(() => {})
|
|
2660
|
+
renderShell()
|
|
2661
|
+
failNoteTransfer(transfer, error)
|
|
2662
|
+
showToast(friendlyError(error), 'error')
|
|
2663
|
+
}
|
|
2664
|
+
}
|
|
2665
|
+
|
|
2666
|
+
async function importDroppedNotes(dataTransfer, parentId) {
|
|
2667
|
+
const payload = captureNoteDropPayload(dataTransfer)
|
|
2668
|
+
if (!payload.entries.length) return uploadNoteFiles(payload.files, parentId)
|
|
2669
|
+
const transfer = beginNoteTransfer('scanning')
|
|
2670
|
+
if (!transfer) return
|
|
2671
|
+
const summary = createNoteImportSummary()
|
|
2672
|
+
try {
|
|
2673
|
+
const plans = []
|
|
2674
|
+
for (const entry of payload.entries) {
|
|
2675
|
+
const plan = await collectNoteEntry(entry, summary, transfer)
|
|
2676
|
+
if (plan) plans.push(plan)
|
|
2677
|
+
}
|
|
2678
|
+
if (!plans.length) {
|
|
2679
|
+
dismissNoteTransfer()
|
|
2680
|
+
showToast('没有可上传的内容,单文件上限为 64 MiB。', 'error')
|
|
2681
|
+
return
|
|
2682
|
+
}
|
|
2683
|
+
prepareNoteTransfer(transfer, summary)
|
|
2684
|
+
await importNotePlans(plans, parentId, transfer)
|
|
2685
|
+
await loadNoteChildren(parentId, true)
|
|
2686
|
+
if (parentId) state.notes.expandedFolders.add(parentId)
|
|
2687
|
+
renderShell()
|
|
2688
|
+
completeNoteTransfer(transfer)
|
|
2689
|
+
showNoteImportResult(summary)
|
|
2690
|
+
} catch (error) {
|
|
2691
|
+
await loadNoteChildren(parentId, true).catch(() => {})
|
|
2692
|
+
renderShell()
|
|
2693
|
+
failNoteTransfer(transfer, error)
|
|
2694
|
+
showToast(friendlyError(error), 'error')
|
|
2695
|
+
}
|
|
2696
|
+
}
|
|
2697
|
+
|
|
2698
|
+
function createNoteImportSummary() {
|
|
2699
|
+
return { files: 0, folders: 0, skipped: 0, bytes: 0 }
|
|
2700
|
+
}
|
|
2701
|
+
|
|
2702
|
+
function createNoteFilePlan(file, summary) {
|
|
2703
|
+
if (file.size > NOTE_MAX_FILE_SIZE) {
|
|
2704
|
+
summary.skipped += 1
|
|
2705
|
+
return []
|
|
2706
|
+
}
|
|
2707
|
+
summary.files += 1
|
|
2708
|
+
summary.bytes += file.size
|
|
2709
|
+
return [{ kind: 'file', name: file.name, file }]
|
|
2710
|
+
}
|
|
2711
|
+
|
|
2712
|
+
function captureNoteDropPayload(dataTransfer) {
|
|
2713
|
+
const files = Array.from(dataTransfer?.files || [])
|
|
2714
|
+
const entries = Array.from(dataTransfer?.items || []).flatMap(item => {
|
|
2715
|
+
if (item.kind !== 'file' || typeof item.webkitGetAsEntry !== 'function') return []
|
|
2716
|
+
try {
|
|
2717
|
+
const entry = item.webkitGetAsEntry()
|
|
2718
|
+
return entry ? [entry] : []
|
|
2719
|
+
} catch {
|
|
2720
|
+
return []
|
|
2721
|
+
}
|
|
2722
|
+
})
|
|
2723
|
+
return { entries, files }
|
|
2724
|
+
}
|
|
2725
|
+
|
|
2726
|
+
async function collectNoteEntry(entry, summary, transfer) {
|
|
2727
|
+
transfer.currentName = entry.name || ''
|
|
2728
|
+
transfer.scannedItems += 1
|
|
2729
|
+
scheduleNoteTransferSync()
|
|
2730
|
+
if (entry.isDirectory) {
|
|
2731
|
+
summary.folders += 1
|
|
2732
|
+
const children = []
|
|
2733
|
+
for (const child of await readNoteDirectoryEntries(entry)) {
|
|
2734
|
+
const plan = await collectNoteEntry(child, summary, transfer)
|
|
2735
|
+
if (plan) children.push(plan)
|
|
2736
|
+
}
|
|
2737
|
+
return { kind: 'folder', name: entry.name, children }
|
|
2738
|
+
}
|
|
2739
|
+
if (!entry.isFile) return null
|
|
2740
|
+
const file = await readNoteFileEntry(entry)
|
|
2741
|
+
return createNoteFilePlan(file, summary)[0] || null
|
|
2742
|
+
}
|
|
2743
|
+
|
|
2744
|
+
async function importNotePlans(plans, parentId, transfer) {
|
|
2745
|
+
for (const plan of plans) {
|
|
2746
|
+
transfer.currentName = plan.name
|
|
2747
|
+
scheduleNoteTransferSync()
|
|
2748
|
+
if (plan.kind === 'folder') {
|
|
2749
|
+
const folder = await api('notes/folders', { method: 'POST', body: { name: plan.name, parentId } })
|
|
2750
|
+
transfer.completedItems += 1
|
|
2751
|
+
scheduleNoteTransferSync()
|
|
2752
|
+
await importNotePlans(plan.children, folder.id, transfer)
|
|
2753
|
+
continue
|
|
2754
|
+
}
|
|
2755
|
+
const settledBytes = transfer.loadedBytes
|
|
2756
|
+
await uploadNoteFile(plan.file, parentId, loaded => {
|
|
2757
|
+
transfer.loadedBytes = settledBytes + Math.min(loaded, plan.file.size)
|
|
2758
|
+
scheduleNoteTransferSync()
|
|
2759
|
+
})
|
|
2760
|
+
transfer.loadedBytes = settledBytes + plan.file.size
|
|
2761
|
+
transfer.completedItems += 1
|
|
2762
|
+
scheduleNoteTransferSync()
|
|
2763
|
+
}
|
|
2764
|
+
}
|
|
2765
|
+
|
|
2766
|
+
async function readNoteDirectoryEntries(entry) {
|
|
2767
|
+
const reader = entry.createReader()
|
|
2768
|
+
const entries = []
|
|
2769
|
+
while (true) {
|
|
2770
|
+
const batch = await new Promise((resolve, reject) => reader.readEntries(resolve, reject))
|
|
2771
|
+
if (!batch.length) return entries
|
|
2772
|
+
entries.push(...batch)
|
|
2773
|
+
}
|
|
2774
|
+
}
|
|
2775
|
+
|
|
2776
|
+
function readNoteFileEntry(entry) {
|
|
2777
|
+
return new Promise((resolve, reject) => entry.file(resolve, reject))
|
|
2778
|
+
}
|
|
2779
|
+
|
|
2780
|
+
async function uploadNoteFile(file, parentId, onProgress) {
|
|
2781
|
+
const params = new URLSearchParams({ name: file.name })
|
|
2782
|
+
if (parentId) params.set('parentId', parentId)
|
|
2783
|
+
return binaryUploadRequest(`notes/files?${params}`, file, {
|
|
2784
|
+
method: 'POST', contentType: file.type || 'application/octet-stream', onProgress,
|
|
2785
|
+
})
|
|
2786
|
+
}
|
|
2787
|
+
|
|
2788
|
+
function beginNoteTransfer(phase) {
|
|
2789
|
+
if (state.notes.transfer && ['scanning', 'uploading'].includes(state.notes.transfer.phase)) {
|
|
2790
|
+
showToast('已有导入任务正在进行,请稍候。', 'error')
|
|
2791
|
+
return null
|
|
2792
|
+
}
|
|
2793
|
+
const transfer = {
|
|
2794
|
+
id: String(++noteTransferSequence), phase, currentName: '', error: '',
|
|
2795
|
+
scannedItems: 0, completedItems: 0, totalItems: 0,
|
|
2796
|
+
loadedBytes: 0, totalBytes: 0, files: 0, folders: 0, skipped: 0,
|
|
2797
|
+
}
|
|
2798
|
+
state.notes.transfer = transfer
|
|
2799
|
+
refreshNoteTransferPanel()
|
|
2800
|
+
return transfer
|
|
2801
|
+
}
|
|
2802
|
+
|
|
2803
|
+
function prepareNoteTransfer(transfer, summary) {
|
|
2804
|
+
transfer.phase = 'uploading'
|
|
2805
|
+
transfer.currentName = ''
|
|
2806
|
+
transfer.totalItems = summary.files + summary.folders
|
|
2807
|
+
transfer.totalBytes = summary.bytes
|
|
2808
|
+
transfer.files = summary.files
|
|
2809
|
+
transfer.folders = summary.folders
|
|
2810
|
+
transfer.skipped = summary.skipped
|
|
2811
|
+
scheduleNoteTransferSync()
|
|
2812
|
+
}
|
|
2813
|
+
|
|
2814
|
+
function completeNoteTransfer(transfer) {
|
|
2815
|
+
if (state.notes.transfer !== transfer) return
|
|
2816
|
+
transfer.phase = 'complete'
|
|
2817
|
+
transfer.currentName = ''
|
|
2818
|
+
transfer.completedItems = transfer.totalItems
|
|
2819
|
+
transfer.loadedBytes = transfer.totalBytes
|
|
2820
|
+
refreshNoteTransferPanel()
|
|
2821
|
+
window.setTimeout(() => {
|
|
2822
|
+
if (state.notes.transfer === transfer && transfer.phase === 'complete') dismissNoteTransfer()
|
|
2823
|
+
}, 2600)
|
|
2824
|
+
}
|
|
2825
|
+
|
|
2826
|
+
function failNoteTransfer(transfer, error) {
|
|
2827
|
+
if (state.notes.transfer !== transfer) return
|
|
2828
|
+
transfer.phase = 'error'
|
|
2829
|
+
transfer.error = friendlyError(error)
|
|
2830
|
+
refreshNoteTransferPanel()
|
|
2831
|
+
}
|
|
2832
|
+
|
|
2833
|
+
function dismissNoteTransfer() {
|
|
2834
|
+
state.notes.transfer = null
|
|
2835
|
+
document.querySelector('.notes-transfer')?.remove()
|
|
2836
|
+
}
|
|
2837
|
+
|
|
2838
|
+
function refreshNoteTransferPanel() {
|
|
2839
|
+
const current = document.querySelector('.notes-transfer')
|
|
2840
|
+
const panel = renderNoteTransfer()
|
|
2841
|
+
if (!panel) {
|
|
2842
|
+
current?.remove()
|
|
2843
|
+
return
|
|
2844
|
+
}
|
|
2845
|
+
if (current) current.replaceWith(panel)
|
|
2846
|
+
else document.querySelector('.notes-workspace')?.append(panel)
|
|
2847
|
+
syncNoteTransferPanel()
|
|
2848
|
+
}
|
|
2849
|
+
|
|
2850
|
+
function scheduleNoteTransferSync() {
|
|
2851
|
+
if (noteTransferFrame) return
|
|
2852
|
+
noteTransferFrame = requestAnimationFrame(() => {
|
|
2853
|
+
noteTransferFrame = 0
|
|
2854
|
+
syncNoteTransferPanel()
|
|
2855
|
+
})
|
|
2856
|
+
}
|
|
2857
|
+
|
|
2858
|
+
function syncNoteTransferPanel() {
|
|
2859
|
+
const transfer = state.notes.transfer
|
|
2860
|
+
const panel = document.querySelector('.notes-transfer')
|
|
2861
|
+
if (!transfer || !panel || panel.dataset.noteTransfer !== transfer.id) return
|
|
2862
|
+
const percent = noteTransferPercent(transfer)
|
|
2863
|
+
panel.dataset.state = transfer.phase
|
|
2864
|
+
panel.style.setProperty('--notes-transfer-progress', String(percent / 100))
|
|
2865
|
+
const title = panel.querySelector('.notes-transfer-title')
|
|
2866
|
+
const detail = panel.querySelector('.notes-transfer-detail')
|
|
2867
|
+
const count = panel.querySelector('.notes-transfer-count')
|
|
2868
|
+
const percentLabel = panel.querySelector('.notes-transfer-percent')
|
|
2869
|
+
const progress = panel.querySelector('.notes-transfer-track')
|
|
2870
|
+
if (title) title.textContent = noteTransferTitle(transfer)
|
|
2871
|
+
if (detail) {
|
|
2872
|
+
detail.textContent = noteTransferDetail(transfer)
|
|
2873
|
+
detail.title = transfer.currentName || ''
|
|
2874
|
+
}
|
|
2875
|
+
if (count) count.textContent = noteTransferCount(transfer)
|
|
2876
|
+
if (percentLabel) percentLabel.textContent = transfer.phase === 'scanning' ? '正在整理' : `${percent}%`
|
|
2877
|
+
if (progress) {
|
|
2878
|
+
if (transfer.phase === 'scanning') progress.removeAttribute('aria-valuenow')
|
|
2879
|
+
else progress.setAttribute('aria-valuenow', String(percent))
|
|
2880
|
+
}
|
|
2881
|
+
}
|
|
2882
|
+
|
|
2883
|
+
function noteTransferPercent(transfer) {
|
|
2884
|
+
if (transfer.phase === 'complete') return 100
|
|
2885
|
+
const value = transfer.totalBytes > 0
|
|
2886
|
+
? transfer.loadedBytes / transfer.totalBytes
|
|
2887
|
+
: transfer.totalItems > 0 ? transfer.completedItems / transfer.totalItems : 0
|
|
2888
|
+
return Math.min(transfer.phase === 'uploading' ? 99 : 100, Math.max(0, Math.round(value * 100)))
|
|
2889
|
+
}
|
|
2890
|
+
|
|
2891
|
+
function noteTransferTitle(transfer) {
|
|
2892
|
+
if (transfer.phase === 'scanning') return '正在整理导入内容'
|
|
2893
|
+
if (transfer.phase === 'complete') return '导入完成'
|
|
2894
|
+
if (transfer.phase === 'error') return '导入未完成'
|
|
2895
|
+
return '正在导入笔记'
|
|
2896
|
+
}
|
|
2897
|
+
|
|
2898
|
+
function noteTransferDetail(transfer) {
|
|
2899
|
+
if (transfer.phase === 'error') return transfer.error || '导入过程中发生错误'
|
|
2900
|
+
if (transfer.phase === 'complete') return `${transfer.files} 个文件、${transfer.folders} 个目录已写入`
|
|
2901
|
+
if (transfer.phase === 'scanning') return transfer.currentName || '正在读取文件和目录…'
|
|
2902
|
+
return transfer.currentName || '准备写入…'
|
|
2903
|
+
}
|
|
2904
|
+
|
|
2905
|
+
function noteTransferCount(transfer) {
|
|
2906
|
+
if (transfer.phase === 'scanning') return `已读取 ${transfer.scannedItems} 项`
|
|
2907
|
+
const count = `${transfer.completedItems} / ${transfer.totalItems} 项`
|
|
2908
|
+
const bytes = transfer.totalBytes ? ` · ${formatBytes(transfer.loadedBytes)} / ${formatBytes(transfer.totalBytes)}` : ''
|
|
2909
|
+
const skipped = transfer.skipped ? ` · 跳过 ${transfer.skipped}` : ''
|
|
2910
|
+
return `${count}${bytes}${skipped}`
|
|
2911
|
+
}
|
|
2912
|
+
|
|
2913
|
+
function showNoteImportResult(summary) {
|
|
2914
|
+
const added = [summary.folders ? `${summary.folders} 个目录` : '', summary.files ? `${summary.files} 个文件` : ''].filter(Boolean).join('、')
|
|
2915
|
+
const skipped = summary.skipped ? `,跳过 ${summary.skipped} 个超过 64 MiB 的文件` : ''
|
|
2916
|
+
showToast(`已添加${added ? ` ${added}` : ''}${skipped}。`)
|
|
2917
|
+
}
|
|
2918
|
+
|
|
2919
|
+
async function saveNoteDocument() {
|
|
2920
|
+
const node = state.notes.selectedNode
|
|
2921
|
+
if (!node || !node.editable || !state.notes.dirty) return true
|
|
2922
|
+
try {
|
|
2923
|
+
const updated = await binaryRequest(`notes/${encodeURIComponent(node.id)}/content`, {
|
|
2924
|
+
method: 'PUT', body: new Blob([state.notes.draft], { type: node.mediaType || 'text/plain' }), contentType: node.mediaType || 'text/plain',
|
|
2925
|
+
})
|
|
2926
|
+
state.notes.selectedNode = updated
|
|
2927
|
+
state.notes.content = state.notes.draft
|
|
2928
|
+
state.notes.dirty = false
|
|
2929
|
+
await loadNoteChildren(node.parentId, true)
|
|
2930
|
+
const size = document.querySelector('[data-note-info="size"] > span')
|
|
2931
|
+
const updatedAt = document.querySelector('[data-note-info="updated"] > span')
|
|
2932
|
+
if (size) size.textContent = formatBytes(updated.size)
|
|
2933
|
+
if (updatedAt) updatedAt.textContent = formatDate(updated.updatedAt)
|
|
2934
|
+
syncNoteEditorChrome()
|
|
2935
|
+
showToast('文件已保存。')
|
|
2936
|
+
return true
|
|
2937
|
+
} catch (error) {
|
|
2938
|
+
showToast(friendlyError(error), 'error')
|
|
2939
|
+
return false
|
|
2940
|
+
}
|
|
2941
|
+
}
|
|
2942
|
+
|
|
2943
|
+
async function downloadNoteFile(node) {
|
|
2944
|
+
try {
|
|
2945
|
+
const blob = await binaryRequest(`notes/${encodeURIComponent(node.id)}/content?download=1`, { responseType: 'blob', accept: node.mediaType || 'application/octet-stream' })
|
|
2946
|
+
const url = URL.createObjectURL(blob)
|
|
2947
|
+
const anchor = element('a', { href: url, download: node.name })
|
|
2948
|
+
document.body.append(anchor); anchor.click(); anchor.remove()
|
|
2949
|
+
window.setTimeout(() => URL.revokeObjectURL(url), 1000)
|
|
2950
|
+
} catch (error) { showToast(friendlyError(error), 'error') }
|
|
2951
|
+
}
|
|
2952
|
+
|
|
2953
|
+
async function copyNoteReference(node) {
|
|
2954
|
+
try { await navigator.clipboard.writeText(noteReference(node)); showToast('笔记引用已复制。') }
|
|
2955
|
+
catch { showToast('复制失败,请手动复制文档编号。', 'error') }
|
|
2956
|
+
}
|
|
2957
|
+
|
|
2958
|
+
async function confirmDeleteNoteNode(node) {
|
|
2959
|
+
try {
|
|
2960
|
+
const references = await api(`notes/${encodeURIComponent(node.id)}/references`)
|
|
2961
|
+
if (references.length) {
|
|
2962
|
+
const names = references.slice(0, 4).map(item => `“${item.documentTitle}”`).join('、')
|
|
2963
|
+
return openModal({
|
|
2964
|
+
title: '仍被知识文档引用', description: node.name,
|
|
2965
|
+
body: element('p', {}, `${names}${references.length > 4 ? `等 ${references.length} 篇文档` : ''}仍在引用这里的内容。请先移除引用,再删除。`),
|
|
2966
|
+
cancelLabel: '知道了',
|
|
2967
|
+
})
|
|
2968
|
+
}
|
|
2969
|
+
openConfirm({
|
|
2970
|
+
title: `删除“${node.name}”?`,
|
|
2971
|
+
message: node.kind === 'folder' ? '目录及其全部子目录和文件会被永久删除。' : '该文档会被永久删除,此操作无法撤销。',
|
|
2972
|
+
confirmLabel: '永久删除', danger: true,
|
|
2973
|
+
onConfirm: async () => {
|
|
2974
|
+
await api(`notes/${encodeURIComponent(node.id)}`, { method: 'DELETE' })
|
|
2975
|
+
await loadNoteChildren(node.parentId, true)
|
|
2976
|
+
if (state.notes.selectedId === node.id || state.notes.breadcrumbs.some(parent => parent.id === node.id)) clearNoteSelection()
|
|
2977
|
+
renderShell(); showToast('已删除。')
|
|
2978
|
+
},
|
|
2979
|
+
})
|
|
2980
|
+
} catch (error) { showToast(friendlyError(error), 'error') }
|
|
2981
|
+
}
|
|
2982
|
+
|
|
2983
|
+
function noteWorkspaceDragEnter(event) {
|
|
2984
|
+
if (!hasDragType(event, 'Files')) return
|
|
2985
|
+
event.preventDefault()
|
|
2986
|
+
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy'
|
|
2987
|
+
event.currentTarget.dataset.dropActive = 'true'
|
|
2988
|
+
}
|
|
2989
|
+
|
|
2990
|
+
function noteWorkspaceDragLeave(event) {
|
|
2991
|
+
if (event.currentTarget.contains(event.relatedTarget)) return
|
|
2992
|
+
clearNoteDragState()
|
|
2993
|
+
}
|
|
2994
|
+
|
|
2995
|
+
function activateNoteFolderDropTarget(event, node) {
|
|
2996
|
+
if (node.kind !== 'folder') return
|
|
2997
|
+
const hasFiles = hasDragType(event, 'Files')
|
|
2998
|
+
if (!hasFiles && !hasDragType(event, 'application/x-dsh-note-id')) return
|
|
2999
|
+
event.preventDefault()
|
|
3000
|
+
event.stopPropagation()
|
|
3001
|
+
if (event.dataTransfer) event.dataTransfer.dropEffect = hasFiles ? 'copy' : 'move'
|
|
3002
|
+
const workspace = document.querySelector('.notes-workspace')
|
|
3003
|
+
if (workspace) workspace.dataset.dropActive = 'false'
|
|
3004
|
+
document.querySelectorAll('.notes-tree-item[data-drop-target="true"]').forEach(target => {
|
|
3005
|
+
if (target !== event.currentTarget) target.dataset.dropTarget = 'false'
|
|
3006
|
+
})
|
|
3007
|
+
event.currentTarget.dataset.dropTarget = 'true'
|
|
3008
|
+
}
|
|
3009
|
+
|
|
3010
|
+
function hasDragType(event, type) {
|
|
3011
|
+
return Array.from(event.dataTransfer?.types || []).includes(type)
|
|
3012
|
+
}
|
|
3013
|
+
|
|
3014
|
+
function clearNoteDragState() {
|
|
3015
|
+
const workspace = document.querySelector('.notes-workspace')
|
|
3016
|
+
if (workspace) workspace.dataset.dropActive = 'false'
|
|
3017
|
+
document.querySelectorAll('.notes-tree-item[data-drop-target="true"]').forEach(node => { node.dataset.dropTarget = 'false' })
|
|
3018
|
+
document.querySelectorAll('.notes-tree-item[data-dragging="true"]').forEach(node => { node.dataset.dragging = 'false' })
|
|
3019
|
+
}
|
|
3020
|
+
|
|
3021
|
+
function installNoteDragRecovery() {
|
|
3022
|
+
window.addEventListener('drop', clearNoteDragState, true)
|
|
3023
|
+
window.addEventListener('dragend', clearNoteDragState, true)
|
|
3024
|
+
window.addEventListener('blur', clearNoteDragState)
|
|
3025
|
+
document.addEventListener('dragleave', event => {
|
|
3026
|
+
if (event.relatedTarget === null) clearNoteDragState()
|
|
3027
|
+
}, true)
|
|
3028
|
+
}
|
|
3029
|
+
|
|
3030
|
+
function noteReference(node) {
|
|
3031
|
+
const label = node.name.replaceAll('\\', '\\\\').replaceAll('[', '\\[').replaceAll(']', '\\]')
|
|
3032
|
+
return `@[${label}](note://${node.id})`
|
|
3033
|
+
}
|
|
3034
|
+
|
|
3035
|
+
function noteMediaKind(node) {
|
|
3036
|
+
if (node.kind === 'folder') return 'folder'
|
|
3037
|
+
if (node.kind === 'document' || node.mediaType?.startsWith('text/')) return 'text'
|
|
3038
|
+
if (node.mediaType?.startsWith('image/')) return 'image'
|
|
3039
|
+
if (node.mediaType === 'application/pdf') return 'pdf'
|
|
3040
|
+
return 'file'
|
|
3041
|
+
}
|
|
3042
|
+
|
|
3043
|
+
function noteExtension(name) {
|
|
3044
|
+
const extension = name.includes('.') ? name.split('.').pop() : 'FILE'
|
|
3045
|
+
return extension.slice(0, 4).toLocaleUpperCase()
|
|
3046
|
+
}
|
|
3047
|
+
|
|
3048
|
+
function shortNoteId(id) {
|
|
3049
|
+
return `${id.slice(0, 11)}…${id.slice(-5)}`
|
|
3050
|
+
}
|
|
3051
|
+
|
|
3052
|
+
function formatBytes(value) {
|
|
3053
|
+
if (value < 1024) return `${value} B`
|
|
3054
|
+
if (value < 1024 * 1024) return `${(value / 1024).toFixed(value < 10 * 1024 ? 1 : 0)} KiB`
|
|
3055
|
+
return `${(value / 1024 / 1024).toFixed(value < 10 * 1024 * 1024 ? 1 : 0)} MiB`
|
|
3056
|
+
}
|
|
3057
|
+
|
|
1487
3058
|
function renderCandidates() {
|
|
1488
3059
|
const statuses = [['pending', '待审核'], ['approved', '已通过'], ['rejected', '已拒绝']]
|
|
1489
3060
|
return element('section', { 'aria-labelledby': 'candidates-heading' },
|
|
@@ -2184,7 +3755,7 @@ function openTokenCreator() {
|
|
|
2184
3755
|
return { permission, input, node: element('label', { class: 'check-option' }, input, permission) }
|
|
2185
3756
|
})
|
|
2186
3757
|
const body = element('form', { class: 'form-grid' }, name.wrapper,
|
|
2187
|
-
element('div', { class: 'field span-2' }, element('label', {}, '权限'), element('div', { class: 'check-grid' }, checkboxes.map(item => item.node)), element('span', { class: 'field-hint' }, '普通客户端建议只授予 read + propose。')))
|
|
3758
|
+
element('div', { class: 'field span-2' }, element('label', {}, '权限'), element('div', { class: 'check-grid' }, checkboxes.map(item => item.node)), element('span', { class: 'field-hint' }, '普通客户端建议只授予 read + propose。write 是当前中央服务的全局写权限,同时允许直接写入、管理知识库、挂载和笔记;仅在确实需要时授予。')))
|
|
2188
3759
|
name.wrapper.classList.add('span-2')
|
|
2189
3760
|
openModal({ title: '创建客户端令牌', description: '原始令牌只会在创建成功后显示一次。', body, primaryLabel: '创建令牌', onPrimary: async () => {
|
|
2190
3761
|
if (!body.reportValidity()) return false
|
|
@@ -2233,21 +3804,25 @@ function openSheet(options) {
|
|
|
2233
3804
|
return openModal({ ...options, presentation: 'sheet' })
|
|
2234
3805
|
}
|
|
2235
3806
|
|
|
2236
|
-
function openModal({ title, description = '', body, primaryLabel, primaryVariant = 'primary', onPrimary, cancelLabel = '取消', presentation = 'modal' }) {
|
|
3807
|
+
function openModal({ title, description = '', body, primaryLabel, primaryVariant = 'primary', onPrimary, cancelLabel = '取消', presentation = 'modal', onClose }) {
|
|
2237
3808
|
const previouslyFocused = document.activeElement
|
|
2238
3809
|
const isSheet = presentation === 'sheet'
|
|
2239
3810
|
const backdrop = element('div', { class: `dialog-backdrop${isSheet ? ' sheet-backdrop' : ''}` })
|
|
2240
3811
|
const dialog = element('section', { class: `dialog${isSheet ? ' sheet' : ''} ${primaryLabel ? '' : 'narrow'}`.trim(), role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'dialog-title' })
|
|
2241
3812
|
let busy = false
|
|
2242
3813
|
let formDirty = false
|
|
3814
|
+
let closed = false
|
|
2243
3815
|
const close = (explicit = false) => {
|
|
2244
|
-
if (busy) return
|
|
3816
|
+
if (busy || closed) return
|
|
2245
3817
|
if (!explicit && formDirty) {
|
|
2246
3818
|
showToast('表单有未保存的修改,请先保存,或使用“取消”放弃修改。', 'error')
|
|
2247
3819
|
return
|
|
2248
3820
|
}
|
|
2249
3821
|
document.removeEventListener('keydown', onKeyDown)
|
|
3822
|
+
closed = true
|
|
2250
3823
|
backdrop.remove()
|
|
3824
|
+
refreshWorkspaceEffects()
|
|
3825
|
+
onClose?.()
|
|
2251
3826
|
if (previouslyFocused instanceof HTMLElement) previouslyFocused.focus()
|
|
2252
3827
|
}
|
|
2253
3828
|
const closeButton = actionButton('×', () => close(), 'ghost', { 'aria-label': '关闭对话框' })
|
|
@@ -2288,6 +3863,7 @@ function openModal({ title, description = '', body, primaryLabel, primaryVariant
|
|
|
2288
3863
|
body.addEventListener('change', () => { formDirty = true })
|
|
2289
3864
|
}
|
|
2290
3865
|
document.body.append(backdrop)
|
|
3866
|
+
refreshWorkspaceEffects(backdrop)
|
|
2291
3867
|
document.addEventListener('keydown', onKeyDown)
|
|
2292
3868
|
window.setTimeout(() => (dialog.querySelector('input, textarea, select, button') || dialog).focus(), 0)
|
|
2293
3869
|
return { close, dialog }
|
|
@@ -2319,9 +3895,10 @@ function friendlyError(error) {
|
|
|
2319
3895
|
|
|
2320
3896
|
window.addEventListener('beforeunload', event => {
|
|
2321
3897
|
const editor = activeDocumentWorkspace()?.view.editor
|
|
2322
|
-
if (!editor?.dirty) return
|
|
3898
|
+
if (!editor?.dirty && !state.notes.dirty) return
|
|
2323
3899
|
event.preventDefault()
|
|
2324
3900
|
event.returnValue = ''
|
|
2325
3901
|
})
|
|
2326
3902
|
|
|
3903
|
+
installNoteDragRecovery()
|
|
2327
3904
|
void installHostThemeBridge().then(() => boot())
|