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

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

584 lines
20 KiB
JavaScript

import {
createFeedbackController,
escapeHtml,
fetchJson,
formatBytes,
optionalQueryParam
} from './modules/common.js';
import { bindLogoutButton, loadAuthState } from './modules/auth-client.js';
import { loadClientSecrets, loadGlobalClientSecrets, saveClientSecrets } from './modules/clientSecrets.js';
import { buildVolumeMap, restoreSegmentBytes, supportsFileDownload } from './modules/media-core.js';
import { createSidebar } from './modules/explorer/sidebar.js';
import { createFileList } from './modules/explorer/fileList.js';
import { createMediaPlayer } from './modules/explorer/mediaPlayer.js';
import { createDetailPanel } from './modules/explorer/detailPanel.js';
import { createManage } from './modules/explorer/manage.js';
import { createPropsDialog } from './modules/explorer/propsDialog.js';
const feedback = createFeedbackController(document.querySelector('#feedback'));
const state = {
sourceId: null,
source: null,
currentPath: '/',
currentParent: null,
selectedIds: new Set(),
fileCount: 0,
batchDownloading: false
};
// ── DOM 引用 ──
const authStatusElement = document.querySelector('#authStatus');
const logoutButton = document.querySelector('#logoutButton');
const sourceTreeElement = document.querySelector('#sourceTree');
const fileAreaElement = document.querySelector('#fileArea');
const detailContentElement = document.querySelector('#detailContent');
const breadcrumbElement = document.querySelector('#breadcrumb');
const splitElement = document.querySelector('#split');
const sidebarElement = document.querySelector('#sidebar');
const sidebarBackdrop = document.querySelector('#sidebarBackdrop');
const searchInput = document.querySelector('#searchInput');
const actionBar = document.querySelector('#actionBar');
const selectAllCheckbox = document.querySelector('#selectAllCheckbox');
const batchDownloadButton = document.querySelector('#batchDownloadButton');
const clearSelectionButton = document.querySelector('#clearSelectionButton');
const selectionCount = document.querySelector('#selectionCount');
const batchProgress = document.querySelector('#batchProgress');
const batchProgressContent = document.querySelector('#batchProgressContent');
const statusItems = document.querySelector('#statusItems');
const statusSelected = document.querySelector('#statusSelected');
const toggleDetailButton = document.querySelector('#toggleDetail');
const thumbnailCanvas = document.querySelector('#thumbnailCaptureCanvas');
const ctxFile = document.querySelector('#ctxFile');
const ctxDrive = document.querySelector('#ctxDrive');
const propsOverlay = document.querySelector('#propsOverlay');
const propsBody = document.querySelector('#propsBody');
const manageOverlay = document.querySelector('#manageOverlay');
const manageBody = document.querySelector('#manageBody');
const credentialOverlay = document.querySelector('#credentialOverlay');
const credentialForm = document.querySelector('#credentialForm');
// ── 组件实例 ──
const sidebar = createSidebar(sourceTreeElement, {
onOpenSource: (sourceId) => void openSource(sourceId),
onContextMenu: (sourceId, pos) => showDriveMenu(sourceId, pos)
});
const mediaPlayer = createMediaPlayer({
canvas: thumbnailCanvas,
onThumbnail: ({ fileId, thumbnail }) => applyThumbnailToList(fileId, thumbnail)
});
const detailPanel = createDetailPanel(detailContentElement, {
mediaPlayer,
feedback,
resolveSecrets: (sourceId, fileInfo) => resolveSecrets(sourceId, fileInfo),
onOpenProps: (sourceId) => openProps(sourceId),
onThumbnailUpdated: (fileId, thumbnail) => applyThumbnailToList(fileId, thumbnail)
});
const fileList = createFileList(fileAreaElement, {
onOpenDir: (path) => void loadDirectory(path),
onOpenFile: (entry) => void openFile(entry),
onSelectFile: (entry) => void detailPanel.showFile(state.sourceId, entry),
onSelectionChange: (selected, fileCount) => onSelectionChange(selected, fileCount),
onContextMenu: (entry, pos) => showFileMenu(entry, pos)
});
const manage = createManage(manageBody, {
feedback,
onSourcesChanged: (sources) => sidebar.setSources(sources),
onOpenSource: (sourceId) => {
closeOverlay(manageOverlay);
void openSource(sourceId);
},
close: () => closeOverlay(manageOverlay)
});
const propsDialog = createPropsDialog(propsBody, {
feedback,
onSourceChanged: (source) => {
if (source.id === state.sourceId) {
state.source = source;
}
void reloadSources();
},
onSourceDeleted: (sourceId) => {
void handleSourceDeleted(sourceId);
},
close: () => closeOverlay(propsOverlay)
});
// ── 凭据解析 ──
async function resolveSecrets(sourceId, fileInfo) {
const [sourceSecrets, globalSecrets] = await Promise.all([
loadClientSecrets(sourceId),
loadGlobalClientSecrets()
]);
const resolved = sourceSecrets ?? globalSecrets;
if (resolved?.webdavBaseUrl && resolved?.passphrase) {
return resolved;
}
// 缺凭据 → 弹窗收集
return promptForCredentials(sourceId, fileInfo, resolved);
}
function promptForCredentials(sourceId, fileInfo, partial) {
return new Promise((resolve, reject) => {
document.querySelector('#credWebdavBaseUrl').value =
partial?.webdavBaseUrl ?? fileInfo?.source?.webdav?.effectiveWebdavBaseUrl ?? state.source?.webdav?.effectiveWebdavBaseUrl ?? '';
document.querySelector('#credAuthMode').value = partial?.authMode ?? 'basic';
document.querySelector('#credUsername').value = partial?.username ?? '';
document.querySelector('#credPassword').value = partial?.password ?? '';
document.querySelector('#credPassphrase').value = partial?.passphrase ?? '';
document.querySelector('#credRemember').checked = false;
openOverlay(credentialOverlay);
function cleanup() {
credentialForm.removeEventListener('submit', onSubmit);
document.querySelector('#credCancel').removeEventListener('click', onCancel);
}
async function onSubmit(event) {
event.preventDefault();
const secrets = {
webdavBaseUrl: document.querySelector('#credWebdavBaseUrl').value.trim(),
authMode: document.querySelector('#credAuthMode').value,
username: document.querySelector('#credUsername').value.trim(),
password: document.querySelector('#credPassword').value,
passphrase: document.querySelector('#credPassphrase').value
};
if (!secrets.webdavBaseUrl || !secrets.passphrase) {
feedback.set('error', '请填写 WebDAV Base URL 和备份口令。');
return;
}
if (document.querySelector('#credRemember').checked) {
await saveClientSecrets(sourceId, secrets);
}
cleanup();
closeOverlay(credentialOverlay);
resolve(secrets);
}
function onCancel() {
cleanup();
closeOverlay(credentialOverlay);
const error = new Error('CREDENTIALS_CANCELLED: 已取消凭据输入。');
error.code = 'CREDENTIALS_CANCELLED';
reject(error);
}
credentialForm.addEventListener('submit', onSubmit);
document.querySelector('#credCancel').addEventListener('click', onCancel);
});
}
// ── 数据加载 ──
async function reloadSources() {
const payload = await fetchJson('/api/sources');
sidebar.setSources(payload.sources);
return payload.sources;
}
async function openSource(sourceId) {
if (!sourceId) {
return;
}
feedback.clear();
state.sourceId = sourceId;
sidebar.setActive(sourceId);
closeSidebarOnMobile();
try {
const payload = await fetchJson(`/api/sources/${encodeURIComponent(sourceId)}`);
state.source = payload.source;
void detailPanel.showSource(payload.source);
await loadDirectory('/');
} catch (error) {
feedback.set('error', error.message);
fileAreaElement.innerHTML = `<div class="empty-state-card">${escapeHtml(error.message)}</div>`;
}
}
async function loadDirectory(path) {
if (!state.sourceId) {
return;
}
feedback.clear();
fileAreaElement.innerHTML = '<div class="empty-state-card">正在读取目录...</div>';
try {
const payload = await fetchJson(
`/api/ls?sourceId=${encodeURIComponent(state.sourceId)}&path=${encodeURIComponent(path)}`
);
state.currentPath = payload.path;
state.currentParent = payload.parent;
renderBreadcrumb(payload.path);
fileList.setEntries(payload.entries);
updateStatusBar();
} catch (error) {
feedback.set('error', error.message);
fileAreaElement.innerHTML = `<div class="empty-state-card"><p>无法列出目录内容。</p><p>${escapeHtml(error.message)}</p></div>`;
}
}
async function openFile(entry) {
// 双击:视频直接预览,其它文件在详情面板展示
await detailPanel.showFile(state.sourceId, entry);
fileList.setActiveFile(entry.id);
setDetailVisible(true);
}
async function handleSourceDeleted(sourceId) {
const sources = await reloadSources();
if (state.sourceId === sourceId) {
state.sourceId = null;
state.source = null;
void detailPanel.clear();
fileAreaElement.innerHTML = '<div class="empty-state-card">数据源已删除,请从左侧选择其它数据源。</div>';
breadcrumbElement.textContent = '/';
updateStatusBar();
if (sources.length > 0) {
void openSource(sources[0].id);
}
}
}
// ── 面包屑 ──
function renderBreadcrumb(path) {
const segments = path === '/' ? [] : path.split('/').filter(Boolean);
const crumbs = ['<button class="explorer-crumb" data-path="/" type="button">根</button>'];
let acc = '';
for (const segment of segments) {
acc += `/${segment}`;
crumbs.push('<span class="explorer-crumb-sep">▸</span>');
crumbs.push(`<button class="explorer-crumb" data-path="${escapeHtml(acc)}" type="button">${escapeHtml(segment)}</button>`);
}
breadcrumbElement.innerHTML = crumbs.join('');
}
// ── 选择与批量下载 ──
function onSelectionChange(selected, fileCount) {
state.selectedIds = selected;
state.fileCount = fileCount;
const count = selected.size;
actionBar.hidden = fileCount === 0;
selectionCount.textContent = `已选 ${count} 项`;
batchDownloadButton.disabled = count === 0 || state.batchDownloading || !supportsFileDownload();
if (!supportsFileDownload()) {
batchDownloadButton.title = '当前浏览器不支持 showSaveFilePicker';
}
selectAllCheckbox.checked = count > 0 && count === fileCount;
selectAllCheckbox.indeterminate = count > 0 && count < fileCount;
updateStatusBar();
}
function updateStatusBar() {
const entries = fileList.getEntries();
const dirs = entries.filter((entry) => entry.type === 'dir').length;
const files = entries.filter((entry) => entry.type === 'file').length;
statusItems.textContent = `${entries.length} 项(${dirs} 个文件夹,${files} 个文件)`;
statusSelected.textContent = state.selectedIds.size > 0 ? `已选 ${state.selectedIds.size} 项` : '';
}
async function initiateBatchDownload() {
if (!supportsFileDownload() || typeof window.showDirectoryPicker !== 'function') {
feedback.set('error', '当前浏览器不支持批量下载(需要 showDirectoryPicker)。');
return;
}
if (state.selectedIds.size === 0) {
return;
}
let secrets;
try {
secrets = await resolveSecrets(state.sourceId, null);
} catch (error) {
if (error?.code !== 'CREDENTIALS_CANCELLED') {
feedback.set('error', error.message);
}
return;
}
let directoryHandle;
try {
directoryHandle = await window.showDirectoryPicker({ mode: 'readwrite' });
} catch {
feedback.set('info', '已取消目录选择。');
return;
}
const fileIds = [...state.selectedIds];
const total = fileIds.length;
state.batchDownloading = true;
batchDownloadButton.disabled = true;
batchProgress.hidden = false;
const errors = [];
let totalWritten = 0;
for (let i = 0; i < fileIds.length; i += 1) {
const fileId = fileIds[i];
try {
const info = await fetchJson(
`/api/file-info?sourceId=${encodeURIComponent(state.sourceId)}&id=${encodeURIComponent(fileId)}`
);
renderBatchProgress(i + 1, total, info.file.name, totalWritten);
const safeName = info.file.name.replace(/[/\\:*?"<>|]/g, '_');
const fileHandle = await directoryHandle.getFileHandle(safeName, { create: true });
const writable = await fileHandle.createWritable();
const volumeByRef = buildVolumeMap(info.volumes);
for (const segment of info.segments) {
const volume = volumeByRef.get(segment.volumeRef);
const bytes = await restoreSegmentBytes({
sourceId: state.sourceId,
volume,
zip: segment.zip,
secrets,
onStatus: ({ phase, currentVolume }) => {
renderBatchProgress(i + 1, total, info.file.name, totalWritten, `${phase ?? ''} ${currentVolume ?? ''}`);
}
});
await writable.write(bytes);
totalWritten += bytes.length;
}
await writable.close();
} catch (error) {
errors.push(error.message);
}
}
state.batchDownloading = false;
batchProgress.hidden = true;
onSelectionChange(state.selectedIds, state.fileCount);
if (errors.length === 0) {
feedback.set('success', `已完成全部 ${total} 个文件的批量下载。`);
fileList.clearSelection();
} else {
feedback.set('error', `批量下载完成:${total - errors.length}/${total} 成功。失败:${errors.join('; ')}`);
}
}
function renderBatchProgress(current, total, fileName, written, phase = '') {
batchProgressContent.innerHTML = `
<div class="summary-stack">
<div class="summary-row"><span>进度</span><strong>${current} / ${total}</strong></div>
<div class="summary-row"><span>当前文件</span><strong>${escapeHtml(fileName)}</strong></div>
${phase ? `<div class="summary-row"><span>阶段</span><strong>${escapeHtml(phase)}</strong></div>` : ''}
<div class="summary-row"><span>已写入</span><strong>${formatBytes(written)}</strong></div>
</div>
`;
}
function applyThumbnailToList(fileId, thumbnail) {
const entries = fileList.getEntries();
const entry = entries.find((item) => item.id === fileId);
if (entry) {
entry.thumbnail = thumbnail;
fileList.setEntries(entries);
}
}
// ── 右键菜单 ──
function hideMenus() {
ctxFile.hidden = true;
ctxDrive.hidden = true;
}
function positionMenu(menu, { x, y }) {
hideMenus();
menu.hidden = false;
const width = menu.offsetWidth || 200;
const height = menu.offsetHeight || 220;
menu.style.left = `${Math.min(x, window.innerWidth - width - 8)}px`;
menu.style.top = `${Math.min(y, window.innerHeight - height - 8)}px`;
}
let ctxTargetEntry = null;
let ctxTargetSourceId = null;
function showFileMenu(entry, pos) {
if (entry.type !== 'file') {
return;
}
ctxTargetEntry = entry;
positionMenu(ctxFile, pos);
}
function showDriveMenu(sourceId, pos) {
ctxTargetSourceId = sourceId;
positionMenu(ctxDrive, pos);
}
ctxFile.addEventListener('click', (event) => {
const action = event.target.closest('[data-action]')?.dataset.action;
hideMenus();
if (!ctxTargetEntry) {
return;
}
if (action === 'open' || action === 'preview') {
void openFile(ctxTargetEntry);
} else if (action === 'download') {
void detailPanel.showFile(state.sourceId, ctxTargetEntry).then(() => {
// 详情面板加载后,用户可点下载;这里直接触发一次选择即可
});
fileList.setActiveFile(ctxTargetEntry.id);
setDetailVisible(true);
} else if (action === 'details') {
void detailPanel.showFile(state.sourceId, ctxTargetEntry);
fileList.setActiveFile(ctxTargetEntry.id);
setDetailVisible(true);
}
});
ctxDrive.addEventListener('click', (event) => {
const action = event.target.closest('[data-action]')?.dataset.action;
hideMenus();
if (!ctxTargetSourceId) {
return;
}
if (action === 'open') {
void openSource(ctxTargetSourceId);
} else if (action === 'refresh') {
if (state.sourceId === ctxTargetSourceId) {
void loadDirectory(state.currentPath);
} else {
void openSource(ctxTargetSourceId);
}
} else if (action === 'props') {
openProps(ctxTargetSourceId);
} else if (action === 'delete') {
openProps(ctxTargetSourceId, 'danger');
}
});
document.addEventListener('click', (event) => {
if (!event.target.closest('.explorer-context-menu')) {
hideMenus();
}
});
window.addEventListener('scroll', hideMenus, true);
// ── 弹窗 ──
function openOverlay(overlay) {
overlay.hidden = false;
}
function closeOverlay(overlay) {
overlay.hidden = true;
}
function openProps(sourceId, tab = 'enhance') {
openOverlay(propsOverlay);
void propsDialog.open(sourceId, tab);
}
for (const overlay of [propsOverlay, manageOverlay, credentialOverlay]) {
overlay.addEventListener('click', (event) => {
if (event.target === overlay && overlay !== credentialOverlay) {
closeOverlay(overlay);
}
});
}
// ── 详情面板显隐 ──
function setDetailVisible(visible) {
splitElement.classList.toggle('with-detail', visible);
toggleDetailButton.textContent = visible ? '隐藏详情面板 ▸' : '显示详情面板 ◂';
}
toggleDetailButton.addEventListener('click', () => {
setDetailVisible(!splitElement.classList.contains('with-detail'));
});
// ── 侧栏(移动端抽屉) ──
function openSidebarMobile() {
sidebarElement.classList.add('open');
sidebarBackdrop.hidden = false;
}
function closeSidebarOnMobile() {
sidebarElement.classList.remove('open');
sidebarBackdrop.hidden = true;
}
document.querySelector('#sidebarToggle').addEventListener('click', () => {
if (sidebarElement.classList.contains('open')) {
closeSidebarOnMobile();
} else {
openSidebarMobile();
}
});
sidebarBackdrop.addEventListener('click', closeSidebarOnMobile);
// ── 管理入口 ──
for (const link of document.querySelectorAll('[data-manage]')) {
link.addEventListener('click', () => {
closeSidebarOnMobile();
openOverlay(manageOverlay);
void manage.open(link.dataset.manage === 'sources' ? 'sources' : link.dataset.manage);
});
}
// ── 导航条 ──
document.querySelector('#navUp').addEventListener('click', () => {
if (state.currentParent !== null && state.currentParent !== undefined) {
void loadDirectory(state.currentParent);
} else if (state.currentPath !== '/') {
void loadDirectory('/');
}
});
document.querySelector('#navRoot').addEventListener('click', () => void loadDirectory('/'));
document.querySelector('#navRefresh').addEventListener('click', () => void loadDirectory(state.currentPath));
breadcrumbElement.addEventListener('click', (event) => {
const crumb = event.target.closest('[data-path]');
if (crumb instanceof HTMLElement) {
void loadDirectory(crumb.dataset.path);
}
});
// ── 视图 / 排序 / 搜索 ──
document.querySelector('#viewList').addEventListener('click', () => {
fileList.setView('list');
document.querySelector('#viewList').classList.add('active');
document.querySelector('#viewGrid').classList.remove('active');
});
document.querySelector('#viewGrid').addEventListener('click', () => {
fileList.setView('grid');
document.querySelector('#viewGrid').classList.add('active');
document.querySelector('#viewList').classList.remove('active');
});
document.querySelector('#sortSelect').addEventListener('change', (event) => {
const [key, dir] = event.target.value.split(':');
fileList.setSort(key, dir);
});
searchInput.addEventListener('input', (event) => {
fileList.setFilter(event.target.value);
});
// ── 选择操作条 ──
selectAllCheckbox.addEventListener('change', (event) => fileList.selectAll(event.target.checked));
clearSelectionButton.addEventListener('click', () => fileList.clearSelection());
batchDownloadButton.addEventListener('click', () => void initiateBatchDownload());
// ── 启动 ──
async function bootstrap() {
const authentication = await loadAuthState();
authStatusElement.textContent = authentication.authenticated ? `已登录:${authentication.username}` : '未登录';
bindLogoutButton(logoutButton, authStatusElement);
const sources = await reloadSources();
const requestedSource = optionalQueryParam('sourceId', '');
const target = sources.find((source) => source.id === requestedSource) ?? sources[0];
if (target) {
await openSource(target.id);
} else {
fileAreaElement.innerHTML = '<div class="empty-state-card">还没有数据源。点左侧「上传任务库」开始。</div>';
void detailPanel.clear();
}
}
void bootstrap().catch((error) => {
feedback.set('error', error.message);
fileAreaElement.innerHTML = '<div class="empty-state-card">无法进入资源管理器。</div>';
});