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 布局与三档响应式断点
This commit is contained in:
2026-07-04 14:53:07 +08:00
parent 10d7b49275
commit fcf56bcbd3
18 changed files with 3444 additions and 3055 deletions
-8
View File
@@ -80,14 +80,6 @@ export function createFeedbackController(element) {
};
}
export function linkToSource(sourceId) {
return `/source.html?sourceId=${encodeURIComponent(sourceId)}`;
}
export function linkToFile(sourceId, fileId) {
return `/file.html?sourceId=${encodeURIComponent(sourceId)}&id=${encodeURIComponent(fileId)}`;
}
export function inferEnhancementLabel(source) {
switch (source?.enhancement?.status) {
case 'ready':
+271
View File
@@ -0,0 +1,271 @@
import { escapeHtml, fetchJson, formatBytes, inferEnhancementClass, inferEnhancementLabel } from '../common.js';
import { buildVolumeMap, isVideoMime, restoreSegmentBytes, supportsFileDownload } from '../media-core.js';
// 右侧详情面板:
// - 选中文件 → 属性 + 内嵌 <video> 播放器 + 预览/下载按钮
// - 选中数据源 → 源概况(移植 source.js renderSourceSummary
// - 空 → 提示
//
// 依赖注入:mediaPlayer(createMediaPlayer 实例)、resolveSecrets(异步返回凭据或抛出)、
// feedback(createFeedbackController)、onThumbnailUpdated(回调刷新列表缩略图)
function isVideoFileInfo(fileInfo) {
return Boolean(fileInfo && isVideoMime(fileInfo.file.mime));
}
export function createDetailPanel(rootElement, deps = {}) {
const { mediaPlayer, resolveSecrets, feedback, onThumbnailUpdated } = deps;
const state = {
mode: 'empty',
sourceId: null,
entry: null,
fileInfo: null,
loadToken: 0
};
function renderEmpty(message = '选中一个文件或数据源查看详情。') {
state.mode = 'empty';
rootElement.innerHTML = `<div class="empty-state-card">${escapeHtml(message)}</div>`;
}
function renderSourceOverview(source) {
state.mode = 'source';
if (!source) {
renderEmpty('未找到对应数据源。');
return;
}
const enhancementError = source.enhancement?.lastErrorMessage
? `<div class="feedback feedback-error">最近失败:${escapeHtml(source.enhancement.lastErrorMessage)}</div>`
: '';
rootElement.innerHTML = `
<div class="explorer-detail-head">
<span class="explorer-detail-ico">💽</span>
<div>
<div class="explorer-detail-title serif">${escapeHtml(source.displayName || source.originalFilename)}</div>
<div class="status-pill ${inferEnhancementClass(source)}">${inferEnhancementLabel(source)}</div>
</div>
</div>
<hr>
<div class="summary-stack">
<div class="summary-row"><span>数据库文件</span><strong>${escapeHtml(source.originalFilename)}</strong></div>
<div class="summary-row"><span>Source ID</span><strong>${escapeHtml(source.id)}</strong></div>
<div class="summary-row"><span>上传大小</span><strong>${formatBytes(source.fileSize)}</strong></div>
<div class="summary-row"><span>目录浏览</span><strong>${source.canBrowse ? '可用' : '不可用'}</strong></div>
<div class="summary-row"><span>预览图缓存</span><strong>${escapeHtml(String(source.thumbnailCache?.count ?? 0))} 张</strong></div>
<div class="summary-row"><span>最新快照</span><strong>${escapeHtml(source.latestSnapshot?.timestamp ?? '暂无')}</strong></div>
</div>
${enhancementError}
<div class="explorer-detail-actions">
<button class="ghost-button explorer-pill" type="button" data-detail-action="props">⚙ 属性 / 增强配置</button>
</div>
`;
}
function renderFileLoading(entry) {
state.mode = 'file';
rootElement.innerHTML = `
<div class="explorer-detail-head">
<span class="explorer-detail-ico">📄</span>
<div><div class="explorer-detail-title serif">${escapeHtml(entry.name)}</div></div>
</div>
<hr>
<div class="summary-card">正在读取文件信息...</div>
`;
}
function renderFile(entry, fileInfo, { error = null } = {}) {
state.mode = 'file';
const canPreview = isVideoFileInfo(fileInfo);
const previewBlock = canPreview
? `
<div class="explorer-preview-frame">
<video id="detailVideo" controls playsinline preload="metadata"></video>
</div>`
: `
<div class="explorer-preview-frame explorer-preview-static">
${
entry.thumbnail?.available
? `<img src="${escapeHtml(entry.thumbnail.thumbnailUrl)}" alt="">`
: '<span class="explorer-detail-ico-lg">📄</span>'
}
</div>`;
const infoAvailable = Boolean(fileInfo);
const errorBlock = error ? `<div class="feedback feedback-error">${escapeHtml(error)}</div>` : '';
rootElement.innerHTML = `
${previewBlock}
<div class="explorer-detail-title serif">${escapeHtml(entry.name)}</div>
<hr>
<div class="summary-stack">
<div class="summary-row"><span>类型</span><strong>${escapeHtml(entry.mime || '未知')}</strong></div>
<div class="summary-row"><span>大小</span><strong>${formatBytes(entry.size)}</strong></div>
<div class="summary-row"><span>修改时间</span><strong>${escapeHtml(entry.mtime ?? '—')}</strong></div>
<div class="summary-row"><span>路径</span><strong class="muted">${escapeHtml(entry.path)}</strong></div>
</div>
${errorBlock}
<div id="detailStatus" class="explorer-detail-status" hidden></div>
<div class="explorer-detail-actions">
${canPreview ? '<button class="primary-button explorer-pill" type="button" data-detail-action="preview">▶ 预览</button>' : ''}
<button class="ghost-button explorer-pill" type="button" data-detail-action="download" ${infoAvailable ? '' : 'disabled'}>↓ 下载</button>
</div>
`;
}
async function loadAndRenderFile(sourceId, entry) {
state.sourceId = sourceId;
state.entry = entry;
state.fileInfo = null;
const token = ++state.loadToken;
renderFileLoading(entry);
try {
const payload = await fetchJson(
`/api/file-info?sourceId=${encodeURIComponent(sourceId)}&id=${encodeURIComponent(entry.id)}`
);
if (token !== state.loadToken) {
return;
}
state.fileInfo = payload;
renderFile(entry, payload);
} catch (error) {
if (token !== state.loadToken) {
return;
}
renderFile(entry, null, { error: error.message });
}
}
function setDetailStatus(message) {
const el = rootElement.querySelector('#detailStatus');
if (!el) {
return;
}
el.hidden = false;
el.textContent = message;
}
async function startPreview() {
const videoElement = rootElement.querySelector('#detailVideo');
if (!videoElement || !state.fileInfo) {
return;
}
try {
const secrets = await resolveSecrets(state.sourceId, state.fileInfo);
feedback?.set('info', '正在注册浏览器端媒体会话...');
await mediaPlayer.play({
videoElement,
sourceId: state.sourceId,
fileId: state.entry.id,
fileInfo: state.fileInfo,
secrets
});
feedback?.set('success', '视频预览已就绪,可播放并拖动进度。');
} catch (error) {
if (error?.code === 'CREDENTIALS_CANCELLED') {
return;
}
feedback?.set('error', error.message);
}
}
async function downloadFile() {
if (!state.fileInfo) {
return;
}
try {
if (!supportsFileDownload()) {
throw new Error('BROWSER_DOWNLOAD_UNSUPPORTED: 当前浏览器不支持 OPFS 或 showSaveFilePicker。');
}
const secrets = await resolveSecrets(state.sourceId, state.fileInfo);
const fileInfo = state.fileInfo;
const extension = fileInfo.file.name.includes('.') ? `.${fileInfo.file.name.split('.').pop()}` : '.bin';
const fileHandle = await showSaveFilePicker({
suggestedName: fileInfo.file.name,
types: [
{
description: 'Restored file',
accept: { [fileInfo.file.mime || 'application/octet-stream']: [extension] }
}
]
});
feedback?.set('info', '正在浏览器直连下载...');
const writable = await fileHandle.createWritable();
const volumeByRef = buildVolumeMap(fileInfo.volumes);
let writtenBytes = 0;
for (const segment of fileInfo.segments) {
const volume = volumeByRef.get(segment.volumeRef);
setDetailStatus(`正在恢复卷 ${volume?.name ?? segment.volumeRef} · 已写入 ${formatBytes(writtenBytes)}`);
const bytes = await restoreSegmentBytes({
sourceId: state.sourceId,
volume,
zip: segment.zip,
secrets,
onStatus: ({ phase, currentVolume }) => {
setDetailStatus(`${phase ?? '处理中'} · ${currentVolume ?? ''} · 已写入 ${formatBytes(writtenBytes)}`);
}
});
await writable.write(bytes);
writtenBytes += bytes.length;
}
await writable.close();
setDetailStatus(`下载完成 · 共 ${formatBytes(writtenBytes)}`);
feedback?.set('success', '文件已直接恢复并写入本地。');
} catch (error) {
if (error?.name === 'AbortError' || error?.code === 'CREDENTIALS_CANCELLED') {
return;
}
setDetailStatus(error.message);
feedback?.set('error', error.message);
}
}
rootElement.addEventListener('click', (event) => {
const button = event.target.closest('[data-detail-action]');
if (!(button instanceof HTMLElement)) {
return;
}
const action = button.dataset.detailAction;
if (action === 'preview') {
void startPreview();
} else if (action === 'download') {
void downloadFile();
} else if (action === 'props') {
deps.onOpenProps?.(state.sourceId);
}
});
return {
async showFile(sourceId, entry) {
await mediaPlayer.stop();
await loadAndRenderFile(sourceId, entry);
},
async showSource(source) {
await mediaPlayer.stop();
state.sourceId = source?.id ?? null;
state.entry = null;
state.fileInfo = null;
renderSourceOverview(source);
},
async clear() {
await mediaPlayer.stop();
state.sourceId = null;
state.entry = null;
state.fileInfo = null;
renderEmpty();
},
updateThumbnail(fileId, thumbnail) {
if (state.entry?.id === fileId) {
state.entry.thumbnail = thumbnail;
}
onThumbnailUpdated?.(fileId, thumbnail);
}
};
}
+341
View File
@@ -0,0 +1,341 @@
import { escapeHtml, formatBytes } from '../common.js';
// 文件列表组件:列表(表格) + 网格双视图、列头/下拉排序、多选、缩略图。
// 移植自 source.js 的 renderBrowse / 批量选择逻辑,改造成表格与网格。
// 事件回调:onOpenDir(path) / onOpenFile(entry) / onSelectFile(entry) / onSelectionChange(set)
function isVideoEntry(entry) {
return String(entry.mime ?? '').toLowerCase().startsWith('video/') || entry.hints?.preview === 'video';
}
function typeLabel(entry) {
if (entry.type === 'dir') {
return '文件夹';
}
if (entry.hints?.preview) {
return entry.hints.preview;
}
return (entry.mime || 'file').split('/').pop();
}
function thumbInner(entry) {
if (entry.type === 'dir') {
return '<span class="explorer-thumb-ico">📁</span>';
}
if (entry.thumbnail?.available) {
return `<img src="${escapeHtml(entry.thumbnail.thumbnailUrl)}" alt="" loading="lazy">`;
}
if (isVideoEntry(entry)) {
return '<span class="explorer-thumb-ico">🎬</span>';
}
if (String(entry.mime ?? '').startsWith('image/')) {
return '<span class="explorer-thumb-ico">🖼</span>';
}
return '<span class="explorer-thumb-ico">📄</span>';
}
function compareEntries(a, b, sortKey, sortDir) {
// 文件夹永远排在文件前面(与后端 sortEntries 一致)
if (a.type !== b.type) {
return a.type === 'dir' ? -1 : 1;
}
let result = 0;
switch (sortKey) {
case 'size':
result = (Number(a.size) || 0) - (Number(b.size) || 0);
break;
case 'type':
result = typeLabel(a).localeCompare(typeLabel(b), 'en');
break;
case 'mtime':
result = String(a.mtime ?? '').localeCompare(String(b.mtime ?? ''));
break;
case 'name':
default:
result = a.name.localeCompare(b.name, 'zh-Hans-CN');
break;
}
return sortDir === 'desc' ? -result : result;
}
export function createFileList(rootElement, handlers = {}) {
const { onOpenDir, onOpenFile, onSelectFile, onSelectionChange } = handlers;
const state = {
entries: [],
view: 'list',
sortKey: 'name',
sortDir: 'asc',
filter: '',
selected: new Set(),
activeFileId: null
};
function visibleEntries() {
const filter = state.filter.trim().toLowerCase();
const filtered = filter
? state.entries.filter((entry) => entry.name.toLowerCase().includes(filter))
: state.entries.slice();
return filtered.sort((a, b) => compareEntries(a, b, state.sortKey, state.sortDir));
}
function fileEntries() {
return state.entries.filter((entry) => entry.type === 'file');
}
function renderRows(entries) {
return entries
.map((entry) => {
const selected = entry.type === 'file' && state.selected.has(entry.id);
const active = entry.type === 'file' && entry.id === state.activeFileId;
const rowClasses = [
'explorer-row',
entry.type === 'dir' ? 'is-dir' : 'is-file',
selected ? 'selected' : '',
active ? 'active' : ''
]
.filter(Boolean)
.join(' ');
const checkboxCell =
entry.type === 'file'
? `<input type="checkbox" class="explorer-row-check" data-file-id="${escapeHtml(entry.id)}" ${selected ? 'checked' : ''} aria-label="选择 ${escapeHtml(entry.name)}">`
: '';
const dataAttrs =
entry.type === 'dir'
? `data-type="dir" data-path="${escapeHtml(entry.path)}"`
: `data-type="file" data-file-id="${escapeHtml(entry.id)}"`;
return `
<tr class="${rowClasses}" ${dataAttrs}>
<td class="explorer-col-chk">${checkboxCell}</td>
<td>
<div class="explorer-name-cell">
<span class="explorer-thumb${entry.type === 'dir' ? ' folder' : ''}">
${thumbInner(entry)}${isVideoEntry(entry) ? '<span class="explorer-play">▶</span>' : ''}
</span>
<span class="explorer-name-txt">${escapeHtml(entry.name)}</span>
</div>
</td>
<td class="explorer-col-size">${entry.type === 'dir' ? '—' : formatBytes(entry.size)}</td>
<td class="explorer-col-type"><span class="explorer-badge-type">${escapeHtml(typeLabel(entry))}</span></td>
<td class="explorer-col-time muted">${escapeHtml(entry.mtime ?? '—')}</td>
</tr>
`;
})
.join('');
}
function renderCards(entries) {
return entries
.map((entry) => {
const selected = entry.type === 'file' && state.selected.has(entry.id);
const active = entry.type === 'file' && entry.id === state.activeFileId;
const cardClasses = [
'explorer-card',
entry.type === 'dir' ? 'is-dir' : 'is-file',
selected ? 'selected' : '',
active ? 'active' : ''
]
.filter(Boolean)
.join(' ');
const dataAttrs =
entry.type === 'dir'
? `data-type="dir" data-path="${escapeHtml(entry.path)}"`
: `data-type="file" data-file-id="${escapeHtml(entry.id)}"`;
const checkbox =
entry.type === 'file'
? `<input type="checkbox" class="explorer-row-check explorer-card-check" data-file-id="${escapeHtml(entry.id)}" ${selected ? 'checked' : ''} aria-label="选择 ${escapeHtml(entry.name)}">`
: '';
return `
<div class="${cardClasses}" ${dataAttrs}>
${checkbox}
<div class="explorer-cover${entry.type === 'dir' ? ' folder' : ''}">
${thumbInner(entry)}${isVideoEntry(entry) ? '<span class="explorer-play-lg">▶</span>' : ''}
</div>
<div class="explorer-card-cap" title="${escapeHtml(entry.name)}">${escapeHtml(entry.name)}</div>
<div class="explorer-card-sub">${entry.type === 'dir' ? '文件夹' : formatBytes(entry.size)}</div>
</div>
`;
})
.join('');
}
function sortArrow(key) {
if (state.sortKey !== key) {
return '';
}
return `<span class="explorer-arrow">${state.sortDir === 'asc' ? '▲' : '▼'}</span>`;
}
function render() {
const entries = visibleEntries();
if (state.entries.length === 0) {
rootElement.innerHTML = '<div class="empty-state-card">这个目录目前没有内容。</div>';
return;
}
if (entries.length === 0) {
rootElement.innerHTML = '<div class="empty-state-card">没有匹配当前搜索的文件。</div>';
return;
}
if (state.view === 'grid') {
rootElement.innerHTML = `<div class="explorer-grid">${renderCards(entries)}</div>`;
return;
}
rootElement.innerHTML = `
<table class="explorer-tbl">
<thead>
<tr>
<th class="explorer-col-chk"></th>
<th class="explorer-sortable" data-sort="name">名称 ${sortArrow('name')}</th>
<th class="explorer-col-size explorer-sortable" data-sort="size">大小 ${sortArrow('size')}</th>
<th class="explorer-col-type explorer-sortable" data-sort="type">类型 ${sortArrow('type')}</th>
<th class="explorer-col-time explorer-sortable" data-sort="mtime">修改时间 ${sortArrow('mtime')}</th>
</tr>
</thead>
<tbody>${renderRows(entries)}</tbody>
</table>
`;
}
function emitSelectionChange() {
onSelectionChange?.(new Set(state.selected), fileEntries().length);
}
function toggleSelection(fileId, selected) {
if (selected) {
state.selected.add(fileId);
} else {
state.selected.delete(fileId);
}
render();
emitSelectionChange();
}
// ── 事件委托 ──
rootElement.addEventListener('click', (event) => {
const checkbox = event.target.closest('.explorer-row-check');
if (checkbox instanceof HTMLInputElement) {
event.stopPropagation();
toggleSelection(checkbox.dataset.fileId, checkbox.checked);
return;
}
const sortable = event.target.closest('.explorer-sortable');
if (sortable instanceof HTMLElement) {
const key = sortable.dataset.sort;
if (state.sortKey === key) {
state.sortDir = state.sortDir === 'asc' ? 'desc' : 'asc';
} else {
state.sortKey = key;
state.sortDir = 'asc';
}
render();
return;
}
const item = event.target.closest('[data-type]');
if (!(item instanceof HTMLElement)) {
return;
}
if (item.dataset.type === 'dir') {
onOpenDir?.(item.dataset.path);
return;
}
const entry = state.entries.find((candidate) => candidate.id === item.dataset.fileId);
if (entry) {
state.activeFileId = entry.id;
render();
onSelectFile?.(entry);
}
});
rootElement.addEventListener('dblclick', (event) => {
const item = event.target.closest('[data-type="file"]');
if (!(item instanceof HTMLElement)) {
return;
}
const entry = state.entries.find((candidate) => candidate.id === item.dataset.fileId);
if (entry) {
onOpenFile?.(entry);
}
});
rootElement.addEventListener('contextmenu', (event) => {
const item = event.target.closest('[data-type]');
if (!(item instanceof HTMLElement) || !handlers.onContextMenu) {
return;
}
event.preventDefault();
const entry =
item.dataset.type === 'file'
? state.entries.find((candidate) => candidate.id === item.dataset.fileId)
: { type: 'dir', path: item.dataset.path };
handlers.onContextMenu(entry, { x: event.clientX, y: event.clientY });
});
return {
setEntries(entries) {
state.entries = Array.isArray(entries) ? entries : [];
state.selected.clear();
state.activeFileId = null;
render();
emitSelectionChange();
},
setView(view) {
state.view = view === 'grid' ? 'grid' : 'list';
render();
},
setSort(key, dir) {
state.sortKey = key;
state.sortDir = dir;
render();
},
setFilter(value) {
state.filter = value ?? '';
render();
},
setActiveFile(fileId) {
state.activeFileId = fileId;
render();
},
selectAll(selected) {
if (selected) {
for (const entry of fileEntries()) {
state.selected.add(entry.id);
}
} else {
state.selected.clear();
}
render();
emitSelectionChange();
},
clearSelection() {
state.selected.clear();
state.activeFileId = null;
render();
emitSelectionChange();
},
getSelectedIds() {
return [...state.selected];
},
getFileCount() {
return fileEntries().length;
},
getEntries() {
return state.entries.slice();
}
};
}
+378
View File
@@ -0,0 +1,378 @@
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);
}
}
};
}
+407
View File
@@ -0,0 +1,407 @@
import { assertBrowserFeature } from '../common.js';
import { isVideoMime, supportsVideoPreview } from '../media-core.js';
import { analyzeFrameLuma, buildThumbnailCandidateTimes } from '../thumbnail-utils.js';
// 参数驱动的媒体播放器组件。从 file.js 抽取媒体会话逻辑,去掉页面全局与硬编码 ID。
// 一个实例管理:Service Worker 注册、session 注册/注销、<video> 挂载、预览图自动抓取。
//
// 用法:
// const player = createMediaPlayer({ canvas, onStatus, onThumbnail });
// await player.play({ videoElement, sourceId, fileId, fileInfo, secrets });
// await player.stop();
const STATUS_CHANNEL_NAME = 'duplicati-media-status';
export function createMediaPlayer({ canvas, onStatus = () => {}, onThumbnail = () => {} } = {}) {
const state = {
videoElement: null,
sourceId: null,
fileId: null,
fileInfo: null,
secrets: null,
currentSessionId: null,
probeSessionId: null,
workerChannel: null,
loadedDataHandler: null,
thumbnail: {
inFlight: false,
attempted: false
}
};
async function sendWorkerMessage(type, payload) {
const registration = await navigator.serviceWorker.ready;
const worker = navigator.serviceWorker.controller ?? registration.active ?? registration.waiting;
if (!worker) {
throw new Error('SERVICE_WORKER_UNAVAILABLE: Service Worker is not ready.');
}
return new Promise((resolve, reject) => {
const channel = new MessageChannel();
const timeoutId = setTimeout(() => {
reject(new Error('SERVICE_WORKER_TIMEOUT: Service Worker did not reply in time.'));
}, 10_000);
channel.port1.onmessage = (event) => {
clearTimeout(timeoutId);
if (event.data?.ok) {
resolve(event.data);
} else {
reject(new Error(event.data?.error ?? 'Service Worker request failed.'));
}
};
worker.postMessage({ type, ...payload }, [channel.port2]);
});
}
async function ensureServiceWorkerReady() {
assertBrowserFeature(navigator.serviceWorker, 'SERVICE_WORKER_UNSUPPORTED: 当前浏览器不支持 Service Worker。');
await navigator.serviceWorker.register('/media-sw.js', { scope: '/', type: 'module' });
await navigator.serviceWorker.ready;
if (!navigator.serviceWorker.controller) {
await new Promise((resolve) => {
navigator.serviceWorker.addEventListener('controllerchange', () => resolve(), { once: true });
});
}
}
function ensureWorkerStatusChannel() {
if (state.workerChannel || typeof BroadcastChannel !== 'function') {
return;
}
state.workerChannel = new BroadcastChannel(STATUS_CHANNEL_NAME);
state.workerChannel.addEventListener('message', (event) => {
if (event.data?.type !== 'status' || event.data.sessionId !== state.currentSessionId) {
return;
}
onStatus(event.data.status);
});
}
async function unregisterWorkerSession(sessionId) {
if (!sessionId) {
return;
}
try {
await ensureServiceWorkerReady();
await sendWorkerMessage('unregister-session', { sessionId });
} catch {
// ignore stale or already-closed sessions
}
}
async function registerPreviewSession() {
await ensureServiceWorkerReady();
ensureWorkerStatusChannel();
if (state.currentSessionId) {
try {
await sendWorkerMessage('unregister-session', { sessionId: state.currentSessionId });
} catch {
// ignore stale sessions during re-registration
}
}
const sessionId = crypto.randomUUID();
const response = await sendWorkerMessage('register-session', {
sessionId,
sourceId: state.sourceId,
fileInfo: state.fileInfo,
secrets: state.secrets
});
state.currentSessionId = sessionId;
onStatus(response.status);
return sessionId;
}
// ── 预览图抓取(隐藏探测视频) ──
function waitForEventOnce(target, eventName, timeoutMs, readyCheck = null) {
if (typeof readyCheck === 'function' && readyCheck()) {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
cleanup();
reject(new Error(`THUMBNAIL_EVENT_TIMEOUT: Timed out waiting for ${eventName}.`));
}, timeoutMs);
function cleanup() {
clearTimeout(timeoutId);
target.removeEventListener(eventName, handleSuccess);
target.removeEventListener('error', handleError);
}
function handleSuccess() {
cleanup();
resolve();
}
function handleError() {
cleanup();
reject(new Error(`THUMBNAIL_VIDEO_ERROR: probe video failed during ${eventName}.`));
}
target.addEventListener(eventName, handleSuccess, { once: true });
target.addEventListener('error', handleError, { once: true });
});
}
async function waitForStableVideoFrame(videoElement) {
if (typeof videoElement.requestVideoFrameCallback === 'function') {
await new Promise((resolve) => {
videoElement.requestVideoFrameCallback(() => resolve());
});
return;
}
await waitForEventOnce(
videoElement,
'loadeddata',
5_000,
() => videoElement.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA
);
await new Promise((resolve) => setTimeout(resolve, 80));
}
async function seekHiddenVideoTo(videoElement, seconds) {
const targetTime = Number(seconds);
if (!Number.isFinite(targetTime) || targetTime < 0) {
return;
}
if (Math.abs(videoElement.currentTime - targetTime) > 0.05) {
const seekPromise = waitForEventOnce(videoElement, 'seeked', 12_000);
videoElement.currentTime = targetTime;
await seekPromise;
} else if (videoElement.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) {
await waitForEventOnce(
videoElement,
'loadeddata',
12_000,
() => videoElement.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA
);
}
await waitForStableVideoFrame(videoElement);
}
async function createThumbnailProbeVideo(sessionId) {
const probeVideo = document.createElement('video');
probeVideo.muted = true;
probeVideo.playsInline = true;
probeVideo.preload = 'auto';
probeVideo.controls = false;
Object.assign(probeVideo.style, {
position: 'fixed',
opacity: '0',
pointerEvents: 'none',
width: '1px',
height: '1px',
left: '-9999px',
top: '-9999px'
});
probeVideo.src = `/__media__/session/${encodeURIComponent(sessionId)}`;
document.body.append(probeVideo);
probeVideo.load();
await waitForEventOnce(
probeVideo,
'loadedmetadata',
12_000,
() => probeVideo.readyState >= HTMLMediaElement.HAVE_METADATA
);
return probeVideo;
}
function cleanupThumbnailProbeVideo(videoElement) {
if (!videoElement) {
return;
}
videoElement.pause();
videoElement.removeAttribute('src');
videoElement.load();
videoElement.remove();
}
async function createThumbnailBlobFromVideo(videoElement) {
const maxWidth = 480;
const sourceWidth = Math.max(videoElement.videoWidth || 0, 1);
const sourceHeight = Math.max(videoElement.videoHeight || 0, 1);
const scale = Math.min(1, maxWidth / sourceWidth);
const targetWidth = Math.max(1, Math.round(sourceWidth * scale));
const targetHeight = Math.max(1, Math.round(sourceHeight * scale));
canvas.width = targetWidth;
canvas.height = targetHeight;
const context = canvas.getContext('2d', { alpha: false });
if (!context) {
throw new Error('THUMBNAIL_CANVAS_UNAVAILABLE: 无法创建缩略图绘制上下文。');
}
context.drawImage(videoElement, 0, 0, targetWidth, targetHeight);
const frameAnalysis = analyzeFrameLuma(context.getImageData(0, 0, targetWidth, targetHeight).data);
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (!blob) {
reject(new Error('THUMBNAIL_CAPTURE_FAILED: 浏览器没有生成有效的缩略图 Blob。'));
return;
}
resolve({ blob, frameAnalysis });
},
'image/webp',
0.82
);
});
}
async function maybeCaptureThumbnail() {
if (!canvas || !state.fileInfo || !state.currentSessionId || !isVideoMime(state.fileInfo.file.mime)) {
return;
}
if (state.fileInfo.file.thumbnail?.available) {
return;
}
if (state.thumbnail.inFlight || state.thumbnail.attempted) {
return;
}
const player = state.videoElement;
if (!player || player.readyState < HTMLMediaElement.HAVE_CURRENT_DATA || player.videoWidth === 0) {
return;
}
state.thumbnail.inFlight = true;
state.thumbnail.attempted = true;
const activeSessionId = state.currentSessionId;
const probeSessionId = crypto.randomUUID();
state.probeSessionId = probeSessionId;
let probeVideo = null;
try {
await sendWorkerMessage('register-session', {
sessionId: probeSessionId,
sourceId: state.sourceId,
fileInfo: state.fileInfo,
secrets: state.secrets
});
probeVideo = await createThumbnailProbeVideo(probeSessionId);
const candidates = buildThumbnailCandidateTimes(probeVideo.duration);
let selectedBlob = null;
let fallbackBlob = null;
for (let index = 0; index < candidates.length; index += 1) {
if (state.currentSessionId !== activeSessionId) {
return;
}
await seekHiddenVideoTo(probeVideo, candidates[index]);
const { blob, frameAnalysis } = await createThumbnailBlobFromVideo(probeVideo);
fallbackBlob = blob;
if (!frameAnalysis.isBlackFrame) {
selectedBlob = blob;
break;
}
}
const blobToUpload = selectedBlob ?? fallbackBlob;
if (!blobToUpload) {
throw new Error('THUMBNAIL_CAPTURE_FAILED: 没有拿到可上传的预览图。');
}
if (state.currentSessionId !== activeSessionId) {
return;
}
const formData = new FormData();
formData.set('fileId', state.fileId);
formData.set('image', blobToUpload, 'preview.webp');
const response = await fetch(`/api/sources/${encodeURIComponent(state.sourceId)}/thumbnails`, {
method: 'POST',
body: formData
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(`${payload.error?.code ?? 'THUMBNAIL_UPLOAD_FAILED'}: ${payload.error?.message ?? '预览图上传失败。'}`);
}
if (state.fileInfo?.file) {
state.fileInfo.file.thumbnail = payload.thumbnail;
}
onThumbnail({ fileId: state.fileId, thumbnail: payload.thumbnail });
} catch {
// 预览图抓取失败不影响播放,静默处理
} finally {
cleanupThumbnailProbeVideo(probeVideo);
await unregisterWorkerSession(probeSessionId);
if (state.probeSessionId === probeSessionId) {
state.probeSessionId = null;
}
state.thumbnail.inFlight = false;
}
}
function detachVideo() {
const player = state.videoElement;
if (!player) {
return;
}
if (state.loadedDataHandler) {
player.removeEventListener('loadeddata', state.loadedDataHandler);
state.loadedDataHandler = null;
}
player.pause();
player.removeAttribute('src');
player.load();
}
return {
canPreview(fileInfo) {
return Boolean(supportsVideoPreview() && fileInfo && isVideoMime(fileInfo.file.mime));
},
async play({ videoElement, sourceId, fileId, fileInfo, secrets }) {
assertBrowserFeature(
supportsVideoPreview(),
'BROWSER_PREVIEW_UNSUPPORTED: 当前浏览器不支持 OPFS、Service Worker 或 Range 预览所需能力。'
);
if (!fileInfo) {
throw new Error('FILE_INFO_UNAVAILABLE: 当前文件还不能恢复。');
}
if (!isVideoMime(fileInfo.file.mime)) {
throw new Error('VIDEO_MIME_UNSUPPORTED: 当前文件不是视频,无法启用播放器。');
}
// 切换文件:先拆掉旧的 video 绑定与 session
detachVideo();
state.videoElement = videoElement;
state.sourceId = sourceId;
state.fileId = fileId;
state.fileInfo = fileInfo;
state.secrets = secrets;
state.thumbnail = { inFlight: false, attempted: Boolean(fileInfo.file.thumbnail?.available) };
const sessionId = await registerPreviewSession();
state.loadedDataHandler = () => {
void maybeCaptureThumbnail();
};
videoElement.addEventListener('loadeddata', state.loadedDataHandler);
videoElement.src = `/__media__/session/${encodeURIComponent(sessionId)}`;
videoElement.load();
return sessionId;
},
async stop() {
detachVideo();
await unregisterWorkerSession(state.probeSessionId);
state.probeSessionId = null;
if (state.currentSessionId) {
await unregisterWorkerSession(state.currentSessionId);
state.currentSessionId = null;
}
state.videoElement = null;
state.fileInfo = null;
state.secrets = null;
onStatus(null);
}
};
}
+226
View File
@@ -0,0 +1,226 @@
import { escapeHtml, fetchJson, formatBytes } from '../common.js';
// 源属性对话框:增强配置(覆盖 WebDAV/启动增强)、预览图缓存清理、删除源。
// 移植自 source.js 的 saveEnhancementSecrets / runEnhancement / clearSourceThumbnails / deleteCurrentSource。
// deps: { feedback, onSourceChanged, onSourceDeleted, close }
const TABS = [
{ key: 'enhance', label: '增强配置' },
{ key: 'thumbnails', label: '预览图缓存' },
{ key: 'danger', label: '危险操作' }
];
export function createPropsDialog(rootElement, deps = {}) {
const { feedback } = deps;
let source = null;
let activeTab = 'enhance';
function webdavHint() {
const webdav = source?.webdav;
if (!webdav) {
return '<div class="summary-card">正在计算这个 source 的默认 WebDAV 目标...</div>';
}
return `
<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>全局默认值</span><strong>${webdav.globalDefaultsConfigured ? '已配置' : '未配置'}</strong></div>
<div class="summary-row"><span>当前 source 覆盖</span><strong>${webdav.sourceOverrideSaved ? '已保存' : '未保存'}</strong></div>
</div>
`;
}
function renderEnhanceTab() {
return `
<p class="panel-copy">增强会优先使用全局 WebDAV 默认值,并尽量从 Server DB 推导目标 URL。这里只填与全局不同的部分;全部留空并保存会清掉当前 source 覆盖。</p>
${webdavHint()}
<form id="propsSecretForm" class="secret-form">
<label><span>Source 覆盖:WebDAV Base URL</span><input id="propsUrl" type="url" placeholder="留空则用全局默认 / Server DB 推导"></label>
<label><span>认证方式</span>
<select id="propsAuthMode">
<option value="">自动 / 继承</option>
<option value="basic">Basic</option>
<option value="anonymous">Anonymous</option>
</select>
</label>
<label><span>用户名</span><input id="propsUsername" type="text" placeholder="留空则继承全局默认"></label>
<label><span>密码</span><input id="propsPassword" type="password" placeholder="留空则继承全局默认"></label>
<label><span>备份口令</span><input id="propsPassphrase" type="password" placeholder="留空则继承全局默认"></label>
<div class="form-actions">
<button id="propsSaveSecrets" class="ghost-button" type="submit">保存覆盖</button>
<button id="propsRunEnhance" class="primary-button" type="button">按当前规则启动增强</button>
</div>
</form>
`;
}
function renderThumbnailsTab() {
const cache = source?.thumbnailCache ?? { count: 0, totalBytes: 0 };
return `
<p class="panel-copy">这里展示当前 source 已缓存到后端的预览图数量。清空后,视频会退回占位图,后续再次成功预览时会自动重建。</p>
<div class="summary-stack">
<div class="summary-row"><span>预览图数量</span><strong>${escapeHtml(String(cache.count))}</strong></div>
<div class="summary-row"><span>总占用空间</span><strong>${formatBytes(cache.totalBytes)}</strong></div>
</div>
<div class="form-actions">
<button id="propsClearThumbs" class="ghost-button danger-button" type="button">清空当前 source 预览图</button>
</div>
`;
}
function renderDangerTab() {
return `
<p class="panel-copy">删除会移除当前任务库、本地增强副本和该 source 的预览图缓存,但不会删除 Duplicati-server.sqlite。</p>
<div class="form-actions">
<button id="propsDeleteSource" class="primary-button danger-button" type="button" style="background:var(--danger)">删除这个 source</button>
</div>
`;
}
function renderBody() {
if (!source) {
rootElement.innerHTML = '<div class="empty-state-card">未找到对应数据源。</div>';
return;
}
let content = '';
if (activeTab === 'thumbnails') {
content = renderThumbnailsTab();
} else if (activeTab === 'danger') {
content = renderDangerTab();
} else {
content = renderEnhanceTab();
}
rootElement.innerHTML = `
<div class="explorer-dialog-head">
<h3 class="serif">${escapeHtml(source.displayName || source.originalFilename)} · 属性</h3>
<button class="explorer-icon-btn" type="button" data-props-close aria-label="关闭">✕</button>
</div>
<div class="tabs">
${TABS.map((tab) => `<button type="button" data-props-tab="${tab.key}" class="${tab.key === activeTab ? 'active' : ''}">${escapeHtml(tab.label)}</button>`).join('')}
</div>
<div class="explorer-manage-content">${content}</div>
`;
}
function readSecretForm() {
return {
webdavBaseUrl: rootElement.querySelector('#propsUrl')?.value.trim() ?? '',
authMode: rootElement.querySelector('#propsAuthMode')?.value ?? '',
username: rootElement.querySelector('#propsUsername')?.value.trim() ?? '',
password: rootElement.querySelector('#propsPassword')?.value ?? '',
passphrase: rootElement.querySelector('#propsPassphrase')?.value ?? ''
};
}
async function reloadSource() {
const payload = await fetchJson(`/api/sources/${encodeURIComponent(source.id)}`);
source = payload.source;
deps.onSourceChanged?.(source);
}
async function saveSecrets() {
feedback?.set('info', '正在保存当前 source 的覆盖规则...');
try {
await fetchJson(`/api/sources/${encodeURIComponent(source.id)}/secrets`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(readSecretForm())
});
await reloadSource();
renderBody();
feedback?.set('success', '覆盖规则已保存。留空项会继续继承全局默认或 Server DB 推导结果。');
} catch (error) {
feedback?.set('error', error.message);
}
}
async function runEnhancement() {
feedback?.set('info', '正在提交增强任务...');
try {
await fetchJson(`/api/sources/${encodeURIComponent(source.id)}/enhance`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(readSecretForm())
});
await reloadSource();
renderBody();
feedback?.set('success', '增强任务已开始。当前规则会合并全局默认、Server DB 推导 URL 和单源覆盖。');
} catch (error) {
feedback?.set('error', error.message);
}
}
async function clearThumbnails() {
if (!window.confirm('确定要清空这个 source 的全部预览图吗?\n\n这不会删除任务库、增强索引或 Server DB,只会删除后端缩略图缓存。')) {
return;
}
feedback?.set('info', '正在清空预览图缓存...');
try {
const payload = await fetchJson(`/api/sources/${encodeURIComponent(source.id)}/thumbnails`, { method: 'DELETE' });
await reloadSource();
renderBody();
feedback?.set('success', `预览图已清空,删除了 ${payload.cleared.removedCount} 张,共 ${formatBytes(payload.cleared.removedBytes)}`);
} catch (error) {
feedback?.set('error', error.message);
}
}
async function deleteSource() {
const label = source.displayName || source.originalFilename || source.id;
if (!window.confirm(`确定要删除这个 source 吗?\n\n${label}\n\n这会删除任务库、本地增强副本和预览图缓存,但不会删除 Duplicati-server.sqlite。`)) {
return;
}
feedback?.set('info', `正在删除 ${label}...`);
try {
await fetchJson(`/api/sources/${encodeURIComponent(source.id)}`, { method: 'DELETE' });
deps.onSourceDeleted?.(source.id);
deps.close?.();
feedback?.set('success', `已删除 ${label}`);
} catch (error) {
feedback?.set('error', error.message);
}
}
rootElement.addEventListener('click', (event) => {
const tab = event.target.closest('[data-props-tab]');
if (tab instanceof HTMLElement) {
activeTab = tab.dataset.propsTab;
renderBody();
return;
}
if (event.target.closest('[data-props-close]')) {
deps.close?.();
return;
}
if (event.target.closest('#propsRunEnhance')) {
void runEnhancement();
} else if (event.target.closest('#propsClearThumbs')) {
void clearThumbnails();
} else if (event.target.closest('#propsDeleteSource')) {
void deleteSource();
}
});
rootElement.addEventListener('submit', (event) => {
if (event.target.id === 'propsSecretForm') {
event.preventDefault();
void saveSecrets();
}
});
return {
async open(sourceId, tab = 'enhance') {
activeTab = TABS.some((item) => item.key === tab) ? tab : 'enhance';
try {
const payload = await fetchJson(`/api/sources/${encodeURIComponent(sourceId)}`);
source = payload.source;
renderBody();
} catch (error) {
source = null;
rootElement.innerHTML = `<div class="empty-state-card">${escapeHtml(error.message)}</div>`;
}
}
};
}
+97
View File
@@ -0,0 +1,97 @@
import { escapeHtml, inferEnhancementClass, inferEnhancementLabel } from '../common.js';
// 侧栏数据源树。数据源当作"盘符",可展开显示一层根目录快捷入口。
// 事件通过回调向编排器上报:onOpenSource / onOpenPath / onContextMenu。
export function createSidebar(rootElement, { onOpenSource, onContextMenu }) {
let sources = [];
let activeSourceId = null;
function statusDotClass(source) {
// 复用增强状态的语义色:ready/busy/error/idle
switch (inferEnhancementClass(source)) {
case 'status-ready':
return 'ready';
case 'status-busy':
return 'busy';
case 'status-error':
return 'error';
default:
return 'idle';
}
}
function render() {
if (sources.length === 0) {
rootElement.innerHTML = '<div class="explorer-tree-empty">暂无数据源,请从「上传任务库」添加。</div>';
return;
}
rootElement.innerHTML = sources
.map((source) => {
const active = source.id === activeSourceId;
const label = source.displayName || source.originalFilename || source.id;
const dot = statusDotClass(source);
const title = `${label} · ${inferEnhancementLabel(source)}`;
return `
<div class="explorer-tree-item${active ? ' active' : ''}"
data-source-id="${escapeHtml(source.id)}"
title="${escapeHtml(title)}"
role="button" tabindex="0">
<span class="explorer-drive-ico" aria-hidden="true">💽</span>
<span class="explorer-tree-name">${escapeHtml(label)}</span>
<span class="explorer-dot ${dot}" title="${escapeHtml(inferEnhancementLabel(source))}"></span>
</div>
`;
})
.join('');
}
function findSource(sourceId) {
return sources.find((source) => source.id === sourceId) ?? null;
}
rootElement.addEventListener('click', (event) => {
const item = event.target.closest('.explorer-tree-item');
if (!(item instanceof HTMLElement)) {
return;
}
onOpenSource?.(item.dataset.sourceId);
});
rootElement.addEventListener('keydown', (event) => {
if (event.key !== 'Enter' && event.key !== ' ') {
return;
}
const item = event.target.closest('.explorer-tree-item');
if (!(item instanceof HTMLElement)) {
return;
}
event.preventDefault();
onOpenSource?.(item.dataset.sourceId);
});
rootElement.addEventListener('contextmenu', (event) => {
const item = event.target.closest('.explorer-tree-item');
if (!(item instanceof HTMLElement)) {
return;
}
event.preventDefault();
onContextMenu?.(item.dataset.sourceId, { x: event.clientX, y: event.clientY });
});
return {
setSources(nextSources) {
sources = Array.isArray(nextSources) ? nextSources : [];
render();
},
setActive(sourceId) {
activeSourceId = sourceId;
render();
},
getSource: findSource,
getSources() {
return sources;
}
};
}