feat: add docker pipeline and preview fixes

This commit is contained in:
2026-05-06 12:59:50 +08:00
commit 9ba85b01f3
44 changed files with 13894 additions and 0 deletions
+449
View File
@@ -0,0 +1,449 @@
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 = `
<div class="summary-stack">
<div class="summary-row">
<span>当前 source 数量</span>
<strong>${escapeHtml(String(sourceCount))}</strong>
</div>
<div class="summary-row">
<span>已增强完成</span>
<strong>${escapeHtml(String(readyCount))}</strong>
</div>
<div class="summary-row">
<span>增强失败</span>
<strong>${escapeHtml(String(failedCount))}</strong>
</div>
<div class="summary-row">
<span>已映射任务名</span>
<strong>${escapeHtml(String(mappedCount))}</strong>
</div>
<div class="summary-row">
<span>Server DB</span>
<strong>${serverDbAvailable ? escapeHtml(state.serverDb.originalFilename) : '未上传'}</strong>
</div>
<div class="summary-row">
<span>全局 WebDAV 默认值</span>
<strong>${defaultsConfigured ? '已配置' : '未配置'}</strong>
</div>
</div>
`;
}
function renderServerDbSummary() {
if (!state.serverDb || !state.serverDb.available) {
serverDbSummaryElement.innerHTML = `
<div class="summary-stack">
<div class="summary-row">
<span>当前状态</span>
<strong>尚未上传 Duplicati-server.sqlite</strong>
</div>
<div class="summary-row">
<span>说明</span>
<strong>没有它也能浏览和增强,只是任务名和默认目标 URL 无法自动映射。</strong>
</div>
</div>
`;
return;
}
serverDbSummaryElement.innerHTML = `
<div class="summary-stack">
<div class="summary-row">
<span>当前文件</span>
<strong>${escapeHtml(state.serverDb.originalFilename)}</strong>
</div>
<div class="summary-row">
<span>上传时间</span>
<strong>${escapeHtml(state.serverDb.uploadedAt)}</strong>
</div>
<div class="summary-row">
<span>任务数量</span>
<strong>${escapeHtml(String(state.serverDb.backupCount))}</strong>
</div>
</div>
`;
}
function renderGlobalDefaultsSummary() {
const defaults = state.globalDefaults;
if (!defaults?.configured) {
globalDefaultsSummaryElement.innerHTML = `
<div class="summary-stack">
<div class="summary-row">
<span>当前状态</span>
<strong>尚未保存全局默认值</strong>
</div>
<div class="summary-row">
<span>说明</span>
<strong>保存一次后,后续 source 可以继承这里的认证信息和备份口令。</strong>
</div>
</div>
`;
writeGlobalDefaultsForm(null);
return;
}
writeGlobalDefaultsForm(defaults);
globalDefaultsSummaryElement.innerHTML = `
<div class="summary-stack">
<div class="summary-row">
<span>Fallback URL</span>
<strong>${escapeHtml(defaults.webdavBaseUrl ?? '未设置')}</strong>
</div>
<div class="summary-row">
<span>认证方式</span>
<strong>${escapeHtml(defaults.authMode ?? '自动')}</strong>
</div>
<div class="summary-row">
<span>已保存凭据</span>
<strong>
username=${defaults.hasUsername ? 'yes' : 'no'},
password=${defaults.hasPassword ? 'yes' : 'no'},
passphrase=${defaults.hasPassphrase ? 'yes' : 'no'}
</strong>
</div>
<div class="summary-row">
<span>更新时间</span>
<strong>${escapeHtml(defaults.updatedAt ?? '未知')}</strong>
</div>
</div>
`;
}
function renderSources() {
if (state.sources.length === 0) {
sourceListElement.innerHTML = `
<div class="empty-state-card">
<p>还没有任何任务数据源。</p>
<p>先上传一个随机名任务库,然后再进入工作台浏览目录、做增强或进入文件页预览下载。</p>
</div>
`;
return;
}
sourceListElement.innerHTML = state.sources
.map((source) => {
const targetHint = source.webdav?.effectiveWebdavBaseUrl
? `<div class="summary-chip">默认目标 URL 来源: ${escapeHtml(source.webdav.effectiveWebdavBaseUrlSource ?? 'unknown')}</div>`
: '<div class="summary-chip">当前还没有可用的默认目标 URL</div>';
return renderSourceSummaryCard(
source,
`
${targetHint}
<div class="card-actions">
<a class="ghost-button button-link" href="${linkToSource(source.id)}#overview">进入工作台</a>
<button class="ghost-button danger-button" type="button" data-delete-source-id="${escapeHtml(source.id)}">删除 source</button>
</div>
`
);
})
.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 = `
<div class="empty-state-card">
<p>无法读取数据源列表。</p>
</div>
`;
});