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 = `
`;
pathTrailElement.textContent = '/';
browseResultElement.innerHTML = `
`;
webdavHintElement.innerHTML = `
`;
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 = `
`;
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
? `
})
`
: '
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 = `
`;
});