feat: rebuild frontend as file-manager style explorer SPA

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

- 新增 explorer.html/js 与 modules/explorer/ 六个组件模块
- 删除旧的 index/source/file 页面及其控制器
- styles.css 新增 explorer 布局与三档响应式断点
This commit is contained in:
2026-07-04 14:53:07 +08:00
parent 10d7b49275
commit fcf56bcbd3
18 changed files with 3444 additions and 3055 deletions
+1 -1
View File
@@ -11,7 +11,7 @@
"start": "node src/server.js", "start": "node src/server.js",
"dev": "node --watch src/server.js", "dev": "node --watch src/server.js",
"test": "node --test", "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": { "dependencies": {
"aes-js": "^3.1.2", "aes-js": "^3.1.2",
-588
View File
@@ -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 `
<div class="summary-stack">
${rows
.map(
(row) => `
<div class="summary-row">
<span>${escapeHtml(row.label)}</span>
<strong>${escapeHtml(row.value)}</strong>
</div>
`
)
.join('')}
</div>
`;
}
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 = `
<div class="empty-state-card">
<p>No task sources uploaded yet.</p>
<p>Upload a Duplicati task database first, then open the source workbench to browse, enhance, preview, or download.</p>
</div>
`;
return;
}
sourceListElement.innerHTML = state.sources
.map((source) => {
const targetHint = source.webdav?.effectiveWebdavBaseUrl
? `<div class="summary-chip">默认目标 URL 来源:${escapeHtml(source.webdav.effectiveWebdavBaseUrlSource ?? 'unknown')}</div>`
: '<div class="summary-chip">暂无可用的默认目标 URL</div>';
return renderSourceSummaryCard(
source,
`
${targetHint}
<div class="card-actions">
<a class="ghost-button button-link" href="${linkToSource(source.id)}#overview">进入工作台</a>
<button class="ghost-button danger-button" type="button" data-delete-source-id="${escapeHtml(source.id)}">删除 source</button>
</div>
`
);
})
.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 = `
<div class="empty-state-card">
<p>无法加载控制台。</p>
</div>
`;
});
+171
View File
@@ -0,0 +1,171 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>数据源资源管理器</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body class="explorer-body">
<div class="explorer-app">
<!-- 顶栏 -->
<header class="explorer-topbar">
<button id="sidebarToggle" class="explorer-icon-btn explorer-hamburger" type="button" title="菜单" aria-label="切换侧栏"></button>
<div class="explorer-brand"><span class="explorer-logo"></span><span>数据源资源管理器</span></div>
<div class="explorer-search">
<span aria-hidden="true">🔎</span>
<input id="searchInput" type="search" placeholder="搜索当前目录…" aria-label="搜索当前目录">
</div>
<div class="explorer-auth">
<span id="authStatus" class="auth-status">正在读取登录状态...</span>
<button id="logoutButton" class="explorer-icon-btn" type="button" title="退出登录" aria-label="退出登录"></button>
</div>
</header>
<div id="feedback" class="feedback explorer-feedback" hidden></div>
<!-- 主分栏 -->
<div id="split" class="explorer-split with-detail">
<!-- 左侧栏 -->
<aside id="sidebarBackdrop" class="explorer-backdrop" hidden></aside>
<aside id="sidebar" class="explorer-sidebar" aria-label="数据源与管理">
<div class="explorer-side-label">数据源</div>
<div id="sourceTree" class="explorer-tree">正在读取数据源...</div>
<div class="explorer-side-divider"></div>
<div class="explorer-side-label">⚙ 管理</div>
<button class="explorer-manage-link" type="button" data-manage="sources">🗂 数据源总览</button>
<button class="explorer-manage-link" type="button" data-manage="upload">⬆ 上传任务库</button>
<button class="explorer-manage-link" type="button" data-manage="server-db">🗄 Server DB</button>
<button class="explorer-manage-link" type="button" data-manage="defaults">🌐 全局 WebDAV</button>
<button class="explorer-manage-link" type="button" data-manage="tls">🔒 HTTPS / TLS</button>
</aside>
<!-- 主内容区 -->
<section class="explorer-main">
<!-- 导航条 -->
<div class="explorer-navbar">
<div class="explorer-nav-arrows">
<button id="navUp" class="explorer-icon-btn" type="button" title="上一级"></button>
<button id="navRoot" class="explorer-icon-btn" type="button" title="根目录"></button>
<button id="navRefresh" class="explorer-icon-btn" type="button" title="刷新"></button>
</div>
<nav id="breadcrumb" class="explorer-crumbs" aria-label="路径">/</nav>
<div class="explorer-view-toggle" role="group" aria-label="视图切换">
<button id="viewGrid" type="button" title="网格视图" aria-label="网格视图"></button>
<button id="viewList" class="active" type="button" title="列表视图" aria-label="列表视图"></button>
</div>
<label class="explorer-sort">
排序
<select id="sortSelect" aria-label="排序方式">
<option value="name:asc">名称 ↑</option>
<option value="name:desc">名称 ↓</option>
<option value="size:asc">大小 ↑</option>
<option value="size:desc">大小 ↓</option>
<option value="type:asc">类型 ↑</option>
<option value="mtime:asc">时间 ↑</option>
<option value="mtime:desc">时间 ↓</option>
</select>
</label>
</div>
<!-- 选择操作条 -->
<div id="actionBar" class="explorer-action-bar" hidden>
<label class="explorer-select-all">
<input id="selectAllCheckbox" type="checkbox">
<span>全选</span>
</label>
<button id="batchDownloadButton" class="primary-button explorer-pill" type="button" disabled>↓ 下载</button>
<button id="clearSelectionButton" class="ghost-button explorer-pill" type="button">清除选择</button>
<div class="explorer-spacer"></div>
<span id="selectionCount" class="batch-count">已选 0 项</span>
</div>
<!-- 文件区 -->
<div id="fileArea" class="explorer-file-area">正在读取目录...</div>
<!-- 批量下载进度 -->
<div id="batchProgress" class="explorer-batch-progress" hidden>
<div id="batchProgressContent" class="summary-card">准备中...</div>
</div>
<!-- 状态栏 -->
<div class="explorer-statusbar">
<span id="statusItems"></span>
<span id="statusSelected"></span>
<div class="explorer-spacer"></div>
<button id="toggleDetail" class="explorer-link-like" type="button">隐藏详情面板 ▸</button>
</div>
</section>
<!-- 右侧详情面板 -->
<aside id="detail" class="explorer-detail" aria-label="详情">
<div id="detailContent" class="explorer-detail-content">
<div class="empty-state-card">选中一个文件或数据源查看详情。</div>
</div>
</aside>
</div>
</div>
<!-- 右键菜单:文件 / 文件夹 -->
<div id="ctxFile" class="explorer-context-menu" hidden>
<button type="button" data-action="open">📂 打开</button>
<button type="button" data-action="preview">▶ 预览</button>
<button type="button" data-action="download">↓ 下载</button>
<hr>
<button type="button" data-action="details"> 详情</button>
</div>
<!-- 右键菜单:数据源盘符 -->
<div id="ctxDrive" class="explorer-context-menu" hidden>
<button type="button" data-action="open">📂 打开</button>
<button type="button" data-action="refresh">⟳ 刷新</button>
<button type="button" data-action="props">⚙ 属性 / 增强配置</button>
<hr>
<button type="button" data-action="delete" class="danger">🗑 删除数据源</button>
</div>
<!-- 源属性对话框 -->
<div id="propsOverlay" class="explorer-overlay" hidden>
<div class="explorer-dialog" role="dialog" aria-modal="true" aria-labelledby="propsTitle">
<div id="propsBody"></div>
</div>
</div>
<!-- 管理对话框 -->
<div id="manageOverlay" class="explorer-overlay" hidden>
<div class="explorer-dialog explorer-dialog-wide" role="dialog" aria-modal="true">
<div id="manageBody"></div>
</div>
</div>
<!-- 批量下载凭据弹窗 -->
<div id="credentialOverlay" class="explorer-overlay" hidden>
<div class="explorer-dialog" role="dialog" aria-modal="true">
<h3 class="serif">浏览器直连凭据</h3>
<p class="panel-copy">下载与预览需要在浏览器端直连 WebDAV 解密,请填写目标 URL 和备份口令。勾选"记住"会保存到浏览器 IndexedDB。</p>
<form id="credentialForm" class="secret-form">
<label><span>WebDAV Base URL</span><input id="credWebdavBaseUrl" name="webdavBaseUrl" type="url" placeholder="WebDAV 目标 URL"></label>
<label><span>认证方式</span>
<select id="credAuthMode" name="authMode">
<option value="basic">Basic</option>
<option value="anonymous">Anonymous</option>
</select>
</label>
<label><span>用户名</span><input id="credUsername" name="username" type="text"></label>
<label><span>密码</span><input id="credPassword" name="password" type="password"></label>
<label><span>备份口令</span><input id="credPassphrase" name="passphrase" type="password"></label>
<label class="checkbox-row"><input id="credRemember" name="rememberOnDevice" type="checkbox"><span>记住为当前 source 的浏览器本地覆盖</span></label>
<div class="form-actions">
<button class="primary-button" type="submit">确认</button>
<button id="credCancel" class="ghost-button" type="button">取消</button>
</div>
</form>
</div>
</div>
<canvas id="thumbnailCaptureCanvas" hidden></canvas>
<script type="module" src="/explorer.js"></script>
</body>
</html>
+583
View File
@@ -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 = `<div class="empty-state-card">${escapeHtml(error.message)}</div>`;
}
}
async function loadDirectory(path) {
if (!state.sourceId) {
return;
}
feedback.clear();
fileAreaElement.innerHTML = '<div class="empty-state-card">正在读取目录...</div>';
try {
const payload = await fetchJson(
`/api/ls?sourceId=${encodeURIComponent(state.sourceId)}&path=${encodeURIComponent(path)}`
);
state.currentPath = payload.path;
state.currentParent = payload.parent;
renderBreadcrumb(payload.path);
fileList.setEntries(payload.entries);
updateStatusBar();
} catch (error) {
feedback.set('error', error.message);
fileAreaElement.innerHTML = `<div class="empty-state-card"><p>无法列出目录内容。</p><p>${escapeHtml(error.message)}</p></div>`;
}
}
async function openFile(entry) {
// 双击:视频直接预览,其它文件在详情面板展示
await detailPanel.showFile(state.sourceId, entry);
fileList.setActiveFile(entry.id);
setDetailVisible(true);
}
async function handleSourceDeleted(sourceId) {
const sources = await reloadSources();
if (state.sourceId === sourceId) {
state.sourceId = null;
state.source = null;
void detailPanel.clear();
fileAreaElement.innerHTML = '<div class="empty-state-card">数据源已删除,请从左侧选择其它数据源。</div>';
breadcrumbElement.textContent = '/';
updateStatusBar();
if (sources.length > 0) {
void openSource(sources[0].id);
}
}
}
// ── 面包屑 ──
function renderBreadcrumb(path) {
const segments = path === '/' ? [] : path.split('/').filter(Boolean);
const crumbs = ['<button class="explorer-crumb" data-path="/" type="button">根</button>'];
let acc = '';
for (const segment of segments) {
acc += `/${segment}`;
crumbs.push('<span class="explorer-crumb-sep">▸</span>');
crumbs.push(`<button class="explorer-crumb" data-path="${escapeHtml(acc)}" type="button">${escapeHtml(segment)}</button>`);
}
breadcrumbElement.innerHTML = crumbs.join('');
}
// ── 选择与批量下载 ──
function onSelectionChange(selected, fileCount) {
state.selectedIds = selected;
state.fileCount = fileCount;
const count = selected.size;
actionBar.hidden = fileCount === 0;
selectionCount.textContent = `已选 ${count}`;
batchDownloadButton.disabled = count === 0 || state.batchDownloading || !supportsFileDownload();
if (!supportsFileDownload()) {
batchDownloadButton.title = '当前浏览器不支持 showSaveFilePicker';
}
selectAllCheckbox.checked = count > 0 && count === fileCount;
selectAllCheckbox.indeterminate = count > 0 && count < fileCount;
updateStatusBar();
}
function updateStatusBar() {
const entries = fileList.getEntries();
const dirs = entries.filter((entry) => entry.type === 'dir').length;
const files = entries.filter((entry) => entry.type === 'file').length;
statusItems.textContent = `${entries.length} 项(${dirs} 个文件夹,${files} 个文件)`;
statusSelected.textContent = state.selectedIds.size > 0 ? `已选 ${state.selectedIds.size}` : '';
}
async function initiateBatchDownload() {
if (!supportsFileDownload() || typeof window.showDirectoryPicker !== 'function') {
feedback.set('error', '当前浏览器不支持批量下载(需要 showDirectoryPicker)。');
return;
}
if (state.selectedIds.size === 0) {
return;
}
let secrets;
try {
secrets = await resolveSecrets(state.sourceId, null);
} catch (error) {
if (error?.code !== 'CREDENTIALS_CANCELLED') {
feedback.set('error', error.message);
}
return;
}
let directoryHandle;
try {
directoryHandle = await window.showDirectoryPicker({ mode: 'readwrite' });
} catch {
feedback.set('info', '已取消目录选择。');
return;
}
const fileIds = [...state.selectedIds];
const total = fileIds.length;
state.batchDownloading = true;
batchDownloadButton.disabled = true;
batchProgress.hidden = false;
const errors = [];
let totalWritten = 0;
for (let i = 0; i < fileIds.length; i += 1) {
const fileId = fileIds[i];
try {
const info = await fetchJson(
`/api/file-info?sourceId=${encodeURIComponent(state.sourceId)}&id=${encodeURIComponent(fileId)}`
);
renderBatchProgress(i + 1, total, info.file.name, totalWritten);
const safeName = info.file.name.replace(/[/\\:*?"<>|]/g, '_');
const fileHandle = await directoryHandle.getFileHandle(safeName, { create: true });
const writable = await fileHandle.createWritable();
const volumeByRef = buildVolumeMap(info.volumes);
for (const segment of info.segments) {
const volume = volumeByRef.get(segment.volumeRef);
const bytes = await restoreSegmentBytes({
sourceId: state.sourceId,
volume,
zip: segment.zip,
secrets,
onStatus: ({ phase, currentVolume }) => {
renderBatchProgress(i + 1, total, info.file.name, totalWritten, `${phase ?? ''} ${currentVolume ?? ''}`);
}
});
await writable.write(bytes);
totalWritten += bytes.length;
}
await writable.close();
} catch (error) {
errors.push(error.message);
}
}
state.batchDownloading = false;
batchProgress.hidden = true;
onSelectionChange(state.selectedIds, state.fileCount);
if (errors.length === 0) {
feedback.set('success', `已完成全部 ${total} 个文件的批量下载。`);
fileList.clearSelection();
} else {
feedback.set('error', `批量下载完成:${total - errors.length}/${total} 成功。失败:${errors.join('; ')}`);
}
}
function renderBatchProgress(current, total, fileName, written, phase = '') {
batchProgressContent.innerHTML = `
<div class="summary-stack">
<div class="summary-row"><span>进度</span><strong>${current} / ${total}</strong></div>
<div class="summary-row"><span>当前文件</span><strong>${escapeHtml(fileName)}</strong></div>
${phase ? `<div class="summary-row"><span>阶段</span><strong>${escapeHtml(phase)}</strong></div>` : ''}
<div class="summary-row"><span>已写入</span><strong>${formatBytes(written)}</strong></div>
</div>
`;
}
function applyThumbnailToList(fileId, thumbnail) {
const entries = fileList.getEntries();
const entry = entries.find((item) => item.id === fileId);
if (entry) {
entry.thumbnail = thumbnail;
fileList.setEntries(entries);
}
}
// ── 右键菜单 ──
function hideMenus() {
ctxFile.hidden = true;
ctxDrive.hidden = true;
}
function positionMenu(menu, { x, y }) {
hideMenus();
menu.hidden = false;
const width = menu.offsetWidth || 200;
const height = menu.offsetHeight || 220;
menu.style.left = `${Math.min(x, window.innerWidth - width - 8)}px`;
menu.style.top = `${Math.min(y, window.innerHeight - height - 8)}px`;
}
let ctxTargetEntry = null;
let ctxTargetSourceId = null;
function showFileMenu(entry, pos) {
if (entry.type !== 'file') {
return;
}
ctxTargetEntry = entry;
positionMenu(ctxFile, pos);
}
function showDriveMenu(sourceId, pos) {
ctxTargetSourceId = sourceId;
positionMenu(ctxDrive, pos);
}
ctxFile.addEventListener('click', (event) => {
const action = event.target.closest('[data-action]')?.dataset.action;
hideMenus();
if (!ctxTargetEntry) {
return;
}
if (action === 'open' || action === 'preview') {
void openFile(ctxTargetEntry);
} else if (action === 'download') {
void detailPanel.showFile(state.sourceId, ctxTargetEntry).then(() => {
// 详情面板加载后,用户可点下载;这里直接触发一次选择即可
});
fileList.setActiveFile(ctxTargetEntry.id);
setDetailVisible(true);
} else if (action === 'details') {
void detailPanel.showFile(state.sourceId, ctxTargetEntry);
fileList.setActiveFile(ctxTargetEntry.id);
setDetailVisible(true);
}
});
ctxDrive.addEventListener('click', (event) => {
const action = event.target.closest('[data-action]')?.dataset.action;
hideMenus();
if (!ctxTargetSourceId) {
return;
}
if (action === 'open') {
void openSource(ctxTargetSourceId);
} else if (action === 'refresh') {
if (state.sourceId === ctxTargetSourceId) {
void loadDirectory(state.currentPath);
} else {
void openSource(ctxTargetSourceId);
}
} else if (action === 'props') {
openProps(ctxTargetSourceId);
} else if (action === 'delete') {
openProps(ctxTargetSourceId, 'danger');
}
});
document.addEventListener('click', (event) => {
if (!event.target.closest('.explorer-context-menu')) {
hideMenus();
}
});
window.addEventListener('scroll', hideMenus, true);
// ── 弹窗 ──
function openOverlay(overlay) {
overlay.hidden = false;
}
function closeOverlay(overlay) {
overlay.hidden = true;
}
function openProps(sourceId, tab = 'enhance') {
openOverlay(propsOverlay);
void propsDialog.open(sourceId, tab);
}
for (const overlay of [propsOverlay, manageOverlay, credentialOverlay]) {
overlay.addEventListener('click', (event) => {
if (event.target === overlay && overlay !== credentialOverlay) {
closeOverlay(overlay);
}
});
}
// ── 详情面板显隐 ──
function setDetailVisible(visible) {
splitElement.classList.toggle('with-detail', visible);
toggleDetailButton.textContent = visible ? '隐藏详情面板 ▸' : '显示详情面板 ◂';
}
toggleDetailButton.addEventListener('click', () => {
setDetailVisible(!splitElement.classList.contains('with-detail'));
});
// ── 侧栏(移动端抽屉) ──
function openSidebarMobile() {
sidebarElement.classList.add('open');
sidebarBackdrop.hidden = false;
}
function closeSidebarOnMobile() {
sidebarElement.classList.remove('open');
sidebarBackdrop.hidden = true;
}
document.querySelector('#sidebarToggle').addEventListener('click', () => {
if (sidebarElement.classList.contains('open')) {
closeSidebarOnMobile();
} else {
openSidebarMobile();
}
});
sidebarBackdrop.addEventListener('click', closeSidebarOnMobile);
// ── 管理入口 ──
for (const link of document.querySelectorAll('[data-manage]')) {
link.addEventListener('click', () => {
closeSidebarOnMobile();
openOverlay(manageOverlay);
void manage.open(link.dataset.manage === 'sources' ? 'sources' : link.dataset.manage);
});
}
// ── 导航条 ──
document.querySelector('#navUp').addEventListener('click', () => {
if (state.currentParent !== null && state.currentParent !== undefined) {
void loadDirectory(state.currentParent);
} else if (state.currentPath !== '/') {
void loadDirectory('/');
}
});
document.querySelector('#navRoot').addEventListener('click', () => void loadDirectory('/'));
document.querySelector('#navRefresh').addEventListener('click', () => void loadDirectory(state.currentPath));
breadcrumbElement.addEventListener('click', (event) => {
const crumb = event.target.closest('[data-path]');
if (crumb instanceof HTMLElement) {
void loadDirectory(crumb.dataset.path);
}
});
// ── 视图 / 排序 / 搜索 ──
document.querySelector('#viewList').addEventListener('click', () => {
fileList.setView('list');
document.querySelector('#viewList').classList.add('active');
document.querySelector('#viewGrid').classList.remove('active');
});
document.querySelector('#viewGrid').addEventListener('click', () => {
fileList.setView('grid');
document.querySelector('#viewGrid').classList.add('active');
document.querySelector('#viewList').classList.remove('active');
});
document.querySelector('#sortSelect').addEventListener('change', (event) => {
const [key, dir] = event.target.value.split(':');
fileList.setSort(key, dir);
});
searchInput.addEventListener('input', (event) => {
fileList.setFilter(event.target.value);
});
// ── 选择操作条 ──
selectAllCheckbox.addEventListener('change', (event) => fileList.selectAll(event.target.checked));
clearSelectionButton.addEventListener('click', () => fileList.clearSelection());
batchDownloadButton.addEventListener('click', () => void initiateBatchDownload());
// ── 启动 ──
async function bootstrap() {
const authentication = await loadAuthState();
authStatusElement.textContent = authentication.authenticated ? `已登录:${authentication.username}` : '未登录';
bindLogoutButton(logoutButton, authStatusElement);
const sources = await reloadSources();
const requestedSource = optionalQueryParam('sourceId', '');
const target = sources.find((source) => source.id === requestedSource) ?? sources[0];
if (target) {
await openSource(target.id);
} else {
fileAreaElement.innerHTML = '<div class="empty-state-card">还没有数据源。点左侧「上传任务库」开始。</div>';
void detailPanel.clear();
}
}
void bootstrap().catch((error) => {
feedback.set('error', error.message);
fileAreaElement.innerHTML = '<div class="empty-state-card">无法进入资源管理器。</div>';
});
-176
View File
@@ -1,176 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>文件预览与下载</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<main class="shell">
<nav class="crumbs">
<a href="/">数据源控制台</a>
<span>/</span>
<a id="sourceCrumb" href="/">数据源工作台</a>
<span>/</span>
<span>文件页</span>
</nav>
<section class="hero hero-compact">
<div>
<p class="eyebrow">File Workspace</p>
<h1 id="fileTitle">文件预览与下载</h1>
</div>
<p class="lead">
这里负责浏览器直连 WebDAV 的视频预览、下载、本地缓存和预览图回写。大流量文件流依然只走前端,不经过后端文件代理。
</p>
</section>
<div class="auth-toolbar">
<span id="authStatus" class="auth-status">正在读取登录状态...</span>
<button id="logoutButton" class="ghost-button" type="button">退出登录</button>
</div>
<div id="feedback" class="feedback" hidden></div>
<section class="workspace-shell">
<aside class="workspace-nav" aria-label="文件工作台菜单">
<a class="workspace-nav-link" href="#overview" data-view-link="overview">文件概况</a>
<a class="workspace-nav-link" href="#preview" data-view-link="preview">视频预览</a>
<a class="workspace-nav-link" href="#download" data-view-link="download">下载</a>
<a class="workspace-nav-link" href="#cache" data-view-link="cache">本地缓存</a>
<a class="workspace-nav-link" href="#thumbnail" data-view-link="thumbnail">预览图</a>
</aside>
<div class="workspace-content">
<section class="workspace-panel" data-view-panel="overview">
<div class="panel">
<div class="panel-header">
<div>
<p class="section-label">File</p>
<h2>文件概况</h2>
</div>
</div>
<div id="fileSummary" class="empty-state-card">正在读取文件摘要...</div>
</div>
</section>
<section class="workspace-panel" data-view-panel="preview" hidden>
<div class="panel stack-gap">
<div class="panel-header">
<div>
<p class="section-label">Preview</p>
<h2>视频预览</h2>
</div>
<div class="form-actions workspace-inline-actions">
<button id="startPreviewButton" class="primary-button" type="button">启动视频预览</button>
<button id="deleteThumbnailFromPreviewButton" class="ghost-button danger-button" type="button">删除当前预览图</button>
</div>
</div>
<p id="videoHint" class="panel-copy">
只有增强完成、并且 MIME 为视频的文件,才会在这里启用浏览器端 Range 播放。
</p>
<div id="previewThumbnailHint" class="summary-card">
这里删除的是后端缩略图缓存,不会删除浏览器本地视频缓存,也不会影响当前视频文件本身。
</div>
<div id="clientSecretHint" class="summary-card">正在计算当前任务的浏览器默认值...</div>
<form id="clientSecretForm" class="secret-form">
<label>
<span>WebDAV Base URL</span>
<input id="clientWebdavBaseUrl" name="webdavBaseUrl" type="url" placeholder="默认会自动带出当前任务的目标 URL">
</label>
<label>
<span>认证方式</span>
<select id="clientAuthMode" name="authMode">
<option value="basic">Basic</option>
<option value="anonymous">Anonymous</option>
</select>
</label>
<label>
<span>用户名</span>
<input id="clientUsername" name="username" type="text" placeholder="默认继承浏览器全局默认值">
</label>
<label>
<span>密码</span>
<input id="clientPassword" name="password" type="password" placeholder="默认继承浏览器全局默认值">
</label>
<label>
<span>备份口令</span>
<input id="clientPassphrase" name="passphrase" type="password" placeholder="默认继承浏览器全局默认值">
</label>
<label class="checkbox-row">
<input id="rememberOnDevice" name="rememberOnDevice" type="checkbox">
<span>记住为当前 source 的浏览器本地覆盖</span>
</label>
<div class="form-actions">
<button id="saveClientSecretsButton" class="ghost-button" type="submit">保存当前 source 覆盖</button>
<button id="saveClientGlobalsButton" class="ghost-button" type="button">保存为浏览器全局默认值</button>
</div>
</form>
<div class="video-frame">
<video id="previewPlayer" controls preload="metadata"></video>
</div>
<canvas id="thumbnailCaptureCanvas" hidden></canvas>
</div>
</section>
<section class="workspace-panel" data-view-panel="download" hidden>
<div class="panel">
<div class="panel-header">
<div>
<p class="section-label">Download</p>
<h2>前端直连下载</h2>
</div>
<button id="downloadButton" class="ghost-button" type="button">下载到本地</button>
</div>
<p class="panel-copy">
下载会复用视频预览相同的浏览器直连配置和 dblock 缓存,顺序读取所需分段并直接写盘,不把整个文件拼进内存。
</p>
<div id="downloadStatus" class="summary-card">尚未开始下载。</div>
</div>
</section>
<section class="workspace-panel" data-view-panel="cache" hidden>
<div class="panel stack-gap">
<div class="panel-header">
<div>
<p class="section-label">Cache</p>
<h2>本地缓存与会话状态</h2>
</div>
</div>
<div id="sessionStatus" class="summary-card">尚未注册媒体会话。</div>
<div id="cacheStatus" class="summary-card">正在统计浏览器本地缓存...</div>
<div class="form-actions">
<button id="clearSourceCacheButton" class="ghost-button danger-button" type="button">清理当前 source 缓存</button>
<button id="clearAllCacheButton" class="ghost-button danger-button" type="button">清理全部浏览器缓存</button>
</div>
</div>
</section>
<section class="workspace-panel" data-view-panel="thumbnail" hidden>
<div class="panel stack-gap">
<div class="panel-header">
<div>
<p class="section-label">Preview Thumbnail</p>
<h2>预览图</h2>
</div>
<button id="deleteThumbnailButton" class="ghost-button danger-button" type="button">删除当前预览图</button>
</div>
<p class="panel-copy">
视频第一次成功预览后,会自动截取可用画面并回写到后端。目录浏览页会直接使用这里缓存的缩略图。
</p>
<div id="thumbnailStatus" class="summary-card">正在读取预览图状态...</div>
<div id="thumbnailPreview" class="thumbnail-card">
<div class="thumbnail-placeholder">暂无预览图</div>
</div>
</div>
</section>
</div>
</section>
</main>
<script type="module" src="/file.js"></script>
</body>
</html>
-1317
View File
File diff suppressed because it is too large Load Diff
-231
View File
@@ -1,231 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>数据源控制台</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<main class="shell">
<section class="hero hero-compact">
<div>
<p class="eyebrow">Zero-Bandwidth Duplicati Web Client</p>
<h1>数据源控制台</h1>
</div>
<p class="lead">
这里负责管理任务数据库、Server DB 元数据、全局 WebDAV 默认值,以及站点级 HTTPS/TLS 配置。
目录浏览、增强、预览和下载继续放在单个数据源工作台与文件页里。
</p>
</section>
<div class="auth-toolbar">
<span id="authStatus" class="auth-status">正在读取登录状态...</span>
<button id="logoutButton" class="ghost-button" type="button">退出登录</button>
</div>
<div id="feedback" class="feedback" hidden></div>
<section class="workspace-shell">
<aside class="workspace-nav" aria-label="数据源控制台菜单">
<a class="workspace-nav-link" href="#overview" data-view-link="overview">总览</a>
<a class="workspace-nav-link" href="#sources" data-view-link="sources">数据源列表</a>
<a class="workspace-nav-link" href="#upload" data-view-link="upload">新增任务库</a>
<a class="workspace-nav-link" href="#server-db" data-view-link="server-db">Server DB</a>
<a class="workspace-nav-link" href="#defaults" data-view-link="defaults">全局 WebDAV</a>
<a class="workspace-nav-link" href="#tls" data-view-link="tls">HTTPS / TLS</a>
</aside>
<div class="workspace-content">
<section class="workspace-panel" data-view-panel="overview">
<div class="panel stack-gap">
<div class="panel-header">
<div>
<p class="section-label">Overview</p>
<h2>系统总览</h2>
</div>
</div>
<div id="overviewSummary" class="summary-card">正在读取系统总览...</div>
</div>
</section>
<section class="workspace-panel" data-view-panel="sources" hidden>
<div class="panel panel-spacious">
<div class="panel-header">
<div>
<p class="section-label">Sources</p>
<h2>已上传任务数据库</h2>
</div>
<button id="refreshButton" class="ghost-button" type="button">刷新</button>
</div>
<p class="panel-copy">
每个上传的 Duplicati 任务数据库都会变成一个独立 source。进入工作台后,可以继续浏览目录、执行增强,或跳转到文件页做预览和下载。
</p>
<div id="sourceList" class="source-list empty-state">正在读取数据源列表...</div>
</div>
</section>
<section class="workspace-panel" data-view-panel="upload" hidden>
<div class="panel">
<div class="panel-header">
<div>
<p class="section-label">Task DB</p>
<h2>上传新的任务库</h2>
</div>
</div>
<p class="panel-copy">
上传随机名任务数据库,例如 <code>TMQRJYNADS.sqlite</code>。重复上传同一个数据库时,系统会复用现有 source,而不是重复创建。
</p>
<form id="uploadForm" class="upload-form">
<label class="file-picker">
<span>选择任务 SQLite 文件</span>
<input id="databaseInput" name="database" type="file" accept=".sqlite,.db,application/octet-stream">
</label>
<div class="form-actions">
<button id="uploadButton" class="primary-button" type="submit">上传为新数据源</button>
</div>
</form>
</div>
</section>
<section class="workspace-panel" data-view-panel="server-db" hidden>
<div class="panel stack-gap">
<div class="panel-header">
<div>
<p class="section-label">Server DB</p>
<h2>上传 Duplicati-server.sqlite</h2>
</div>
</div>
<p class="panel-copy">
上传后,界面可以自动解析任务名称,并尽量从 Duplicati 元数据中推导默认目标 URL。
</p>
<form id="serverDbForm" class="upload-form">
<label class="file-picker">
<span>选择服务器数据库</span>
<input id="serverDbInput" name="database" type="file" accept=".sqlite,.db,application/octet-stream">
</label>
<div class="form-actions">
<button id="serverDbUploadButton" class="ghost-button" type="submit">上传 Server DB</button>
</div>
</form>
<div id="serverDbSummary" class="summary-card">正在读取 Server DB 状态...</div>
</div>
</section>
<section class="workspace-panel" data-view-panel="defaults" hidden>
<div class="panel stack-gap">
<div class="panel-header">
<div>
<p class="section-label">Global WebDAV</p>
<h2>共享默认值</h2>
</div>
</div>
<p class="panel-copy">
如果大多数任务共用同一套 WebDAV 认证和口令,可以在这里统一保存一次。
</p>
<form id="globalDefaultsForm" class="secret-form">
<label>
<span>兜底 WebDAV Base URL</span>
<input id="globalWebdavBaseUrl" name="webdavBaseUrl" type="url" placeholder="可选,共享兜底 URL">
</label>
<label>
<span>认证方式</span>
<select id="globalAuthMode" name="authMode">
<option value="">自动 / 继承</option>
<option value="basic">Basic</option>
<option value="anonymous">匿名</option>
</select>
</label>
<label>
<span>用户名</span>
<input id="globalUsername" name="username" type="text" placeholder="可选,共享用户名">
</label>
<label>
<span>密码</span>
<input id="globalPassword" name="password" type="password" placeholder="可选,共享密码">
</label>
<label>
<span>备份口令</span>
<input id="globalPassphrase" name="passphrase" type="password" placeholder="可选,共享备份口令">
</label>
<div class="form-actions">
<button id="globalDefaultsSaveButton" class="ghost-button" type="submit">保存默认值</button>
</div>
</form>
<div id="globalDefaultsSummary" class="summary-card">正在读取 WebDAV 默认值...</div>
</div>
</section>
<section class="workspace-panel" data-view-panel="tls" hidden>
<div class="panel stack-gap">
<div class="panel-header">
<div>
<p class="section-label">HTTPS / TLS</p>
<h2>域名与证书设置</h2>
</div>
</div>
<p class="panel-copy">
HTTP 和 HTTPS 会同时可用。没有自定义证书时,系统会自动维护一张自签证书,保证 HTTPS 始终可访问。
</p>
<div id="tlsSummary" class="summary-card">正在读取 TLS 配置...</div>
<form id="tlsJsonForm" class="secret-form">
<label>
<span>证书模式</span>
<select id="tlsMode" name="mode">
<option value="self-signed">自签证书</option>
<option value="custom-pem">自定义 PEM</option>
</select>
</label>
<label>
<span>主域名</span>
<input id="tlsPrimaryDomain" name="primaryDomain" type="text" placeholder="例如:preview.example.com">
</label>
<label>
<span>额外 SAN 条目</span>
<textarea id="tlsSubjectAltNames" name="subjectAltNames" rows="3" placeholder="每行一个域名或 IP,也支持逗号分隔"></textarea>
</label>
<label>
<span>证书 PEM</span>
<textarea id="tlsCertPem" name="certPem" rows="8" placeholder="-----BEGIN CERTIFICATE-----"></textarea>
</label>
<label>
<span>私钥 PEM</span>
<textarea id="tlsKeyPem" name="keyPem" rows="8" placeholder="-----BEGIN PRIVATE KEY-----"></textarea>
</label>
<label>
<span>可选链证书 PEM</span>
<textarea id="tlsChainPem" name="chainPem" rows="6" placeholder="可选,中间证书链"></textarea>
</label>
<div class="form-actions">
<button id="tlsSaveButton" class="primary-button" type="submit">保存并应用</button>
<button id="tlsDeleteCustomButton" class="ghost-button danger-button" type="button">删除自定义证书并回退</button>
</div>
</form>
<form id="tlsUploadForm" class="upload-form">
<label class="file-picker">
<span>上传证书文件</span>
<input id="tlsCertificateInput" name="certificate" type="file" accept=".pem,.crt,.cer,text/plain">
</label>
<label class="file-picker">
<span>上传私钥文件</span>
<input id="tlsPrivateKeyInput" name="privateKey" type="file" accept=".pem,.key,text/plain">
</label>
<label class="file-picker">
<span>上传可选链证书文件</span>
<input id="tlsChainInput" name="chain" type="file" accept=".pem,.crt,.cer,text/plain">
</label>
<div class="form-actions">
<button id="tlsUploadButton" class="ghost-button" type="submit">应用上传的证书</button>
</div>
</form>
</div>
</section>
</div>
</section>
</main>
<script type="module" src="/app.js"></script>
</body>
</html>
-8
View File
@@ -80,14 +80,6 @@ export function createFeedbackController(element) {
}; };
} }
export function linkToSource(sourceId) {
return `/source.html?sourceId=${encodeURIComponent(sourceId)}`;
}
export function linkToFile(sourceId, fileId) {
return `/file.html?sourceId=${encodeURIComponent(sourceId)}&id=${encodeURIComponent(fileId)}`;
}
export function inferEnhancementLabel(source) { export function inferEnhancementLabel(source) {
switch (source?.enhancement?.status) { switch (source?.enhancement?.status) {
case 'ready': case 'ready':
+271
View File
@@ -0,0 +1,271 @@
import { escapeHtml, fetchJson, formatBytes, inferEnhancementClass, inferEnhancementLabel } from '../common.js';
import { buildVolumeMap, isVideoMime, restoreSegmentBytes, supportsFileDownload } from '../media-core.js';
// 右侧详情面板:
// - 选中文件 → 属性 + 内嵌 <video> 播放器 + 预览/下载按钮
// - 选中数据源 → 源概况(移植 source.js renderSourceSummary
// - 空 → 提示
//
// 依赖注入:mediaPlayer(createMediaPlayer 实例)、resolveSecrets(异步返回凭据或抛出)、
// feedback(createFeedbackController)、onThumbnailUpdated(回调刷新列表缩略图)
function isVideoFileInfo(fileInfo) {
return Boolean(fileInfo && isVideoMime(fileInfo.file.mime));
}
export function createDetailPanel(rootElement, deps = {}) {
const { mediaPlayer, resolveSecrets, feedback, onThumbnailUpdated } = deps;
const state = {
mode: 'empty',
sourceId: null,
entry: null,
fileInfo: null,
loadToken: 0
};
function renderEmpty(message = '选中一个文件或数据源查看详情。') {
state.mode = 'empty';
rootElement.innerHTML = `<div class="empty-state-card">${escapeHtml(message)}</div>`;
}
function renderSourceOverview(source) {
state.mode = 'source';
if (!source) {
renderEmpty('未找到对应数据源。');
return;
}
const enhancementError = source.enhancement?.lastErrorMessage
? `<div class="feedback feedback-error">最近失败:${escapeHtml(source.enhancement.lastErrorMessage)}</div>`
: '';
rootElement.innerHTML = `
<div class="explorer-detail-head">
<span class="explorer-detail-ico">💽</span>
<div>
<div class="explorer-detail-title serif">${escapeHtml(source.displayName || source.originalFilename)}</div>
<div class="status-pill ${inferEnhancementClass(source)}">${inferEnhancementLabel(source)}</div>
</div>
</div>
<hr>
<div class="summary-stack">
<div class="summary-row"><span>数据库文件</span><strong>${escapeHtml(source.originalFilename)}</strong></div>
<div class="summary-row"><span>Source ID</span><strong>${escapeHtml(source.id)}</strong></div>
<div class="summary-row"><span>上传大小</span><strong>${formatBytes(source.fileSize)}</strong></div>
<div class="summary-row"><span>目录浏览</span><strong>${source.canBrowse ? '可用' : '不可用'}</strong></div>
<div class="summary-row"><span>预览图缓存</span><strong>${escapeHtml(String(source.thumbnailCache?.count ?? 0))} 张</strong></div>
<div class="summary-row"><span>最新快照</span><strong>${escapeHtml(source.latestSnapshot?.timestamp ?? '暂无')}</strong></div>
</div>
${enhancementError}
<div class="explorer-detail-actions">
<button class="ghost-button explorer-pill" type="button" data-detail-action="props">⚙ 属性 / 增强配置</button>
</div>
`;
}
function renderFileLoading(entry) {
state.mode = 'file';
rootElement.innerHTML = `
<div class="explorer-detail-head">
<span class="explorer-detail-ico">📄</span>
<div><div class="explorer-detail-title serif">${escapeHtml(entry.name)}</div></div>
</div>
<hr>
<div class="summary-card">正在读取文件信息...</div>
`;
}
function renderFile(entry, fileInfo, { error = null } = {}) {
state.mode = 'file';
const canPreview = isVideoFileInfo(fileInfo);
const previewBlock = canPreview
? `
<div class="explorer-preview-frame">
<video id="detailVideo" controls playsinline preload="metadata"></video>
</div>`
: `
<div class="explorer-preview-frame explorer-preview-static">
${
entry.thumbnail?.available
? `<img src="${escapeHtml(entry.thumbnail.thumbnailUrl)}" alt="">`
: '<span class="explorer-detail-ico-lg">📄</span>'
}
</div>`;
const infoAvailable = Boolean(fileInfo);
const errorBlock = error ? `<div class="feedback feedback-error">${escapeHtml(error)}</div>` : '';
rootElement.innerHTML = `
${previewBlock}
<div class="explorer-detail-title serif">${escapeHtml(entry.name)}</div>
<hr>
<div class="summary-stack">
<div class="summary-row"><span>类型</span><strong>${escapeHtml(entry.mime || '未知')}</strong></div>
<div class="summary-row"><span>大小</span><strong>${formatBytes(entry.size)}</strong></div>
<div class="summary-row"><span>修改时间</span><strong>${escapeHtml(entry.mtime ?? '—')}</strong></div>
<div class="summary-row"><span>路径</span><strong class="muted">${escapeHtml(entry.path)}</strong></div>
</div>
${errorBlock}
<div id="detailStatus" class="explorer-detail-status" hidden></div>
<div class="explorer-detail-actions">
${canPreview ? '<button class="primary-button explorer-pill" type="button" data-detail-action="preview">▶ 预览</button>' : ''}
<button class="ghost-button explorer-pill" type="button" data-detail-action="download" ${infoAvailable ? '' : 'disabled'}>↓ 下载</button>
</div>
`;
}
async function loadAndRenderFile(sourceId, entry) {
state.sourceId = sourceId;
state.entry = entry;
state.fileInfo = null;
const token = ++state.loadToken;
renderFileLoading(entry);
try {
const payload = await fetchJson(
`/api/file-info?sourceId=${encodeURIComponent(sourceId)}&id=${encodeURIComponent(entry.id)}`
);
if (token !== state.loadToken) {
return;
}
state.fileInfo = payload;
renderFile(entry, payload);
} catch (error) {
if (token !== state.loadToken) {
return;
}
renderFile(entry, null, { error: error.message });
}
}
function setDetailStatus(message) {
const el = rootElement.querySelector('#detailStatus');
if (!el) {
return;
}
el.hidden = false;
el.textContent = message;
}
async function startPreview() {
const videoElement = rootElement.querySelector('#detailVideo');
if (!videoElement || !state.fileInfo) {
return;
}
try {
const secrets = await resolveSecrets(state.sourceId, state.fileInfo);
feedback?.set('info', '正在注册浏览器端媒体会话...');
await mediaPlayer.play({
videoElement,
sourceId: state.sourceId,
fileId: state.entry.id,
fileInfo: state.fileInfo,
secrets
});
feedback?.set('success', '视频预览已就绪,可播放并拖动进度。');
} catch (error) {
if (error?.code === 'CREDENTIALS_CANCELLED') {
return;
}
feedback?.set('error', error.message);
}
}
async function downloadFile() {
if (!state.fileInfo) {
return;
}
try {
if (!supportsFileDownload()) {
throw new Error('BROWSER_DOWNLOAD_UNSUPPORTED: 当前浏览器不支持 OPFS 或 showSaveFilePicker。');
}
const secrets = await resolveSecrets(state.sourceId, state.fileInfo);
const fileInfo = state.fileInfo;
const extension = fileInfo.file.name.includes('.') ? `.${fileInfo.file.name.split('.').pop()}` : '.bin';
const fileHandle = await showSaveFilePicker({
suggestedName: fileInfo.file.name,
types: [
{
description: 'Restored file',
accept: { [fileInfo.file.mime || 'application/octet-stream']: [extension] }
}
]
});
feedback?.set('info', '正在浏览器直连下载...');
const writable = await fileHandle.createWritable();
const volumeByRef = buildVolumeMap(fileInfo.volumes);
let writtenBytes = 0;
for (const segment of fileInfo.segments) {
const volume = volumeByRef.get(segment.volumeRef);
setDetailStatus(`正在恢复卷 ${volume?.name ?? segment.volumeRef} · 已写入 ${formatBytes(writtenBytes)}`);
const bytes = await restoreSegmentBytes({
sourceId: state.sourceId,
volume,
zip: segment.zip,
secrets,
onStatus: ({ phase, currentVolume }) => {
setDetailStatus(`${phase ?? '处理中'} · ${currentVolume ?? ''} · 已写入 ${formatBytes(writtenBytes)}`);
}
});
await writable.write(bytes);
writtenBytes += bytes.length;
}
await writable.close();
setDetailStatus(`下载完成 · 共 ${formatBytes(writtenBytes)}`);
feedback?.set('success', '文件已直接恢复并写入本地。');
} catch (error) {
if (error?.name === 'AbortError' || error?.code === 'CREDENTIALS_CANCELLED') {
return;
}
setDetailStatus(error.message);
feedback?.set('error', error.message);
}
}
rootElement.addEventListener('click', (event) => {
const button = event.target.closest('[data-detail-action]');
if (!(button instanceof HTMLElement)) {
return;
}
const action = button.dataset.detailAction;
if (action === 'preview') {
void startPreview();
} else if (action === 'download') {
void downloadFile();
} else if (action === 'props') {
deps.onOpenProps?.(state.sourceId);
}
});
return {
async showFile(sourceId, entry) {
await mediaPlayer.stop();
await loadAndRenderFile(sourceId, entry);
},
async showSource(source) {
await mediaPlayer.stop();
state.sourceId = source?.id ?? null;
state.entry = null;
state.fileInfo = null;
renderSourceOverview(source);
},
async clear() {
await mediaPlayer.stop();
state.sourceId = null;
state.entry = null;
state.fileInfo = null;
renderEmpty();
},
updateThumbnail(fileId, thumbnail) {
if (state.entry?.id === fileId) {
state.entry.thumbnail = thumbnail;
}
onThumbnailUpdated?.(fileId, thumbnail);
}
};
}
+341
View File
@@ -0,0 +1,341 @@
import { escapeHtml, formatBytes } from '../common.js';
// 文件列表组件:列表(表格) + 网格双视图、列头/下拉排序、多选、缩略图。
// 移植自 source.js 的 renderBrowse / 批量选择逻辑,改造成表格与网格。
// 事件回调:onOpenDir(path) / onOpenFile(entry) / onSelectFile(entry) / onSelectionChange(set)
function isVideoEntry(entry) {
return String(entry.mime ?? '').toLowerCase().startsWith('video/') || entry.hints?.preview === 'video';
}
function typeLabel(entry) {
if (entry.type === 'dir') {
return '文件夹';
}
if (entry.hints?.preview) {
return entry.hints.preview;
}
return (entry.mime || 'file').split('/').pop();
}
function thumbInner(entry) {
if (entry.type === 'dir') {
return '<span class="explorer-thumb-ico">📁</span>';
}
if (entry.thumbnail?.available) {
return `<img src="${escapeHtml(entry.thumbnail.thumbnailUrl)}" alt="" loading="lazy">`;
}
if (isVideoEntry(entry)) {
return '<span class="explorer-thumb-ico">🎬</span>';
}
if (String(entry.mime ?? '').startsWith('image/')) {
return '<span class="explorer-thumb-ico">🖼</span>';
}
return '<span class="explorer-thumb-ico">📄</span>';
}
function compareEntries(a, b, sortKey, sortDir) {
// 文件夹永远排在文件前面(与后端 sortEntries 一致)
if (a.type !== b.type) {
return a.type === 'dir' ? -1 : 1;
}
let result = 0;
switch (sortKey) {
case 'size':
result = (Number(a.size) || 0) - (Number(b.size) || 0);
break;
case 'type':
result = typeLabel(a).localeCompare(typeLabel(b), 'en');
break;
case 'mtime':
result = String(a.mtime ?? '').localeCompare(String(b.mtime ?? ''));
break;
case 'name':
default:
result = a.name.localeCompare(b.name, 'zh-Hans-CN');
break;
}
return sortDir === 'desc' ? -result : result;
}
export function createFileList(rootElement, handlers = {}) {
const { onOpenDir, onOpenFile, onSelectFile, onSelectionChange } = handlers;
const state = {
entries: [],
view: 'list',
sortKey: 'name',
sortDir: 'asc',
filter: '',
selected: new Set(),
activeFileId: null
};
function visibleEntries() {
const filter = state.filter.trim().toLowerCase();
const filtered = filter
? state.entries.filter((entry) => entry.name.toLowerCase().includes(filter))
: state.entries.slice();
return filtered.sort((a, b) => compareEntries(a, b, state.sortKey, state.sortDir));
}
function fileEntries() {
return state.entries.filter((entry) => entry.type === 'file');
}
function renderRows(entries) {
return entries
.map((entry) => {
const selected = entry.type === 'file' && state.selected.has(entry.id);
const active = entry.type === 'file' && entry.id === state.activeFileId;
const rowClasses = [
'explorer-row',
entry.type === 'dir' ? 'is-dir' : 'is-file',
selected ? 'selected' : '',
active ? 'active' : ''
]
.filter(Boolean)
.join(' ');
const checkboxCell =
entry.type === 'file'
? `<input type="checkbox" class="explorer-row-check" data-file-id="${escapeHtml(entry.id)}" ${selected ? 'checked' : ''} aria-label="选择 ${escapeHtml(entry.name)}">`
: '';
const dataAttrs =
entry.type === 'dir'
? `data-type="dir" data-path="${escapeHtml(entry.path)}"`
: `data-type="file" data-file-id="${escapeHtml(entry.id)}"`;
return `
<tr class="${rowClasses}" ${dataAttrs}>
<td class="explorer-col-chk">${checkboxCell}</td>
<td>
<div class="explorer-name-cell">
<span class="explorer-thumb${entry.type === 'dir' ? ' folder' : ''}">
${thumbInner(entry)}${isVideoEntry(entry) ? '<span class="explorer-play">▶</span>' : ''}
</span>
<span class="explorer-name-txt">${escapeHtml(entry.name)}</span>
</div>
</td>
<td class="explorer-col-size">${entry.type === 'dir' ? '—' : formatBytes(entry.size)}</td>
<td class="explorer-col-type"><span class="explorer-badge-type">${escapeHtml(typeLabel(entry))}</span></td>
<td class="explorer-col-time muted">${escapeHtml(entry.mtime ?? '—')}</td>
</tr>
`;
})
.join('');
}
function renderCards(entries) {
return entries
.map((entry) => {
const selected = entry.type === 'file' && state.selected.has(entry.id);
const active = entry.type === 'file' && entry.id === state.activeFileId;
const cardClasses = [
'explorer-card',
entry.type === 'dir' ? 'is-dir' : 'is-file',
selected ? 'selected' : '',
active ? 'active' : ''
]
.filter(Boolean)
.join(' ');
const dataAttrs =
entry.type === 'dir'
? `data-type="dir" data-path="${escapeHtml(entry.path)}"`
: `data-type="file" data-file-id="${escapeHtml(entry.id)}"`;
const checkbox =
entry.type === 'file'
? `<input type="checkbox" class="explorer-row-check explorer-card-check" data-file-id="${escapeHtml(entry.id)}" ${selected ? 'checked' : ''} aria-label="选择 ${escapeHtml(entry.name)}">`
: '';
return `
<div class="${cardClasses}" ${dataAttrs}>
${checkbox}
<div class="explorer-cover${entry.type === 'dir' ? ' folder' : ''}">
${thumbInner(entry)}${isVideoEntry(entry) ? '<span class="explorer-play-lg">▶</span>' : ''}
</div>
<div class="explorer-card-cap" title="${escapeHtml(entry.name)}">${escapeHtml(entry.name)}</div>
<div class="explorer-card-sub">${entry.type === 'dir' ? '文件夹' : formatBytes(entry.size)}</div>
</div>
`;
})
.join('');
}
function sortArrow(key) {
if (state.sortKey !== key) {
return '';
}
return `<span class="explorer-arrow">${state.sortDir === 'asc' ? '▲' : '▼'}</span>`;
}
function render() {
const entries = visibleEntries();
if (state.entries.length === 0) {
rootElement.innerHTML = '<div class="empty-state-card">这个目录目前没有内容。</div>';
return;
}
if (entries.length === 0) {
rootElement.innerHTML = '<div class="empty-state-card">没有匹配当前搜索的文件。</div>';
return;
}
if (state.view === 'grid') {
rootElement.innerHTML = `<div class="explorer-grid">${renderCards(entries)}</div>`;
return;
}
rootElement.innerHTML = `
<table class="explorer-tbl">
<thead>
<tr>
<th class="explorer-col-chk"></th>
<th class="explorer-sortable" data-sort="name">名称 ${sortArrow('name')}</th>
<th class="explorer-col-size explorer-sortable" data-sort="size">大小 ${sortArrow('size')}</th>
<th class="explorer-col-type explorer-sortable" data-sort="type">类型 ${sortArrow('type')}</th>
<th class="explorer-col-time explorer-sortable" data-sort="mtime">修改时间 ${sortArrow('mtime')}</th>
</tr>
</thead>
<tbody>${renderRows(entries)}</tbody>
</table>
`;
}
function emitSelectionChange() {
onSelectionChange?.(new Set(state.selected), fileEntries().length);
}
function toggleSelection(fileId, selected) {
if (selected) {
state.selected.add(fileId);
} else {
state.selected.delete(fileId);
}
render();
emitSelectionChange();
}
// ── 事件委托 ──
rootElement.addEventListener('click', (event) => {
const checkbox = event.target.closest('.explorer-row-check');
if (checkbox instanceof HTMLInputElement) {
event.stopPropagation();
toggleSelection(checkbox.dataset.fileId, checkbox.checked);
return;
}
const sortable = event.target.closest('.explorer-sortable');
if (sortable instanceof HTMLElement) {
const key = sortable.dataset.sort;
if (state.sortKey === key) {
state.sortDir = state.sortDir === 'asc' ? 'desc' : 'asc';
} else {
state.sortKey = key;
state.sortDir = 'asc';
}
render();
return;
}
const item = event.target.closest('[data-type]');
if (!(item instanceof HTMLElement)) {
return;
}
if (item.dataset.type === 'dir') {
onOpenDir?.(item.dataset.path);
return;
}
const entry = state.entries.find((candidate) => candidate.id === item.dataset.fileId);
if (entry) {
state.activeFileId = entry.id;
render();
onSelectFile?.(entry);
}
});
rootElement.addEventListener('dblclick', (event) => {
const item = event.target.closest('[data-type="file"]');
if (!(item instanceof HTMLElement)) {
return;
}
const entry = state.entries.find((candidate) => candidate.id === item.dataset.fileId);
if (entry) {
onOpenFile?.(entry);
}
});
rootElement.addEventListener('contextmenu', (event) => {
const item = event.target.closest('[data-type]');
if (!(item instanceof HTMLElement) || !handlers.onContextMenu) {
return;
}
event.preventDefault();
const entry =
item.dataset.type === 'file'
? state.entries.find((candidate) => candidate.id === item.dataset.fileId)
: { type: 'dir', path: item.dataset.path };
handlers.onContextMenu(entry, { x: event.clientX, y: event.clientY });
});
return {
setEntries(entries) {
state.entries = Array.isArray(entries) ? entries : [];
state.selected.clear();
state.activeFileId = null;
render();
emitSelectionChange();
},
setView(view) {
state.view = view === 'grid' ? 'grid' : 'list';
render();
},
setSort(key, dir) {
state.sortKey = key;
state.sortDir = dir;
render();
},
setFilter(value) {
state.filter = value ?? '';
render();
},
setActiveFile(fileId) {
state.activeFileId = fileId;
render();
},
selectAll(selected) {
if (selected) {
for (const entry of fileEntries()) {
state.selected.add(entry.id);
}
} else {
state.selected.clear();
}
render();
emitSelectionChange();
},
clearSelection() {
state.selected.clear();
state.activeFileId = null;
render();
emitSelectionChange();
},
getSelectedIds() {
return [...state.selected];
},
getFileCount() {
return fileEntries().length;
},
getEntries() {
return state.entries.slice();
}
};
}
+378
View File
@@ -0,0 +1,378 @@
import { escapeHtml, fetchJson, renderSourceSummaryCard } from '../common.js';
// 管理对话框:折叠 app.js 的六个面板(overview/sources/upload/server-db/defaults/tls)。
// 通过侧栏「管理」按钮打开,顶部标签页切换。数据加载与表单提交逻辑移植自 app.js。
// deps: { feedback, onSourcesChanged(reloads sidebar/tree), close() }
const TABS = [
{ key: 'sources', label: '数据源' },
{ key: 'upload', label: '上传任务库' },
{ key: 'server-db', label: 'Server DB' },
{ key: 'defaults', label: '全局 WebDAV' },
{ key: 'tls', label: 'HTTPS / TLS' }
];
function summaryRows(rows) {
return `
<div class="summary-stack">
${rows
.map((row) => `<div class="summary-row"><span>${escapeHtml(row.label)}</span><strong>${escapeHtml(row.value)}</strong></div>`)
.join('')}
</div>
`;
}
export function createManage(rootElement, deps = {}) {
const { feedback, onSourcesChanged } = deps;
const cache = { sources: [], serverDb: null, defaults: null, tls: null };
let activeTab = 'sources';
function tabsHtml() {
return `
<div class="tabs">
${TABS.map(
(tab) => `<button type="button" data-tab="${tab.key}" class="${tab.key === activeTab ? 'active' : ''}">${escapeHtml(tab.label)}</button>`
).join('')}
</div>
`;
}
function renderSourcesTab() {
if (cache.sources.length === 0) {
return '<div class="empty-state-card">还没有上传任务数据库。切到「上传任务库」添加。</div>';
}
return `<div class="source-list">${cache.sources
.map((source) =>
renderSourceSummaryCard(
source,
`<div class="card-actions">
<button class="ghost-button button-link" type="button" data-open-source="${escapeHtml(source.id)}">在资源管理器打开</button>
<button class="ghost-button danger-button" type="button" data-delete-source="${escapeHtml(source.id)}">删除 source</button>
</div>`
)
)
.join('')}</div>`;
}
function renderUploadTab() {
return `
<p class="panel-copy">上传随机名任务数据库(如 <code>TMQRJYNADS.sqlite</code>)。重复上传会复用现有 source。</p>
<form id="mgUploadForm" class="upload-form">
<label class="file-picker"><span>选择任务 SQLite 文件</span>
<input id="mgDatabaseInput" name="database" type="file" accept=".sqlite,.db,application/octet-stream"></label>
<div class="form-actions"><button class="primary-button" type="submit">上传为新数据源</button></div>
</form>
`;
}
function renderServerDbTab() {
const summary = cache.serverDb?.available
? summaryRows([
{ label: '当前文件', value: cache.serverDb.originalFilename },
{ label: '上传时间', value: cache.serverDb.uploadedAt },
{ label: '任务条目数', value: String(cache.serverDb.backupCount) }
])
: summaryRows([{ label: '状态', value: '未上传 Duplicati-server.sqlite' }]);
return `
<p class="panel-copy">上传后可自动解析任务名称,并尽量从元数据推导默认目标 URL。</p>
<form id="mgServerDbForm" class="upload-form">
<label class="file-picker"><span>选择服务器数据库</span>
<input id="mgServerDbInput" name="database" type="file" accept=".sqlite,.db,application/octet-stream"></label>
<div class="form-actions"><button class="ghost-button" type="submit">上传 Server DB</button></div>
</form>
${summary}
`;
}
function renderDefaultsTab() {
const defaults = cache.defaults;
const summary = defaults?.configured
? summaryRows([
{ label: '兜底 URL', value: defaults.webdavBaseUrl ?? '未设置' },
{ label: '认证方式', value: defaults.authMode ?? '自动' },
{
label: '已保存凭据',
value: `username=${defaults.hasUsername ? 'yes' : 'no'}, password=${defaults.hasPassword ? 'yes' : 'no'}, passphrase=${defaults.hasPassphrase ? 'yes' : 'no'}`
}
])
: summaryRows([{ label: '状态', value: '尚未保存全局默认值' }]);
return `
<p class="panel-copy">如果大多数任务共用同一套 WebDAV 账号和口令,可以在这里统一保存一次。</p>
<form id="mgDefaultsForm" class="secret-form">
<label><span>兜底 WebDAV Base URL</span><input id="mgGlobalUrl" type="url" value="${escapeHtml(defaults?.webdavBaseUrl ?? '')}" placeholder="可选,共享兜底 URL"></label>
<label><span>认证方式</span>
<select id="mgGlobalAuthMode">
<option value="" ${!defaults?.authMode ? 'selected' : ''}>自动 / 继承</option>
<option value="basic" ${defaults?.authMode === 'basic' ? 'selected' : ''}>Basic</option>
<option value="anonymous" ${defaults?.authMode === 'anonymous' ? 'selected' : ''}>匿名</option>
</select>
</label>
<label><span>用户名</span><input id="mgGlobalUsername" type="text" placeholder="可选,共享用户名"></label>
<label><span>密码</span><input id="mgGlobalPassword" type="password" placeholder="可选,共享密码"></label>
<label><span>备份口令</span><input id="mgGlobalPassphrase" type="password" placeholder="可选,共享备份口令"></label>
<div class="form-actions"><button class="ghost-button" type="submit">保存默认值</button></div>
</form>
${summary}
`;
}
function renderTlsTab() {
const tls = cache.tls;
const summary = tls
? summaryRows([
{ label: 'HTTP 地址', value: tls.access?.httpBaseUrl ?? '未知' },
{ label: 'HTTPS 地址', value: tls.access?.httpsBaseUrl ?? '未知' },
{ label: '当前生效证书', value: tls.activeSource ?? 'self-signed' },
{ label: '主域名', value: tls.primaryDomain || 'localhost' },
{ label: '有效期', value: tls.certificate ? `${tls.certificate.validFrom} -> ${tls.certificate.validTo}` : '未知' }
])
: summaryRows([{ label: '状态', value: 'TLS 配置尚未加载' }]);
const sans = Array.isArray(tls?.subjectAltNames) ? tls.subjectAltNames.join('\n') : '';
return `
<p class="panel-copy">HTTP 和 HTTPS 会同时可用。没有自定义证书时系统会自动维护一张自签证书。</p>
${summary}
<form id="mgTlsForm" class="secret-form">
<label><span>证书模式</span>
<select id="mgTlsMode">
<option value="self-signed" ${tls?.mode !== 'custom-pem' ? 'selected' : ''}>自签证书</option>
<option value="custom-pem" ${tls?.mode === 'custom-pem' ? 'selected' : ''}>自定义 PEM</option>
</select>
</label>
<label><span>主域名</span><input id="mgTlsPrimaryDomain" type="text" value="${escapeHtml(tls?.primaryDomain ?? '')}" placeholder="例如:preview.example.com"></label>
<label><span>额外 SAN 条目</span><textarea id="mgTlsSans" rows="2" placeholder="每行一个域名或 IP">${escapeHtml(sans)}</textarea></label>
<label><span>证书 PEM</span><textarea id="mgTlsCertPem" rows="4" placeholder="-----BEGIN CERTIFICATE-----"></textarea></label>
<label><span>私钥 PEM</span><textarea id="mgTlsKeyPem" rows="4" placeholder="-----BEGIN PRIVATE KEY-----"></textarea></label>
<label><span>可选链证书 PEM</span><textarea id="mgTlsChainPem" rows="3" placeholder="可选,中间证书链"></textarea></label>
<div class="form-actions">
<button class="primary-button" type="submit">保存并应用</button>
<button id="mgTlsDeleteCustom" class="ghost-button danger-button" type="button" ${tls?.hasCustomCertificate ? '' : 'disabled'}>删除自定义证书并回退</button>
</div>
</form>
`;
}
function renderBody() {
let content = '';
switch (activeTab) {
case 'upload':
content = renderUploadTab();
break;
case 'server-db':
content = renderServerDbTab();
break;
case 'defaults':
content = renderDefaultsTab();
break;
case 'tls':
content = renderTlsTab();
break;
case 'sources':
default:
content = renderSourcesTab();
break;
}
rootElement.innerHTML = `
<div class="explorer-dialog-head">
<h3 class="serif">系统管理</h3>
<button class="explorer-icon-btn" type="button" data-manage-close aria-label="关闭">✕</button>
</div>
${tabsHtml()}
<div class="explorer-manage-content">${content}</div>
`;
}
async function reloadAll() {
const [sources, serverDb, defaults, tls] = await Promise.all([
fetchJson('/api/sources'),
fetchJson('/api/server-db'),
fetchJson('/api/webdav-defaults'),
fetchJson('/api/system/tls')
]);
cache.sources = sources.sources;
cache.serverDb = serverDb.serverDb;
cache.defaults = defaults.defaults;
cache.tls = tls.tls;
onSourcesChanged?.(cache.sources);
}
// ── 表单提交 ──
async function submitUpload(form) {
const file = form.querySelector('#mgDatabaseInput')?.files?.[0];
if (!file) {
feedback?.set('error', '请先选择一个任务 SQLite 文件。');
return;
}
feedback?.set('info', '正在上传任务数据库...');
try {
const formData = new FormData();
formData.set('database', file);
const response = await fetch('/api/sources/upload', { method: 'POST', body: formData });
const payload = await response.json();
if (!response.ok) {
throw new Error(`${payload.error?.code ?? 'UPLOAD_FAILED'}: ${payload.error?.message ?? '上传失败。'}`);
}
await reloadAll();
activeTab = 'sources';
renderBody();
feedback?.set(payload.reused ? 'info' : 'success', payload.reused ? '该数据库已存在,复用现有 source。' : '任务数据库上传成功。');
} catch (error) {
feedback?.set('error', error.message);
}
}
async function submitServerDb(form) {
const file = form.querySelector('#mgServerDbInput')?.files?.[0];
if (!file) {
feedback?.set('error', '请先选择 Duplicati-server.sqlite。');
return;
}
feedback?.set('info', '正在上传 Server DB...');
try {
const formData = new FormData();
formData.set('database', file);
const response = await fetch('/api/server-db/upload', { method: 'POST', body: formData });
const payload = await response.json();
if (!response.ok) {
throw new Error(`${payload.error?.code ?? 'UPLOAD_FAILED'}: ${payload.error?.message ?? '上传失败。'}`);
}
await reloadAll();
renderBody();
feedback?.set('success', 'Server DB 上传成功。');
} catch (error) {
feedback?.set('error', error.message);
}
}
async function submitDefaults() {
feedback?.set('info', '正在保存全局 WebDAV 默认值...');
try {
const body = {
webdavBaseUrl: rootElement.querySelector('#mgGlobalUrl')?.value.trim() ?? '',
authMode: rootElement.querySelector('#mgGlobalAuthMode')?.value ?? '',
username: rootElement.querySelector('#mgGlobalUsername')?.value.trim() ?? '',
password: rootElement.querySelector('#mgGlobalPassword')?.value ?? '',
passphrase: rootElement.querySelector('#mgGlobalPassphrase')?.value ?? ''
};
const payload = await fetchJson('/api/webdav-defaults', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
cache.defaults = payload.defaults;
renderBody();
feedback?.set(payload.defaults.configured ? 'success' : 'info', payload.defaults.configured ? '全局默认值已保存。' : '全局默认值已清空。');
} catch (error) {
feedback?.set('error', error.message);
}
}
async function submitTls() {
feedback?.set('info', '正在保存 TLS 配置...');
try {
const body = {
mode: rootElement.querySelector('#mgTlsMode')?.value ?? 'self-signed',
primaryDomain: rootElement.querySelector('#mgTlsPrimaryDomain')?.value.trim() ?? '',
subjectAltNames: rootElement.querySelector('#mgTlsSans')?.value ?? '',
certPem: rootElement.querySelector('#mgTlsCertPem')?.value ?? '',
keyPem: rootElement.querySelector('#mgTlsKeyPem')?.value ?? '',
chainPem: rootElement.querySelector('#mgTlsChainPem')?.value ?? ''
};
const payload = await fetchJson('/api/system/tls', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
cache.tls = payload.tls;
renderBody();
feedback?.set('success', `TLS 配置已应用,当前证书来源:${payload.tls.activeSource}`);
} catch (error) {
feedback?.set('error', error.message);
}
}
async function deleteCustomTls() {
if (!window.confirm('确定要删除当前自定义证书,并回退到自签证书吗?')) {
return;
}
feedback?.set('info', '正在回退到自签证书...');
try {
const payload = await fetchJson('/api/system/tls/custom-certificate', { method: 'DELETE' });
cache.tls = payload.tls;
renderBody();
feedback?.set('success', '自定义证书已删除,已回退到自签证书。');
} catch (error) {
feedback?.set('error', error.message);
}
}
async function deleteSource(sourceId) {
const source = cache.sources.find((item) => item.id === sourceId);
const label = source?.displayName || source?.originalFilename || sourceId;
if (!window.confirm(`确定要删除这个 source 吗?\n\n${label}\n\n这只会删除当前 source 及其本地增强数据,不会删除 Duplicati-server.sqlite。`)) {
return;
}
feedback?.set('info', `正在删除 ${label}...`);
try {
await fetchJson(`/api/sources/${encodeURIComponent(sourceId)}`, { method: 'DELETE' });
await reloadAll();
renderBody();
feedback?.set('success', `已删除 ${label}`);
} catch (error) {
feedback?.set('error', error.message);
}
}
rootElement.addEventListener('click', (event) => {
const tab = event.target.closest('[data-tab]');
if (tab instanceof HTMLElement) {
activeTab = tab.dataset.tab;
renderBody();
return;
}
if (event.target.closest('[data-manage-close]')) {
deps.close?.();
return;
}
const openSource = event.target.closest('[data-open-source]');
if (openSource instanceof HTMLElement) {
deps.onOpenSource?.(openSource.dataset.openSource);
return;
}
const del = event.target.closest('[data-delete-source]');
if (del instanceof HTMLElement) {
void deleteSource(del.dataset.deleteSource);
return;
}
if (event.target.closest('#mgTlsDeleteCustom')) {
void deleteCustomTls();
}
});
rootElement.addEventListener('submit', (event) => {
event.preventDefault();
const form = event.target;
if (form.id === 'mgUploadForm') {
void submitUpload(form);
} else if (form.id === 'mgServerDbForm') {
void submitServerDb(form);
} else if (form.id === 'mgDefaultsForm') {
void submitDefaults();
} else if (form.id === 'mgTlsForm') {
void submitTls();
}
});
return {
async open(tab = 'sources') {
activeTab = TABS.some((item) => item.key === tab) ? tab : 'sources';
renderBody();
try {
await reloadAll();
renderBody();
} catch (error) {
feedback?.set('error', error.message);
}
}
};
}
+407
View File
@@ -0,0 +1,407 @@
import { assertBrowserFeature } from '../common.js';
import { isVideoMime, supportsVideoPreview } from '../media-core.js';
import { analyzeFrameLuma, buildThumbnailCandidateTimes } from '../thumbnail-utils.js';
// 参数驱动的媒体播放器组件。从 file.js 抽取媒体会话逻辑,去掉页面全局与硬编码 ID。
// 一个实例管理:Service Worker 注册、session 注册/注销、<video> 挂载、预览图自动抓取。
//
// 用法:
// const player = createMediaPlayer({ canvas, onStatus, onThumbnail });
// await player.play({ videoElement, sourceId, fileId, fileInfo, secrets });
// await player.stop();
const STATUS_CHANNEL_NAME = 'duplicati-media-status';
export function createMediaPlayer({ canvas, onStatus = () => {}, onThumbnail = () => {} } = {}) {
const state = {
videoElement: null,
sourceId: null,
fileId: null,
fileInfo: null,
secrets: null,
currentSessionId: null,
probeSessionId: null,
workerChannel: null,
loadedDataHandler: null,
thumbnail: {
inFlight: false,
attempted: false
}
};
async function sendWorkerMessage(type, payload) {
const registration = await navigator.serviceWorker.ready;
const worker = navigator.serviceWorker.controller ?? registration.active ?? registration.waiting;
if (!worker) {
throw new Error('SERVICE_WORKER_UNAVAILABLE: Service Worker is not ready.');
}
return new Promise((resolve, reject) => {
const channel = new MessageChannel();
const timeoutId = setTimeout(() => {
reject(new Error('SERVICE_WORKER_TIMEOUT: Service Worker did not reply in time.'));
}, 10_000);
channel.port1.onmessage = (event) => {
clearTimeout(timeoutId);
if (event.data?.ok) {
resolve(event.data);
} else {
reject(new Error(event.data?.error ?? 'Service Worker request failed.'));
}
};
worker.postMessage({ type, ...payload }, [channel.port2]);
});
}
async function ensureServiceWorkerReady() {
assertBrowserFeature(navigator.serviceWorker, 'SERVICE_WORKER_UNSUPPORTED: 当前浏览器不支持 Service Worker。');
await navigator.serviceWorker.register('/media-sw.js', { scope: '/', type: 'module' });
await navigator.serviceWorker.ready;
if (!navigator.serviceWorker.controller) {
await new Promise((resolve) => {
navigator.serviceWorker.addEventListener('controllerchange', () => resolve(), { once: true });
});
}
}
function ensureWorkerStatusChannel() {
if (state.workerChannel || typeof BroadcastChannel !== 'function') {
return;
}
state.workerChannel = new BroadcastChannel(STATUS_CHANNEL_NAME);
state.workerChannel.addEventListener('message', (event) => {
if (event.data?.type !== 'status' || event.data.sessionId !== state.currentSessionId) {
return;
}
onStatus(event.data.status);
});
}
async function unregisterWorkerSession(sessionId) {
if (!sessionId) {
return;
}
try {
await ensureServiceWorkerReady();
await sendWorkerMessage('unregister-session', { sessionId });
} catch {
// ignore stale or already-closed sessions
}
}
async function registerPreviewSession() {
await ensureServiceWorkerReady();
ensureWorkerStatusChannel();
if (state.currentSessionId) {
try {
await sendWorkerMessage('unregister-session', { sessionId: state.currentSessionId });
} catch {
// ignore stale sessions during re-registration
}
}
const sessionId = crypto.randomUUID();
const response = await sendWorkerMessage('register-session', {
sessionId,
sourceId: state.sourceId,
fileInfo: state.fileInfo,
secrets: state.secrets
});
state.currentSessionId = sessionId;
onStatus(response.status);
return sessionId;
}
// ── 预览图抓取(隐藏探测视频) ──
function waitForEventOnce(target, eventName, timeoutMs, readyCheck = null) {
if (typeof readyCheck === 'function' && readyCheck()) {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
cleanup();
reject(new Error(`THUMBNAIL_EVENT_TIMEOUT: Timed out waiting for ${eventName}.`));
}, timeoutMs);
function cleanup() {
clearTimeout(timeoutId);
target.removeEventListener(eventName, handleSuccess);
target.removeEventListener('error', handleError);
}
function handleSuccess() {
cleanup();
resolve();
}
function handleError() {
cleanup();
reject(new Error(`THUMBNAIL_VIDEO_ERROR: probe video failed during ${eventName}.`));
}
target.addEventListener(eventName, handleSuccess, { once: true });
target.addEventListener('error', handleError, { once: true });
});
}
async function waitForStableVideoFrame(videoElement) {
if (typeof videoElement.requestVideoFrameCallback === 'function') {
await new Promise((resolve) => {
videoElement.requestVideoFrameCallback(() => resolve());
});
return;
}
await waitForEventOnce(
videoElement,
'loadeddata',
5_000,
() => videoElement.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA
);
await new Promise((resolve) => setTimeout(resolve, 80));
}
async function seekHiddenVideoTo(videoElement, seconds) {
const targetTime = Number(seconds);
if (!Number.isFinite(targetTime) || targetTime < 0) {
return;
}
if (Math.abs(videoElement.currentTime - targetTime) > 0.05) {
const seekPromise = waitForEventOnce(videoElement, 'seeked', 12_000);
videoElement.currentTime = targetTime;
await seekPromise;
} else if (videoElement.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) {
await waitForEventOnce(
videoElement,
'loadeddata',
12_000,
() => videoElement.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA
);
}
await waitForStableVideoFrame(videoElement);
}
async function createThumbnailProbeVideo(sessionId) {
const probeVideo = document.createElement('video');
probeVideo.muted = true;
probeVideo.playsInline = true;
probeVideo.preload = 'auto';
probeVideo.controls = false;
Object.assign(probeVideo.style, {
position: 'fixed',
opacity: '0',
pointerEvents: 'none',
width: '1px',
height: '1px',
left: '-9999px',
top: '-9999px'
});
probeVideo.src = `/__media__/session/${encodeURIComponent(sessionId)}`;
document.body.append(probeVideo);
probeVideo.load();
await waitForEventOnce(
probeVideo,
'loadedmetadata',
12_000,
() => probeVideo.readyState >= HTMLMediaElement.HAVE_METADATA
);
return probeVideo;
}
function cleanupThumbnailProbeVideo(videoElement) {
if (!videoElement) {
return;
}
videoElement.pause();
videoElement.removeAttribute('src');
videoElement.load();
videoElement.remove();
}
async function createThumbnailBlobFromVideo(videoElement) {
const maxWidth = 480;
const sourceWidth = Math.max(videoElement.videoWidth || 0, 1);
const sourceHeight = Math.max(videoElement.videoHeight || 0, 1);
const scale = Math.min(1, maxWidth / sourceWidth);
const targetWidth = Math.max(1, Math.round(sourceWidth * scale));
const targetHeight = Math.max(1, Math.round(sourceHeight * scale));
canvas.width = targetWidth;
canvas.height = targetHeight;
const context = canvas.getContext('2d', { alpha: false });
if (!context) {
throw new Error('THUMBNAIL_CANVAS_UNAVAILABLE: 无法创建缩略图绘制上下文。');
}
context.drawImage(videoElement, 0, 0, targetWidth, targetHeight);
const frameAnalysis = analyzeFrameLuma(context.getImageData(0, 0, targetWidth, targetHeight).data);
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (!blob) {
reject(new Error('THUMBNAIL_CAPTURE_FAILED: 浏览器没有生成有效的缩略图 Blob。'));
return;
}
resolve({ blob, frameAnalysis });
},
'image/webp',
0.82
);
});
}
async function maybeCaptureThumbnail() {
if (!canvas || !state.fileInfo || !state.currentSessionId || !isVideoMime(state.fileInfo.file.mime)) {
return;
}
if (state.fileInfo.file.thumbnail?.available) {
return;
}
if (state.thumbnail.inFlight || state.thumbnail.attempted) {
return;
}
const player = state.videoElement;
if (!player || player.readyState < HTMLMediaElement.HAVE_CURRENT_DATA || player.videoWidth === 0) {
return;
}
state.thumbnail.inFlight = true;
state.thumbnail.attempted = true;
const activeSessionId = state.currentSessionId;
const probeSessionId = crypto.randomUUID();
state.probeSessionId = probeSessionId;
let probeVideo = null;
try {
await sendWorkerMessage('register-session', {
sessionId: probeSessionId,
sourceId: state.sourceId,
fileInfo: state.fileInfo,
secrets: state.secrets
});
probeVideo = await createThumbnailProbeVideo(probeSessionId);
const candidates = buildThumbnailCandidateTimes(probeVideo.duration);
let selectedBlob = null;
let fallbackBlob = null;
for (let index = 0; index < candidates.length; index += 1) {
if (state.currentSessionId !== activeSessionId) {
return;
}
await seekHiddenVideoTo(probeVideo, candidates[index]);
const { blob, frameAnalysis } = await createThumbnailBlobFromVideo(probeVideo);
fallbackBlob = blob;
if (!frameAnalysis.isBlackFrame) {
selectedBlob = blob;
break;
}
}
const blobToUpload = selectedBlob ?? fallbackBlob;
if (!blobToUpload) {
throw new Error('THUMBNAIL_CAPTURE_FAILED: 没有拿到可上传的预览图。');
}
if (state.currentSessionId !== activeSessionId) {
return;
}
const formData = new FormData();
formData.set('fileId', state.fileId);
formData.set('image', blobToUpload, 'preview.webp');
const response = await fetch(`/api/sources/${encodeURIComponent(state.sourceId)}/thumbnails`, {
method: 'POST',
body: formData
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(`${payload.error?.code ?? 'THUMBNAIL_UPLOAD_FAILED'}: ${payload.error?.message ?? '预览图上传失败。'}`);
}
if (state.fileInfo?.file) {
state.fileInfo.file.thumbnail = payload.thumbnail;
}
onThumbnail({ fileId: state.fileId, thumbnail: payload.thumbnail });
} catch {
// 预览图抓取失败不影响播放,静默处理
} finally {
cleanupThumbnailProbeVideo(probeVideo);
await unregisterWorkerSession(probeSessionId);
if (state.probeSessionId === probeSessionId) {
state.probeSessionId = null;
}
state.thumbnail.inFlight = false;
}
}
function detachVideo() {
const player = state.videoElement;
if (!player) {
return;
}
if (state.loadedDataHandler) {
player.removeEventListener('loadeddata', state.loadedDataHandler);
state.loadedDataHandler = null;
}
player.pause();
player.removeAttribute('src');
player.load();
}
return {
canPreview(fileInfo) {
return Boolean(supportsVideoPreview() && fileInfo && isVideoMime(fileInfo.file.mime));
},
async play({ videoElement, sourceId, fileId, fileInfo, secrets }) {
assertBrowserFeature(
supportsVideoPreview(),
'BROWSER_PREVIEW_UNSUPPORTED: 当前浏览器不支持 OPFS、Service Worker 或 Range 预览所需能力。'
);
if (!fileInfo) {
throw new Error('FILE_INFO_UNAVAILABLE: 当前文件还不能恢复。');
}
if (!isVideoMime(fileInfo.file.mime)) {
throw new Error('VIDEO_MIME_UNSUPPORTED: 当前文件不是视频,无法启用播放器。');
}
// 切换文件:先拆掉旧的 video 绑定与 session
detachVideo();
state.videoElement = videoElement;
state.sourceId = sourceId;
state.fileId = fileId;
state.fileInfo = fileInfo;
state.secrets = secrets;
state.thumbnail = { inFlight: false, attempted: Boolean(fileInfo.file.thumbnail?.available) };
const sessionId = await registerPreviewSession();
state.loadedDataHandler = () => {
void maybeCaptureThumbnail();
};
videoElement.addEventListener('loadeddata', state.loadedDataHandler);
videoElement.src = `/__media__/session/${encodeURIComponent(sessionId)}`;
videoElement.load();
return sessionId;
},
async stop() {
detachVideo();
await unregisterWorkerSession(state.probeSessionId);
state.probeSessionId = null;
if (state.currentSessionId) {
await unregisterWorkerSession(state.currentSessionId);
state.currentSessionId = null;
}
state.videoElement = null;
state.fileInfo = null;
state.secrets = null;
onStatus(null);
}
};
}
+226
View File
@@ -0,0 +1,226 @@
import { escapeHtml, fetchJson, formatBytes } from '../common.js';
// 源属性对话框:增强配置(覆盖 WebDAV/启动增强)、预览图缓存清理、删除源。
// 移植自 source.js 的 saveEnhancementSecrets / runEnhancement / clearSourceThumbnails / deleteCurrentSource。
// deps: { feedback, onSourceChanged, onSourceDeleted, close }
const TABS = [
{ key: 'enhance', label: '增强配置' },
{ key: 'thumbnails', label: '预览图缓存' },
{ key: 'danger', label: '危险操作' }
];
export function createPropsDialog(rootElement, deps = {}) {
const { feedback } = deps;
let source = null;
let activeTab = 'enhance';
function webdavHint() {
const webdav = source?.webdav;
if (!webdav) {
return '<div class="summary-card">正在计算这个 source 的默认 WebDAV 目标...</div>';
}
return `
<div class="summary-stack">
<div class="summary-row"><span>任务默认 URL</span><strong>${escapeHtml(webdav.effectiveWebdavBaseUrl ?? webdav.derivedWebdavBaseUrl ?? '未推导到')}</strong></div>
<div class="summary-row"><span>默认 URL 来源</span><strong>${escapeHtml(webdav.effectiveWebdavBaseUrlSource ?? 'none')}</strong></div>
<div class="summary-row"><span>全局默认值</span><strong>${webdav.globalDefaultsConfigured ? '已配置' : '未配置'}</strong></div>
<div class="summary-row"><span>当前 source 覆盖</span><strong>${webdav.sourceOverrideSaved ? '已保存' : '未保存'}</strong></div>
</div>
`;
}
function renderEnhanceTab() {
return `
<p class="panel-copy">增强会优先使用全局 WebDAV 默认值,并尽量从 Server DB 推导目标 URL。这里只填与全局不同的部分;全部留空并保存会清掉当前 source 覆盖。</p>
${webdavHint()}
<form id="propsSecretForm" class="secret-form">
<label><span>Source 覆盖:WebDAV Base URL</span><input id="propsUrl" type="url" placeholder="留空则用全局默认 / Server DB 推导"></label>
<label><span>认证方式</span>
<select id="propsAuthMode">
<option value="">自动 / 继承</option>
<option value="basic">Basic</option>
<option value="anonymous">Anonymous</option>
</select>
</label>
<label><span>用户名</span><input id="propsUsername" type="text" placeholder="留空则继承全局默认"></label>
<label><span>密码</span><input id="propsPassword" type="password" placeholder="留空则继承全局默认"></label>
<label><span>备份口令</span><input id="propsPassphrase" type="password" placeholder="留空则继承全局默认"></label>
<div class="form-actions">
<button id="propsSaveSecrets" class="ghost-button" type="submit">保存覆盖</button>
<button id="propsRunEnhance" class="primary-button" type="button">按当前规则启动增强</button>
</div>
</form>
`;
}
function renderThumbnailsTab() {
const cache = source?.thumbnailCache ?? { count: 0, totalBytes: 0 };
return `
<p class="panel-copy">这里展示当前 source 已缓存到后端的预览图数量。清空后,视频会退回占位图,后续再次成功预览时会自动重建。</p>
<div class="summary-stack">
<div class="summary-row"><span>预览图数量</span><strong>${escapeHtml(String(cache.count))}</strong></div>
<div class="summary-row"><span>总占用空间</span><strong>${formatBytes(cache.totalBytes)}</strong></div>
</div>
<div class="form-actions">
<button id="propsClearThumbs" class="ghost-button danger-button" type="button">清空当前 source 预览图</button>
</div>
`;
}
function renderDangerTab() {
return `
<p class="panel-copy">删除会移除当前任务库、本地增强副本和该 source 的预览图缓存,但不会删除 Duplicati-server.sqlite。</p>
<div class="form-actions">
<button id="propsDeleteSource" class="primary-button danger-button" type="button" style="background:var(--danger)">删除这个 source</button>
</div>
`;
}
function renderBody() {
if (!source) {
rootElement.innerHTML = '<div class="empty-state-card">未找到对应数据源。</div>';
return;
}
let content = '';
if (activeTab === 'thumbnails') {
content = renderThumbnailsTab();
} else if (activeTab === 'danger') {
content = renderDangerTab();
} else {
content = renderEnhanceTab();
}
rootElement.innerHTML = `
<div class="explorer-dialog-head">
<h3 class="serif">${escapeHtml(source.displayName || source.originalFilename)} · 属性</h3>
<button class="explorer-icon-btn" type="button" data-props-close aria-label="关闭">✕</button>
</div>
<div class="tabs">
${TABS.map((tab) => `<button type="button" data-props-tab="${tab.key}" class="${tab.key === activeTab ? 'active' : ''}">${escapeHtml(tab.label)}</button>`).join('')}
</div>
<div class="explorer-manage-content">${content}</div>
`;
}
function readSecretForm() {
return {
webdavBaseUrl: rootElement.querySelector('#propsUrl')?.value.trim() ?? '',
authMode: rootElement.querySelector('#propsAuthMode')?.value ?? '',
username: rootElement.querySelector('#propsUsername')?.value.trim() ?? '',
password: rootElement.querySelector('#propsPassword')?.value ?? '',
passphrase: rootElement.querySelector('#propsPassphrase')?.value ?? ''
};
}
async function reloadSource() {
const payload = await fetchJson(`/api/sources/${encodeURIComponent(source.id)}`);
source = payload.source;
deps.onSourceChanged?.(source);
}
async function saveSecrets() {
feedback?.set('info', '正在保存当前 source 的覆盖规则...');
try {
await fetchJson(`/api/sources/${encodeURIComponent(source.id)}/secrets`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(readSecretForm())
});
await reloadSource();
renderBody();
feedback?.set('success', '覆盖规则已保存。留空项会继续继承全局默认或 Server DB 推导结果。');
} catch (error) {
feedback?.set('error', error.message);
}
}
async function runEnhancement() {
feedback?.set('info', '正在提交增强任务...');
try {
await fetchJson(`/api/sources/${encodeURIComponent(source.id)}/enhance`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(readSecretForm())
});
await reloadSource();
renderBody();
feedback?.set('success', '增强任务已开始。当前规则会合并全局默认、Server DB 推导 URL 和单源覆盖。');
} catch (error) {
feedback?.set('error', error.message);
}
}
async function clearThumbnails() {
if (!window.confirm('确定要清空这个 source 的全部预览图吗?\n\n这不会删除任务库、增强索引或 Server DB,只会删除后端缩略图缓存。')) {
return;
}
feedback?.set('info', '正在清空预览图缓存...');
try {
const payload = await fetchJson(`/api/sources/${encodeURIComponent(source.id)}/thumbnails`, { method: 'DELETE' });
await reloadSource();
renderBody();
feedback?.set('success', `预览图已清空,删除了 ${payload.cleared.removedCount} 张,共 ${formatBytes(payload.cleared.removedBytes)}`);
} catch (error) {
feedback?.set('error', error.message);
}
}
async function deleteSource() {
const label = source.displayName || source.originalFilename || source.id;
if (!window.confirm(`确定要删除这个 source 吗?\n\n${label}\n\n这会删除任务库、本地增强副本和预览图缓存,但不会删除 Duplicati-server.sqlite。`)) {
return;
}
feedback?.set('info', `正在删除 ${label}...`);
try {
await fetchJson(`/api/sources/${encodeURIComponent(source.id)}`, { method: 'DELETE' });
deps.onSourceDeleted?.(source.id);
deps.close?.();
feedback?.set('success', `已删除 ${label}`);
} catch (error) {
feedback?.set('error', error.message);
}
}
rootElement.addEventListener('click', (event) => {
const tab = event.target.closest('[data-props-tab]');
if (tab instanceof HTMLElement) {
activeTab = tab.dataset.propsTab;
renderBody();
return;
}
if (event.target.closest('[data-props-close]')) {
deps.close?.();
return;
}
if (event.target.closest('#propsRunEnhance')) {
void runEnhancement();
} else if (event.target.closest('#propsClearThumbs')) {
void clearThumbnails();
} else if (event.target.closest('#propsDeleteSource')) {
void deleteSource();
}
});
rootElement.addEventListener('submit', (event) => {
if (event.target.id === 'propsSecretForm') {
event.preventDefault();
void saveSecrets();
}
});
return {
async open(sourceId, tab = 'enhance') {
activeTab = TABS.some((item) => item.key === tab) ? tab : 'enhance';
try {
const payload = await fetchJson(`/api/sources/${encodeURIComponent(sourceId)}`);
source = payload.source;
renderBody();
} catch (error) {
source = null;
rootElement.innerHTML = `<div class="empty-state-card">${escapeHtml(error.message)}</div>`;
}
}
};
}
+97
View File
@@ -0,0 +1,97 @@
import { escapeHtml, inferEnhancementClass, inferEnhancementLabel } from '../common.js';
// 侧栏数据源树。数据源当作"盘符",可展开显示一层根目录快捷入口。
// 事件通过回调向编排器上报:onOpenSource / onOpenPath / onContextMenu。
export function createSidebar(rootElement, { onOpenSource, onContextMenu }) {
let sources = [];
let activeSourceId = null;
function statusDotClass(source) {
// 复用增强状态的语义色:ready/busy/error/idle
switch (inferEnhancementClass(source)) {
case 'status-ready':
return 'ready';
case 'status-busy':
return 'busy';
case 'status-error':
return 'error';
default:
return 'idle';
}
}
function render() {
if (sources.length === 0) {
rootElement.innerHTML = '<div class="explorer-tree-empty">暂无数据源,请从「上传任务库」添加。</div>';
return;
}
rootElement.innerHTML = sources
.map((source) => {
const active = source.id === activeSourceId;
const label = source.displayName || source.originalFilename || source.id;
const dot = statusDotClass(source);
const title = `${label} · ${inferEnhancementLabel(source)}`;
return `
<div class="explorer-tree-item${active ? ' active' : ''}"
data-source-id="${escapeHtml(source.id)}"
title="${escapeHtml(title)}"
role="button" tabindex="0">
<span class="explorer-drive-ico" aria-hidden="true">💽</span>
<span class="explorer-tree-name">${escapeHtml(label)}</span>
<span class="explorer-dot ${dot}" title="${escapeHtml(inferEnhancementLabel(source))}"></span>
</div>
`;
})
.join('');
}
function findSource(sourceId) {
return sources.find((source) => source.id === sourceId) ?? null;
}
rootElement.addEventListener('click', (event) => {
const item = event.target.closest('.explorer-tree-item');
if (!(item instanceof HTMLElement)) {
return;
}
onOpenSource?.(item.dataset.sourceId);
});
rootElement.addEventListener('keydown', (event) => {
if (event.key !== 'Enter' && event.key !== ' ') {
return;
}
const item = event.target.closest('.explorer-tree-item');
if (!(item instanceof HTMLElement)) {
return;
}
event.preventDefault();
onOpenSource?.(item.dataset.sourceId);
});
rootElement.addEventListener('contextmenu', (event) => {
const item = event.target.closest('.explorer-tree-item');
if (!(item instanceof HTMLElement)) {
return;
}
event.preventDefault();
onContextMenu?.(item.dataset.sourceId, { x: event.clientX, y: event.clientY });
});
return {
setSources(nextSources) {
sources = Array.isArray(nextSources) ? nextSources : [];
render();
},
setActive(sourceId) {
activeSourceId = sourceId;
render();
},
getSource: findSource,
getSources() {
return sources;
}
};
}
-145
View File
@@ -1,145 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>数据源工作台</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<main class="shell">
<nav class="crumbs">
<a href="/">数据源控制台</a>
<span>/</span>
<span>数据源工作台</span>
</nav>
<section class="hero hero-compact">
<div>
<p class="eyebrow">Source Workbench</p>
<h1 id="pageTitle">数据源工作台</h1>
</div>
<p class="lead">
这里负责单个数据源的目录浏览、增强配置和预览图缓存管理。页面已经拆成菜单式工作台,避免所有功能都堆在一个长页面里。
</p>
</section>
<div class="auth-toolbar">
<span id="authStatus" class="auth-status">正在读取登录状态...</span>
<button id="logoutButton" class="ghost-button" type="button">退出登录</button>
</div>
<div id="feedback" class="feedback" hidden></div>
<section class="workspace-shell">
<aside class="workspace-nav" aria-label="数据源工作台菜单">
<a class="workspace-nav-link" href="#overview" data-view-link="overview">概况</a>
<a class="workspace-nav-link" href="#browse" data-view-link="browse">目录浏览</a>
<a class="workspace-nav-link" href="#enhance" data-view-link="enhance">增强配置</a>
<a class="workspace-nav-link" href="#thumbnails" data-view-link="thumbnails">预览图缓存</a>
</aside>
<div class="workspace-content">
<section class="workspace-panel" data-view-panel="overview">
<div class="panel">
<div class="panel-header">
<div>
<p class="section-label">Source</p>
<h2>源概况</h2>
</div>
</div>
<div id="sourceSummary" class="empty-state-card">正在读取数据源概况...</div>
</div>
</section>
<section class="workspace-panel" data-view-panel="browse" hidden>
<div class="panel">
<div class="panel-header">
<div>
<p class="section-label">Directory</p>
<h2>目录浏览</h2>
</div>
</div>
<div class="toolbar-cluster">
<div class="file-toolbar">
<button id="browseUpButton" class="ghost-button" type="button">上一级</button>
<button id="browseRootButton" class="ghost-button" type="button">根目录</button>
<button id="browseRefreshButton" class="ghost-button" type="button">刷新当前目录</button>
</div>
<div id="pathTrail" class="path-trail">/</div>
</div>
<div id="browseResult" class="browse-shell">正在读取目录...</div>
</div>
</section>
<section class="workspace-panel" data-view-panel="enhance" hidden>
<div class="panel">
<div class="panel-header">
<div>
<p class="section-label">Enhancement</p>
<h2>增强配置</h2>
</div>
</div>
<p class="panel-copy">
默认情况下,增强会优先使用全局 WebDAV 默认值,并尽量从 <code>Duplicati-server.sqlite</code> 自动推导当前任务的目标 URL。
这里只需要填写与全局不同的部分;全部留空并保存,会清掉当前 source 的覆盖设置。
</p>
<div id="webdavHint" class="summary-card">正在计算这个 source 的默认 WebDAV 目标...</div>
<form id="secretForm" class="secret-form">
<label>
<span>Source override: WebDAV Base URL</span>
<input id="webdavBaseUrl" name="webdavBaseUrl" type="url" placeholder="留空则自动使用全局默认或 Server DB 推导值">
</label>
<label>
<span>认证方式</span>
<select id="authMode" name="authMode">
<option value="">自动 / 继承</option>
<option value="basic">Basic</option>
<option value="anonymous">Anonymous</option>
</select>
</label>
<label>
<span>用户名</span>
<input id="username" name="username" type="text" placeholder="留空则继承全局默认">
</label>
<label>
<span>密码</span>
<input id="password" name="password" type="password" placeholder="留空则继承全局默认">
</label>
<label>
<span>备份口令</span>
<input id="passphrase" name="passphrase" type="password" placeholder="留空则继承全局默认">
</label>
<div class="form-actions">
<button id="saveSecretsButton" class="ghost-button" type="submit">保存覆盖</button>
<button id="runEnhancementButton" class="primary-button" type="button">按当前规则启动增强</button>
<button id="deleteSourceButton" class="ghost-button danger-button" type="button">删除这个 source</button>
</div>
</form>
</div>
</section>
<section class="workspace-panel" data-view-panel="thumbnails" hidden>
<div class="panel">
<div class="panel-header">
<div>
<p class="section-label">Preview Thumbnails</p>
<h2>预览图缓存</h2>
</div>
</div>
<p class="panel-copy">
这里展示当前 source 已缓存到后端的预览图数量。清空后,目录里的视频文件会退回占位图,后续再次成功预览时会自动重建。
</p>
<div id="sourceThumbnailSummary" class="summary-card">正在统计这个 source 的预览图缓存...</div>
<div class="form-actions">
<button id="clearSourceThumbnailsButton" class="ghost-button danger-button" type="button">清空当前 source 预览图</button>
</div>
</div>
</section>
</div>
</section>
</main>
<script type="module" src="/source.js"></script>
</body>
</html>
-585
View File
@@ -1,585 +0,0 @@
import {
createFeedbackController,
escapeHtml,
fetchJson,
formatBytes,
inferEnhancementClass,
inferEnhancementLabel,
linkToFile,
optionalQueryParam
} from './modules/common.js';
import { bindLogoutButton, loadAuthState } from './modules/auth-client.js';
const SOURCE_VIEWS = new Set(['overview', 'browse', 'enhance', 'thumbnails']);
const state = {
sourceId: optionalQueryParam('sourceId', ''),
source: null,
currentPath: '/',
currentParent: null,
currentView: 'overview'
};
const feedback = createFeedbackController(document.querySelector('#feedback'));
const pageTitleElement = document.querySelector('#pageTitle');
const sourceSummaryElement = document.querySelector('#sourceSummary');
const browseResultElement = document.querySelector('#browseResult');
const pathTrailElement = document.querySelector('#pathTrail');
const browseUpButton = document.querySelector('#browseUpButton');
const browseRootButton = document.querySelector('#browseRootButton');
const browseRefreshButton = document.querySelector('#browseRefreshButton');
const secretForm = document.querySelector('#secretForm');
const runEnhancementButton = document.querySelector('#runEnhancementButton');
const webdavHintElement = document.querySelector('#webdavHint');
const deleteSourceButton = document.querySelector('#deleteSourceButton');
const sourceThumbnailSummaryElement = document.querySelector('#sourceThumbnailSummary');
const clearSourceThumbnailsButton = document.querySelector('#clearSourceThumbnailsButton');
const authStatusElement = document.querySelector('#authStatus');
const logoutButton = document.querySelector('#logoutButton');
const viewPanels = [...document.querySelectorAll('[data-view-panel]')];
const viewLinks = [...document.querySelectorAll('[data-view-link]')];
function normalizeView(value) {
return SOURCE_VIEWS.has(value) ? value : 'overview';
}
function setControlsDisabled(disabled) {
for (const element of [
browseUpButton,
browseRootButton,
browseRefreshButton,
runEnhancementButton,
deleteSourceButton,
clearSourceThumbnailsButton,
...secretForm.querySelectorAll('input, select, button')
]) {
if (element) {
element.disabled = disabled;
}
}
}
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 renderMissingRouteContext() {
setControlsDisabled(true);
pageTitleElement.textContent = '数据源工作台未就绪';
sourceSummaryElement.innerHTML = `
<div class="empty-state-card">
<p>这个页面缺少必要的地址参数。</p>
<p>请从首页的数据源列表进入,或检查当前地址是否包含 <code>sourceId</code>。</p>
</div>
`;
sourceThumbnailSummaryElement.innerHTML = `
<div class="summary-stack">
<div class="summary-row">
<span>状态</span>
<strong>缺少 sourceId</strong>
</div>
</div>
`;
pathTrailElement.textContent = '/';
browseResultElement.innerHTML = `
<div class="empty-state-card">
<p>缺少 <code>sourceId</code>,无法读取目录。</p>
</div>
`;
webdavHintElement.innerHTML = `
<div class="summary-stack">
<div class="summary-row">
<span>状态</span>
<strong>缺少 sourceId</strong>
</div>
</div>
`;
setCurrentView('overview', { replace: true });
}
function renderPathTrail(pathValue) {
const segments = pathValue === '/' ? [] : pathValue.split('/').filter(Boolean);
const crumbs = [
'<button class="path-chip" data-path="/" type="button">根</button>'
];
let currentPath = '';
for (const segment of segments) {
currentPath += `/${segment}`;
crumbs.push('<span class="path-separator">/</span>');
crumbs.push(
`<button class="path-chip" data-path="${escapeHtml(currentPath)}" type="button">${escapeHtml(segment)}</button>`
);
}
pathTrailElement.innerHTML = crumbs.join('');
}
function renderSourceSummary() {
const source = state.source;
if (!source) {
sourceSummaryElement.innerHTML = '<div class="empty-state-card">未找到对应数据源。</div>';
return;
}
pageTitleElement.textContent = source.displayName || source.originalFilename;
const enhancementError = source.enhancement.lastErrorMessage
? `<div class="feedback feedback-error">最近失败: ${escapeHtml(source.enhancement.lastErrorMessage)}</div>`
: '';
sourceSummaryElement.innerHTML = `
<article class="source-card">
<div class="source-main">
<div>
<p class="source-name">${escapeHtml(source.displayName || source.originalFilename)}</p>
<p class="source-meta">原始库名: ${escapeHtml(source.originalFilename)}</p>
<p class="source-meta">sourceId: ${escapeHtml(source.id)}</p>
</div>
<span class="status-pill ${inferEnhancementClass(source)}">${inferEnhancementLabel(source)}</span>
</div>
<div class="stats-grid">
<div class="stat">
<span>任务名来源</span>
<strong>${escapeHtml(source.displayNameSource)}</strong>
</div>
<div class="stat">
<span>匹配任务名</span>
<strong>${escapeHtml(source.matchedBackupName ?? '未匹配')}</strong>
</div>
<div class="stat">
<span>源布局</span>
<strong>${escapeHtml(source.sourceLayout)}</strong>
</div>
<div class="stat">
<span>上传大小</span>
<strong>${formatBytes(source.fileSize)}</strong>
</div>
<div class="stat">
<span>目录浏览</span>
<strong>${source.canBrowse ? '可用' : '不可用'}</strong>
</div>
<div class="stat">
<span>预览图缓存</span>
<strong>${escapeHtml(String(source.thumbnailCache?.count ?? 0))} 张</strong>
</div>
<div class="stat">
<span>archive_entry_index</span>
<strong>${source.capabilities.archiveEntryIndex ? 'Yes' : 'No'}</strong>
</div>
<div class="stat">
<span>volume_crypto_cache</span>
<strong>${source.capabilities.volumeCryptoCache ? 'Yes' : 'No'}</strong>
</div>
</div>
<div class="summary-chip">最新快照: ${escapeHtml(source.latestSnapshot?.timestamp ?? '暂无')}</div>
${enhancementError}
</article>
`;
}
function renderWebdavHint() {
const webdav = state.source?.webdav;
if (!webdav) {
webdavHintElement.innerHTML = `
<div class="summary-stack">
<div class="summary-row">
<span>默认目标 URL</span>
<strong>未知</strong>
</div>
</div>
`;
return;
}
webdavHintElement.innerHTML = `
<div class="summary-stack">
<div class="summary-row">
<span>任务默认 URL</span>
<strong>${escapeHtml(webdav.effectiveWebdavBaseUrl ?? webdav.derivedWebdavBaseUrl ?? '未推导到')}</strong>
</div>
<div class="summary-row">
<span>默认 URL 来源</span>
<strong>${escapeHtml(webdav.effectiveWebdavBaseUrlSource ?? 'none')}</strong>
</div>
<div class="summary-row">
<span>Server DB 推导</span>
<strong>${escapeHtml(webdav.derivedTargetUrl ?? '无')}</strong>
</div>
<div class="summary-row">
<span>全局默认值</span>
<strong>${webdav.globalDefaultsConfigured ? '已配置' : '未配置'}</strong>
</div>
<div class="summary-row">
<span>当前 source 覆盖</span>
<strong>${webdav.sourceOverrideSaved ? '已保存' : '未保存'}</strong>
</div>
</div>
`;
}
function renderSourceThumbnailSummary() {
const thumbnailCache = state.source?.thumbnailCache ?? {
count: 0,
totalBytes: 0
};
sourceThumbnailSummaryElement.innerHTML = `
<div class="summary-stack">
<div class="summary-row">
<span>当前 source 预览图数量</span>
<strong>${escapeHtml(String(thumbnailCache.count))}</strong>
</div>
<div class="summary-row">
<span>总占用空间</span>
<strong>${formatBytes(thumbnailCache.totalBytes)}</strong>
</div>
</div>
`;
}
function renderBrowse(payload) {
state.currentPath = payload.path;
state.currentParent = payload.parent;
renderPathTrail(payload.path);
if (payload.entries.length === 0) {
browseResultElement.innerHTML = `
<div class="empty-state-card">
<p>这个目录目前没有内容。</p>
</div>
`;
return;
}
browseResultElement.innerHTML = `
<div class="browse-list">
${payload.entries
.map((entry) => {
if (entry.type === 'dir') {
return `
<article class="browse-row browse-row-clickable" data-path="${escapeHtml(entry.path)}" data-entry-type="dir">
<div class="browse-row-main">
<div class="browse-thumbnail browse-thumbnail-placeholder browse-thumbnail-folder">DIR</div>
<div>
<strong>${escapeHtml(entry.name)}</strong>
<span class="browse-type">dir</span>
</div>
</div>
<div class="browse-meta">
<span>${escapeHtml(entry.path)}</span>
<span>${entry.mtime ? escapeHtml(entry.mtime) : '目录'}</span>
<span>点击进入目录</span>
</div>
</article>
`;
}
const thumbnail = entry.thumbnail?.available
? `
<img
class="browse-thumbnail-image"
src="${escapeHtml(entry.thumbnail.thumbnailUrl)}"
alt="${escapeHtml(entry.name)} 的预览图"
loading="lazy"
>
`
: '<div class="browse-thumbnail browse-thumbnail-placeholder">FILE</div>';
return `
<article class="browse-row browse-row-clickable" data-file-id="${escapeHtml(entry.id)}" data-entry-type="file">
<div class="browse-row-main">
<div class="browse-thumbnail">${thumbnail}</div>
<div>
<strong>${escapeHtml(entry.name)}</strong>
<span class="browse-type">${escapeHtml(entry.mime ?? 'file')}</span>
</div>
</div>
<div class="browse-meta">
<span>${escapeHtml(entry.path)}</span>
<span>${formatBytes(entry.size)}</span>
<span>${entry.thumbnail?.available ? '已缓存预览图' : '点击打开文件页'}</span>
</div>
</article>
`;
})
.join('')}
</div>
`;
}
function renderBrowseError(pathValue, error) {
renderPathTrail(pathValue);
browseResultElement.innerHTML = `
<div class="empty-state-card">
<p>无法列出目录内容。</p>
<p>${escapeHtml(error.message)}</p>
</div>
`;
}
function readEnhancementForm() {
return {
webdavBaseUrl: document.querySelector('#webdavBaseUrl')?.value?.trim() ?? '',
authMode: document.querySelector('#authMode')?.value ?? '',
username: document.querySelector('#username')?.value?.trim() ?? '',
password: document.querySelector('#password')?.value ?? '',
passphrase: document.querySelector('#passphrase')?.value ?? ''
};
}
async function loadSource() {
const payload = await fetchJson(`/api/sources/${encodeURIComponent(state.sourceId)}`);
state.source = payload.source;
renderSourceSummary();
renderWebdavHint();
renderSourceThumbnailSummary();
}
async function loadDirectory(pathValue) {
state.currentPath = pathValue;
browseResultElement.innerHTML = `
<div class="empty-state-card">
<p>正在读取目录...</p>
</div>
`;
try {
const payload = await fetchJson(
`/api/ls?sourceId=${encodeURIComponent(state.sourceId)}&path=${encodeURIComponent(pathValue)}`
);
renderBrowse(payload);
} catch (error) {
renderBrowseError(pathValue, error);
throw error;
}
}
async function saveEnhancementSecrets(event) {
event.preventDefault();
feedback.set('info', '正在保存当前 source 的覆盖规则...');
try {
await fetchJson(`/api/sources/${encodeURIComponent(state.sourceId)}/secrets`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(readEnhancementForm())
});
await loadSource();
secretForm.reset();
feedback.set('success', '覆盖规则已保存。留空项会继续继承全局默认值或 Server DB 推导结果。');
} catch (error) {
feedback.set('error', error.message);
}
}
async function runEnhancement() {
feedback.set('info', '正在提交增强任务...');
try {
await fetchJson(`/api/sources/${encodeURIComponent(state.sourceId)}/enhance`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(readEnhancementForm())
});
await loadSource();
secretForm.reset();
feedback.set('success', '增强任务已开始。当前规则会自动合并全局默认值、Server DB 推导 URL 和单源覆盖。');
} catch (error) {
feedback.set('error', error.message);
}
}
async function clearSourceThumbnails() {
const confirmed = window.confirm(
'确定要清空这个 source 的全部预览图吗?\n\n这不会删除任务库、增强索引或 Duplicati-server.sqlite,只会删除后端缩略图缓存。'
);
if (!confirmed) {
return;
}
feedback.set('info', '正在清空当前 source 的预览图缓存...');
try {
const payload = await fetchJson(`/api/sources/${encodeURIComponent(state.sourceId)}/thumbnails`, {
method: 'DELETE'
});
await loadSource();
await loadDirectory(state.currentPath);
feedback.set(
'success',
`当前 source 的预览图已清空,删除了 ${payload.cleared.removedCount} 张,共 ${formatBytes(payload.cleared.removedBytes)}`
);
} catch (error) {
feedback.set('error', error.message);
}
}
async function deleteCurrentSource() {
if (!state.source) {
return;
}
const label = state.source.displayName || state.source.originalFilename || state.source.id;
const confirmed = window.confirm(
`确定要删除这个 source 吗?\n\n${label}\n\n这会删除当前任务库、本地增强副本和该 source 的预览图缓存,但不会删除 Duplicati-server.sqlite。`
);
if (!confirmed) {
return;
}
feedback.set('info', `正在删除 ${label}...`);
try {
await fetchJson(`/api/sources/${encodeURIComponent(state.sourceId)}`, {
method: 'DELETE'
});
window.location.href = '/';
} catch (error) {
feedback.set('error', error.message);
}
}
browseUpButton.addEventListener('click', () => {
feedback.clear();
void loadDirectory(state.currentParent ?? '/').catch((error) => {
feedback.set('error', error.message);
});
});
browseRootButton.addEventListener('click', () => {
feedback.clear();
void loadDirectory('/').catch((error) => {
feedback.set('error', error.message);
});
});
browseRefreshButton.addEventListener('click', () => {
feedback.clear();
void loadDirectory(state.currentPath).catch((error) => {
feedback.set('error', error.message);
});
});
browseResultElement.addEventListener('click', (event) => {
const target = event.target;
if (!(target instanceof HTMLElement)) {
return;
}
const rowElement = target.closest('.browse-row-clickable');
if (rowElement instanceof HTMLElement) {
const entryType = rowElement.dataset.entryType;
if (entryType === 'dir' && rowElement.dataset.path) {
feedback.clear();
void loadDirectory(rowElement.dataset.path).catch((error) => {
feedback.set('error', error.message);
});
return;
}
if (entryType === 'file' && rowElement.dataset.fileId) {
window.location.href = linkToFile(state.sourceId, rowElement.dataset.fileId);
}
}
});
pathTrailElement.addEventListener('click', (event) => {
const target = event.target;
if (!(target instanceof HTMLElement)) {
return;
}
const actionElement = target.closest('[data-path]');
if (!(actionElement instanceof HTMLElement) || !actionElement.dataset.path) {
return;
}
feedback.clear();
void loadDirectory(actionElement.dataset.path).catch((error) => {
feedback.set('error', error.message);
});
});
secretForm.addEventListener('submit', (event) => {
void saveEnhancementSecrets(event);
});
runEnhancementButton.addEventListener('click', () => {
void runEnhancement();
});
clearSourceThumbnailsButton.addEventListener('click', () => {
void clearSourceThumbnails();
});
deleteSourceButton.addEventListener('click', () => {
void deleteCurrentSource();
});
for (const link of viewLinks) {
link.addEventListener('click', (event) => {
event.preventDefault();
const nextView = normalizeView(link.dataset.viewLink ?? 'browse');
setCurrentView(nextView);
});
}
window.addEventListener('hashchange', () => {
setCurrentView(normalizeView(window.location.hash.slice(1)), { syncHash: false });
});
async function bootstrap() {
const authentication = await loadAuthState();
authStatusElement.textContent = authentication.authenticated
? `已登录:${authentication.username}`
: '未登录';
bindLogoutButton(logoutButton, authStatusElement);
if (!state.sourceId) {
renderMissingRouteContext();
return;
}
const initialView = normalizeView(window.location.hash.slice(1) || 'overview');
setCurrentView(initialView, { replace: true });
await loadSource();
await loadDirectory('/');
}
void bootstrap().catch((error) => {
feedback.set('error', error.message);
browseResultElement.innerHTML = `
<div class="empty-state-card">
<p>无法进入数据源工作台。</p>
</div>
`;
});
+967
View File
@@ -640,6 +640,98 @@ textarea:focus {
justify-self: end; justify-self: end;
} }
/* 批量选择 */
.browse-checkbox {
display: flex;
align-items: center;
flex-shrink: 0;
cursor: default;
padding: 4px;
}
.browse-checkbox input {
width: 18px;
height: 18px;
cursor: pointer;
accent-color: var(--accent);
}
.browse-row-selected {
border-color: rgba(15, 118, 110, 0.34) !important;
background: rgba(15, 118, 110, 0.06) !important;
}
/* 批量工具栏 */
.batch-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 14px;
margin-top: 14px;
padding: 14px 18px;
border-radius: 18px;
background: rgba(15, 118, 110, 0.06);
border: 1px solid rgba(15, 118, 110, 0.14);
}
.batch-select-all {
display: flex;
gap: 8px;
align-items: center;
cursor: pointer;
font-weight: 600;
font-size: 14px;
}
.batch-select-all input {
width: 18px;
height: 18px;
accent-color: var(--accent);
}
.batch-count {
font-size: 13px;
color: var(--muted);
font-weight: 600;
}
.batch-progress {
margin-top: 14px;
}
/* 凭据弹窗 */
.credential-overlay {
position: fixed;
inset: 0;
z-index: 1000;
display: grid;
place-items: center;
background: rgba(26, 36, 31, 0.5);
backdrop-filter: blur(4px);
}
.credential-overlay-panel {
width: min(520px, calc(100vw - 40px));
max-height: calc(100vh - 60px);
overflow-y: auto;
padding: 28px;
border-radius: 26px;
background: var(--panel);
border: 1px solid var(--line);
box-shadow: var(--shadow);
}
.credential-overlay-panel h3 {
margin: 0 0 8px;
font-size: 18px;
}
.credential-overlay-panel p {
margin: 0 0 16px;
color: var(--muted);
font-size: 14px;
}
.video-frame { .video-frame {
position: relative; position: relative;
min-height: 320px; min-height: 320px;
@@ -737,3 +829,878 @@ video {
min-height: 220px; min-height: 220px;
} }
} }
/* ═══════════════════════════════════════════════════════════
资源管理器(Explorer)布局
复用 :root 变量与 .panel/.status-pill/.feedback/.summary-* 等既有类
═══════════════════════════════════════════════════════════ */
.explorer-body {
height: 100vh;
overflow: hidden;
}
.serif {
font-family: Georgia, "Times New Roman", serif;
}
.explorer-app {
height: 100vh;
display: grid;
grid-template-rows: 56px auto 1fr;
}
/* 顶栏 */
.explorer-topbar {
display: flex;
align-items: center;
gap: 16px;
padding: 0 18px;
background: var(--panel);
border-bottom: 1px solid var(--line);
}
.explorer-brand {
display: flex;
align-items: center;
gap: 10px;
font-family: Georgia, "Times New Roman", serif;
font-size: 18px;
font-weight: 600;
}
.explorer-logo {
width: 30px;
height: 30px;
display: grid;
place-items: center;
border-radius: 9px;
background: linear-gradient(135deg, var(--accent), #138579);
color: #fff;
font-size: 16px;
}
.explorer-search {
margin-left: auto;
display: flex;
align-items: center;
gap: 8px;
width: min(360px, 32vw);
padding: 6px 14px;
border-radius: 999px;
background: var(--panel-strong);
border: 1px solid var(--line);
color: var(--muted);
}
.explorer-search input {
border: none;
background: transparent;
outline: none;
width: 100%;
padding: 4px 0;
color: var(--ink);
}
.explorer-auth {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
color: var(--muted);
}
.explorer-icon-btn {
width: 34px;
height: 34px;
display: grid;
place-items: center;
border-radius: 10px;
border: 1px solid transparent;
background: transparent;
color: var(--muted);
font-size: 15px;
}
.explorer-icon-btn:hover {
background: rgba(15, 118, 110, 0.08);
color: var(--ink);
}
.explorer-hamburger {
display: none;
}
.explorer-feedback {
margin: 0;
border-radius: 0;
}
/* 主分栏 */
.explorer-split {
display: grid;
grid-template-columns: 250px minmax(0, 1fr);
min-height: 0;
height: 100%;
}
.explorer-split.with-detail {
grid-template-columns: 250px minmax(0, 1fr) 320px;
}
/* 左侧栏 */
.explorer-sidebar {
border-right: 1px solid var(--line);
background: rgba(255, 255, 255, 0.42);
padding: 14px 12px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 6px;
}
.explorer-side-label {
padding: 10px 10px 4px;
font-size: 11px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--accent-strong);
}
.explorer-tree {
display: flex;
flex-direction: column;
gap: 4px;
}
.explorer-tree-empty {
padding: 12px;
color: var(--muted);
font-size: 13px;
line-height: 1.6;
}
.explorer-tree-item {
display: flex;
align-items: center;
gap: 8px;
padding: 9px 10px;
border-radius: 12px;
color: var(--ink);
border: 1px solid transparent;
cursor: pointer;
user-select: none;
}
.explorer-tree-item:hover {
background: rgba(255, 255, 255, 0.7);
}
.explorer-tree-item.active {
background: rgba(15, 118, 110, 0.12);
border-color: rgba(15, 118, 110, 0.22);
color: var(--accent-strong);
font-weight: 600;
}
.explorer-drive-ico {
font-size: 15px;
}
.explorer-tree-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.explorer-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.explorer-dot.ready {
background: var(--success);
}
.explorer-dot.idle {
background: var(--accent);
}
.explorer-dot.error {
background: var(--danger);
}
.explorer-dot.busy {
background: var(--gold);
}
.explorer-side-divider {
height: 1px;
background: var(--line);
margin: 8px 4px;
}
.explorer-manage-link {
display: flex;
align-items: center;
gap: 9px;
padding: 8px 10px;
border-radius: 11px;
color: var(--muted);
font-size: 14px;
border: 1px solid transparent;
background: transparent;
text-align: left;
}
.explorer-manage-link:hover {
background: rgba(255, 255, 255, 0.7);
color: var(--ink);
}
/* 主内容区 */
.explorer-main {
min-width: 0;
min-height: 0;
display: grid;
grid-template-rows: auto auto 1fr auto auto;
background: rgba(255, 255, 255, 0.28);
}
.explorer-navbar {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
border-bottom: 1px solid var(--line);
}
.explorer-nav-arrows {
display: flex;
gap: 4px;
}
.explorer-crumbs {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
min-width: 0;
overflow-x: auto;
scrollbar-width: none;
}
.explorer-crumbs::-webkit-scrollbar {
display: none;
}
.explorer-crumb {
padding: 6px 11px;
border-radius: 999px;
background: rgba(15, 118, 110, 0.08);
color: var(--accent-strong);
border: none;
white-space: nowrap;
font: inherit;
}
.explorer-crumb:hover {
background: rgba(15, 118, 110, 0.16);
}
.explorer-crumb-sep {
color: var(--muted);
font-size: 12px;
}
.explorer-view-toggle {
display: flex;
border: 1px solid var(--line-strong);
border-radius: 10px;
overflow: hidden;
}
.explorer-view-toggle button {
width: 38px;
height: 34px;
border: none;
background: transparent;
color: var(--muted);
}
.explorer-view-toggle button.active {
background: rgba(15, 118, 110, 0.14);
color: var(--accent-strong);
}
.explorer-sort {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--muted);
}
.explorer-sort select {
width: auto;
padding: 7px 10px;
border-radius: 10px;
}
/* 选择操作条 */
.explorer-action-bar {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
background: rgba(15, 118, 110, 0.06);
border-bottom: 1px solid rgba(15, 118, 110, 0.14);
}
.explorer-select-all {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
cursor: pointer;
}
.explorer-select-all input {
width: 17px;
height: 17px;
accent-color: var(--accent);
}
.explorer-pill {
padding: 8px 15px;
font-size: 13px;
}
.explorer-spacer {
flex: 1;
}
/* 文件区 */
.explorer-file-area {
overflow: auto;
min-height: 0;
padding: 8px 12px 16px;
}
/* 列表视图(表格) */
.explorer-tbl {
width: 100%;
border-collapse: collapse;
}
.explorer-tbl thead th {
position: sticky;
top: 0;
z-index: 1;
text-align: left;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.04em;
color: var(--muted);
padding: 10px 12px;
background: var(--bg-strong);
border-bottom: 1px solid var(--line);
user-select: none;
}
.explorer-sortable:hover {
color: var(--ink);
cursor: pointer;
}
.explorer-arrow {
color: var(--accent);
font-size: 10px;
}
.explorer-col-chk {
width: 42px;
text-align: center;
}
.explorer-col-size {
width: 110px;
text-align: right;
}
.explorer-col-type {
width: 120px;
}
.explorer-col-time {
width: 175px;
}
.explorer-tbl tbody tr {
border-bottom: 1px solid rgba(26, 36, 31, 0.06);
cursor: pointer;
}
.explorer-tbl tbody tr:hover {
background: rgba(255, 255, 255, 0.72);
}
.explorer-tbl tbody tr.selected {
background: rgba(15, 118, 110, 0.08);
}
.explorer-tbl tbody tr.active {
outline: 2px solid rgba(15, 118, 110, 0.28);
outline-offset: -2px;
}
.explorer-tbl td {
padding: 8px 12px;
font-size: 14px;
vertical-align: middle;
}
.explorer-row-check {
width: 17px;
height: 17px;
accent-color: var(--accent);
}
.explorer-name-cell {
display: flex;
align-items: center;
gap: 11px;
min-width: 0;
}
.explorer-thumb {
position: relative;
width: 46px;
height: 32px;
flex-shrink: 0;
border-radius: 8px;
overflow: hidden;
display: grid;
place-items: center;
background: rgba(255, 255, 255, 0.82);
border: 1px solid rgba(26, 36, 31, 0.08);
}
.explorer-thumb.folder {
background: linear-gradient(135deg, rgba(15, 118, 110, 0.16), rgba(15, 118, 110, 0.04));
}
.explorer-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.explorer-thumb-ico {
font-size: 17px;
}
.explorer-play {
position: absolute;
width: 18px;
height: 18px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.45);
color: #fff;
display: grid;
place-items: center;
font-size: 9px;
}
.explorer-name-txt {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.explorer-badge-type {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--accent-strong);
}
/* 网格视图 */
.explorer-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 14px;
padding: 6px;
}
.explorer-card {
border-radius: 16px;
padding: 12px;
background: var(--panel);
border: 1px solid var(--line);
box-shadow: var(--shadow-soft, 0 8px 22px rgba(31, 35, 32, 0.08));
display: flex;
flex-direction: column;
gap: 9px;
position: relative;
cursor: pointer;
transition: transform 0.14s ease, border-color 0.14s ease;
}
.explorer-card:hover {
transform: translateY(-2px);
border-color: rgba(15, 118, 110, 0.26);
}
.explorer-card.selected {
border-color: rgba(15, 118, 110, 0.4);
background: rgba(15, 118, 110, 0.06);
}
.explorer-card.active {
outline: 2px solid rgba(15, 118, 110, 0.28);
}
.explorer-card-check {
position: absolute;
top: 12px;
left: 12px;
z-index: 1;
}
.explorer-cover {
position: relative;
aspect-ratio: 4 / 3;
border-radius: 11px;
overflow: hidden;
display: grid;
place-items: center;
font-size: 34px;
background: rgba(255, 255, 255, 0.72);
border: 1px solid rgba(26, 36, 31, 0.08);
}
.explorer-cover.folder {
background: linear-gradient(135deg, rgba(15, 118, 110, 0.16), rgba(15, 118, 110, 0.04));
}
.explorer-cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.explorer-play-lg {
position: absolute;
width: 34px;
height: 34px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.45);
color: #fff;
display: grid;
place-items: center;
}
.explorer-card-cap {
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.explorer-card-sub {
font-size: 12px;
color: var(--muted);
}
/* 批量进度 */
.explorer-batch-progress {
padding: 0 16px 12px;
}
/* 状态栏 */
.explorer-statusbar {
display: flex;
align-items: center;
gap: 16px;
padding: 8px 16px;
font-size: 12px;
color: var(--muted);
border-top: 1px solid var(--line);
background: var(--panel);
}
.explorer-link-like {
color: var(--accent-strong);
background: none;
border: none;
font: inherit;
}
/* 右侧详情面板 */
.explorer-detail {
border-left: 1px solid var(--line);
background: rgba(255, 255, 255, 0.5);
overflow-y: auto;
}
.explorer-split:not(.with-detail) .explorer-detail {
display: none;
}
.explorer-detail-content {
padding: 16px;
display: flex;
flex-direction: column;
gap: 14px;
}
.explorer-detail-head {
display: flex;
align-items: center;
gap: 12px;
}
.explorer-detail-ico {
font-size: 28px;
}
.explorer-detail-ico-lg {
font-size: 44px;
}
.explorer-detail-title {
font-size: 16px;
font-weight: 600;
word-break: break-word;
}
.explorer-preview-frame {
border-radius: 14px;
overflow: hidden;
background: linear-gradient(160deg, rgba(17, 24, 39, 0.92), rgba(38, 48, 62, 0.95));
display: grid;
place-items: center;
min-height: 180px;
}
.explorer-preview-frame video {
width: 100%;
display: block;
min-height: 180px;
background: transparent;
}
.explorer-preview-static {
aspect-ratio: 16 / 10;
color: #fff;
}
.explorer-preview-static img {
width: 100%;
height: 100%;
object-fit: cover;
}
.explorer-detail-status {
font-size: 13px;
color: var(--muted);
padding: 10px 12px;
border-radius: 12px;
background: var(--panel-strong);
word-break: break-word;
}
.explorer-detail-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
/* 右键菜单 */
.explorer-context-menu {
position: fixed;
z-index: 50;
min-width: 180px;
padding: 6px;
border-radius: 14px;
background: var(--panel);
border: 1px solid var(--line);
box-shadow: var(--shadow);
}
.explorer-context-menu[hidden] {
display: none;
}
.explorer-context-menu button {
width: 100%;
text-align: left;
display: flex;
align-items: center;
gap: 10px;
padding: 9px 11px;
border: none;
background: transparent;
border-radius: 9px;
font: inherit;
color: var(--ink);
}
.explorer-context-menu button:hover {
background: rgba(15, 118, 110, 0.1);
}
.explorer-context-menu .danger {
color: var(--danger);
}
.explorer-context-menu hr {
border: none;
border-top: 1px solid var(--line);
margin: 4px 2px;
}
/* 对话框 */
.explorer-overlay {
position: fixed;
inset: 0;
z-index: 100;
background: rgba(26, 36, 31, 0.5);
backdrop-filter: blur(4px);
display: grid;
place-items: center;
}
.explorer-overlay[hidden] {
display: none;
}
.explorer-dialog {
width: min(560px, calc(100vw - 40px));
max-height: calc(100vh - 60px);
overflow-y: auto;
border-radius: 22px;
background: var(--panel);
border: 1px solid var(--line);
box-shadow: var(--shadow);
padding: 24px;
}
.explorer-dialog-wide {
width: min(760px, calc(100vw - 40px));
}
.explorer-dialog-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16px;
}
.explorer-dialog-head h3 {
font-size: 22px;
}
.explorer-manage-content {
display: grid;
gap: 16px;
}
/* 移动端遮罩 */
.explorer-backdrop {
display: none;
}
/* ── 响应式:平板 ── */
@media (max-width: 1080px) {
.explorer-split,
.explorer-split.with-detail {
grid-template-columns: 220px minmax(0, 1fr);
}
/* 详情面板改为从右滑入的覆盖式抽屉 */
.explorer-split.with-detail .explorer-detail {
position: fixed;
top: 56px;
right: 0;
bottom: 0;
width: min(360px, 90vw);
z-index: 40;
box-shadow: var(--shadow);
display: block;
}
/* 隐藏类型列,节省空间 */
.explorer-col-type {
display: none;
}
}
/* ── 响应式:手机 ── */
@media (max-width: 760px) {
.explorer-hamburger {
display: grid;
}
.explorer-brand span:last-child {
display: none;
}
.explorer-split,
.explorer-split.with-detail {
grid-template-columns: minmax(0, 1fr);
}
/* 侧栏改为离屏抽屉 */
.explorer-sidebar {
position: fixed;
top: 56px;
left: 0;
bottom: 0;
width: min(260px, 82vw);
z-index: 45;
transform: translateX(-100%);
transition: transform 0.2s ease;
}
.explorer-sidebar.open {
transform: translateX(0);
box-shadow: var(--shadow);
}
.explorer-backdrop {
display: block;
position: fixed;
inset: 56px 0 0 0;
z-index: 44;
background: rgba(26, 36, 31, 0.4);
}
.explorer-backdrop[hidden] {
display: none;
}
/* 详情/播放器全屏覆盖 */
.explorer-split.with-detail .explorer-detail {
width: 100vw;
top: 56px;
}
.explorer-search {
width: auto;
flex: 1;
}
.explorer-grid {
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
}
.explorer-col-size {
display: none;
}
.explorer-action-bar {
flex-wrap: wrap;
}
}
+2 -4
View File
@@ -214,10 +214,8 @@ export async function createServerApp(overrides = {}) {
app.get('/login.html', sendPage('login.html')); app.get('/login.html', sendPage('login.html'));
app.get('/setup.html', sendPage('setup.html')); app.get('/setup.html', sendPage('setup.html'));
app.get('/', requirePageAuth, sendPage('index.html')); app.get('/', requirePageAuth, sendPage('explorer.html'));
app.get('/index.html', requirePageAuth, sendPage('index.html')); app.get('/explorer.html', requirePageAuth, sendPage('explorer.html'));
app.get('/source.html', requirePageAuth, sendPage('source.html'));
app.get('/file.html', requirePageAuth, sendPage('file.html'));
app.use( app.use(
express.static(publicDir, { express.static(publicDir, {