import { escapeHtml, fetchJson, renderSourceSummaryCard } from '../common.js'; // 管理对话框:折叠 app.js 的六个面板(overview/sources/upload/server-db/defaults/tls)。 // 通过侧栏「管理」按钮打开,顶部标签页切换。数据加载与表单提交逻辑移植自 app.js。 // deps: { feedback, onSourcesChanged(reloads sidebar/tree), close() } const TABS = [ { key: 'sources', label: '数据源' }, { key: 'upload', label: '上传任务库' }, { key: 'server-db', label: 'Server DB' }, { key: 'defaults', label: '全局 WebDAV' }, { key: 'tls', label: 'HTTPS / TLS' } ]; function summaryRows(rows) { return `
${rows .map((row) => `
${escapeHtml(row.label)}${escapeHtml(row.value)}
`) .join('')}
`; } export function createManage(rootElement, deps = {}) { const { feedback, onSourcesChanged } = deps; const cache = { sources: [], serverDb: null, defaults: null, tls: null }; let activeTab = 'sources'; function tabsHtml() { return `
${TABS.map( (tab) => `` ).join('')}
`; } function renderSourcesTab() { if (cache.sources.length === 0) { return '
还没有上传任务数据库。切到「上传任务库」添加。
'; } return `
${cache.sources .map((source) => renderSourceSummaryCard( source, `
` ) ) .join('')}
`; } function renderUploadTab() { return `

上传随机名任务数据库(如 TMQRJYNADS.sqlite)。重复上传会复用现有 source。

`; } function renderServerDbTab() { const summary = cache.serverDb?.available ? summaryRows([ { label: '当前文件', value: cache.serverDb.originalFilename }, { label: '上传时间', value: cache.serverDb.uploadedAt }, { label: '任务条目数', value: String(cache.serverDb.backupCount) } ]) : summaryRows([{ label: '状态', value: '未上传 Duplicati-server.sqlite' }]); return `

上传后可自动解析任务名称,并尽量从元数据推导默认目标 URL。

