From fcf56bcbd34415e0a738c033baabd0dbfec977e3 Mon Sep 17 00:00:00 2001 From: nanxun Date: Sat, 4 Jul 2026 14:53:07 +0800 Subject: [PATCH] feat: rebuild frontend as file-manager style explorer SPA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将原来的三页跳转结构(控制台/工作台/文件页)重构为单页资源管理器: 左侧数据源盘符树、主区列表/网格双视图、右侧内嵌播放器与详情面板, 管理功能收进侧栏对话框。媒体引擎(media-core + media-sw)原样复用, 仅把 file.js 的播放器逻辑抽成参数驱动组件。默认路由指向 explorer。 - 新增 explorer.html/js 与 modules/explorer/ 六个组件模块 - 删除旧的 index/source/file 页面及其控制器 - styles.css 新增 explorer 布局与三档响应式断点 --- package.json | 2 +- public/app.js | 588 ----------- public/explorer.html | 171 +++ public/explorer.js | 583 +++++++++++ public/file.html | 176 ---- public/file.js | 1317 ------------------------ public/index.html | 231 ----- public/modules/common.js | 8 - public/modules/explorer/detailPanel.js | 271 +++++ public/modules/explorer/fileList.js | 341 ++++++ public/modules/explorer/manage.js | 378 +++++++ public/modules/explorer/mediaPlayer.js | 407 ++++++++ public/modules/explorer/propsDialog.js | 226 ++++ public/modules/explorer/sidebar.js | 97 ++ public/source.html | 145 --- public/source.js | 585 ----------- public/styles.css | 967 +++++++++++++++++ src/server.js | 6 +- 18 files changed, 3444 insertions(+), 3055 deletions(-) delete mode 100644 public/app.js create mode 100644 public/explorer.html create mode 100644 public/explorer.js delete mode 100644 public/file.html delete mode 100644 public/file.js delete mode 100644 public/index.html create mode 100644 public/modules/explorer/detailPanel.js create mode 100644 public/modules/explorer/fileList.js create mode 100644 public/modules/explorer/manage.js create mode 100644 public/modules/explorer/mediaPlayer.js create mode 100644 public/modules/explorer/propsDialog.js create mode 100644 public/modules/explorer/sidebar.js delete mode 100644 public/source.html delete mode 100644 public/source.js diff --git a/package.json b/package.json index 42be6a0..71dac94 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "start": "node src/server.js", "dev": "node --watch src/server.js", "test": "node --test", - "check": "node --check src/server.js && node --check src/db/duplicatiRepository.js && node --check src/db/sourceCatalog.js && node --check src/services/activeSourceService.js && node --check src/services/sourceEnhancementService.js && node --check src/services/authService.js && node --check src/services/tlsService.js && node --check src/lib/aesCrypt.js && node --check src/lib/zip.js && node --check src/lib/auth.js && node --check public/app.js && node --check public/source.js && node --check public/file.js && node --check public/login.js && node --check public/setup.js && node --check public/media-sw.js && node --check public/modules/common.js && node --check public/modules/auth-client.js && node --check public/modules/clientSecrets.js && node --check public/modules/media-core.js && node --check public/modules/thumbnail-utils.js && node --check public/vendor/aes-js-esm.js" + "check": "node --check src/server.js && node --check src/db/duplicatiRepository.js && node --check src/db/sourceCatalog.js && node --check src/services/activeSourceService.js && node --check src/services/sourceEnhancementService.js && node --check src/services/authService.js && node --check src/services/tlsService.js && node --check src/lib/aesCrypt.js && node --check src/lib/zip.js && node --check src/lib/auth.js && node --check public/login.js && node --check public/setup.js && node --check public/media-sw.js && node --check public/modules/common.js && node --check public/modules/auth-client.js && node --check public/modules/clientSecrets.js && node --check public/modules/media-core.js && node --check public/modules/thumbnail-utils.js && node --check public/vendor/aes-js-esm.js && node --check public/explorer.js && node --check public/modules/explorer/sidebar.js && node --check public/modules/explorer/fileList.js && node --check public/modules/explorer/mediaPlayer.js && node --check public/modules/explorer/detailPanel.js && node --check public/modules/explorer/manage.js && node --check public/modules/explorer/propsDialog.js" }, "dependencies": { "aes-js": "^3.1.2", diff --git a/public/app.js b/public/app.js deleted file mode 100644 index 4ded376..0000000 --- a/public/app.js +++ /dev/null @@ -1,588 +0,0 @@ -import { - createFeedbackController, - escapeHtml, - fetchJson, - linkToSource, - renderSourceSummaryCard -} from './modules/common.js'; -import { bindLogoutButton, loadAuthState } from './modules/auth-client.js'; - -const CONTROL_VIEWS = new Set(['overview', 'sources', 'upload', 'server-db', 'defaults', 'tls']); - -const state = { - sources: [], - serverDb: null, - globalDefaults: null, - tls: null, - currentView: 'overview' -}; - -const uploadForm = document.querySelector('#uploadForm'); -const uploadButton = document.querySelector('#uploadButton'); -const databaseInput = document.querySelector('#databaseInput'); -const serverDbForm = document.querySelector('#serverDbForm'); -const serverDbUploadButton = document.querySelector('#serverDbUploadButton'); -const serverDbInput = document.querySelector('#serverDbInput'); -const globalDefaultsForm = document.querySelector('#globalDefaultsForm'); -const globalDefaultsSaveButton = document.querySelector('#globalDefaultsSaveButton'); -const tlsJsonForm = document.querySelector('#tlsJsonForm'); -const tlsUploadForm = document.querySelector('#tlsUploadForm'); -const tlsSaveButton = document.querySelector('#tlsSaveButton'); -const tlsUploadButton = document.querySelector('#tlsUploadButton'); -const tlsDeleteCustomButton = document.querySelector('#tlsDeleteCustomButton'); -const refreshButton = document.querySelector('#refreshButton'); -const serverDbSummaryElement = document.querySelector('#serverDbSummary'); -const globalDefaultsSummaryElement = document.querySelector('#globalDefaultsSummary'); -const tlsSummaryElement = document.querySelector('#tlsSummary'); -const overviewSummaryElement = document.querySelector('#overviewSummary'); -const sourceListElement = document.querySelector('#sourceList'); -const authStatusElement = document.querySelector('#authStatus'); -const logoutButton = document.querySelector('#logoutButton'); -const viewPanels = [...document.querySelectorAll('[data-view-panel]')]; -const viewLinks = [...document.querySelectorAll('[data-view-link]')]; -const feedback = createFeedbackController(document.querySelector('#feedback')); - -function normalizeView(value) { - return CONTROL_VIEWS.has(value) ? value : 'overview'; -} - -function setCurrentView(nextView, { syncHash = true, replace = false } = {}) { - state.currentView = normalizeView(nextView); - - for (const panel of viewPanels) { - panel.hidden = panel.dataset.viewPanel !== state.currentView; - } - - for (const link of viewLinks) { - const active = link.dataset.viewLink === state.currentView; - link.classList.toggle('is-active', active); - link.setAttribute('aria-current', active ? 'page' : 'false'); - } - - if (!syncHash) { - return; - } - - const url = new URL(window.location.href); - url.hash = `#${state.currentView}`; - if (replace) { - window.history.replaceState(null, '', url); - } else { - window.history.pushState(null, '', url); - } -} - -function renderSummaryRows(rows) { - return ` -
- ${rows - .map( - (row) => ` -
- ${escapeHtml(row.label)} - ${escapeHtml(row.value)} -
- ` - ) - .join('')} -
- `; -} - -function readGlobalDefaultsForm() { - return { - webdavBaseUrl: document.querySelector('#globalWebdavBaseUrl')?.value?.trim() ?? '', - authMode: document.querySelector('#globalAuthMode')?.value ?? '', - username: document.querySelector('#globalUsername')?.value?.trim() ?? '', - password: document.querySelector('#globalPassword')?.value ?? '', - passphrase: document.querySelector('#globalPassphrase')?.value ?? '' - }; -} - -function writeGlobalDefaultsForm(defaults) { - document.querySelector('#globalWebdavBaseUrl').value = defaults?.webdavBaseUrl ?? ''; - document.querySelector('#globalAuthMode').value = defaults?.authMode ?? ''; - document.querySelector('#globalUsername').value = ''; - document.querySelector('#globalPassword').value = ''; - document.querySelector('#globalPassphrase').value = ''; -} - -function readTlsJsonForm() { - return { - mode: document.querySelector('#tlsMode')?.value ?? 'self-signed', - primaryDomain: document.querySelector('#tlsPrimaryDomain')?.value?.trim() ?? '', - subjectAltNames: document.querySelector('#tlsSubjectAltNames')?.value ?? '', - certPem: document.querySelector('#tlsCertPem')?.value ?? '', - keyPem: document.querySelector('#tlsKeyPem')?.value ?? '', - chainPem: document.querySelector('#tlsChainPem')?.value ?? '' - }; -} - -function writeTlsForm(tlsSummary) { - document.querySelector('#tlsMode').value = tlsSummary?.mode ?? 'self-signed'; - document.querySelector('#tlsPrimaryDomain').value = tlsSummary?.primaryDomain ?? ''; - document.querySelector('#tlsSubjectAltNames').value = Array.isArray(tlsSummary?.subjectAltNames) - ? tlsSummary.subjectAltNames.join('\n') - : ''; - document.querySelector('#tlsCertPem').value = ''; - document.querySelector('#tlsKeyPem').value = ''; - document.querySelector('#tlsChainPem').value = ''; -} - -function renderOverviewSummary() { - const sourceCount = state.sources.length; - const readyCount = state.sources.filter((source) => source.enhancement?.status === 'ready').length; - const failedCount = state.sources.filter((source) => source.enhancement?.status === 'failed').length; - const mappedCount = state.sources.filter((source) => source.displayNameSource === 'server-db').length; - - overviewSummaryElement.innerHTML = renderSummaryRows([ - { label: '数据源总数', value: String(sourceCount) }, - { label: '已增强数据源', value: String(readyCount) }, - { label: '增强失败', value: String(failedCount) }, - { label: '已映射任务名', value: String(mappedCount) }, - { label: 'Server DB', value: state.serverDb?.available ? state.serverDb.originalFilename : '未上传' }, - { label: '全局 WebDAV', value: state.globalDefaults?.configured ? '已配置' : '未配置' }, - { label: '当前证书来源', value: state.tls?.activeSource ?? '不可用' } - ]); -} - -function renderServerDbSummary() { - if (!state.serverDb?.available) { - serverDbSummaryElement.innerHTML = renderSummaryRows([ - { label: 'Status', value: 'No server database uploaded yet' }, - { label: '说明', value: '未上传 Duplicati-server.sqlite 之前,任务名和推导目标 URL 不可用。' } - ]); - return; - } - - serverDbSummaryElement.innerHTML = renderSummaryRows([ - { label: '当前文件', value: state.serverDb.originalFilename }, - { label: '上传时间', value: state.serverDb.uploadedAt }, - { label: '任务条目数', value: String(state.serverDb.backupCount) } - ]); -} - -function renderGlobalDefaultsSummary() { - const defaults = state.globalDefaults; - if (!defaults?.configured) { - writeGlobalDefaultsForm(null); - globalDefaultsSummaryElement.innerHTML = renderSummaryRows([ - { label: '状态', value: '尚未保存全局默认值' }, - { label: '说明', value: '如果大多数任务共用同一套 WebDAV 账号和口令,可以在这里统一保存。' } - ]); - return; - } - - writeGlobalDefaultsForm(defaults); - globalDefaultsSummaryElement.innerHTML = renderSummaryRows([ - { 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'}` - }, - { label: '更新时间', value: defaults.updatedAt ?? '未知' } - ]); -} - -function renderTlsSummary() { - if (!state.tls) { - tlsSummaryElement.innerHTML = renderSummaryRows([{ label: '状态', value: 'TLS 配置尚未加载' }]); - return; - } - - writeTlsForm(state.tls); - tlsDeleteCustomButton.disabled = !state.tls.hasCustomCertificate; - tlsSummaryElement.innerHTML = renderSummaryRows([ - { label: 'HTTP 访问地址', value: state.tls.access?.httpBaseUrl ?? '未知' }, - { label: 'HTTPS 访问地址', value: state.tls.access?.httpsBaseUrl ?? '未知' }, - { label: '配置模式', value: state.tls.mode ?? 'self-signed' }, - { label: '当前生效证书', value: state.tls.activeSource ?? 'self-signed' }, - { label: '主域名', value: state.tls.primaryDomain || 'localhost' }, - { label: '额外 SAN', value: (state.tls.subjectAltNames ?? []).join(', ') || '无' }, - { - label: '有效期', - value: state.tls.certificate ? `${state.tls.certificate.validFrom} -> ${state.tls.certificate.validTo}` : '未知' - }, - { label: '指纹', value: state.tls.certificate?.fingerprint256 ?? '未知' }, - { label: '最近错误', value: state.tls.lastErrorMessage ?? '无' }, - { label: 'Cookie 说明', value: state.tls.cookieNote ?? 'HTTP/HTTPS 共用登录态' } - ]); -} - -function renderSources() { - if (state.sources.length === 0) { - sourceListElement.innerHTML = ` -
-

No task sources uploaded yet.

-

Upload a Duplicati task database first, then open the source workbench to browse, enhance, preview, or download.

-
- `; - return; - } - - sourceListElement.innerHTML = state.sources - .map((source) => { - const targetHint = source.webdav?.effectiveWebdavBaseUrl - ? `
默认目标 URL 来源:${escapeHtml(source.webdav.effectiveWebdavBaseUrlSource ?? 'unknown')}
` - : '
暂无可用的默认目标 URL
'; - - return renderSourceSummaryCard( - source, - ` - ${targetHint} -
- 进入工作台 - -
- ` - ); - }) - .join(''); -} - -async function loadServerDbSummary() { - const payload = await fetchJson('/api/server-db'); - state.serverDb = payload.serverDb; - renderServerDbSummary(); - renderOverviewSummary(); -} - -async function loadGlobalDefaults() { - const payload = await fetchJson('/api/webdav-defaults'); - state.globalDefaults = payload.defaults; - renderGlobalDefaultsSummary(); - renderOverviewSummary(); -} - -async function loadTlsSummary() { - const payload = await fetchJson('/api/system/tls'); - state.tls = payload.tls; - renderTlsSummary(); - renderOverviewSummary(); -} - -async function loadSources() { - const payload = await fetchJson('/api/sources'); - state.sources = payload.sources; - renderSources(); - renderOverviewSummary(); -} - -async function handleTaskDbUpload(event) { - event.preventDefault(); - feedback.clear(); - - const file = databaseInput.files?.[0]; - if (!file) { - feedback.set('error', '请先选择一个任务 SQLite 文件。'); - return; - } - - uploadButton.disabled = true; - refreshButton.disabled = true; - 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 ?? 'Upload failed.'}`); - } - - await Promise.all([loadSources(), loadServerDbSummary()]); - uploadForm.reset(); - setCurrentView('sources'); - feedback.set( - payload.reused ? 'info' : 'success', - payload.reused - ? '这个任务数据库之前已经上传过,系统复用了现有 source。' - : '任务数据库上传成功。' - ); - } catch (error) { - feedback.set('error', error.message); - } finally { - uploadButton.disabled = false; - refreshButton.disabled = false; - } -} - -async function handleServerDbUpload(event) { - event.preventDefault(); - feedback.clear(); - - const file = serverDbInput.files?.[0]; - if (!file) { - feedback.set('error', '请先选择 Duplicati-server.sqlite。'); - return; - } - - serverDbUploadButton.disabled = true; - 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 ?? 'Upload failed.'}`); - } - - await Promise.all([loadSources(), loadServerDbSummary()]); - serverDbForm.reset(); - feedback.set('success', 'Server DB 上传成功。'); - } catch (error) { - feedback.set('error', error.message); - } finally { - serverDbUploadButton.disabled = false; - } -} - -async function handleGlobalDefaultsSave(event) { - event.preventDefault(); - feedback.clear(); - globalDefaultsSaveButton.disabled = true; - feedback.set('info', '正在保存全局 WebDAV 默认值...'); - - try { - const payload = await fetchJson('/api/webdav-defaults', { - method: 'PUT', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(readGlobalDefaultsForm()) - }); - - state.globalDefaults = payload.defaults; - renderGlobalDefaultsSummary(); - renderOverviewSummary(); - await loadSources(); - feedback.set( - payload.defaults.configured ? 'success' : 'info', - payload.defaults.configured ? '全局默认值已保存。' : '全局默认值已清空。' - ); - } catch (error) { - feedback.set('error', error.message); - } finally { - globalDefaultsSaveButton.disabled = false; - } -} - -async function handleTlsJsonSave(event) { - event.preventDefault(); - feedback.clear(); - tlsSaveButton.disabled = true; - tlsUploadButton.disabled = true; - feedback.set('info', '正在保存 TLS 配置并应用证书...'); - - try { - const payload = await fetchJson('/api/system/tls', { - method: 'PUT', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(readTlsJsonForm()) - }); - state.tls = payload.tls; - renderTlsSummary(); - renderOverviewSummary(); - feedback.set('success', `TLS 配置已应用,当前证书来源:${payload.tls.activeSource}。`); - } catch (error) { - feedback.set('error', error.message); - } finally { - tlsSaveButton.disabled = false; - tlsUploadButton.disabled = false; - } -} - -async function handleTlsUpload(event) { - event.preventDefault(); - feedback.clear(); - - const certificateFile = document.querySelector('#tlsCertificateInput')?.files?.[0]; - const privateKeyFile = document.querySelector('#tlsPrivateKeyInput')?.files?.[0]; - const chainFile = document.querySelector('#tlsChainInput')?.files?.[0] ?? null; - - if (!certificateFile || !privateKeyFile) { - feedback.set('error', '请同时上传证书文件和私钥文件。'); - return; - } - - tlsSaveButton.disabled = true; - tlsUploadButton.disabled = true; - feedback.set('info', '正在上传自定义证书...'); - - try { - const formData = new FormData(); - formData.set('primaryDomain', document.querySelector('#tlsPrimaryDomain')?.value?.trim() ?? ''); - formData.set('subjectAltNames', document.querySelector('#tlsSubjectAltNames')?.value ?? ''); - formData.set('certificate', certificateFile); - formData.set('privateKey', privateKeyFile); - if (chainFile) { - formData.set('chain', chainFile); - } - - const response = await fetch('/api/system/tls/upload', { - method: 'POST', - body: formData - }); - const payload = await response.json(); - if (!response.ok) { - throw new Error(`${payload.error?.code ?? 'TLS_UPLOAD_FAILED'}: ${payload.error?.message ?? 'Upload failed.'}`); - } - - state.tls = payload.tls; - renderTlsSummary(); - renderOverviewSummary(); - tlsUploadForm.reset(); - feedback.set('success', '自定义证书已上传并生效。'); - } catch (error) { - feedback.set('error', error.message); - } finally { - tlsSaveButton.disabled = false; - tlsUploadButton.disabled = false; - } -} - -async function handleDeleteCustomTls() { - feedback.clear(); - if (!window.confirm('确定要删除当前自定义证书,并回退到自签证书吗?')) { - return; - } - - tlsDeleteCustomButton.disabled = true; - feedback.set('info', '正在删除自定义证书,并切回自签证书...'); - - try { - const payload = await fetchJson('/api/system/tls/custom-certificate', { - method: 'DELETE' - }); - state.tls = payload.tls; - renderTlsSummary(); - renderOverviewSummary(); - feedback.set('success', '自定义证书已删除,系统已回退到自签证书。'); - } catch (error) { - feedback.set('error', error.message); - } finally { - tlsDeleteCustomButton.disabled = false; - } -} - -async function deleteSource(sourceId) { - feedback.clear(); - const source = state.sources.find((item) => item.id === sourceId); - const label = source?.displayName || source?.originalFilename || sourceId; - const confirmed = window.confirm( - `确定要删除这个 source 吗?\n\n${label}\n\n这只会删除当前 source 及其本地增强数据,不会删除 Duplicati-server.sqlite。` - ); - if (!confirmed) { - return; - } - - feedback.set('info', `正在删除 ${label}...`); - - try { - const payload = await fetchJson(`/api/sources/${encodeURIComponent(sourceId)}`, { - method: 'DELETE' - }); - await Promise.all([loadSources(), loadServerDbSummary()]); - feedback.set('success', `已删除 ${payload.deleted.originalFilename},Server DB 保持不变。`); - } catch (error) { - feedback.set('error', error.message); - } -} - -uploadForm.addEventListener('submit', (event) => { - void handleTaskDbUpload(event); -}); - -serverDbForm.addEventListener('submit', (event) => { - void handleServerDbUpload(event); -}); - -globalDefaultsForm.addEventListener('submit', (event) => { - void handleGlobalDefaultsSave(event); -}); - -tlsJsonForm.addEventListener('submit', (event) => { - void handleTlsJsonSave(event); -}); - -tlsUploadForm.addEventListener('submit', (event) => { - void handleTlsUpload(event); -}); - -tlsDeleteCustomButton.addEventListener('click', () => { - void handleDeleteCustomTls(); -}); - -refreshButton.addEventListener('click', () => { - feedback.clear(); - sourceListElement.textContent = '正在刷新数据源列表...'; - void Promise.all([loadSources(), loadServerDbSummary(), loadGlobalDefaults(), loadTlsSummary()]).catch((error) => { - feedback.set('error', error.message); - }); -}); - -sourceListElement.addEventListener('click', (event) => { - const target = event.target; - if (!(target instanceof HTMLElement)) { - return; - } - - const actionElement = target.closest('[data-delete-source-id]'); - if (!(actionElement instanceof HTMLElement)) { - return; - } - - const sourceId = actionElement.dataset.deleteSourceId; - if (!sourceId) { - return; - } - - void deleteSource(sourceId); -}); - -for (const link of viewLinks) { - link.addEventListener('click', (event) => { - event.preventDefault(); - setCurrentView(normalizeView(link.dataset.viewLink ?? 'overview')); - }); -} - -window.addEventListener('hashchange', () => { - setCurrentView(normalizeView(window.location.hash.slice(1)), { syncHash: false }); -}); - -async function bootstrap() { - const initialView = normalizeView(window.location.hash.slice(1) || 'overview'); - setCurrentView(initialView, { replace: true }); - - const authentication = await loadAuthState(); - authStatusElement.textContent = authentication.authenticated - ? `已登录:${authentication.username}` - : '未登录'; - bindLogoutButton(logoutButton, authStatusElement); - - await Promise.all([loadServerDbSummary(), loadGlobalDefaults(), loadTlsSummary(), loadSources()]); -} - -void bootstrap().catch((error) => { - feedback.set('error', error.message); - sourceListElement.innerHTML = ` -
-

无法加载控制台。

-
- `; -}); diff --git a/public/explorer.html b/public/explorer.html new file mode 100644 index 0000000..75d035f --- /dev/null +++ b/public/explorer.html @@ -0,0 +1,171 @@ + + + + + + 数据源资源管理器 + + + +
+ +
+ +
数据源资源管理器
+ +
+ 正在读取登录状态... + +
+
+ + + + +
+ + + + + +
+ +
+
+ + + +
+ +
+ + +
+ +
+ + + + + +
正在读取目录...
+ + + + + +
+ + +
+ +
+
+ + + +
+
+ + + + + + + + + + + + + + + + + + + + + diff --git a/public/explorer.js b/public/explorer.js new file mode 100644 index 0000000..d4015cc --- /dev/null +++ b/public/explorer.js @@ -0,0 +1,583 @@ +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 = '
无法进入资源管理器。
'; +}); diff --git a/public/file.html b/public/file.html deleted file mode 100644 index 98d8bfc..0000000 --- a/public/file.html +++ /dev/null @@ -1,176 +0,0 @@ - - - - - - 文件预览与下载 - - - -
- - -
-
-

File Workspace

-

文件预览与下载

-
-

- 这里负责浏览器直连 WebDAV 的视频预览、下载、本地缓存和预览图回写。大流量文件流依然只走前端,不经过后端文件代理。 -

-
- -
- 正在读取登录状态... - -
- - - -
- - -
-
-
-
-
- -

文件概况

-
-
-
正在读取文件摘要...
-
-
- - - - - - - - -
-
-
- - - - diff --git a/public/file.js b/public/file.js deleted file mode 100644 index 65d52df..0000000 --- a/public/file.js +++ /dev/null @@ -1,1317 +0,0 @@ -import { - assertBrowserFeature, - createFeedbackController, - escapeHtml, - fetchJson, - formatBytes, - linkToSource, - optionalQueryParam -} from './modules/common.js'; -import { bindLogoutButton, loadAuthState } from './modules/auth-client.js'; -import { - loadClientSecrets, - loadGlobalClientSecrets, - removeClientSecrets, - saveClientSecrets, - saveGlobalClientSecrets -} from './modules/clientSecrets.js'; -import { - buildVolumeMap, - clearAllCachedVolumes, - clearCachedVolumesForSource, - getCachedVolumeStats, - getGlobalCacheStats, - isVideoMime, - restoreSegmentBytes, - supportsFileDownload, - supportsOpfs, - supportsVideoPreview -} from './modules/media-core.js'; -import { - analyzeFrameLuma, - buildThumbnailCandidateTimes -} from './modules/thumbnail-utils.js'; - -const STATUS_CHANNEL_NAME = 'duplicati-media-status'; -const FILE_VIEWS = new Set(['overview', 'preview', 'download', 'cache', 'thumbnail']); - -const state = { - sourceId: optionalQueryParam('sourceId', ''), - fileId: optionalQueryParam('id', ''), - source: null, - fileInfo: null, - fileInfoError: null, - cacheStats: { - currentSource: null, - global: null - }, - resolvedBrowserSecrets: null, - resolvedBrowserSecretsError: null, - currentSessionId: null, - workerStatus: null, - workerChannel: null, - browserGlobalSecrets: null, - browserSourceSecrets: null, - activePreviewSecrets: null, - currentView: 'overview', - pageLocked: false, - thumbnailCapture: { - inFlight: false, - attempted: false, - suppressed: false, - sessionId: null, - probeSessionId: null, - lastMessage: '尚未生成预览图。' - } -}; - -const feedback = createFeedbackController(document.querySelector('#feedback')); -const sourceCrumb = document.querySelector('#sourceCrumb'); -const fileTitleElement = document.querySelector('#fileTitle'); -const fileSummaryElement = document.querySelector('#fileSummary'); -const sessionStatusElement = document.querySelector('#sessionStatus'); -const cacheStatusElement = document.querySelector('#cacheStatus'); -const downloadStatusElement = document.querySelector('#downloadStatus'); -const previewPlayer = document.querySelector('#previewPlayer'); -const videoHintElement = document.querySelector('#videoHint'); -const startPreviewButton = document.querySelector('#startPreviewButton'); -const deleteThumbnailFromPreviewButton = document.querySelector('#deleteThumbnailFromPreviewButton'); -const downloadButton = document.querySelector('#downloadButton'); -const clearSourceCacheButton = document.querySelector('#clearSourceCacheButton'); -const clearAllCacheButton = document.querySelector('#clearAllCacheButton'); -const saveClientSecretsButton = document.querySelector('#saveClientSecretsButton'); -const saveClientGlobalsButton = document.querySelector('#saveClientGlobalsButton'); -const deleteThumbnailButton = document.querySelector('#deleteThumbnailButton'); -const clientSecretForm = document.querySelector('#clientSecretForm'); -const clientSecretHintElement = document.querySelector('#clientSecretHint'); -const previewThumbnailHintElement = document.querySelector('#previewThumbnailHint'); -const thumbnailStatusElement = document.querySelector('#thumbnailStatus'); -const thumbnailPreviewElement = document.querySelector('#thumbnailPreview'); -const thumbnailCaptureCanvas = document.querySelector('#thumbnailCaptureCanvas'); -const authStatusElement = document.querySelector('#authStatus'); -const logoutButton = document.querySelector('#logoutButton'); -const viewPanels = [...document.querySelectorAll('[data-view-panel]')]; -const viewLinks = [...document.querySelectorAll('[data-view-link]')]; - -sourceCrumb.href = state.sourceId ? `${linkToSource(state.sourceId)}#browse` : '/'; - -function normalizeView(value) { - return FILE_VIEWS.has(value) ? value : 'overview'; -} - -function setCurrentView(nextView, { syncHash = true, replace = false } = {}) { - state.currentView = normalizeView(nextView); - - for (const panel of viewPanels) { - panel.hidden = panel.dataset.viewPanel !== state.currentView; - } - - for (const link of viewLinks) { - const active = link.dataset.viewLink === state.currentView; - link.classList.toggle('is-active', active); - link.setAttribute('aria-current', active ? 'page' : 'false'); - } - - if (!syncHash) { - return; - } - - const url = new URL(window.location.href); - url.hash = `#${state.currentView}`; - if (replace) { - window.history.replaceState(null, '', url); - } else { - window.history.pushState(null, '', url); - } -} - -function setSummaryCard(element, message) { - element.innerHTML = `
${escapeHtml(message)}
`; -} - -function isPreviewAvailable() { - return Boolean(state.fileInfo && isVideoMime(state.fileInfo.file.mime)); -} - -function isFileRestorable() { - return Boolean(state.fileInfo); -} - -function updateActionStates() { - const locked = state.pageLocked; - startPreviewButton.disabled = locked || !isPreviewAvailable(); - downloadButton.disabled = locked || !isFileRestorable(); - clearSourceCacheButton.disabled = locked || !supportsOpfs() || !state.sourceId; - clearAllCacheButton.disabled = locked || !supportsOpfs(); - saveClientSecretsButton.disabled = locked; - saveClientGlobalsButton.disabled = locked; - deleteThumbnailButton.disabled = locked || !Boolean(state.fileInfo?.file?.thumbnail?.available); - deleteThumbnailFromPreviewButton.disabled = locked || !Boolean(state.fileInfo?.file?.thumbnail?.available); - - for (const element of clientSecretForm.querySelectorAll('input, select')) { - element.disabled = locked; - } -} - -function setControlsDisabled(disabled) { - state.pageLocked = disabled; - updateActionStates(); -} - -function renderMissingRouteContext() { - const missing = []; - if (!state.sourceId) { - missing.push('sourceId'); - } - if (!state.fileId) { - missing.push('id'); - } - - setControlsDisabled(true); - fileTitleElement.textContent = '文件页未就绪'; - fileSummaryElement.innerHTML = ` -
-

这个页面缺少必要的地址参数。

-

缺少:${escapeHtml(missing.join(', '))}

-

请从数据源工作台里的文件列表进入,或检查当前地址是否包含 sourceIdid

-
- `; - setSummaryCard(sessionStatusElement, '尚未注册媒体会话。'); - setSummaryCard(cacheStatusElement, '本地缓存不可用。'); - setSummaryCard(downloadStatusElement, '下载不可用。'); - setSummaryCard(thumbnailStatusElement, '预览图不可用。'); - setSummaryCard(previewThumbnailHintElement, '当前无法管理预览图。'); - thumbnailPreviewElement.innerHTML = '
缺少地址参数
'; - setCurrentView('overview', { replace: true }); -} - -function readClientSecretsForm() { - return { - webdavBaseUrl: document.querySelector('#clientWebdavBaseUrl')?.value?.trim() ?? '', - authMode: document.querySelector('#clientAuthMode')?.value ?? 'basic', - username: document.querySelector('#clientUsername')?.value?.trim() ?? '', - password: document.querySelector('#clientPassword')?.value ?? '', - passphrase: document.querySelector('#clientPassphrase')?.value ?? '', - rememberOnDevice: Boolean(document.querySelector('#rememberOnDevice')?.checked) - }; -} - -function writeClientSecretsForm(secrets) { - document.querySelector('#clientWebdavBaseUrl').value = secrets?.webdavBaseUrl ?? ''; - document.querySelector('#clientAuthMode').value = secrets?.authMode ?? 'basic'; - document.querySelector('#clientUsername').value = secrets?.username ?? ''; - document.querySelector('#clientPassword').value = secrets?.password ?? ''; - document.querySelector('#clientPassphrase').value = secrets?.passphrase ?? ''; - document.querySelector('#rememberOnDevice').checked = Boolean(secrets?.rememberOnDevice); -} - -function mergeClientSecrets({ sourceSecrets, globalSecrets, resolvedSecrets, sourceWebdav }) { - return { - webdavBaseUrl: - sourceSecrets?.webdavBaseUrl ?? - resolvedSecrets?.webdavBaseUrl ?? - sourceWebdav?.effectiveWebdavBaseUrl ?? - sourceWebdav?.derivedWebdavBaseUrl ?? - globalSecrets?.webdavBaseUrl ?? - '', - authMode: sourceSecrets?.authMode ?? globalSecrets?.authMode ?? resolvedSecrets?.authMode ?? 'basic', - username: sourceSecrets?.username ?? globalSecrets?.username ?? resolvedSecrets?.username ?? '', - password: sourceSecrets?.password ?? globalSecrets?.password ?? resolvedSecrets?.password ?? '', - passphrase: sourceSecrets?.passphrase ?? globalSecrets?.passphrase ?? resolvedSecrets?.passphrase ?? '', - rememberOnDevice: Boolean(sourceSecrets) - }; -} - -function requireClientSecrets() { - const secrets = readClientSecretsForm(); - if (!secrets.webdavBaseUrl) { - throw new Error('CLIENT_SECRETS_MISSING: 请输入浏览器直连使用的 WebDAV Base URL。'); - } - - if (!secrets.passphrase) { - throw new Error('CLIENT_SECRETS_MISSING: 请输入浏览器直连使用的备份口令。'); - } - - return secrets; -} - -async function persistClientSecretsPreference(secrets) { - if (secrets.rememberOnDevice) { - await saveClientSecrets(state.sourceId, secrets); - } else { - await removeClientSecrets(state.sourceId); - } -} - -function renderClientSecretHint() { - const sourceWebdav = state.source?.webdav; - clientSecretHintElement.innerHTML = ` -
-
- 任务默认 URL - ${escapeHtml(sourceWebdav?.effectiveWebdavBaseUrl ?? sourceWebdav?.derivedWebdavBaseUrl ?? '未推导到')} -
-
- 默认 URL 来源 - ${escapeHtml(sourceWebdav?.effectiveWebdavBaseUrlSource ?? 'none')} -
-
- 浏览器全局默认值 - ${state.browserGlobalSecrets ? 'available' : 'missing'} -
-
- 浏览器单源覆盖 - ${state.browserSourceSecrets ? 'saved' : 'none'} -
-
- 后端解析后的直连凭据 - ${state.resolvedBrowserSecrets ? 'available' : state.resolvedBrowserSecretsError ? 'missing' : 'pending'} -
- ${ - state.resolvedBrowserSecretsError - ? ` -
- 后端凭据状态 - ${escapeHtml(state.resolvedBrowserSecretsError.message)} -
- ` - : '' - } -
- `; -} - -function renderWorkerStatus() { - if (!state.workerStatus) { - setSummaryCard(sessionStatusElement, '尚未注册媒体会话。'); - return; - } - - sessionStatusElement.innerHTML = ` -
-
- 当前阶段 - ${escapeHtml(state.workerStatus.phase ?? 'idle')} -
-
- 当前卷 - ${escapeHtml(state.workerStatus.currentVolume ?? '无')} -
-
- 已缓存 dblock - ${escapeHtml(String((state.workerStatus.cachedVolumes ?? []).length))} -
-
- 缓存列表 - ${escapeHtml((state.workerStatus.cachedVolumes ?? []).join(', ') || '暂无缓存')} -
-
- 最近错误 - ${escapeHtml(state.workerStatus.lastError ?? '无')} -
-
- `; -} - -function renderPreviewThumbnailHint() { - if (!state.fileInfo) { - setSummaryCard(previewThumbnailHintElement, state.fileInfoError?.message ?? '当前文件还不能管理预览图。'); - return; - } - - const thumbnail = state.fileInfo.file.thumbnail; - const captureMessage = state.thumbnailCapture.lastMessage ?? '尚未生成预览图。'; - previewThumbnailHintElement.innerHTML = ` -
-
- 当前缩略图 - ${thumbnail?.available ? '已缓存到后端' : '暂无'} -
-
- 自动截帧状态 - ${escapeHtml(captureMessage)} -
-
- 删除说明 - 只删除后端缩略图缓存,不会删除浏览器本地视频缓存。 -
-
- `; -} - -function renderCacheStatus() { - if (!supportsOpfs()) { - setSummaryCard(cacheStatusElement, '当前浏览器不支持 OPFS,本地缓存功能不可用。'); - updateActionStates(); - return; - } - - const currentSource = state.cacheStats.currentSource ?? { - volumeCount: 0, - totalBytes: 0 - }; - const global = state.cacheStats.global ?? { - sourceCount: 0, - volumeCount: 0, - totalBytes: 0 - }; - - cacheStatusElement.innerHTML = ` -
-
- 当前 source 缓存 - ${escapeHtml(String(currentSource.volumeCount))} 个 dblock / ${formatBytes(currentSource.totalBytes)} -
-
- 全部浏览器缓存 - ${escapeHtml(String(global.sourceCount))} 个 source,${escapeHtml(String(global.volumeCount))} 个 dblock / ${formatBytes(global.totalBytes)} -
-
- `; -} - -function renderFileSummary() { - if (state.fileInfo) { - const file = state.fileInfo.file; - const volumeCount = state.fileInfo.volumes.length; - const isVideo = isVideoMime(file.mime); - fileTitleElement.textContent = file.name; - fileSummaryElement.innerHTML = ` -
-
-
-

${escapeHtml(file.name)}

-

${escapeHtml(file.path)}

-

sourceId: ${escapeHtml(state.sourceId)}

-
- - ${escapeHtml(state.source?.enhancement?.status ?? 'unknown')} - -
- -
-
- MIME - ${escapeHtml(file.mime ?? 'unknown')} -
-
- 文件大小 - ${formatBytes(file.size)} -
-
- 恢复片段 - ${escapeHtml(String(state.fileInfo.restorePlan.segmentCount))} -
-
- dblock 数量 - ${escapeHtml(String(volumeCount))} -
-
- 块大小 - ${formatBytes(state.fileInfo.restorePlan.blockSize)} -
-
- 范围播放 - ${state.fileInfo.hints.rangeReady ? '可用' : '不可用'} -
-
- 后端预览图 - ${state.fileInfo.file.thumbnail?.available ? '已缓存' : '暂无'} -
-
- -
${isVideo ? '这是一个视频文件,可以启用前端 Range 播放。' : '这不是视频文件,播放器不会启用,但仍然可以直接下载。'}
-
- `; - videoHintElement.textContent = isVideo - ? '点击“启动视频预览”后,浏览器会注册本地媒体会话,并通过 Service Worker 伪造 Range 请求。' - : '这个文件不是视频,视频预览区不会启用,但仍然可以直接下载。'; - updateActionStates(); - return; - } - - const errorMessage = state.fileInfoError?.message ?? '尚未获得文件恢复信息。'; - fileTitleElement.textContent = '文件操作未就绪'; - fileSummaryElement.innerHTML = ` -
-

这个文件当前还不能进入视频预览或下载流程。

-

${escapeHtml(errorMessage)}

-
- `; - videoHintElement.textContent = '请先回到数据源工作台完成增强,再回到文件页。'; - updateActionStates(); -} - -function renderThumbnailPanel() { - if (!state.fileInfo) { - setSummaryCard(thumbnailStatusElement, state.fileInfoError?.message ?? '当前文件还不能读取预览图状态。'); - thumbnailPreviewElement.innerHTML = '
暂无预览图
'; - renderPreviewThumbnailHint(); - updateActionStates(); - return; - } - - const thumbnail = state.fileInfo.file.thumbnail; - const capture = state.thumbnailCapture; - const statusText = thumbnail?.available - ? '已缓存到后端' - : capture.inFlight - ? '正在从当前视频首帧生成' - : '暂无预览图'; - const captureMessage = capture.lastMessage ?? '尚未生成预览图。'; - - thumbnailStatusElement.innerHTML = ` -
-
- 当前状态 - ${escapeHtml(statusText)} -
-
- 自动生成状态 - ${escapeHtml(captureMessage)} -
-
- 缩略图 ID - ${escapeHtml(thumbnail?.thumbnailId ?? '无')} -
-
- `; - - if (thumbnail?.available) { - thumbnailPreviewElement.innerHTML = ` - ${escapeHtml(state.fileInfo.file.name)} 的预览图 - `; - } else { - thumbnailPreviewElement.innerHTML = ` -
${capture.inFlight ? '正在生成预览图...' : '暂无预览图'}
- `; - } - - renderPreviewThumbnailHint(); - updateActionStates(); -} - -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; - } - - const previousCachedCount = state.workerStatus?.cachedVolumes?.length ?? 0; - state.workerStatus = event.data.status; - renderWorkerStatus(); - - const nextCachedCount = state.workerStatus?.cachedVolumes?.length ?? 0; - if (nextCachedCount !== previousCachedCount) { - void refreshCacheStats(); - } - }); -} - -async function registerPreviewSession(secrets) { - await ensureServiceWorkerReady(); - ensureWorkerStatusChannel(); - - if (state.currentSessionId) { - try { - await sendWorkerMessage('unregister-session', { - sessionId: state.currentSessionId - }); - } catch (error) { - // 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.currentSessionId = sessionId; - state.workerStatus = response.status; - renderWorkerStatus(); - return sessionId; -} - -function resetThumbnailCaptureState(sessionId = null) { - state.thumbnailCapture = { - inFlight: false, - attempted: Boolean(state.fileInfo?.file?.thumbnail?.available), - suppressed: false, - sessionId, - probeSessionId: null, - lastMessage: state.fileInfo?.file?.thumbnail?.available - ? '当前文件已存在后端预览图。' - : '等待视频首帧解码后自动生成。' - }; - renderThumbnailPanel(); -} - -async function unregisterWorkerSession(sessionId) { - if (!sessionId) { - return; - } - - try { - await ensureServiceWorkerReady(); - await sendWorkerMessage('unregister-session', { - sessionId - }); - } catch (error) { - // ignore stale or already-closed sessions during local cleanup - } -} - -async function stopCurrentPreviewSession() { - previewPlayer.pause(); - previewPlayer.removeAttribute('src'); - previewPlayer.load(); - await unregisterWorkerSession(state.thumbnailCapture.probeSessionId); - state.thumbnailCapture.probeSessionId = null; - - if (!state.currentSessionId) { - state.workerStatus = null; - renderWorkerStatus(); - return; - } - - await unregisterWorkerSession(state.currentSessionId); - state.currentSessionId = null; - state.activePreviewSecrets = null; - state.workerStatus = null; - renderWorkerStatus(); -} - -async function saveClientSecretsLocally(event) { - event.preventDefault(); - feedback.set('info', '正在更新浏览器本地 source 覆盖...'); - - try { - const secrets = requireClientSecrets(); - await persistClientSecretsPreference(secrets); - state.browserSourceSecrets = secrets.rememberOnDevice ? secrets : null; - renderClientSecretHint(); - feedback.set( - secrets.rememberOnDevice ? 'success' : 'info', - secrets.rememberOnDevice - ? '当前 source 的浏览器本地覆盖已保存。' - : '没有勾选记住,本次只保留在当前页面内存中。' - ); - } catch (error) { - feedback.set('error', error.message); - } -} - -async function saveClientSecretsAsGlobal() { - feedback.set('info', '正在保存浏览器全局默认值...'); - - try { - const secrets = requireClientSecrets(); - await saveGlobalClientSecrets(secrets); - state.browserGlobalSecrets = secrets; - renderClientSecretHint(); - feedback.set('success', '浏览器全局默认值已保存。后续文件页会默认继承这些字段。'); - } catch (error) { - feedback.set('error', error.message); - } -} - -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: Hidden thumbnail 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 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)); - - thumbnailCaptureCanvas.width = targetWidth; - thumbnailCaptureCanvas.height = targetHeight; - const context = thumbnailCaptureCanvas.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) => { - thumbnailCaptureCanvas.toBlob( - (blob) => { - if (!blob) { - reject(new Error('THUMBNAIL_CAPTURE_FAILED: 浏览器没有生成有效的缩略图 Blob。')); - return; - } - - resolve({ - blob, - frameAnalysis - }); - }, - 'image/webp', - 0.82 - ); - }); -} - -async function createThumbnailProbeVideo(sessionId) { - const probeVideo = document.createElement('video'); - probeVideo.muted = true; - probeVideo.playsInline = true; - probeVideo.preload = 'auto'; - probeVideo.controls = false; - probeVideo.style.position = 'fixed'; - probeVideo.style.opacity = '0'; - probeVideo.style.pointerEvents = 'none'; - probeVideo.style.width = '1px'; - probeVideo.style.height = '1px'; - probeVideo.style.left = '-9999px'; - probeVideo.style.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 maybeCapturePreviewThumbnail() { - if (!state.fileInfo || !state.currentSessionId || !isVideoMime(state.fileInfo.file.mime)) { - return; - } - - if (state.fileInfo.file.thumbnail?.available) { - return; - } - - if (state.thumbnailCapture.inFlight || state.thumbnailCapture.attempted || state.thumbnailCapture.suppressed) { - return; - } - - if (previewPlayer.readyState < HTMLMediaElement.HAVE_CURRENT_DATA || previewPlayer.videoWidth === 0) { - return; - } - - state.thumbnailCapture.inFlight = true; - state.thumbnailCapture.attempted = true; - state.thumbnailCapture.lastMessage = '正在寻找有效画面(准备探测候选时间点)...'; - renderThumbnailPanel(); - - const activeSessionId = state.currentSessionId; - const previewSecrets = state.activePreviewSecrets ?? readClientSecretsForm(); - const probeSessionId = crypto.randomUUID(); - state.thumbnailCapture.probeSessionId = probeSessionId; - - let probeVideo = null; - try { - await sendWorkerMessage('register-session', { - sessionId: probeSessionId, - sourceId: state.sourceId, - fileInfo: state.fileInfo, - secrets: previewSecrets - }); - - probeVideo = await createThumbnailProbeVideo(probeSessionId); - const candidates = buildThumbnailCandidateTimes(probeVideo.duration); - let selectedBlob = null; - let selectedMessage = ''; - let fallbackBlob = null; - - for (let index = 0; index < candidates.length; index += 1) { - if (state.currentSessionId !== activeSessionId || state.thumbnailCapture.sessionId !== activeSessionId) { - state.thumbnailCapture.lastMessage = '当前会话已切换,跳过旧会话的预览图上传。'; - return; - } - - const candidate = candidates[index]; - state.thumbnailCapture.lastMessage = `正在寻找有效画面(第 ${index + 1}/${candidates.length} 个候选时间点,${candidate.toFixed(2)}s)`; - renderThumbnailPanel(); - - await seekHiddenVideoTo(probeVideo, candidate); - const { blob, frameAnalysis } = await createThumbnailBlobFromVideo(probeVideo); - fallbackBlob = blob; - - if (!frameAnalysis.isBlackFrame) { - selectedBlob = blob; - selectedMessage = `已选中第 ${index + 1}/${candidates.length} 个候选时间点(${candidate.toFixed(2)}s)。`; - break; - } - - state.thumbnailCapture.lastMessage = `已跳过黑帧(第 ${index + 1}/${candidates.length} 个候选时间点,${candidate.toFixed(2)}s),继续向后寻找。`; - renderThumbnailPanel(); - } - - const blobToUpload = selectedBlob ?? fallbackBlob; - if (!blobToUpload) { - throw new Error('THUMBNAIL_CAPTURE_FAILED: 没有拿到可上传的预览图。'); - } - - if (!selectedMessage) { - selectedMessage = '前面的候选时间点都偏黑,已回退到最后一个可用画面。'; - } - - if (state.currentSessionId !== activeSessionId || state.thumbnailCapture.sessionId !== activeSessionId) { - state.thumbnailCapture.lastMessage = '当前会话已切换,跳过旧会话的预览图上传。'; - return; - } - - const formData = new FormData(); - formData.set('fileId', state.fileId); - formData.set('image', blobToUpload, 'preview.webp'); - const payload = await fetchJson(`/api/sources/${encodeURIComponent(state.sourceId)}/thumbnails`, { - method: 'POST', - body: formData - }); - - state.fileInfo.file.thumbnail = payload.thumbnail; - state.thumbnailCapture.lastMessage = `${selectedMessage} 预览图已缓存到后端。`; - renderFileSummary(); - renderThumbnailPanel(); - } catch (error) { - state.thumbnailCapture.lastMessage = error.message; - renderThumbnailPanel(); - } finally { - cleanupThumbnailProbeVideo(probeVideo); - await unregisterWorkerSession(probeSessionId); - if (state.thumbnailCapture.probeSessionId === probeSessionId) { - state.thumbnailCapture.probeSessionId = null; - } - state.thumbnailCapture.inFlight = false; - renderThumbnailPanel(); - } -} - -async function startVideoPreview() { - feedback.set('info', '正在注册浏览器端媒体会话...'); - - try { - assertBrowserFeature( - supportsVideoPreview(), - 'BROWSER_PREVIEW_UNSUPPORTED: 当前浏览器不支持 OPFS、Service Worker 或 Range 预览所需能力。' - ); - if (!state.fileInfo) { - throw state.fileInfoError ?? new Error('FILE_INFO_UNAVAILABLE: 当前文件还不能恢复。'); - } - if (!isVideoMime(state.fileInfo.file.mime)) { - throw new Error('VIDEO_MIME_UNSUPPORTED: 当前文件不是视频,无法启用播放器。'); - } - - const secrets = requireClientSecrets(); - await persistClientSecretsPreference(secrets); - state.activePreviewSecrets = { ...secrets }; - const sessionId = await registerPreviewSession(secrets); - resetThumbnailCaptureState(sessionId); - previewPlayer.src = `/__media__/session/${encodeURIComponent(sessionId)}`; - previewPlayer.load(); - setCurrentView('preview'); - feedback.set('success', '视频预览会话已注册,现在可以开始播放并拖动进度。'); - } catch (error) { - feedback.set('error', error.message); - } -} - -async function downloadFileDirectly() { - feedback.set('info', '正在准备浏览器直连下载...'); - - try { - assertBrowserFeature( - supportsFileDownload(), - 'BROWSER_DOWNLOAD_UNSUPPORTED: 当前浏览器不支持 OPFS 或 showSaveFilePicker。' - ); - if (!state.fileInfo) { - throw state.fileInfoError ?? new Error('FILE_INFO_UNAVAILABLE: 当前文件还不能恢复。'); - } - - const secrets = requireClientSecrets(); - await persistClientSecretsPreference(secrets); - - const extension = state.fileInfo.file.name.includes('.') - ? `.${state.fileInfo.file.name.split('.').pop()}` - : '.bin'; - const fileHandle = await showSaveFilePicker({ - suggestedName: state.fileInfo.file.name, - types: [ - { - description: 'Restored file', - accept: { - [state.fileInfo.file.mime || 'application/octet-stream']: [extension] - } - } - ] - }); - const writable = await fileHandle.createWritable(); - const volumeByRef = buildVolumeMap(state.fileInfo.volumes); - let writtenBytes = 0; - - for (const segment of state.fileInfo.segments) { - const volume = volumeByRef.get(segment.volumeRef); - downloadStatusElement.innerHTML = ` -
-
- 当前卷 - ${escapeHtml(volume?.name ?? segment.volumeRef)} -
-
- 已写入 - ${formatBytes(writtenBytes)} / ${formatBytes(state.fileInfo.file.size)} -
-
- `; - - const bytes = await restoreSegmentBytes({ - sourceId: state.sourceId, - volume, - zip: segment.zip, - secrets, - onStatus: ({ phase, currentVolume }) => { - state.workerStatus = { - phase, - currentVolume, - cachedVolumes: state.workerStatus?.cachedVolumes ?? [], - lastError: null - }; - renderWorkerStatus(); - } - }); - - await writable.write(bytes); - writtenBytes += bytes.length; - } - - await writable.close(); - await refreshCacheStats(); - downloadStatusElement.innerHTML = ` -
-
- 下载状态 - 已完成 -
-
- 写入总量 - ${formatBytes(writtenBytes)} -
-
- `; - feedback.set('success', '文件已经直接恢复并写入本地。'); - } catch (error) { - downloadStatusElement.innerHTML = ` -
-
- 下载状态 - ${escapeHtml(error.message)} -
-
- `; - feedback.set('error', error.message); - } -} - -async function refreshCacheStats() { - if (!supportsOpfs()) { - renderCacheStatus(); - return; - } - - const [currentSource, global] = await Promise.all([ - state.sourceId ? getCachedVolumeStats(state.sourceId) : Promise.resolve({ volumeCount: 0, totalBytes: 0 }), - getGlobalCacheStats() - ]); - - state.cacheStats = { - currentSource, - global - }; - renderCacheStatus(); -} - -async function clearCurrentSourceCache() { - feedback.set('info', '正在清理当前 source 的浏览器本地缓存...'); - - try { - assertBrowserFeature(supportsOpfs(), 'CACHE_UNSUPPORTED: 当前浏览器不支持 OPFS,本地缓存不可清理。'); - await stopCurrentPreviewSession(); - const result = await clearCachedVolumesForSource(state.sourceId); - await refreshCacheStats(); - feedback.set( - 'success', - `当前 source 缓存已清理,删除了 ${result.removedVolumes} 个 dblock,共 ${formatBytes(result.removedBytes)}。` - ); - } catch (error) { - feedback.set('error', error.message); - } -} - -async function clearAllBrowserCache() { - const confirmed = window.confirm( - '确定要清理当前浏览器保存的全部视频和下载缓存吗?\n\n这不会删除后端数据库、增强索引或 Duplicati-server.sqlite,只会删除浏览器 OPFS 里的本地缓存。' - ); - if (!confirmed) { - return; - } - - feedback.set('info', '正在清理全部浏览器本地缓存...'); - - try { - assertBrowserFeature(supportsOpfs(), 'CACHE_UNSUPPORTED: 当前浏览器不支持 OPFS,本地缓存不可清理。'); - await stopCurrentPreviewSession(); - const result = await clearAllCachedVolumes(); - await refreshCacheStats(); - feedback.set( - 'success', - `全部浏览器缓存已清理,删除了 ${result.removedSources} 个 source、${result.removedVolumes} 个 dblock,共 ${formatBytes(result.removedBytes)}。` - ); - } catch (error) { - feedback.set('error', error.message); - } -} - -async function deleteCurrentThumbnail() { - const thumbnailId = state.fileInfo?.file?.thumbnail?.thumbnailId; - if (!thumbnailId) { - return; - } - - const confirmed = window.confirm( - '确定要删除当前文件的预览图吗?\n\n这不会影响视频播放、下载或 Duplicati-server.sqlite,只会删除后端保存的这张缩略图。' - ); - if (!confirmed) { - return; - } - - feedback.set('info', '正在删除当前文件的预览图...'); - - try { - await fetchJson( - `/api/sources/${encodeURIComponent(state.sourceId)}/thumbnails/${encodeURIComponent(thumbnailId)}`, - { - method: 'DELETE' - } - ); - state.fileInfo.file.thumbnail = { - available: false, - thumbnailId: null, - thumbnailUrl: null - }; - state.thumbnailCapture.suppressed = true; - state.thumbnailCapture.attempted = true; - state.thumbnailCapture.lastMessage = '当前预览图已删除。下次启动新预览会话时会重新生成。'; - renderFileSummary(); - renderThumbnailPanel(); - feedback.set('success', '当前文件的预览图已删除。'); - } catch (error) { - feedback.set('error', error.message); - } -} - -async function loadSource() { - const payload = await fetchJson(`/api/sources/${encodeURIComponent(state.sourceId)}`); - state.source = payload.source; - renderClientSecretHint(); -} - -async function loadFileInfo() { - try { - const payload = await fetchJson( - `/api/file-info?sourceId=${encodeURIComponent(state.sourceId)}&id=${encodeURIComponent(state.fileId)}` - ); - state.fileInfo = payload; - state.fileInfoError = null; - if (!state.fileInfo.file.thumbnail) { - state.fileInfo.file.thumbnail = { - available: false, - thumbnailId: null, - thumbnailUrl: null - }; - } - } catch (error) { - state.fileInfo = null; - state.fileInfoError = error; - } -} - -async function loadResolvedBrowserSecrets() { - try { - const payload = await fetchJson(`/api/sources/${encodeURIComponent(state.sourceId)}/browser-secrets`); - state.resolvedBrowserSecrets = payload.secrets ?? null; - state.resolvedBrowserSecretsError = null; - } catch (error) { - state.resolvedBrowserSecrets = null; - state.resolvedBrowserSecretsError = error; - } -} - -async function preloadClientSecrets() { - const [globalSecrets, sourceSecrets] = await Promise.all([ - loadGlobalClientSecrets(), - loadClientSecrets(state.sourceId) - ]); - state.browserGlobalSecrets = globalSecrets; - state.browserSourceSecrets = sourceSecrets; - - const merged = mergeClientSecrets({ - sourceSecrets, - globalSecrets, - resolvedSecrets: state.resolvedBrowserSecrets, - sourceWebdav: state.source?.webdav - }); - writeClientSecretsForm(merged); - renderClientSecretHint(); -} - -clientSecretForm.addEventListener('submit', (event) => { - void saveClientSecretsLocally(event); -}); - -saveClientGlobalsButton.addEventListener('click', () => { - void saveClientSecretsAsGlobal(); -}); - -clearSourceCacheButton.addEventListener('click', () => { - void clearCurrentSourceCache(); -}); - -clearAllCacheButton.addEventListener('click', () => { - void clearAllBrowserCache(); -}); - -startPreviewButton.addEventListener('click', () => { - void startVideoPreview(); -}); - -deleteThumbnailFromPreviewButton.addEventListener('click', () => { - void deleteCurrentThumbnail(); -}); - -downloadButton.addEventListener('click', () => { - void downloadFileDirectly(); -}); - -deleteThumbnailButton.addEventListener('click', () => { - void deleteCurrentThumbnail(); -}); - -previewPlayer.addEventListener('loadeddata', () => { - void maybeCapturePreviewThumbnail(); -}); - -for (const link of viewLinks) { - link.addEventListener('click', (event) => { - event.preventDefault(); - setCurrentView(normalizeView(link.dataset.viewLink ?? 'overview')); - }); -} - -window.addEventListener('hashchange', () => { - setCurrentView(normalizeView(window.location.hash.slice(1)), { syncHash: false }); -}); - -window.addEventListener('beforeunload', () => { - if (!navigator.serviceWorker?.controller) { - return; - } - - if (state.currentSessionId) { - navigator.serviceWorker.controller.postMessage({ - type: 'unregister-session', - sessionId: state.currentSessionId - }); - } - - if (state.thumbnailCapture.probeSessionId) { - navigator.serviceWorker.controller.postMessage({ - type: 'unregister-session', - sessionId: state.thumbnailCapture.probeSessionId - }); - } -}); - -async function bootstrap() { - const initialView = normalizeView(window.location.hash.slice(1) || 'overview'); - setCurrentView(initialView, { replace: true }); - const authentication = await loadAuthState(); - authStatusElement.textContent = authentication.authenticated - ? `已登录:${authentication.username}` - : '未登录'; - bindLogoutButton(logoutButton, authStatusElement); - - if (!state.sourceId || !state.fileId) { - renderMissingRouteContext(); - return; - } - - setControlsDisabled(false); - setSummaryCard(sessionStatusElement, '尚未注册媒体会话。'); - setSummaryCard(cacheStatusElement, '正在统计浏览器本地缓存...'); - setSummaryCard(downloadStatusElement, '尚未开始下载。'); - setSummaryCard(thumbnailStatusElement, '正在读取预览图状态...'); - - await Promise.all([loadSource(), loadFileInfo(), loadResolvedBrowserSecrets()]); - await preloadClientSecrets(); - await refreshCacheStats(); - resetThumbnailCaptureState(null); - renderFileSummary(); - renderWorkerStatus(); - renderThumbnailPanel(); - updateActionStates(); -} - -void bootstrap().catch((error) => { - feedback.set('error', error.message); - fileSummaryElement.innerHTML = ` -
-

无法进入文件页。

-
- `; -}); diff --git a/public/index.html b/public/index.html deleted file mode 100644 index 865a752..0000000 --- a/public/index.html +++ /dev/null @@ -1,231 +0,0 @@ - - - - - - 数据源控制台 - - - -
-
-
-

Zero-Bandwidth Duplicati Web Client

-

数据源控制台

-
-

- 这里负责管理任务数据库、Server DB 元数据、全局 WebDAV 默认值,以及站点级 HTTPS/TLS 配置。 - 目录浏览、增强、预览和下载继续放在单个数据源工作台与文件页里。 -

-
- -
- 正在读取登录状态... - -
- - - -
- - -
-
-
-
-
- -

系统总览

-
-
-
正在读取系统总览...
-
-
- - - - - - - - - - -
-
-
- - - - diff --git a/public/modules/common.js b/public/modules/common.js index aee764f..3371355 100644 --- a/public/modules/common.js +++ b/public/modules/common.js @@ -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': diff --git a/public/modules/explorer/detailPanel.js b/public/modules/explorer/detailPanel.js new file mode 100644 index 0000000..fc99fcc --- /dev/null +++ b/public/modules/explorer/detailPanel.js @@ -0,0 +1,271 @@ +import { escapeHtml, fetchJson, formatBytes, inferEnhancementClass, inferEnhancementLabel } from '../common.js'; +import { buildVolumeMap, isVideoMime, restoreSegmentBytes, supportsFileDownload } from '../media-core.js'; + +// 右侧详情面板: +// - 选中文件 → 属性 + 内嵌