Files
duplicati_preview/public/modules/explorer/manage.js
T
nanxun fcf56bcbd3 feat: rebuild frontend as file-manager style explorer SPA
将原来的三页跳转结构(控制台/工作台/文件页)重构为单页资源管理器:
左侧数据源盘符树、主区列表/网格双视图、右侧内嵌播放器与详情面板,
管理功能收进侧栏对话框。媒体引擎(media-core + media-sw)原样复用,
仅把 file.js 的播放器逻辑抽成参数驱动组件。默认路由指向 explorer。

- 新增 explorer.html/js 与 modules/explorer/ 六个组件模块
- 删除旧的 index/source/file 页面及其控制器
- styles.css 新增 explorer 布局与三档响应式断点
2026-07-04 14:53:07 +08:00

379 lines
16 KiB
JavaScript

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 `
<div class="summary-stack">
${rows
.map((row) => `<div class="summary-row"><span>${escapeHtml(row.label)}</span><strong>${escapeHtml(row.value)}</strong></div>`)
.join('')}
</div>
`;
}
export function createManage(rootElement, deps = {}) {
const { feedback, onSourcesChanged } = deps;
const cache = { sources: [], serverDb: null, defaults: null, tls: null };
let activeTab = 'sources';
function tabsHtml() {
return `
<div class="tabs">
${TABS.map(
(tab) => `<button type="button" data-tab="${tab.key}" class="${tab.key === activeTab ? 'active' : ''}">${escapeHtml(tab.label)}</button>`
).join('')}
</div>
`;
}
function renderSourcesTab() {
if (cache.sources.length === 0) {
return '<div class="empty-state-card">还没有上传任务数据库。切到「上传任务库」添加。</div>';
}
return `<div class="source-list">${cache.sources
.map((source) =>
renderSourceSummaryCard(
source,
`<div class="card-actions">
<button class="ghost-button button-link" type="button" data-open-source="${escapeHtml(source.id)}">在资源管理器打开</button>
<button class="ghost-button danger-button" type="button" data-delete-source="${escapeHtml(source.id)}">删除 source</button>
</div>`
)
)
.join('')}</div>`;
}
function renderUploadTab() {
return `
<p class="panel-copy">上传随机名任务数据库(如 <code>TMQRJYNADS.sqlite</code>)。重复上传会复用现有 source。</p>
<form id="mgUploadForm" class="upload-form">
<label class="file-picker"><span>选择任务 SQLite 文件</span>
<input id="mgDatabaseInput" name="database" type="file" accept=".sqlite,.db,application/octet-stream"></label>
<div class="form-actions"><button class="primary-button" type="submit">上传为新数据源</button></div>
</form>
`;
}
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 `
<p class="panel-copy">上传后可自动解析任务名称,并尽量从元数据推导默认目标 URL。</p>
<form id="mgServerDbForm" class="upload-form">
<label class="file-picker"><span>选择服务器数据库</span>
<input id="mgServerDbInput" name="database" type="file" accept=".sqlite,.db,application/octet-stream"></label>
<div class="form-actions"><button class="ghost-button" type="submit">上传 Server DB</button></div>
</form>
${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 `
<p class="panel-copy">如果大多数任务共用同一套 WebDAV 账号和口令,可以在这里统一保存一次。</p>
<form id="mgDefaultsForm" class="secret-form">
<label><span>兜底 WebDAV Base URL</span><input id="mgGlobalUrl" type="url" value="${escapeHtml(defaults?.webdavBaseUrl ?? '')}" placeholder="可选,共享兜底 URL"></label>
<label><span>认证方式</span>
<select id="mgGlobalAuthMode">
<option value="" ${!defaults?.authMode ? 'selected' : ''}>自动 / 继承</option>
<option value="basic" ${defaults?.authMode === 'basic' ? 'selected' : ''}>Basic</option>
<option value="anonymous" ${defaults?.authMode === 'anonymous' ? 'selected' : ''}>匿名</option>
</select>
</label>
<label><span>用户名</span><input id="mgGlobalUsername" type="text" placeholder="可选,共享用户名"></label>
<label><span>密码</span><input id="mgGlobalPassword" type="password" placeholder="可选,共享密码"></label>
<label><span>备份口令</span><input id="mgGlobalPassphrase" type="password" placeholder="可选,共享备份口令"></label>
<div class="form-actions"><button class="ghost-button" type="submit">保存默认值</button></div>
</form>
${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 `
<p class="panel-copy">HTTP 和 HTTPS 会同时可用。没有自定义证书时系统会自动维护一张自签证书。</p>
${summary}
<form id="mgTlsForm" class="secret-form">
<label><span>证书模式</span>
<select id="mgTlsMode">
<option value="self-signed" ${tls?.mode !== 'custom-pem' ? 'selected' : ''}>自签证书</option>
<option value="custom-pem" ${tls?.mode === 'custom-pem' ? 'selected' : ''}>自定义 PEM</option>
</select>
</label>
<label><span>主域名</span><input id="mgTlsPrimaryDomain" type="text" value="${escapeHtml(tls?.primaryDomain ?? '')}" placeholder="例如:preview.example.com"></label>
<label><span>额外 SAN 条目</span><textarea id="mgTlsSans" rows="2" placeholder="每行一个域名或 IP">${escapeHtml(sans)}</textarea></label>
<label><span>证书 PEM</span><textarea id="mgTlsCertPem" rows="4" placeholder="-----BEGIN CERTIFICATE-----"></textarea></label>
<label><span>私钥 PEM</span><textarea id="mgTlsKeyPem" rows="4" placeholder="-----BEGIN PRIVATE KEY-----"></textarea></label>
<label><span>可选链证书 PEM</span><textarea id="mgTlsChainPem" rows="3" placeholder="可选,中间证书链"></textarea></label>
<div class="form-actions">
<button class="primary-button" type="submit">保存并应用</button>
<button id="mgTlsDeleteCustom" class="ghost-button danger-button" type="button" ${tls?.hasCustomCertificate ? '' : 'disabled'}>删除自定义证书并回退</button>
</div>
</form>
`;
}
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 = `
<div class="explorer-dialog-head">
<h3 class="serif">系统管理</h3>
<button class="explorer-icon-btn" type="button" data-manage-close aria-label="关闭">✕</button>
</div>
${tabsHtml()}
<div class="explorer-manage-content">${content}</div>
`;
}
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);
}
}
};
}