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 = `
${escapeHtml(error.message)}
`; } } async function loadDirectory(path) { if (!state.sourceId) { return; } feedback.clear(); fileAreaElement.innerHTML = '
正在读取目录...
'; 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 = `

无法列出目录内容。

${escapeHtml(error.message)}

`; } } 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 = '
数据源已删除,请从左侧选择其它数据源。
'; breadcrumbElement.textContent = '/'; updateStatusBar(); if (sources.length > 0) { void openSource(sources[0].id); } } } // ── 面包屑 ── function renderBreadcrumb(path) { const segments = path === '/' ? [] : path.split('/').filter(Boolean); const crumbs = ['']; let acc = ''; for (const segment of segments) { acc += `/${segment}`; crumbs.push(''); crumbs.push(``); } 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 = `
进度${current} / ${total}
当前文件${escapeHtml(fileName)}
${phase ? `
阶段${escapeHtml(phase)}
` : ''}
已写入${formatBytes(written)}
`; } 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 = '
还没有数据源。点左侧「上传任务库」开始。
'; void detailPanel.clear(); } } void bootstrap().catch((error) => { feedback.set('error', error.message); fileAreaElement.innerHTML = '
无法进入资源管理器。
'; });