586 lines
18 KiB
JavaScript
586 lines
18 KiB
JavaScript
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 = `
|
|
<div class="empty-state-card">
|
|
<p>这个页面缺少必要的地址参数。</p>
|
|
<p>请从首页的数据源列表进入,或检查当前地址是否包含 <code>sourceId</code>。</p>
|
|
</div>
|
|
`;
|
|
sourceThumbnailSummaryElement.innerHTML = `
|
|
<div class="summary-stack">
|
|
<div class="summary-row">
|
|
<span>状态</span>
|
|
<strong>缺少 sourceId</strong>
|
|
</div>
|
|
</div>
|
|
`;
|
|
pathTrailElement.textContent = '/';
|
|
browseResultElement.innerHTML = `
|
|
<div class="empty-state-card">
|
|
<p>缺少 <code>sourceId</code>,无法读取目录。</p>
|
|
</div>
|
|
`;
|
|
webdavHintElement.innerHTML = `
|
|
<div class="summary-stack">
|
|
<div class="summary-row">
|
|
<span>状态</span>
|
|
<strong>缺少 sourceId</strong>
|
|
</div>
|
|
</div>
|
|
`;
|
|
setCurrentView('overview', { replace: true });
|
|
}
|
|
|
|
function renderPathTrail(pathValue) {
|
|
const segments = pathValue === '/' ? [] : pathValue.split('/').filter(Boolean);
|
|
const crumbs = [
|
|
'<button class="path-chip" data-path="/" type="button">根</button>'
|
|
];
|
|
|
|
let currentPath = '';
|
|
for (const segment of segments) {
|
|
currentPath += `/${segment}`;
|
|
crumbs.push('<span class="path-separator">/</span>');
|
|
crumbs.push(
|
|
`<button class="path-chip" data-path="${escapeHtml(currentPath)}" type="button">${escapeHtml(segment)}</button>`
|
|
);
|
|
}
|
|
|
|
pathTrailElement.innerHTML = crumbs.join('');
|
|
}
|
|
|
|
function renderSourceSummary() {
|
|
const source = state.source;
|
|
if (!source) {
|
|
sourceSummaryElement.innerHTML = '<div class="empty-state-card">未找到对应数据源。</div>';
|
|
return;
|
|
}
|
|
|
|
pageTitleElement.textContent = source.displayName || source.originalFilename;
|
|
const enhancementError = source.enhancement.lastErrorMessage
|
|
? `<div class="feedback feedback-error">最近失败: ${escapeHtml(source.enhancement.lastErrorMessage)}</div>`
|
|
: '';
|
|
|
|
sourceSummaryElement.innerHTML = `
|
|
<article class="source-card">
|
|
<div class="source-main">
|
|
<div>
|
|
<p class="source-name">${escapeHtml(source.displayName || source.originalFilename)}</p>
|
|
<p class="source-meta">原始库名: ${escapeHtml(source.originalFilename)}</p>
|
|
<p class="source-meta">sourceId: ${escapeHtml(source.id)}</p>
|
|
</div>
|
|
<span class="status-pill ${inferEnhancementClass(source)}">${inferEnhancementLabel(source)}</span>
|
|
</div>
|
|
|
|
<div class="stats-grid">
|
|
<div class="stat">
|
|
<span>任务名来源</span>
|
|
<strong>${escapeHtml(source.displayNameSource)}</strong>
|
|
</div>
|
|
<div class="stat">
|
|
<span>匹配任务名</span>
|
|
<strong>${escapeHtml(source.matchedBackupName ?? '未匹配')}</strong>
|
|
</div>
|
|
<div class="stat">
|
|
<span>源布局</span>
|
|
<strong>${escapeHtml(source.sourceLayout)}</strong>
|
|
</div>
|
|
<div class="stat">
|
|
<span>上传大小</span>
|
|
<strong>${formatBytes(source.fileSize)}</strong>
|
|
</div>
|
|
<div class="stat">
|
|
<span>目录浏览</span>
|
|
<strong>${source.canBrowse ? '可用' : '不可用'}</strong>
|
|
</div>
|
|
<div class="stat">
|
|
<span>预览图缓存</span>
|
|
<strong>${escapeHtml(String(source.thumbnailCache?.count ?? 0))} 张</strong>
|
|
</div>
|
|
<div class="stat">
|
|
<span>archive_entry_index</span>
|
|
<strong>${source.capabilities.archiveEntryIndex ? 'Yes' : 'No'}</strong>
|
|
</div>
|
|
<div class="stat">
|
|
<span>volume_crypto_cache</span>
|
|
<strong>${source.capabilities.volumeCryptoCache ? 'Yes' : 'No'}</strong>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="summary-chip">最新快照: ${escapeHtml(source.latestSnapshot?.timestamp ?? '暂无')}</div>
|
|
${enhancementError}
|
|
</article>
|
|
`;
|
|
}
|
|
|
|
function renderWebdavHint() {
|
|
const webdav = state.source?.webdav;
|
|
if (!webdav) {
|
|
webdavHintElement.innerHTML = `
|
|
<div class="summary-stack">
|
|
<div class="summary-row">
|
|
<span>默认目标 URL</span>
|
|
<strong>未知</strong>
|
|
</div>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
webdavHintElement.innerHTML = `
|
|
<div class="summary-stack">
|
|
<div class="summary-row">
|
|
<span>任务默认 URL</span>
|
|
<strong>${escapeHtml(webdav.effectiveWebdavBaseUrl ?? webdav.derivedWebdavBaseUrl ?? '未推导到')}</strong>
|
|
</div>
|
|
<div class="summary-row">
|
|
<span>默认 URL 来源</span>
|
|
<strong>${escapeHtml(webdav.effectiveWebdavBaseUrlSource ?? 'none')}</strong>
|
|
</div>
|
|
<div class="summary-row">
|
|
<span>Server DB 推导</span>
|
|
<strong>${escapeHtml(webdav.derivedTargetUrl ?? '无')}</strong>
|
|
</div>
|
|
<div class="summary-row">
|
|
<span>全局默认值</span>
|
|
<strong>${webdav.globalDefaultsConfigured ? '已配置' : '未配置'}</strong>
|
|
</div>
|
|
<div class="summary-row">
|
|
<span>当前 source 覆盖</span>
|
|
<strong>${webdav.sourceOverrideSaved ? '已保存' : '未保存'}</strong>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderSourceThumbnailSummary() {
|
|
const thumbnailCache = state.source?.thumbnailCache ?? {
|
|
count: 0,
|
|
totalBytes: 0
|
|
};
|
|
|
|
sourceThumbnailSummaryElement.innerHTML = `
|
|
<div class="summary-stack">
|
|
<div class="summary-row">
|
|
<span>当前 source 预览图数量</span>
|
|
<strong>${escapeHtml(String(thumbnailCache.count))}</strong>
|
|
</div>
|
|
<div class="summary-row">
|
|
<span>总占用空间</span>
|
|
<strong>${formatBytes(thumbnailCache.totalBytes)}</strong>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderBrowse(payload) {
|
|
state.currentPath = payload.path;
|
|
state.currentParent = payload.parent;
|
|
renderPathTrail(payload.path);
|
|
|
|
if (payload.entries.length === 0) {
|
|
browseResultElement.innerHTML = `
|
|
<div class="empty-state-card">
|
|
<p>这个目录目前没有内容。</p>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
browseResultElement.innerHTML = `
|
|
<div class="browse-list">
|
|
${payload.entries
|
|
.map((entry) => {
|
|
if (entry.type === 'dir') {
|
|
return `
|
|
<article class="browse-row browse-row-clickable" data-path="${escapeHtml(entry.path)}" data-entry-type="dir">
|
|
<div class="browse-row-main">
|
|
<div class="browse-thumbnail browse-thumbnail-placeholder browse-thumbnail-folder">DIR</div>
|
|
<div>
|
|
<strong>${escapeHtml(entry.name)}</strong>
|
|
<span class="browse-type">dir</span>
|
|
</div>
|
|
</div>
|
|
<div class="browse-meta">
|
|
<span>${escapeHtml(entry.path)}</span>
|
|
<span>${entry.mtime ? escapeHtml(entry.mtime) : '目录'}</span>
|
|
<span>点击进入目录</span>
|
|
</div>
|
|
</article>
|
|
`;
|
|
}
|
|
|
|
const thumbnail = entry.thumbnail?.available
|
|
? `
|
|
<img
|
|
class="browse-thumbnail-image"
|
|
src="${escapeHtml(entry.thumbnail.thumbnailUrl)}"
|
|
alt="${escapeHtml(entry.name)} 的预览图"
|
|
loading="lazy"
|
|
>
|
|
`
|
|
: '<div class="browse-thumbnail browse-thumbnail-placeholder">FILE</div>';
|
|
|
|
return `
|
|
<article class="browse-row browse-row-clickable" data-file-id="${escapeHtml(entry.id)}" data-entry-type="file">
|
|
<div class="browse-row-main">
|
|
<div class="browse-thumbnail">${thumbnail}</div>
|
|
<div>
|
|
<strong>${escapeHtml(entry.name)}</strong>
|
|
<span class="browse-type">${escapeHtml(entry.mime ?? 'file')}</span>
|
|
</div>
|
|
</div>
|
|
<div class="browse-meta">
|
|
<span>${escapeHtml(entry.path)}</span>
|
|
<span>${formatBytes(entry.size)}</span>
|
|
<span>${entry.thumbnail?.available ? '已缓存预览图' : '点击打开文件页'}</span>
|
|
</div>
|
|
</article>
|
|
`;
|
|
})
|
|
.join('')}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderBrowseError(pathValue, error) {
|
|
renderPathTrail(pathValue);
|
|
browseResultElement.innerHTML = `
|
|
<div class="empty-state-card">
|
|
<p>无法列出目录内容。</p>
|
|
<p>${escapeHtml(error.message)}</p>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
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 = `
|
|
<div class="empty-state-card">
|
|
<p>正在读取目录...</p>
|
|
</div>
|
|
`;
|
|
|
|
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 = `
|
|
<div class="empty-state-card">
|
|
<p>无法进入数据源工作台。</p>
|
|
</div>
|
|
`;
|
|
});
|