import { createFeedbackController, escapeHtml, fetchJson, formatBytes, inferEnhancementClass, inferEnhancementLabel, linkToFile, optionalQueryParam } from './modules/common.js'; import { bindLogoutButton, loadAuthState } from './modules/auth-client.js'; const SOURCE_VIEWS = new Set(['overview', 'browse', 'enhance', 'thumbnails']); const state = { sourceId: optionalQueryParam('sourceId', ''), source: null, currentPath: '/', currentParent: null, currentView: 'overview' }; const feedback = createFeedbackController(document.querySelector('#feedback')); const pageTitleElement = document.querySelector('#pageTitle'); const sourceSummaryElement = document.querySelector('#sourceSummary'); const browseResultElement = document.querySelector('#browseResult'); const pathTrailElement = document.querySelector('#pathTrail'); const browseUpButton = document.querySelector('#browseUpButton'); const browseRootButton = document.querySelector('#browseRootButton'); const browseRefreshButton = document.querySelector('#browseRefreshButton'); const secretForm = document.querySelector('#secretForm'); const runEnhancementButton = document.querySelector('#runEnhancementButton'); const webdavHintElement = document.querySelector('#webdavHint'); const deleteSourceButton = document.querySelector('#deleteSourceButton'); const sourceThumbnailSummaryElement = document.querySelector('#sourceThumbnailSummary'); const clearSourceThumbnailsButton = document.querySelector('#clearSourceThumbnailsButton'); const authStatusElement = document.querySelector('#authStatus'); const logoutButton = document.querySelector('#logoutButton'); const viewPanels = [...document.querySelectorAll('[data-view-panel]')]; const viewLinks = [...document.querySelectorAll('[data-view-link]')]; function normalizeView(value) { return SOURCE_VIEWS.has(value) ? value : 'overview'; } function setControlsDisabled(disabled) { for (const element of [ browseUpButton, browseRootButton, browseRefreshButton, runEnhancementButton, deleteSourceButton, clearSourceThumbnailsButton, ...secretForm.querySelectorAll('input, select, button') ]) { if (element) { element.disabled = disabled; } } } function setCurrentView(nextView, { syncHash = true, replace = false } = {}) { state.currentView = normalizeView(nextView); for (const panel of viewPanels) { panel.hidden = panel.dataset.viewPanel !== state.currentView; } for (const link of viewLinks) { const active = link.dataset.viewLink === state.currentView; link.classList.toggle('is-active', active); link.setAttribute('aria-current', active ? 'page' : 'false'); } if (!syncHash) { return; } const url = new URL(window.location.href); url.hash = `#${state.currentView}`; if (replace) { window.history.replaceState(null, '', url); } else { window.history.pushState(null, '', url); } } function renderMissingRouteContext() { setControlsDisabled(true); pageTitleElement.textContent = '数据源工作台未就绪'; sourceSummaryElement.innerHTML = `

这个页面缺少必要的地址参数。

请从首页的数据源列表进入,或检查当前地址是否包含 sourceId

`; sourceThumbnailSummaryElement.innerHTML = `
状态 缺少 sourceId
`; pathTrailElement.textContent = '/'; browseResultElement.innerHTML = `

缺少 sourceId,无法读取目录。

`; webdavHintElement.innerHTML = `
状态 缺少 sourceId
`; setCurrentView('overview', { replace: true }); } function renderPathTrail(pathValue) { const segments = pathValue === '/' ? [] : pathValue.split('/').filter(Boolean); const crumbs = [ '' ]; let currentPath = ''; for (const segment of segments) { currentPath += `/${segment}`; crumbs.push('/'); crumbs.push( `` ); } pathTrailElement.innerHTML = crumbs.join(''); } function renderSourceSummary() { const source = state.source; if (!source) { sourceSummaryElement.innerHTML = '
未找到对应数据源。
'; return; } pageTitleElement.textContent = source.displayName || source.originalFilename; const enhancementError = source.enhancement.lastErrorMessage ? `
最近失败: ${escapeHtml(source.enhancement.lastErrorMessage)}
` : ''; sourceSummaryElement.innerHTML = `

${escapeHtml(source.displayName || source.originalFilename)}

原始库名: ${escapeHtml(source.originalFilename)}

sourceId: ${escapeHtml(source.id)}

${inferEnhancementLabel(source)}
任务名来源 ${escapeHtml(source.displayNameSource)}
匹配任务名 ${escapeHtml(source.matchedBackupName ?? '未匹配')}
源布局 ${escapeHtml(source.sourceLayout)}
上传大小 ${formatBytes(source.fileSize)}
目录浏览 ${source.canBrowse ? '可用' : '不可用'}
预览图缓存 ${escapeHtml(String(source.thumbnailCache?.count ?? 0))} 张
archive_entry_index ${source.capabilities.archiveEntryIndex ? 'Yes' : 'No'}
volume_crypto_cache ${source.capabilities.volumeCryptoCache ? 'Yes' : 'No'}
最新快照: ${escapeHtml(source.latestSnapshot?.timestamp ?? '暂无')}
${enhancementError}
`; } function renderWebdavHint() { const webdav = state.source?.webdav; if (!webdav) { webdavHintElement.innerHTML = `
默认目标 URL 未知
`; return; } webdavHintElement.innerHTML = `
任务默认 URL ${escapeHtml(webdav.effectiveWebdavBaseUrl ?? webdav.derivedWebdavBaseUrl ?? '未推导到')}
默认 URL 来源 ${escapeHtml(webdav.effectiveWebdavBaseUrlSource ?? 'none')}
Server DB 推导 ${escapeHtml(webdav.derivedTargetUrl ?? '无')}
全局默认值 ${webdav.globalDefaultsConfigured ? '已配置' : '未配置'}
当前 source 覆盖 ${webdav.sourceOverrideSaved ? '已保存' : '未保存'}
`; } function renderSourceThumbnailSummary() { const thumbnailCache = state.source?.thumbnailCache ?? { count: 0, totalBytes: 0 }; sourceThumbnailSummaryElement.innerHTML = `
当前 source 预览图数量 ${escapeHtml(String(thumbnailCache.count))}
总占用空间 ${formatBytes(thumbnailCache.totalBytes)}
`; } function renderBrowse(payload) { state.currentPath = payload.path; state.currentParent = payload.parent; renderPathTrail(payload.path); if (payload.entries.length === 0) { browseResultElement.innerHTML = `

这个目录目前没有内容。

`; return; } browseResultElement.innerHTML = `
${payload.entries .map((entry) => { if (entry.type === 'dir') { return `
DIR
${escapeHtml(entry.name)} dir
${escapeHtml(entry.path)} ${entry.mtime ? escapeHtml(entry.mtime) : '目录'} 点击进入目录
`; } const thumbnail = entry.thumbnail?.available ? ` ${escapeHtml(entry.name)} 的预览图 ` : '
FILE
'; return `
${thumbnail}
${escapeHtml(entry.name)} ${escapeHtml(entry.mime ?? 'file')}
${escapeHtml(entry.path)} ${formatBytes(entry.size)} ${entry.thumbnail?.available ? '已缓存预览图' : '点击打开文件页'}
`; }) .join('')}
`; } function renderBrowseError(pathValue, error) { renderPathTrail(pathValue); browseResultElement.innerHTML = `

无法列出目录内容。

${escapeHtml(error.message)}

`; } function readEnhancementForm() { return { webdavBaseUrl: document.querySelector('#webdavBaseUrl')?.value?.trim() ?? '', authMode: document.querySelector('#authMode')?.value ?? '', username: document.querySelector('#username')?.value?.trim() ?? '', password: document.querySelector('#password')?.value ?? '', passphrase: document.querySelector('#passphrase')?.value ?? '' }; } async function loadSource() { const payload = await fetchJson(`/api/sources/${encodeURIComponent(state.sourceId)}`); state.source = payload.source; renderSourceSummary(); renderWebdavHint(); renderSourceThumbnailSummary(); } async function loadDirectory(pathValue) { state.currentPath = pathValue; browseResultElement.innerHTML = `

正在读取目录...

`; try { const payload = await fetchJson( `/api/ls?sourceId=${encodeURIComponent(state.sourceId)}&path=${encodeURIComponent(pathValue)}` ); renderBrowse(payload); } catch (error) { renderBrowseError(pathValue, error); throw error; } } async function saveEnhancementSecrets(event) { event.preventDefault(); feedback.set('info', '正在保存当前 source 的覆盖规则...'); try { await fetchJson(`/api/sources/${encodeURIComponent(state.sourceId)}/secrets`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(readEnhancementForm()) }); await loadSource(); secretForm.reset(); feedback.set('success', '覆盖规则已保存。留空项会继续继承全局默认值或 Server DB 推导结果。'); } catch (error) { feedback.set('error', error.message); } } async function runEnhancement() { feedback.set('info', '正在提交增强任务...'); try { await fetchJson(`/api/sources/${encodeURIComponent(state.sourceId)}/enhance`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(readEnhancementForm()) }); await loadSource(); secretForm.reset(); feedback.set('success', '增强任务已开始。当前规则会自动合并全局默认值、Server DB 推导 URL 和单源覆盖。'); } catch (error) { feedback.set('error', error.message); } } async function clearSourceThumbnails() { const confirmed = window.confirm( '确定要清空这个 source 的全部预览图吗?\n\n这不会删除任务库、增强索引或 Duplicati-server.sqlite,只会删除后端缩略图缓存。' ); if (!confirmed) { return; } feedback.set('info', '正在清空当前 source 的预览图缓存...'); try { const payload = await fetchJson(`/api/sources/${encodeURIComponent(state.sourceId)}/thumbnails`, { method: 'DELETE' }); await loadSource(); await loadDirectory(state.currentPath); feedback.set( 'success', `当前 source 的预览图已清空,删除了 ${payload.cleared.removedCount} 张,共 ${formatBytes(payload.cleared.removedBytes)}。` ); } catch (error) { feedback.set('error', error.message); } } async function deleteCurrentSource() { if (!state.source) { return; } const label = state.source.displayName || state.source.originalFilename || state.source.id; const confirmed = window.confirm( `确定要删除这个 source 吗?\n\n${label}\n\n这会删除当前任务库、本地增强副本和该 source 的预览图缓存,但不会删除 Duplicati-server.sqlite。` ); if (!confirmed) { return; } feedback.set('info', `正在删除 ${label}...`); try { await fetchJson(`/api/sources/${encodeURIComponent(state.sourceId)}`, { method: 'DELETE' }); window.location.href = '/'; } catch (error) { feedback.set('error', error.message); } } browseUpButton.addEventListener('click', () => { feedback.clear(); void loadDirectory(state.currentParent ?? '/').catch((error) => { feedback.set('error', error.message); }); }); browseRootButton.addEventListener('click', () => { feedback.clear(); void loadDirectory('/').catch((error) => { feedback.set('error', error.message); }); }); browseRefreshButton.addEventListener('click', () => { feedback.clear(); void loadDirectory(state.currentPath).catch((error) => { feedback.set('error', error.message); }); }); browseResultElement.addEventListener('click', (event) => { const target = event.target; if (!(target instanceof HTMLElement)) { return; } const rowElement = target.closest('.browse-row-clickable'); if (rowElement instanceof HTMLElement) { const entryType = rowElement.dataset.entryType; if (entryType === 'dir' && rowElement.dataset.path) { feedback.clear(); void loadDirectory(rowElement.dataset.path).catch((error) => { feedback.set('error', error.message); }); return; } if (entryType === 'file' && rowElement.dataset.fileId) { window.location.href = linkToFile(state.sourceId, rowElement.dataset.fileId); } } }); pathTrailElement.addEventListener('click', (event) => { const target = event.target; if (!(target instanceof HTMLElement)) { return; } const actionElement = target.closest('[data-path]'); if (!(actionElement instanceof HTMLElement) || !actionElement.dataset.path) { return; } feedback.clear(); void loadDirectory(actionElement.dataset.path).catch((error) => { feedback.set('error', error.message); }); }); secretForm.addEventListener('submit', (event) => { void saveEnhancementSecrets(event); }); runEnhancementButton.addEventListener('click', () => { void runEnhancement(); }); clearSourceThumbnailsButton.addEventListener('click', () => { void clearSourceThumbnails(); }); deleteSourceButton.addEventListener('click', () => { void deleteCurrentSource(); }); for (const link of viewLinks) { link.addEventListener('click', (event) => { event.preventDefault(); const nextView = normalizeView(link.dataset.viewLink ?? 'browse'); setCurrentView(nextView); }); } window.addEventListener('hashchange', () => { setCurrentView(normalizeView(window.location.hash.slice(1)), { syncHash: false }); }); async function bootstrap() { const authentication = await loadAuthState(); authStatusElement.textContent = authentication.authenticated ? `已登录:${authentication.username}` : '未登录'; bindLogoutButton(logoutButton, authStatusElement); if (!state.sourceId) { renderMissingRouteContext(); return; } const initialView = normalizeView(window.location.hash.slice(1) || 'overview'); setCurrentView(initialView, { replace: true }); await loadSource(); await loadDirectory('/'); } void bootstrap().catch((error) => { feedback.set('error', error.message); browseResultElement.innerHTML = `

无法进入数据源工作台。

`; });