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);
}
}
};
}