${summary} `; } function renderDefaultsTab() { const defaults = cache.defaults; const summary = defaults?.configured ? summaryRows([ { 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'}` } ]) : summaryRows([{ label: '状态', value: '尚未保存全局默认值' }]); return `

如果大多数任务共用同一套 WebDAV 账号和口令,可以在这里统一保存一次。

${summary} `; } function renderTlsTab() { const tls = cache.tls; const summary = tls ? summaryRows([ { label: 'HTTP 地址', value: tls.access?.httpBaseUrl ?? '未知' }, { label: 'HTTPS 地址', value: tls.access?.httpsBaseUrl ?? '未知' }, { label: '当前生效证书', value: tls.activeSource ?? 'self-signed' }, { label: '主域名', value: tls.primaryDomain || 'localhost' }, { label: '有效期', value: tls.certificate ? `${tls.certificate.validFrom} -> ${tls.certificate.validTo}` : '未知' } ]) : summaryRows([{ label: '状态', value: 'TLS 配置尚未加载' }]); const sans = Array.isArray(tls?.subjectAltNames) ? tls.subjectAltNames.join('\n') : ''; return `

HTTP 和 HTTPS 会同时可用。没有自定义证书时系统会自动维护一张自签证书。

${summary}
`; } function renderBody() { let content = ''; switch (activeTab) { case 'upload': content = renderUploadTab(); break; case 'server-db': content = renderServerDbTab(); break; case 'defaults': content = renderDefaultsTab(); break; case 'tls': content = renderTlsTab(); break; case 'sources': default: content = renderSourcesTab(); break; } rootElement.innerHTML = `

系统管理

${tabsHtml()}
${content}
`; } async function reloadAll() { const [sources, serverDb, defaults, tls] = await Promise.all([ fetchJson('/api/sources'), fetchJson('/api/server-db'), fetchJson('/api/webdav-defaults'), fetchJson('/api/system/tls') ]); cache.sources = sources.sources; cache.serverDb = serverDb.serverDb; cache.defaults = defaults.defaults; cache.tls = tls.tls; onSourcesChanged?.(cache.sources); } // ── 表单提交 ── async function submitUpload(form) { const file = form.querySelector('#mgDatabaseInput')?.files?.[0]; if (!file) { feedback?.set('error', '请先选择一个任务 SQLite 文件。'); return; } 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 ?? '上传失败。'}`); } await reloadAll(); activeTab = 'sources'; renderBody(); feedback?.set(payload.reused ? 'info' : 'success', payload.reused ? '该数据库已存在,复用现有 source。' : '任务数据库上传成功。'); } catch (error) { feedback?.set('error', error.message); } } async function submitServerDb(form) { const file = form.querySelector('#mgServerDbInput')?.files?.[0]; if (!file) { feedback?.set('error', '请先选择 Duplicati-server.sqlite。'); return; } 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 ?? '上传失败。'}`); } await reloadAll(); renderBody(); feedback?.set('success', 'Server DB 上传成功。'); } catch (error) { feedback?.set('error', error.message); } } async function submitDefaults() { feedback?.set('info', '正在保存全局 WebDAV 默认值...'); try { const body = { webdavBaseUrl: rootElement.querySelector('#mgGlobalUrl')?.value.trim() ?? '', authMode: rootElement.querySelector('#mgGlobalAuthMode')?.value ?? '', username: rootElement.querySelector('#mgGlobalUsername')?.value.trim() ?? '', password: rootElement.querySelector('#mgGlobalPassword')?.value ?? '', passphrase: rootElement.querySelector('#mgGlobalPassphrase')?.value ?? '' }; const payload = await fetchJson('/api/webdav-defaults', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); cache.defaults = payload.defaults; renderBody(); feedback?.set(payload.defaults.configured ? 'success' : 'info', payload.defaults.configured ? '全局默认值已保存。' : '全局默认值已清空。'); } catch (error) { feedback?.set('error', error.message); } } async function submitTls() { feedback?.set('info', '正在保存 TLS 配置...'); try { const body = { mode: rootElement.querySelector('#mgTlsMode')?.value ?? 'self-signed', primaryDomain: rootElement.querySelector('#mgTlsPrimaryDomain')?.value.trim() ?? '', subjectAltNames: rootElement.querySelector('#mgTlsSans')?.value ?? '', certPem: rootElement.querySelector('#mgTlsCertPem')?.value ?? '', keyPem: rootElement.querySelector('#mgTlsKeyPem')?.value ?? '', chainPem: rootElement.querySelector('#mgTlsChainPem')?.value ?? '' }; const payload = await fetchJson('/api/system/tls', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); cache.tls = payload.tls; renderBody(); feedback?.set('success', `TLS 配置已应用,当前证书来源:${payload.tls.activeSource}。`); } catch (error) { feedback?.set('error', error.message); } } async function deleteCustomTls() { if (!window.confirm('确定要删除当前自定义证书,并回退到自签证书吗?')) { return; } feedback?.set('info', '正在回退到自签证书...'); try { const payload = await fetchJson('/api/system/tls/custom-certificate', { method: 'DELETE' }); cache.tls = payload.tls; renderBody(); feedback?.set('success', '自定义证书已删除,已回退到自签证书。'); } catch (error) { feedback?.set('error', error.message); } } async function deleteSource(sourceId) { const source = cache.sources.find((item) => item.id === sourceId); const label = source?.displayName || source?.originalFilename || sourceId; if (!window.confirm(`确定要删除这个 source 吗?\n\n${label}\n\n这只会删除当前 source 及其本地增强数据,不会删除 Duplicati-server.sqlite。`)) { return; } feedback?.set('info', `正在删除 ${label}...`); try { await fetchJson(`/api/sources/${encodeURIComponent(sourceId)}`, { method: 'DELETE' }); await reloadAll(); renderBody(); feedback?.set('success', `已删除 ${label}。`); } catch (error) { feedback?.set('error', error.message); } } rootElement.addEventListener('click', (event) => { const tab = event.target.closest('[data-tab]'); if (tab instanceof HTMLElement) { activeTab = tab.dataset.tab; renderBody(); return; } if (event.target.closest('[data-manage-close]')) { deps.close?.(); return; } const openSource = event.target.closest('[data-open-source]'); if (openSource instanceof HTMLElement) { deps.onOpenSource?.(openSource.dataset.openSource); return; } const del = event.target.closest('[data-delete-source]'); if (del instanceof HTMLElement) { void deleteSource(del.dataset.deleteSource); return; } if (event.target.closest('#mgTlsDeleteCustom')) { void deleteCustomTls(); } }); rootElement.addEventListener('submit', (event) => { event.preventDefault(); const form = event.target; if (form.id === 'mgUploadForm') { void submitUpload(form); } else if (form.id === 'mgServerDbForm') { void submitServerDb(form); } else if (form.id === 'mgDefaultsForm') { void submitDefaults(); } else if (form.id === 'mgTlsForm') { void submitTls(); } }); return { async open(tab = 'sources') { activeTab = TABS.some((item) => item.key === tab) ? tab : 'sources'; renderBody(); try { await reloadAll(); renderBody(); } catch (error) { feedback?.set('error', error.message); } } }; }