import { createFeedbackController, escapeHtml, fetchJson, linkToSource, renderSourceSummaryCard } from './modules/common.js'; import { bindLogoutButton, loadAuthState } from './modules/auth-client.js'; const CONTROL_VIEWS = new Set(['overview', 'sources', 'upload', 'server-db', 'defaults', 'tls']); const state = { sources: [], serverDb: null, globalDefaults: null, tls: null, currentView: 'overview' }; const uploadForm = document.querySelector('#uploadForm'); const uploadButton = document.querySelector('#uploadButton'); const databaseInput = document.querySelector('#databaseInput'); const serverDbForm = document.querySelector('#serverDbForm'); const serverDbUploadButton = document.querySelector('#serverDbUploadButton'); const serverDbInput = document.querySelector('#serverDbInput'); const globalDefaultsForm = document.querySelector('#globalDefaultsForm'); const globalDefaultsSaveButton = document.querySelector('#globalDefaultsSaveButton'); const tlsJsonForm = document.querySelector('#tlsJsonForm'); const tlsUploadForm = document.querySelector('#tlsUploadForm'); const tlsSaveButton = document.querySelector('#tlsSaveButton'); const tlsUploadButton = document.querySelector('#tlsUploadButton'); const tlsDeleteCustomButton = document.querySelector('#tlsDeleteCustomButton'); const refreshButton = document.querySelector('#refreshButton'); const serverDbSummaryElement = document.querySelector('#serverDbSummary'); const globalDefaultsSummaryElement = document.querySelector('#globalDefaultsSummary'); const tlsSummaryElement = document.querySelector('#tlsSummary'); const overviewSummaryElement = document.querySelector('#overviewSummary'); const sourceListElement = document.querySelector('#sourceList'); const authStatusElement = document.querySelector('#authStatus'); const logoutButton = document.querySelector('#logoutButton'); const viewPanels = [...document.querySelectorAll('[data-view-panel]')]; const viewLinks = [...document.querySelectorAll('[data-view-link]')]; const feedback = createFeedbackController(document.querySelector('#feedback')); function normalizeView(value) { return CONTROL_VIEWS.has(value) ? value : 'overview'; } 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 renderSummaryRows(rows) { return `
${rows .map( (row) => `
${escapeHtml(row.label)} ${escapeHtml(row.value)}
` ) .join('')}
`; } function readGlobalDefaultsForm() { return { webdavBaseUrl: document.querySelector('#globalWebdavBaseUrl')?.value?.trim() ?? '', authMode: document.querySelector('#globalAuthMode')?.value ?? '', username: document.querySelector('#globalUsername')?.value?.trim() ?? '', password: document.querySelector('#globalPassword')?.value ?? '', passphrase: document.querySelector('#globalPassphrase')?.value ?? '' }; } function writeGlobalDefaultsForm(defaults) { document.querySelector('#globalWebdavBaseUrl').value = defaults?.webdavBaseUrl ?? ''; document.querySelector('#globalAuthMode').value = defaults?.authMode ?? ''; document.querySelector('#globalUsername').value = ''; document.querySelector('#globalPassword').value = ''; document.querySelector('#globalPassphrase').value = ''; } function readTlsJsonForm() { return { mode: document.querySelector('#tlsMode')?.value ?? 'self-signed', primaryDomain: document.querySelector('#tlsPrimaryDomain')?.value?.trim() ?? '', subjectAltNames: document.querySelector('#tlsSubjectAltNames')?.value ?? '', certPem: document.querySelector('#tlsCertPem')?.value ?? '', keyPem: document.querySelector('#tlsKeyPem')?.value ?? '', chainPem: document.querySelector('#tlsChainPem')?.value ?? '' }; } function writeTlsForm(tlsSummary) { document.querySelector('#tlsMode').value = tlsSummary?.mode ?? 'self-signed'; document.querySelector('#tlsPrimaryDomain').value = tlsSummary?.primaryDomain ?? ''; document.querySelector('#tlsSubjectAltNames').value = Array.isArray(tlsSummary?.subjectAltNames) ? tlsSummary.subjectAltNames.join('\n') : ''; document.querySelector('#tlsCertPem').value = ''; document.querySelector('#tlsKeyPem').value = ''; document.querySelector('#tlsChainPem').value = ''; } function renderOverviewSummary() { const sourceCount = state.sources.length; const readyCount = state.sources.filter((source) => source.enhancement?.status === 'ready').length; const failedCount = state.sources.filter((source) => source.enhancement?.status === 'failed').length; const mappedCount = state.sources.filter((source) => source.displayNameSource === 'server-db').length; overviewSummaryElement.innerHTML = renderSummaryRows([ { label: '数据源总数', value: String(sourceCount) }, { label: '已增强数据源', value: String(readyCount) }, { label: '增强失败', value: String(failedCount) }, { label: '已映射任务名', value: String(mappedCount) }, { label: 'Server DB', value: state.serverDb?.available ? state.serverDb.originalFilename : '未上传' }, { label: '全局 WebDAV', value: state.globalDefaults?.configured ? '已配置' : '未配置' }, { label: '当前证书来源', value: state.tls?.activeSource ?? '不可用' } ]); } function renderServerDbSummary() { if (!state.serverDb?.available) { serverDbSummaryElement.innerHTML = renderSummaryRows([ { label: 'Status', value: 'No server database uploaded yet' }, { label: '说明', value: '未上传 Duplicati-server.sqlite 之前,任务名和推导目标 URL 不可用。' } ]); return; } serverDbSummaryElement.innerHTML = renderSummaryRows([ { label: '当前文件', value: state.serverDb.originalFilename }, { label: '上传时间', value: state.serverDb.uploadedAt }, { label: '任务条目数', value: String(state.serverDb.backupCount) } ]); } function renderGlobalDefaultsSummary() { const defaults = state.globalDefaults; if (!defaults?.configured) { writeGlobalDefaultsForm(null); globalDefaultsSummaryElement.innerHTML = renderSummaryRows([ { label: '状态', value: '尚未保存全局默认值' }, { label: '说明', value: '如果大多数任务共用同一套 WebDAV 账号和口令,可以在这里统一保存。' } ]); return; } writeGlobalDefaultsForm(defaults); globalDefaultsSummaryElement.innerHTML = renderSummaryRows([ { label: '兜底 URL', value: defaults.webdavBaseUrl ?? '未设置' }, { label: '认证方式', value: defaults.authMode ?? '自动' }, { label: '已保存凭据', value: `username=${defaults.hasUsername ? 'yes' : 'no'}, password=${defaults.hasPassword ? 'yes' : 'no'}, passphrase=${defaults.hasPassphrase ? 'yes' : 'no'}` }, { label: '更新时间', value: defaults.updatedAt ?? '未知' } ]); } function renderTlsSummary() { if (!state.tls) { tlsSummaryElement.innerHTML = renderSummaryRows([{ label: '状态', value: 'TLS 配置尚未加载' }]); return; } writeTlsForm(state.tls); tlsDeleteCustomButton.disabled = !state.tls.hasCustomCertificate; tlsSummaryElement.innerHTML = renderSummaryRows([ { label: 'HTTP 访问地址', value: state.tls.access?.httpBaseUrl ?? '未知' }, { label: 'HTTPS 访问地址', value: state.tls.access?.httpsBaseUrl ?? '未知' }, { label: '配置模式', value: state.tls.mode ?? 'self-signed' }, { label: '当前生效证书', value: state.tls.activeSource ?? 'self-signed' }, { label: '主域名', value: state.tls.primaryDomain || 'localhost' }, { label: '额外 SAN', value: (state.tls.subjectAltNames ?? []).join(', ') || '无' }, { label: '有效期', value: state.tls.certificate ? `${state.tls.certificate.validFrom} -> ${state.tls.certificate.validTo}` : '未知' }, { label: '指纹', value: state.tls.certificate?.fingerprint256 ?? '未知' }, { label: '最近错误', value: state.tls.lastErrorMessage ?? '无' }, { label: 'Cookie 说明', value: state.tls.cookieNote ?? 'HTTP/HTTPS 共用登录态' } ]); } function renderSources() { if (state.sources.length === 0) { sourceListElement.innerHTML = `

No task sources uploaded yet.

Upload a Duplicati task database first, then open the source workbench to browse, enhance, preview, or download.

`; return; } sourceListElement.innerHTML = state.sources .map((source) => { const targetHint = source.webdav?.effectiveWebdavBaseUrl ? `
默认目标 URL 来源:${escapeHtml(source.webdav.effectiveWebdavBaseUrlSource ?? 'unknown')}
` : '
暂无可用的默认目标 URL
'; return renderSourceSummaryCard( source, ` ${targetHint}
进入工作台
` ); }) .join(''); } async function loadServerDbSummary() { const payload = await fetchJson('/api/server-db'); state.serverDb = payload.serverDb; renderServerDbSummary(); renderOverviewSummary(); } async function loadGlobalDefaults() { const payload = await fetchJson('/api/webdav-defaults'); state.globalDefaults = payload.defaults; renderGlobalDefaultsSummary(); renderOverviewSummary(); } async function loadTlsSummary() { const payload = await fetchJson('/api/system/tls'); state.tls = payload.tls; renderTlsSummary(); renderOverviewSummary(); } async function loadSources() { const payload = await fetchJson('/api/sources'); state.sources = payload.sources; renderSources(); renderOverviewSummary(); } async function handleTaskDbUpload(event) { event.preventDefault(); feedback.clear(); const file = databaseInput.files?.[0]; if (!file) { feedback.set('error', '请先选择一个任务 SQLite 文件。'); return; } uploadButton.disabled = true; refreshButton.disabled = true; feedback.set('info', '正在上传任务数据库...'); try { const formData = new FormData(); formData.set('database', file); const response = await fetch('/api/sources/upload', { method: 'POST', body: formData }); const payload = await response.json(); if (!response.ok) { throw new Error(`${payload.error?.code ?? 'UPLOAD_FAILED'}: ${payload.error?.message ?? 'Upload failed.'}`); } await Promise.all([loadSources(), loadServerDbSummary()]); uploadForm.reset(); setCurrentView('sources'); feedback.set( payload.reused ? 'info' : 'success', payload.reused ? '这个任务数据库之前已经上传过,系统复用了现有 source。' : '任务数据库上传成功。' ); } catch (error) { feedback.set('error', error.message); } finally { uploadButton.disabled = false; refreshButton.disabled = false; } } async function handleServerDbUpload(event) { event.preventDefault(); feedback.clear(); const file = serverDbInput.files?.[0]; if (!file) { feedback.set('error', '请先选择 Duplicati-server.sqlite。'); return; } serverDbUploadButton.disabled = true; feedback.set('info', '正在上传 Server DB 并刷新任务名映射...'); try { const formData = new FormData(); formData.set('database', file); const response = await fetch('/api/server-db/upload', { method: 'POST', body: formData }); const payload = await response.json(); if (!response.ok) { throw new Error(`${payload.error?.code ?? 'UPLOAD_FAILED'}: ${payload.error?.message ?? 'Upload failed.'}`); } await Promise.all([loadSources(), loadServerDbSummary()]); serverDbForm.reset(); feedback.set('success', 'Server DB 上传成功。'); } catch (error) { feedback.set('error', error.message); } finally { serverDbUploadButton.disabled = false; } } async function handleGlobalDefaultsSave(event) { event.preventDefault(); feedback.clear(); globalDefaultsSaveButton.disabled = true; feedback.set('info', '正在保存全局 WebDAV 默认值...'); try { const payload = await fetchJson('/api/webdav-defaults', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(readGlobalDefaultsForm()) }); state.globalDefaults = payload.defaults; renderGlobalDefaultsSummary(); renderOverviewSummary(); await loadSources(); feedback.set( payload.defaults.configured ? 'success' : 'info', payload.defaults.configured ? '全局默认值已保存。' : '全局默认值已清空。' ); } catch (error) { feedback.set('error', error.message); } finally { globalDefaultsSaveButton.disabled = false; } } async function handleTlsJsonSave(event) { event.preventDefault(); feedback.clear(); tlsSaveButton.disabled = true; tlsUploadButton.disabled = true; feedback.set('info', '正在保存 TLS 配置并应用证书...'); try { const payload = await fetchJson('/api/system/tls', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(readTlsJsonForm()) }); state.tls = payload.tls; renderTlsSummary(); renderOverviewSummary(); feedback.set('success', `TLS 配置已应用,当前证书来源:${payload.tls.activeSource}。`); } catch (error) { feedback.set('error', error.message); } finally { tlsSaveButton.disabled = false; tlsUploadButton.disabled = false; } } async function handleTlsUpload(event) { event.preventDefault(); feedback.clear(); const certificateFile = document.querySelector('#tlsCertificateInput')?.files?.[0]; const privateKeyFile = document.querySelector('#tlsPrivateKeyInput')?.files?.[0]; const chainFile = document.querySelector('#tlsChainInput')?.files?.[0] ?? null; if (!certificateFile || !privateKeyFile) { feedback.set('error', '请同时上传证书文件和私钥文件。'); return; } tlsSaveButton.disabled = true; tlsUploadButton.disabled = true; feedback.set('info', '正在上传自定义证书...'); try { const formData = new FormData(); formData.set('primaryDomain', document.querySelector('#tlsPrimaryDomain')?.value?.trim() ?? ''); formData.set('subjectAltNames', document.querySelector('#tlsSubjectAltNames')?.value ?? ''); formData.set('certificate', certificateFile); formData.set('privateKey', privateKeyFile); if (chainFile) { formData.set('chain', chainFile); } const response = await fetch('/api/system/tls/upload', { method: 'POST', body: formData }); const payload = await response.json(); if (!response.ok) { throw new Error(`${payload.error?.code ?? 'TLS_UPLOAD_FAILED'}: ${payload.error?.message ?? 'Upload failed.'}`); } state.tls = payload.tls; renderTlsSummary(); renderOverviewSummary(); tlsUploadForm.reset(); feedback.set('success', '自定义证书已上传并生效。'); } catch (error) { feedback.set('error', error.message); } finally { tlsSaveButton.disabled = false; tlsUploadButton.disabled = false; } } async function handleDeleteCustomTls() { feedback.clear(); if (!window.confirm('确定要删除当前自定义证书,并回退到自签证书吗?')) { return; } tlsDeleteCustomButton.disabled = true; feedback.set('info', '正在删除自定义证书,并切回自签证书...'); try { const payload = await fetchJson('/api/system/tls/custom-certificate', { method: 'DELETE' }); state.tls = payload.tls; renderTlsSummary(); renderOverviewSummary(); feedback.set('success', '自定义证书已删除,系统已回退到自签证书。'); } catch (error) { feedback.set('error', error.message); } finally { tlsDeleteCustomButton.disabled = false; } } async function deleteSource(sourceId) { feedback.clear(); const source = state.sources.find((item) => item.id === sourceId); const label = source?.displayName || source?.originalFilename || sourceId; const confirmed = window.confirm( `确定要删除这个 source 吗?\n\n${label}\n\n这只会删除当前 source 及其本地增强数据,不会删除 Duplicati-server.sqlite。` ); if (!confirmed) { return; } feedback.set('info', `正在删除 ${label}...`); try { const payload = await fetchJson(`/api/sources/${encodeURIComponent(sourceId)}`, { method: 'DELETE' }); await Promise.all([loadSources(), loadServerDbSummary()]); feedback.set('success', `已删除 ${payload.deleted.originalFilename},Server DB 保持不变。`); } catch (error) { feedback.set('error', error.message); } } uploadForm.addEventListener('submit', (event) => { void handleTaskDbUpload(event); }); serverDbForm.addEventListener('submit', (event) => { void handleServerDbUpload(event); }); globalDefaultsForm.addEventListener('submit', (event) => { void handleGlobalDefaultsSave(event); }); tlsJsonForm.addEventListener('submit', (event) => { void handleTlsJsonSave(event); }); tlsUploadForm.addEventListener('submit', (event) => { void handleTlsUpload(event); }); tlsDeleteCustomButton.addEventListener('click', () => { void handleDeleteCustomTls(); }); refreshButton.addEventListener('click', () => { feedback.clear(); sourceListElement.textContent = '正在刷新数据源列表...'; void Promise.all([loadSources(), loadServerDbSummary(), loadGlobalDefaults(), loadTlsSummary()]).catch((error) => { feedback.set('error', error.message); }); }); sourceListElement.addEventListener('click', (event) => { const target = event.target; if (!(target instanceof HTMLElement)) { return; } const actionElement = target.closest('[data-delete-source-id]'); if (!(actionElement instanceof HTMLElement)) { return; } const sourceId = actionElement.dataset.deleteSourceId; if (!sourceId) { return; } void deleteSource(sourceId); }); for (const link of viewLinks) { link.addEventListener('click', (event) => { event.preventDefault(); setCurrentView(normalizeView(link.dataset.viewLink ?? 'overview')); }); } window.addEventListener('hashchange', () => { setCurrentView(normalizeView(window.location.hash.slice(1)), { syncHash: false }); }); async function bootstrap() { const initialView = normalizeView(window.location.hash.slice(1) || 'overview'); setCurrentView(initialView, { replace: true }); const authentication = await loadAuthState(); authStatusElement.textContent = authentication.authenticated ? `已登录:${authentication.username}` : '未登录'; bindLogoutButton(logoutButton, authStatusElement); await Promise.all([loadServerDbSummary(), loadGlobalDefaults(), loadTlsSummary(), loadSources()]); } void bootstrap().catch((error) => { feedback.set('error', error.message); sourceListElement.innerHTML = `

无法加载控制台。

`; });