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:
@@ -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':
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user