将原来的三页跳转结构(控制台/工作台/文件页)重构为单页资源管理器: 左侧数据源盘符树、主区列表/网格双视图、右侧内嵌播放器与详情面板, 管理功能收进侧栏对话框。媒体引擎(media-core + media-sw)原样复用, 仅把 file.js 的播放器逻辑抽成参数驱动组件。默认路由指向 explorer。 - 新增 explorer.html/js 与 modules/explorer/ 六个组件模块 - 删除旧的 index/source/file 页面及其控制器 - styles.css 新增 explorer 布局与三档响应式断点
379 lines
16 KiB
JavaScript
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);
|
|
}
|
|
}
|
|
};
|
|
}
|