import {
createFeedbackController,
escapeHtml,
fetchJson,
linkToSource,
renderSourceSummaryCard
} from './modules/common.js';
const CONTROL_VIEWS = new Set(['overview', 'sources', 'upload', 'server-db', 'defaults']);
const state = {
sources: [],
serverDb: null,
globalDefaults: 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 refreshButton = document.querySelector('#refreshButton');
const serverDbSummaryElement = document.querySelector('#serverDbSummary');
const globalDefaultsSummaryElement = document.querySelector('#globalDefaultsSummary');
const overviewSummaryElement = document.querySelector('#overviewSummary');
const sourceListElement = document.querySelector('#sourceList');
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 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 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;
const serverDbAvailable = Boolean(state.serverDb?.available);
const defaultsConfigured = Boolean(state.globalDefaults?.configured);
overviewSummaryElement.innerHTML = `
当前 source 数量
${escapeHtml(String(sourceCount))}
已增强完成
${escapeHtml(String(readyCount))}
增强失败
${escapeHtml(String(failedCount))}
已映射任务名
${escapeHtml(String(mappedCount))}
Server DB
${serverDbAvailable ? escapeHtml(state.serverDb.originalFilename) : '未上传'}
全局 WebDAV 默认值
${defaultsConfigured ? '已配置' : '未配置'}
`;
}
function renderServerDbSummary() {
if (!state.serverDb || !state.serverDb.available) {
serverDbSummaryElement.innerHTML = `
当前状态
尚未上传 Duplicati-server.sqlite
说明
没有它也能浏览和增强,只是任务名和默认目标 URL 无法自动映射。
`;
return;
}
serverDbSummaryElement.innerHTML = `
当前文件
${escapeHtml(state.serverDb.originalFilename)}
上传时间
${escapeHtml(state.serverDb.uploadedAt)}
任务数量
${escapeHtml(String(state.serverDb.backupCount))}
`;
}
function renderGlobalDefaultsSummary() {
const defaults = state.globalDefaults;
if (!defaults?.configured) {
globalDefaultsSummaryElement.innerHTML = `
当前状态
尚未保存全局默认值
说明
保存一次后,后续 source 可以继承这里的认证信息和备份口令。
`;
writeGlobalDefaultsForm(null);
return;
}
writeGlobalDefaultsForm(defaults);
globalDefaultsSummaryElement.innerHTML = `
Fallback URL
${escapeHtml(defaults.webdavBaseUrl ?? '未设置')}
认证方式
${escapeHtml(defaults.authMode ?? '自动')}
已保存凭据
username=${defaults.hasUsername ? 'yes' : 'no'},
password=${defaults.hasPassword ? 'yes' : 'no'},
passphrase=${defaults.hasPassphrase ? 'yes' : 'no'}
更新时间
${escapeHtml(defaults.updatedAt ?? '未知')}
`;
}
function renderSources() {
if (state.sources.length === 0) {
sourceListElement.innerHTML = `
还没有任何任务数据源。
先上传一个随机名任务库,然后再进入工作台浏览目录、做增强或进入文件页预览下载。
`;
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 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 并刷新任务名与目标 URL 映射...');
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 上传成功,任务名和默认目标 URL 已刷新。');
} 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 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这只会删除当前任务库和本地增强副本,不会删除 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);
});
refreshButton.addEventListener('click', () => {
feedback.clear();
sourceListElement.textContent = '正在刷新数据源列表...';
void Promise.all([loadSources(), loadServerDbSummary(), loadGlobalDefaults()]).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 });
await Promise.all([loadServerDbSummary(), loadGlobalDefaults(), loadSources()]);
}
void bootstrap().catch((error) => {
feedback.set('error', error.message);
sourceListElement.innerHTML = `
`;
});