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:
-588
@@ -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>
|
||||
`;
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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>';
|
||||
});
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -80,14 +80,6 @@ export function createFeedbackController(element) {
|
||||
};
|
||||
}
|
||||
|
||||
export function linkToSource(sourceId) {
|
||||
return `/source.html?sourceId=${encodeURIComponent(sourceId)}`;
|
||||
}
|
||||
|
||||
export function linkToFile(sourceId, fileId) {
|
||||
return `/file.html?sourceId=${encodeURIComponent(sourceId)}&id=${encodeURIComponent(fileId)}`;
|
||||
}
|
||||
|
||||
export function inferEnhancementLabel(source) {
|
||||
switch (source?.enhancement?.status) {
|
||||
case 'ready':
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import { escapeHtml, fetchJson, formatBytes, inferEnhancementClass, inferEnhancementLabel } from '../common.js';
|
||||
import { buildVolumeMap, isVideoMime, restoreSegmentBytes, supportsFileDownload } from '../media-core.js';
|
||||
|
||||
// 右侧详情面板:
|
||||
// - 选中文件 → 属性 + 内嵌 <video> 播放器 + 预览/下载按钮
|
||||
// - 选中数据源 → 源概况(移植 source.js renderSourceSummary)
|
||||
// - 空 → 提示
|
||||
//
|
||||
// 依赖注入:mediaPlayer(createMediaPlayer 实例)、resolveSecrets(异步返回凭据或抛出)、
|
||||
// feedback(createFeedbackController)、onThumbnailUpdated(回调刷新列表缩略图)
|
||||
|
||||
function isVideoFileInfo(fileInfo) {
|
||||
return Boolean(fileInfo && isVideoMime(fileInfo.file.mime));
|
||||
}
|
||||
|
||||
export function createDetailPanel(rootElement, deps = {}) {
|
||||
const { mediaPlayer, resolveSecrets, feedback, onThumbnailUpdated } = deps;
|
||||
|
||||
const state = {
|
||||
mode: 'empty',
|
||||
sourceId: null,
|
||||
entry: null,
|
||||
fileInfo: null,
|
||||
loadToken: 0
|
||||
};
|
||||
|
||||
function renderEmpty(message = '选中一个文件或数据源查看详情。') {
|
||||
state.mode = 'empty';
|
||||
rootElement.innerHTML = `<div class="empty-state-card">${escapeHtml(message)}</div>`;
|
||||
}
|
||||
|
||||
function renderSourceOverview(source) {
|
||||
state.mode = 'source';
|
||||
if (!source) {
|
||||
renderEmpty('未找到对应数据源。');
|
||||
return;
|
||||
}
|
||||
|
||||
const enhancementError = source.enhancement?.lastErrorMessage
|
||||
? `<div class="feedback feedback-error">最近失败:${escapeHtml(source.enhancement.lastErrorMessage)}</div>`
|
||||
: '';
|
||||
|
||||
rootElement.innerHTML = `
|
||||
<div class="explorer-detail-head">
|
||||
<span class="explorer-detail-ico">💽</span>
|
||||
<div>
|
||||
<div class="explorer-detail-title serif">${escapeHtml(source.displayName || source.originalFilename)}</div>
|
||||
<div class="status-pill ${inferEnhancementClass(source)}">${inferEnhancementLabel(source)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr>
|
||||
<div class="summary-stack">
|
||||
<div class="summary-row"><span>数据库文件</span><strong>${escapeHtml(source.originalFilename)}</strong></div>
|
||||
<div class="summary-row"><span>Source ID</span><strong>${escapeHtml(source.id)}</strong></div>
|
||||
<div class="summary-row"><span>上传大小</span><strong>${formatBytes(source.fileSize)}</strong></div>
|
||||
<div class="summary-row"><span>目录浏览</span><strong>${source.canBrowse ? '可用' : '不可用'}</strong></div>
|
||||
<div class="summary-row"><span>预览图缓存</span><strong>${escapeHtml(String(source.thumbnailCache?.count ?? 0))} 张</strong></div>
|
||||
<div class="summary-row"><span>最新快照</span><strong>${escapeHtml(source.latestSnapshot?.timestamp ?? '暂无')}</strong></div>
|
||||
</div>
|
||||
${enhancementError}
|
||||
<div class="explorer-detail-actions">
|
||||
<button class="ghost-button explorer-pill" type="button" data-detail-action="props">⚙ 属性 / 增强配置</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderFileLoading(entry) {
|
||||
state.mode = 'file';
|
||||
rootElement.innerHTML = `
|
||||
<div class="explorer-detail-head">
|
||||
<span class="explorer-detail-ico">📄</span>
|
||||
<div><div class="explorer-detail-title serif">${escapeHtml(entry.name)}</div></div>
|
||||
</div>
|
||||
<hr>
|
||||
<div class="summary-card">正在读取文件信息...</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderFile(entry, fileInfo, { error = null } = {}) {
|
||||
state.mode = 'file';
|
||||
const canPreview = isVideoFileInfo(fileInfo);
|
||||
const previewBlock = canPreview
|
||||
? `
|
||||
<div class="explorer-preview-frame">
|
||||
<video id="detailVideo" controls playsinline preload="metadata"></video>
|
||||
</div>`
|
||||
: `
|
||||
<div class="explorer-preview-frame explorer-preview-static">
|
||||
${
|
||||
entry.thumbnail?.available
|
||||
? `<img src="${escapeHtml(entry.thumbnail.thumbnailUrl)}" alt="">`
|
||||
: '<span class="explorer-detail-ico-lg">📄</span>'
|
||||
}
|
||||
</div>`;
|
||||
|
||||
const infoAvailable = Boolean(fileInfo);
|
||||
const errorBlock = error ? `<div class="feedback feedback-error">${escapeHtml(error)}</div>` : '';
|
||||
|
||||
rootElement.innerHTML = `
|
||||
${previewBlock}
|
||||
<div class="explorer-detail-title serif">${escapeHtml(entry.name)}</div>
|
||||
<hr>
|
||||
<div class="summary-stack">
|
||||
<div class="summary-row"><span>类型</span><strong>${escapeHtml(entry.mime || '未知')}</strong></div>
|
||||
<div class="summary-row"><span>大小</span><strong>${formatBytes(entry.size)}</strong></div>
|
||||
<div class="summary-row"><span>修改时间</span><strong>${escapeHtml(entry.mtime ?? '—')}</strong></div>
|
||||
<div class="summary-row"><span>路径</span><strong class="muted">${escapeHtml(entry.path)}</strong></div>
|
||||
</div>
|
||||
${errorBlock}
|
||||
<div id="detailStatus" class="explorer-detail-status" hidden></div>
|
||||
<div class="explorer-detail-actions">
|
||||
${canPreview ? '<button class="primary-button explorer-pill" type="button" data-detail-action="preview">▶ 预览</button>' : ''}
|
||||
<button class="ghost-button explorer-pill" type="button" data-detail-action="download" ${infoAvailable ? '' : 'disabled'}>↓ 下载</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function loadAndRenderFile(sourceId, entry) {
|
||||
state.sourceId = sourceId;
|
||||
state.entry = entry;
|
||||
state.fileInfo = null;
|
||||
const token = ++state.loadToken;
|
||||
|
||||
renderFileLoading(entry);
|
||||
|
||||
try {
|
||||
const payload = await fetchJson(
|
||||
`/api/file-info?sourceId=${encodeURIComponent(sourceId)}&id=${encodeURIComponent(entry.id)}`
|
||||
);
|
||||
if (token !== state.loadToken) {
|
||||
return;
|
||||
}
|
||||
state.fileInfo = payload;
|
||||
renderFile(entry, payload);
|
||||
} catch (error) {
|
||||
if (token !== state.loadToken) {
|
||||
return;
|
||||
}
|
||||
renderFile(entry, null, { error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
function setDetailStatus(message) {
|
||||
const el = rootElement.querySelector('#detailStatus');
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
el.hidden = false;
|
||||
el.textContent = message;
|
||||
}
|
||||
|
||||
async function startPreview() {
|
||||
const videoElement = rootElement.querySelector('#detailVideo');
|
||||
if (!videoElement || !state.fileInfo) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const secrets = await resolveSecrets(state.sourceId, state.fileInfo);
|
||||
feedback?.set('info', '正在注册浏览器端媒体会话...');
|
||||
await mediaPlayer.play({
|
||||
videoElement,
|
||||
sourceId: state.sourceId,
|
||||
fileId: state.entry.id,
|
||||
fileInfo: state.fileInfo,
|
||||
secrets
|
||||
});
|
||||
feedback?.set('success', '视频预览已就绪,可播放并拖动进度。');
|
||||
} catch (error) {
|
||||
if (error?.code === 'CREDENTIALS_CANCELLED') {
|
||||
return;
|
||||
}
|
||||
feedback?.set('error', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadFile() {
|
||||
if (!state.fileInfo) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (!supportsFileDownload()) {
|
||||
throw new Error('BROWSER_DOWNLOAD_UNSUPPORTED: 当前浏览器不支持 OPFS 或 showSaveFilePicker。');
|
||||
}
|
||||
const secrets = await resolveSecrets(state.sourceId, state.fileInfo);
|
||||
const fileInfo = state.fileInfo;
|
||||
const extension = fileInfo.file.name.includes('.') ? `.${fileInfo.file.name.split('.').pop()}` : '.bin';
|
||||
const fileHandle = await showSaveFilePicker({
|
||||
suggestedName: fileInfo.file.name,
|
||||
types: [
|
||||
{
|
||||
description: 'Restored file',
|
||||
accept: { [fileInfo.file.mime || 'application/octet-stream']: [extension] }
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
feedback?.set('info', '正在浏览器直连下载...');
|
||||
const writable = await fileHandle.createWritable();
|
||||
const volumeByRef = buildVolumeMap(fileInfo.volumes);
|
||||
let writtenBytes = 0;
|
||||
|
||||
for (const segment of fileInfo.segments) {
|
||||
const volume = volumeByRef.get(segment.volumeRef);
|
||||
setDetailStatus(`正在恢复卷 ${volume?.name ?? segment.volumeRef} · 已写入 ${formatBytes(writtenBytes)}`);
|
||||
const bytes = await restoreSegmentBytes({
|
||||
sourceId: state.sourceId,
|
||||
volume,
|
||||
zip: segment.zip,
|
||||
secrets,
|
||||
onStatus: ({ phase, currentVolume }) => {
|
||||
setDetailStatus(`${phase ?? '处理中'} · ${currentVolume ?? ''} · 已写入 ${formatBytes(writtenBytes)}`);
|
||||
}
|
||||
});
|
||||
await writable.write(bytes);
|
||||
writtenBytes += bytes.length;
|
||||
}
|
||||
|
||||
await writable.close();
|
||||
setDetailStatus(`下载完成 · 共 ${formatBytes(writtenBytes)}`);
|
||||
feedback?.set('success', '文件已直接恢复并写入本地。');
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError' || error?.code === 'CREDENTIALS_CANCELLED') {
|
||||
return;
|
||||
}
|
||||
setDetailStatus(error.message);
|
||||
feedback?.set('error', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
rootElement.addEventListener('click', (event) => {
|
||||
const button = event.target.closest('[data-detail-action]');
|
||||
if (!(button instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
const action = button.dataset.detailAction;
|
||||
if (action === 'preview') {
|
||||
void startPreview();
|
||||
} else if (action === 'download') {
|
||||
void downloadFile();
|
||||
} else if (action === 'props') {
|
||||
deps.onOpenProps?.(state.sourceId);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
async showFile(sourceId, entry) {
|
||||
await mediaPlayer.stop();
|
||||
await loadAndRenderFile(sourceId, entry);
|
||||
},
|
||||
async showSource(source) {
|
||||
await mediaPlayer.stop();
|
||||
state.sourceId = source?.id ?? null;
|
||||
state.entry = null;
|
||||
state.fileInfo = null;
|
||||
renderSourceOverview(source);
|
||||
},
|
||||
async clear() {
|
||||
await mediaPlayer.stop();
|
||||
state.sourceId = null;
|
||||
state.entry = null;
|
||||
state.fileInfo = null;
|
||||
renderEmpty();
|
||||
},
|
||||
updateThumbnail(fileId, thumbnail) {
|
||||
if (state.entry?.id === fileId) {
|
||||
state.entry.thumbnail = thumbnail;
|
||||
}
|
||||
onThumbnailUpdated?.(fileId, thumbnail);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
import { escapeHtml, formatBytes } from '../common.js';
|
||||
|
||||
// 文件列表组件:列表(表格) + 网格双视图、列头/下拉排序、多选、缩略图。
|
||||
// 移植自 source.js 的 renderBrowse / 批量选择逻辑,改造成表格与网格。
|
||||
// 事件回调:onOpenDir(path) / onOpenFile(entry) / onSelectFile(entry) / onSelectionChange(set)
|
||||
|
||||
function isVideoEntry(entry) {
|
||||
return String(entry.mime ?? '').toLowerCase().startsWith('video/') || entry.hints?.preview === 'video';
|
||||
}
|
||||
|
||||
function typeLabel(entry) {
|
||||
if (entry.type === 'dir') {
|
||||
return '文件夹';
|
||||
}
|
||||
if (entry.hints?.preview) {
|
||||
return entry.hints.preview;
|
||||
}
|
||||
return (entry.mime || 'file').split('/').pop();
|
||||
}
|
||||
|
||||
function thumbInner(entry) {
|
||||
if (entry.type === 'dir') {
|
||||
return '<span class="explorer-thumb-ico">📁</span>';
|
||||
}
|
||||
if (entry.thumbnail?.available) {
|
||||
return `<img src="${escapeHtml(entry.thumbnail.thumbnailUrl)}" alt="" loading="lazy">`;
|
||||
}
|
||||
if (isVideoEntry(entry)) {
|
||||
return '<span class="explorer-thumb-ico">🎬</span>';
|
||||
}
|
||||
if (String(entry.mime ?? '').startsWith('image/')) {
|
||||
return '<span class="explorer-thumb-ico">🖼</span>';
|
||||
}
|
||||
return '<span class="explorer-thumb-ico">📄</span>';
|
||||
}
|
||||
|
||||
function compareEntries(a, b, sortKey, sortDir) {
|
||||
// 文件夹永远排在文件前面(与后端 sortEntries 一致)
|
||||
if (a.type !== b.type) {
|
||||
return a.type === 'dir' ? -1 : 1;
|
||||
}
|
||||
|
||||
let result = 0;
|
||||
switch (sortKey) {
|
||||
case 'size':
|
||||
result = (Number(a.size) || 0) - (Number(b.size) || 0);
|
||||
break;
|
||||
case 'type':
|
||||
result = typeLabel(a).localeCompare(typeLabel(b), 'en');
|
||||
break;
|
||||
case 'mtime':
|
||||
result = String(a.mtime ?? '').localeCompare(String(b.mtime ?? ''));
|
||||
break;
|
||||
case 'name':
|
||||
default:
|
||||
result = a.name.localeCompare(b.name, 'zh-Hans-CN');
|
||||
break;
|
||||
}
|
||||
|
||||
return sortDir === 'desc' ? -result : result;
|
||||
}
|
||||
|
||||
export function createFileList(rootElement, handlers = {}) {
|
||||
const { onOpenDir, onOpenFile, onSelectFile, onSelectionChange } = handlers;
|
||||
|
||||
const state = {
|
||||
entries: [],
|
||||
view: 'list',
|
||||
sortKey: 'name',
|
||||
sortDir: 'asc',
|
||||
filter: '',
|
||||
selected: new Set(),
|
||||
activeFileId: null
|
||||
};
|
||||
|
||||
function visibleEntries() {
|
||||
const filter = state.filter.trim().toLowerCase();
|
||||
const filtered = filter
|
||||
? state.entries.filter((entry) => entry.name.toLowerCase().includes(filter))
|
||||
: state.entries.slice();
|
||||
return filtered.sort((a, b) => compareEntries(a, b, state.sortKey, state.sortDir));
|
||||
}
|
||||
|
||||
function fileEntries() {
|
||||
return state.entries.filter((entry) => entry.type === 'file');
|
||||
}
|
||||
|
||||
function renderRows(entries) {
|
||||
return entries
|
||||
.map((entry) => {
|
||||
const selected = entry.type === 'file' && state.selected.has(entry.id);
|
||||
const active = entry.type === 'file' && entry.id === state.activeFileId;
|
||||
const rowClasses = [
|
||||
'explorer-row',
|
||||
entry.type === 'dir' ? 'is-dir' : 'is-file',
|
||||
selected ? 'selected' : '',
|
||||
active ? 'active' : ''
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
const checkboxCell =
|
||||
entry.type === 'file'
|
||||
? `<input type="checkbox" class="explorer-row-check" data-file-id="${escapeHtml(entry.id)}" ${selected ? 'checked' : ''} aria-label="选择 ${escapeHtml(entry.name)}">`
|
||||
: '';
|
||||
|
||||
const dataAttrs =
|
||||
entry.type === 'dir'
|
||||
? `data-type="dir" data-path="${escapeHtml(entry.path)}"`
|
||||
: `data-type="file" data-file-id="${escapeHtml(entry.id)}"`;
|
||||
|
||||
return `
|
||||
<tr class="${rowClasses}" ${dataAttrs}>
|
||||
<td class="explorer-col-chk">${checkboxCell}</td>
|
||||
<td>
|
||||
<div class="explorer-name-cell">
|
||||
<span class="explorer-thumb${entry.type === 'dir' ? ' folder' : ''}">
|
||||
${thumbInner(entry)}${isVideoEntry(entry) ? '<span class="explorer-play">▶</span>' : ''}
|
||||
</span>
|
||||
<span class="explorer-name-txt">${escapeHtml(entry.name)}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="explorer-col-size">${entry.type === 'dir' ? '—' : formatBytes(entry.size)}</td>
|
||||
<td class="explorer-col-type"><span class="explorer-badge-type">${escapeHtml(typeLabel(entry))}</span></td>
|
||||
<td class="explorer-col-time muted">${escapeHtml(entry.mtime ?? '—')}</td>
|
||||
</tr>
|
||||
`;
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
function renderCards(entries) {
|
||||
return entries
|
||||
.map((entry) => {
|
||||
const selected = entry.type === 'file' && state.selected.has(entry.id);
|
||||
const active = entry.type === 'file' && entry.id === state.activeFileId;
|
||||
const cardClasses = [
|
||||
'explorer-card',
|
||||
entry.type === 'dir' ? 'is-dir' : 'is-file',
|
||||
selected ? 'selected' : '',
|
||||
active ? 'active' : ''
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
const dataAttrs =
|
||||
entry.type === 'dir'
|
||||
? `data-type="dir" data-path="${escapeHtml(entry.path)}"`
|
||||
: `data-type="file" data-file-id="${escapeHtml(entry.id)}"`;
|
||||
|
||||
const checkbox =
|
||||
entry.type === 'file'
|
||||
? `<input type="checkbox" class="explorer-row-check explorer-card-check" data-file-id="${escapeHtml(entry.id)}" ${selected ? 'checked' : ''} aria-label="选择 ${escapeHtml(entry.name)}">`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="${cardClasses}" ${dataAttrs}>
|
||||
${checkbox}
|
||||
<div class="explorer-cover${entry.type === 'dir' ? ' folder' : ''}">
|
||||
${thumbInner(entry)}${isVideoEntry(entry) ? '<span class="explorer-play-lg">▶</span>' : ''}
|
||||
</div>
|
||||
<div class="explorer-card-cap" title="${escapeHtml(entry.name)}">${escapeHtml(entry.name)}</div>
|
||||
<div class="explorer-card-sub">${entry.type === 'dir' ? '文件夹' : formatBytes(entry.size)}</div>
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
function sortArrow(key) {
|
||||
if (state.sortKey !== key) {
|
||||
return '';
|
||||
}
|
||||
return `<span class="explorer-arrow">${state.sortDir === 'asc' ? '▲' : '▼'}</span>`;
|
||||
}
|
||||
|
||||
function render() {
|
||||
const entries = visibleEntries();
|
||||
|
||||
if (state.entries.length === 0) {
|
||||
rootElement.innerHTML = '<div class="empty-state-card">这个目录目前没有内容。</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
rootElement.innerHTML = '<div class="empty-state-card">没有匹配当前搜索的文件。</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.view === 'grid') {
|
||||
rootElement.innerHTML = `<div class="explorer-grid">${renderCards(entries)}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
rootElement.innerHTML = `
|
||||
<table class="explorer-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="explorer-col-chk"></th>
|
||||
<th class="explorer-sortable" data-sort="name">名称 ${sortArrow('name')}</th>
|
||||
<th class="explorer-col-size explorer-sortable" data-sort="size">大小 ${sortArrow('size')}</th>
|
||||
<th class="explorer-col-type explorer-sortable" data-sort="type">类型 ${sortArrow('type')}</th>
|
||||
<th class="explorer-col-time explorer-sortable" data-sort="mtime">修改时间 ${sortArrow('mtime')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${renderRows(entries)}</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
|
||||
function emitSelectionChange() {
|
||||
onSelectionChange?.(new Set(state.selected), fileEntries().length);
|
||||
}
|
||||
|
||||
function toggleSelection(fileId, selected) {
|
||||
if (selected) {
|
||||
state.selected.add(fileId);
|
||||
} else {
|
||||
state.selected.delete(fileId);
|
||||
}
|
||||
render();
|
||||
emitSelectionChange();
|
||||
}
|
||||
|
||||
// ── 事件委托 ──
|
||||
rootElement.addEventListener('click', (event) => {
|
||||
const checkbox = event.target.closest('.explorer-row-check');
|
||||
if (checkbox instanceof HTMLInputElement) {
|
||||
event.stopPropagation();
|
||||
toggleSelection(checkbox.dataset.fileId, checkbox.checked);
|
||||
return;
|
||||
}
|
||||
|
||||
const sortable = event.target.closest('.explorer-sortable');
|
||||
if (sortable instanceof HTMLElement) {
|
||||
const key = sortable.dataset.sort;
|
||||
if (state.sortKey === key) {
|
||||
state.sortDir = state.sortDir === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
state.sortKey = key;
|
||||
state.sortDir = 'asc';
|
||||
}
|
||||
render();
|
||||
return;
|
||||
}
|
||||
|
||||
const item = event.target.closest('[data-type]');
|
||||
if (!(item instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.dataset.type === 'dir') {
|
||||
onOpenDir?.(item.dataset.path);
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = state.entries.find((candidate) => candidate.id === item.dataset.fileId);
|
||||
if (entry) {
|
||||
state.activeFileId = entry.id;
|
||||
render();
|
||||
onSelectFile?.(entry);
|
||||
}
|
||||
});
|
||||
|
||||
rootElement.addEventListener('dblclick', (event) => {
|
||||
const item = event.target.closest('[data-type="file"]');
|
||||
if (!(item instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
const entry = state.entries.find((candidate) => candidate.id === item.dataset.fileId);
|
||||
if (entry) {
|
||||
onOpenFile?.(entry);
|
||||
}
|
||||
});
|
||||
|
||||
rootElement.addEventListener('contextmenu', (event) => {
|
||||
const item = event.target.closest('[data-type]');
|
||||
if (!(item instanceof HTMLElement) || !handlers.onContextMenu) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const entry =
|
||||
item.dataset.type === 'file'
|
||||
? state.entries.find((candidate) => candidate.id === item.dataset.fileId)
|
||||
: { type: 'dir', path: item.dataset.path };
|
||||
handlers.onContextMenu(entry, { x: event.clientX, y: event.clientY });
|
||||
});
|
||||
|
||||
return {
|
||||
setEntries(entries) {
|
||||
state.entries = Array.isArray(entries) ? entries : [];
|
||||
state.selected.clear();
|
||||
state.activeFileId = null;
|
||||
render();
|
||||
emitSelectionChange();
|
||||
},
|
||||
setView(view) {
|
||||
state.view = view === 'grid' ? 'grid' : 'list';
|
||||
render();
|
||||
},
|
||||
setSort(key, dir) {
|
||||
state.sortKey = key;
|
||||
state.sortDir = dir;
|
||||
render();
|
||||
},
|
||||
setFilter(value) {
|
||||
state.filter = value ?? '';
|
||||
render();
|
||||
},
|
||||
setActiveFile(fileId) {
|
||||
state.activeFileId = fileId;
|
||||
render();
|
||||
},
|
||||
selectAll(selected) {
|
||||
if (selected) {
|
||||
for (const entry of fileEntries()) {
|
||||
state.selected.add(entry.id);
|
||||
}
|
||||
} else {
|
||||
state.selected.clear();
|
||||
}
|
||||
render();
|
||||
emitSelectionChange();
|
||||
},
|
||||
clearSelection() {
|
||||
state.selected.clear();
|
||||
state.activeFileId = null;
|
||||
render();
|
||||
emitSelectionChange();
|
||||
},
|
||||
getSelectedIds() {
|
||||
return [...state.selected];
|
||||
},
|
||||
getFileCount() {
|
||||
return fileEntries().length;
|
||||
},
|
||||
getEntries() {
|
||||
return state.entries.slice();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
import { escapeHtml, fetchJson, renderSourceSummaryCard } from '../common.js';
|
||||
|
||||
// 管理对话框:折叠 app.js 的六个面板(overview/sources/upload/server-db/defaults/tls)。
|
||||
// 通过侧栏「管理」按钮打开,顶部标签页切换。数据加载与表单提交逻辑移植自 app.js。
|
||||
// deps: { feedback, onSourcesChanged(reloads sidebar/tree), close() }
|
||||
|
||||
const TABS = [
|
||||
{ key: 'sources', label: '数据源' },
|
||||
{ key: 'upload', label: '上传任务库' },
|
||||
{ key: 'server-db', label: 'Server DB' },
|
||||
{ key: 'defaults', label: '全局 WebDAV' },
|
||||
{ key: 'tls', label: 'HTTPS / TLS' }
|
||||
];
|
||||
|
||||
function summaryRows(rows) {
|
||||
return `
|
||||
<div class="summary-stack">
|
||||
${rows
|
||||
.map((row) => `<div class="summary-row"><span>${escapeHtml(row.label)}</span><strong>${escapeHtml(row.value)}</strong></div>`)
|
||||
.join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function createManage(rootElement, deps = {}) {
|
||||
const { feedback, onSourcesChanged } = deps;
|
||||
const cache = { sources: [], serverDb: null, defaults: null, tls: null };
|
||||
let activeTab = 'sources';
|
||||
|
||||
function tabsHtml() {
|
||||
return `
|
||||
<div class="tabs">
|
||||
${TABS.map(
|
||||
(tab) => `<button type="button" data-tab="${tab.key}" class="${tab.key === activeTab ? 'active' : ''}">${escapeHtml(tab.label)}</button>`
|
||||
).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderSourcesTab() {
|
||||
if (cache.sources.length === 0) {
|
||||
return '<div class="empty-state-card">还没有上传任务数据库。切到「上传任务库」添加。</div>';
|
||||
}
|
||||
return `<div class="source-list">${cache.sources
|
||||
.map((source) =>
|
||||
renderSourceSummaryCard(
|
||||
source,
|
||||
`<div class="card-actions">
|
||||
<button class="ghost-button button-link" type="button" data-open-source="${escapeHtml(source.id)}">在资源管理器打开</button>
|
||||
<button class="ghost-button danger-button" type="button" data-delete-source="${escapeHtml(source.id)}">删除 source</button>
|
||||
</div>`
|
||||
)
|
||||
)
|
||||
.join('')}</div>`;
|
||||
}
|
||||
|
||||
function renderUploadTab() {
|
||||
return `
|
||||
<p class="panel-copy">上传随机名任务数据库(如 <code>TMQRJYNADS.sqlite</code>)。重复上传会复用现有 source。</p>
|
||||
<form id="mgUploadForm" class="upload-form">
|
||||
<label class="file-picker"><span>选择任务 SQLite 文件</span>
|
||||
<input id="mgDatabaseInput" name="database" type="file" accept=".sqlite,.db,application/octet-stream"></label>
|
||||
<div class="form-actions"><button class="primary-button" type="submit">上传为新数据源</button></div>
|
||||
</form>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderServerDbTab() {
|
||||
const summary = cache.serverDb?.available
|
||||
? summaryRows([
|
||||
{ label: '当前文件', value: cache.serverDb.originalFilename },
|
||||
{ label: '上传时间', value: cache.serverDb.uploadedAt },
|
||||
{ label: '任务条目数', value: String(cache.serverDb.backupCount) }
|
||||
])
|
||||
: summaryRows([{ label: '状态', value: '未上传 Duplicati-server.sqlite' }]);
|
||||
return `
|
||||
<p class="panel-copy">上传后可自动解析任务名称,并尽量从元数据推导默认目标 URL。</p>
|
||||
<form id="mgServerDbForm" class="upload-form">
|
||||
<label class="file-picker"><span>选择服务器数据库</span>
|
||||
<input id="mgServerDbInput" name="database" type="file" accept=".sqlite,.db,application/octet-stream"></label>
|
||||
<div class="form-actions"><button class="ghost-button" type="submit">上传 Server DB</button></div>
|
||||
</form>
|
||||
${summary}
|
||||
`;
|
||||
}
|
||||
|
||||
function renderDefaultsTab() {
|
||||
const defaults = cache.defaults;
|
||||
const summary = defaults?.configured
|
||||
? summaryRows([
|
||||
{ label: '兜底 URL', value: defaults.webdavBaseUrl ?? '未设置' },
|
||||
{ label: '认证方式', value: defaults.authMode ?? '自动' },
|
||||
{
|
||||
label: '已保存凭据',
|
||||
value: `username=${defaults.hasUsername ? 'yes' : 'no'}, password=${defaults.hasPassword ? 'yes' : 'no'}, passphrase=${defaults.hasPassphrase ? 'yes' : 'no'}`
|
||||
}
|
||||
])
|
||||
: summaryRows([{ label: '状态', value: '尚未保存全局默认值' }]);
|
||||
return `
|
||||
<p class="panel-copy">如果大多数任务共用同一套 WebDAV 账号和口令,可以在这里统一保存一次。</p>
|
||||
<form id="mgDefaultsForm" class="secret-form">
|
||||
<label><span>兜底 WebDAV Base URL</span><input id="mgGlobalUrl" type="url" value="${escapeHtml(defaults?.webdavBaseUrl ?? '')}" placeholder="可选,共享兜底 URL"></label>
|
||||
<label><span>认证方式</span>
|
||||
<select id="mgGlobalAuthMode">
|
||||
<option value="" ${!defaults?.authMode ? 'selected' : ''}>自动 / 继承</option>
|
||||
<option value="basic" ${defaults?.authMode === 'basic' ? 'selected' : ''}>Basic</option>
|
||||
<option value="anonymous" ${defaults?.authMode === 'anonymous' ? 'selected' : ''}>匿名</option>
|
||||
</select>
|
||||
</label>
|
||||
<label><span>用户名</span><input id="mgGlobalUsername" type="text" placeholder="可选,共享用户名"></label>
|
||||
<label><span>密码</span><input id="mgGlobalPassword" type="password" placeholder="可选,共享密码"></label>
|
||||
<label><span>备份口令</span><input id="mgGlobalPassphrase" type="password" placeholder="可选,共享备份口令"></label>
|
||||
<div class="form-actions"><button class="ghost-button" type="submit">保存默认值</button></div>
|
||||
</form>
|
||||
${summary}
|
||||
`;
|
||||
}
|
||||
|
||||
function renderTlsTab() {
|
||||
const tls = cache.tls;
|
||||
const summary = tls
|
||||
? summaryRows([
|
||||
{ label: 'HTTP 地址', value: tls.access?.httpBaseUrl ?? '未知' },
|
||||
{ label: 'HTTPS 地址', value: tls.access?.httpsBaseUrl ?? '未知' },
|
||||
{ label: '当前生效证书', value: tls.activeSource ?? 'self-signed' },
|
||||
{ label: '主域名', value: tls.primaryDomain || 'localhost' },
|
||||
{ label: '有效期', value: tls.certificate ? `${tls.certificate.validFrom} -> ${tls.certificate.validTo}` : '未知' }
|
||||
])
|
||||
: summaryRows([{ label: '状态', value: 'TLS 配置尚未加载' }]);
|
||||
const sans = Array.isArray(tls?.subjectAltNames) ? tls.subjectAltNames.join('\n') : '';
|
||||
return `
|
||||
<p class="panel-copy">HTTP 和 HTTPS 会同时可用。没有自定义证书时系统会自动维护一张自签证书。</p>
|
||||
${summary}
|
||||
<form id="mgTlsForm" class="secret-form">
|
||||
<label><span>证书模式</span>
|
||||
<select id="mgTlsMode">
|
||||
<option value="self-signed" ${tls?.mode !== 'custom-pem' ? 'selected' : ''}>自签证书</option>
|
||||
<option value="custom-pem" ${tls?.mode === 'custom-pem' ? 'selected' : ''}>自定义 PEM</option>
|
||||
</select>
|
||||
</label>
|
||||
<label><span>主域名</span><input id="mgTlsPrimaryDomain" type="text" value="${escapeHtml(tls?.primaryDomain ?? '')}" placeholder="例如:preview.example.com"></label>
|
||||
<label><span>额外 SAN 条目</span><textarea id="mgTlsSans" rows="2" placeholder="每行一个域名或 IP">${escapeHtml(sans)}</textarea></label>
|
||||
<label><span>证书 PEM</span><textarea id="mgTlsCertPem" rows="4" placeholder="-----BEGIN CERTIFICATE-----"></textarea></label>
|
||||
<label><span>私钥 PEM</span><textarea id="mgTlsKeyPem" rows="4" placeholder="-----BEGIN PRIVATE KEY-----"></textarea></label>
|
||||
<label><span>可选链证书 PEM</span><textarea id="mgTlsChainPem" rows="3" placeholder="可选,中间证书链"></textarea></label>
|
||||
<div class="form-actions">
|
||||
<button class="primary-button" type="submit">保存并应用</button>
|
||||
<button id="mgTlsDeleteCustom" class="ghost-button danger-button" type="button" ${tls?.hasCustomCertificate ? '' : 'disabled'}>删除自定义证书并回退</button>
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderBody() {
|
||||
let content = '';
|
||||
switch (activeTab) {
|
||||
case 'upload':
|
||||
content = renderUploadTab();
|
||||
break;
|
||||
case 'server-db':
|
||||
content = renderServerDbTab();
|
||||
break;
|
||||
case 'defaults':
|
||||
content = renderDefaultsTab();
|
||||
break;
|
||||
case 'tls':
|
||||
content = renderTlsTab();
|
||||
break;
|
||||
case 'sources':
|
||||
default:
|
||||
content = renderSourcesTab();
|
||||
break;
|
||||
}
|
||||
|
||||
rootElement.innerHTML = `
|
||||
<div class="explorer-dialog-head">
|
||||
<h3 class="serif">系统管理</h3>
|
||||
<button class="explorer-icon-btn" type="button" data-manage-close aria-label="关闭">✕</button>
|
||||
</div>
|
||||
${tabsHtml()}
|
||||
<div class="explorer-manage-content">${content}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function reloadAll() {
|
||||
const [sources, serverDb, defaults, tls] = await Promise.all([
|
||||
fetchJson('/api/sources'),
|
||||
fetchJson('/api/server-db'),
|
||||
fetchJson('/api/webdav-defaults'),
|
||||
fetchJson('/api/system/tls')
|
||||
]);
|
||||
cache.sources = sources.sources;
|
||||
cache.serverDb = serverDb.serverDb;
|
||||
cache.defaults = defaults.defaults;
|
||||
cache.tls = tls.tls;
|
||||
onSourcesChanged?.(cache.sources);
|
||||
}
|
||||
|
||||
// ── 表单提交 ──
|
||||
async function submitUpload(form) {
|
||||
const file = form.querySelector('#mgDatabaseInput')?.files?.[0];
|
||||
if (!file) {
|
||||
feedback?.set('error', '请先选择一个任务 SQLite 文件。');
|
||||
return;
|
||||
}
|
||||
feedback?.set('info', '正在上传任务数据库...');
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.set('database', file);
|
||||
const response = await fetch('/api/sources/upload', { method: 'POST', body: formData });
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(`${payload.error?.code ?? 'UPLOAD_FAILED'}: ${payload.error?.message ?? '上传失败。'}`);
|
||||
}
|
||||
await reloadAll();
|
||||
activeTab = 'sources';
|
||||
renderBody();
|
||||
feedback?.set(payload.reused ? 'info' : 'success', payload.reused ? '该数据库已存在,复用现有 source。' : '任务数据库上传成功。');
|
||||
} catch (error) {
|
||||
feedback?.set('error', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitServerDb(form) {
|
||||
const file = form.querySelector('#mgServerDbInput')?.files?.[0];
|
||||
if (!file) {
|
||||
feedback?.set('error', '请先选择 Duplicati-server.sqlite。');
|
||||
return;
|
||||
}
|
||||
feedback?.set('info', '正在上传 Server DB...');
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.set('database', file);
|
||||
const response = await fetch('/api/server-db/upload', { method: 'POST', body: formData });
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(`${payload.error?.code ?? 'UPLOAD_FAILED'}: ${payload.error?.message ?? '上传失败。'}`);
|
||||
}
|
||||
await reloadAll();
|
||||
renderBody();
|
||||
feedback?.set('success', 'Server DB 上传成功。');
|
||||
} catch (error) {
|
||||
feedback?.set('error', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitDefaults() {
|
||||
feedback?.set('info', '正在保存全局 WebDAV 默认值...');
|
||||
try {
|
||||
const body = {
|
||||
webdavBaseUrl: rootElement.querySelector('#mgGlobalUrl')?.value.trim() ?? '',
|
||||
authMode: rootElement.querySelector('#mgGlobalAuthMode')?.value ?? '',
|
||||
username: rootElement.querySelector('#mgGlobalUsername')?.value.trim() ?? '',
|
||||
password: rootElement.querySelector('#mgGlobalPassword')?.value ?? '',
|
||||
passphrase: rootElement.querySelector('#mgGlobalPassphrase')?.value ?? ''
|
||||
};
|
||||
const payload = await fetchJson('/api/webdav-defaults', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
cache.defaults = payload.defaults;
|
||||
renderBody();
|
||||
feedback?.set(payload.defaults.configured ? 'success' : 'info', payload.defaults.configured ? '全局默认值已保存。' : '全局默认值已清空。');
|
||||
} catch (error) {
|
||||
feedback?.set('error', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitTls() {
|
||||
feedback?.set('info', '正在保存 TLS 配置...');
|
||||
try {
|
||||
const body = {
|
||||
mode: rootElement.querySelector('#mgTlsMode')?.value ?? 'self-signed',
|
||||
primaryDomain: rootElement.querySelector('#mgTlsPrimaryDomain')?.value.trim() ?? '',
|
||||
subjectAltNames: rootElement.querySelector('#mgTlsSans')?.value ?? '',
|
||||
certPem: rootElement.querySelector('#mgTlsCertPem')?.value ?? '',
|
||||
keyPem: rootElement.querySelector('#mgTlsKeyPem')?.value ?? '',
|
||||
chainPem: rootElement.querySelector('#mgTlsChainPem')?.value ?? ''
|
||||
};
|
||||
const payload = await fetchJson('/api/system/tls', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
cache.tls = payload.tls;
|
||||
renderBody();
|
||||
feedback?.set('success', `TLS 配置已应用,当前证书来源:${payload.tls.activeSource}。`);
|
||||
} catch (error) {
|
||||
feedback?.set('error', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteCustomTls() {
|
||||
if (!window.confirm('确定要删除当前自定义证书,并回退到自签证书吗?')) {
|
||||
return;
|
||||
}
|
||||
feedback?.set('info', '正在回退到自签证书...');
|
||||
try {
|
||||
const payload = await fetchJson('/api/system/tls/custom-certificate', { method: 'DELETE' });
|
||||
cache.tls = payload.tls;
|
||||
renderBody();
|
||||
feedback?.set('success', '自定义证书已删除,已回退到自签证书。');
|
||||
} catch (error) {
|
||||
feedback?.set('error', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSource(sourceId) {
|
||||
const source = cache.sources.find((item) => item.id === sourceId);
|
||||
const label = source?.displayName || source?.originalFilename || sourceId;
|
||||
if (!window.confirm(`确定要删除这个 source 吗?\n\n${label}\n\n这只会删除当前 source 及其本地增强数据,不会删除 Duplicati-server.sqlite。`)) {
|
||||
return;
|
||||
}
|
||||
feedback?.set('info', `正在删除 ${label}...`);
|
||||
try {
|
||||
await fetchJson(`/api/sources/${encodeURIComponent(sourceId)}`, { method: 'DELETE' });
|
||||
await reloadAll();
|
||||
renderBody();
|
||||
feedback?.set('success', `已删除 ${label}。`);
|
||||
} catch (error) {
|
||||
feedback?.set('error', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
rootElement.addEventListener('click', (event) => {
|
||||
const tab = event.target.closest('[data-tab]');
|
||||
if (tab instanceof HTMLElement) {
|
||||
activeTab = tab.dataset.tab;
|
||||
renderBody();
|
||||
return;
|
||||
}
|
||||
if (event.target.closest('[data-manage-close]')) {
|
||||
deps.close?.();
|
||||
return;
|
||||
}
|
||||
const openSource = event.target.closest('[data-open-source]');
|
||||
if (openSource instanceof HTMLElement) {
|
||||
deps.onOpenSource?.(openSource.dataset.openSource);
|
||||
return;
|
||||
}
|
||||
const del = event.target.closest('[data-delete-source]');
|
||||
if (del instanceof HTMLElement) {
|
||||
void deleteSource(del.dataset.deleteSource);
|
||||
return;
|
||||
}
|
||||
if (event.target.closest('#mgTlsDeleteCustom')) {
|
||||
void deleteCustomTls();
|
||||
}
|
||||
});
|
||||
|
||||
rootElement.addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
const form = event.target;
|
||||
if (form.id === 'mgUploadForm') {
|
||||
void submitUpload(form);
|
||||
} else if (form.id === 'mgServerDbForm') {
|
||||
void submitServerDb(form);
|
||||
} else if (form.id === 'mgDefaultsForm') {
|
||||
void submitDefaults();
|
||||
} else if (form.id === 'mgTlsForm') {
|
||||
void submitTls();
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
async open(tab = 'sources') {
|
||||
activeTab = TABS.some((item) => item.key === tab) ? tab : 'sources';
|
||||
renderBody();
|
||||
try {
|
||||
await reloadAll();
|
||||
renderBody();
|
||||
} catch (error) {
|
||||
feedback?.set('error', error.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
import { assertBrowserFeature } from '../common.js';
|
||||
import { isVideoMime, supportsVideoPreview } from '../media-core.js';
|
||||
import { analyzeFrameLuma, buildThumbnailCandidateTimes } from '../thumbnail-utils.js';
|
||||
|
||||
// 参数驱动的媒体播放器组件。从 file.js 抽取媒体会话逻辑,去掉页面全局与硬编码 ID。
|
||||
// 一个实例管理:Service Worker 注册、session 注册/注销、<video> 挂载、预览图自动抓取。
|
||||
//
|
||||
// 用法:
|
||||
// const player = createMediaPlayer({ canvas, onStatus, onThumbnail });
|
||||
// await player.play({ videoElement, sourceId, fileId, fileInfo, secrets });
|
||||
// await player.stop();
|
||||
|
||||
const STATUS_CHANNEL_NAME = 'duplicati-media-status';
|
||||
|
||||
export function createMediaPlayer({ canvas, onStatus = () => {}, onThumbnail = () => {} } = {}) {
|
||||
const state = {
|
||||
videoElement: null,
|
||||
sourceId: null,
|
||||
fileId: null,
|
||||
fileInfo: null,
|
||||
secrets: null,
|
||||
currentSessionId: null,
|
||||
probeSessionId: null,
|
||||
workerChannel: null,
|
||||
loadedDataHandler: null,
|
||||
thumbnail: {
|
||||
inFlight: false,
|
||||
attempted: false
|
||||
}
|
||||
};
|
||||
|
||||
async function sendWorkerMessage(type, payload) {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const worker = navigator.serviceWorker.controller ?? registration.active ?? registration.waiting;
|
||||
if (!worker) {
|
||||
throw new Error('SERVICE_WORKER_UNAVAILABLE: Service Worker is not ready.');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const channel = new MessageChannel();
|
||||
const timeoutId = setTimeout(() => {
|
||||
reject(new Error('SERVICE_WORKER_TIMEOUT: Service Worker did not reply in time.'));
|
||||
}, 10_000);
|
||||
|
||||
channel.port1.onmessage = (event) => {
|
||||
clearTimeout(timeoutId);
|
||||
if (event.data?.ok) {
|
||||
resolve(event.data);
|
||||
} else {
|
||||
reject(new Error(event.data?.error ?? 'Service Worker request failed.'));
|
||||
}
|
||||
};
|
||||
|
||||
worker.postMessage({ type, ...payload }, [channel.port2]);
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureServiceWorkerReady() {
|
||||
assertBrowserFeature(navigator.serviceWorker, 'SERVICE_WORKER_UNSUPPORTED: 当前浏览器不支持 Service Worker。');
|
||||
await navigator.serviceWorker.register('/media-sw.js', { scope: '/', type: 'module' });
|
||||
await navigator.serviceWorker.ready;
|
||||
|
||||
if (!navigator.serviceWorker.controller) {
|
||||
await new Promise((resolve) => {
|
||||
navigator.serviceWorker.addEventListener('controllerchange', () => resolve(), { once: true });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function ensureWorkerStatusChannel() {
|
||||
if (state.workerChannel || typeof BroadcastChannel !== 'function') {
|
||||
return;
|
||||
}
|
||||
state.workerChannel = new BroadcastChannel(STATUS_CHANNEL_NAME);
|
||||
state.workerChannel.addEventListener('message', (event) => {
|
||||
if (event.data?.type !== 'status' || event.data.sessionId !== state.currentSessionId) {
|
||||
return;
|
||||
}
|
||||
onStatus(event.data.status);
|
||||
});
|
||||
}
|
||||
|
||||
async function unregisterWorkerSession(sessionId) {
|
||||
if (!sessionId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await ensureServiceWorkerReady();
|
||||
await sendWorkerMessage('unregister-session', { sessionId });
|
||||
} catch {
|
||||
// ignore stale or already-closed sessions
|
||||
}
|
||||
}
|
||||
|
||||
async function registerPreviewSession() {
|
||||
await ensureServiceWorkerReady();
|
||||
ensureWorkerStatusChannel();
|
||||
|
||||
if (state.currentSessionId) {
|
||||
try {
|
||||
await sendWorkerMessage('unregister-session', { sessionId: state.currentSessionId });
|
||||
} catch {
|
||||
// ignore stale sessions during re-registration
|
||||
}
|
||||
}
|
||||
|
||||
const sessionId = crypto.randomUUID();
|
||||
const response = await sendWorkerMessage('register-session', {
|
||||
sessionId,
|
||||
sourceId: state.sourceId,
|
||||
fileInfo: state.fileInfo,
|
||||
secrets: state.secrets
|
||||
});
|
||||
|
||||
state.currentSessionId = sessionId;
|
||||
onStatus(response.status);
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
// ── 预览图抓取(隐藏探测视频) ──
|
||||
|
||||
function waitForEventOnce(target, eventName, timeoutMs, readyCheck = null) {
|
||||
if (typeof readyCheck === 'function' && readyCheck()) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error(`THUMBNAIL_EVENT_TIMEOUT: Timed out waiting for ${eventName}.`));
|
||||
}, timeoutMs);
|
||||
function cleanup() {
|
||||
clearTimeout(timeoutId);
|
||||
target.removeEventListener(eventName, handleSuccess);
|
||||
target.removeEventListener('error', handleError);
|
||||
}
|
||||
function handleSuccess() {
|
||||
cleanup();
|
||||
resolve();
|
||||
}
|
||||
function handleError() {
|
||||
cleanup();
|
||||
reject(new Error(`THUMBNAIL_VIDEO_ERROR: probe video failed during ${eventName}.`));
|
||||
}
|
||||
target.addEventListener(eventName, handleSuccess, { once: true });
|
||||
target.addEventListener('error', handleError, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForStableVideoFrame(videoElement) {
|
||||
if (typeof videoElement.requestVideoFrameCallback === 'function') {
|
||||
await new Promise((resolve) => {
|
||||
videoElement.requestVideoFrameCallback(() => resolve());
|
||||
});
|
||||
return;
|
||||
}
|
||||
await waitForEventOnce(
|
||||
videoElement,
|
||||
'loadeddata',
|
||||
5_000,
|
||||
() => videoElement.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 80));
|
||||
}
|
||||
|
||||
async function seekHiddenVideoTo(videoElement, seconds) {
|
||||
const targetTime = Number(seconds);
|
||||
if (!Number.isFinite(targetTime) || targetTime < 0) {
|
||||
return;
|
||||
}
|
||||
if (Math.abs(videoElement.currentTime - targetTime) > 0.05) {
|
||||
const seekPromise = waitForEventOnce(videoElement, 'seeked', 12_000);
|
||||
videoElement.currentTime = targetTime;
|
||||
await seekPromise;
|
||||
} else if (videoElement.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) {
|
||||
await waitForEventOnce(
|
||||
videoElement,
|
||||
'loadeddata',
|
||||
12_000,
|
||||
() => videoElement.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA
|
||||
);
|
||||
}
|
||||
await waitForStableVideoFrame(videoElement);
|
||||
}
|
||||
|
||||
async function createThumbnailProbeVideo(sessionId) {
|
||||
const probeVideo = document.createElement('video');
|
||||
probeVideo.muted = true;
|
||||
probeVideo.playsInline = true;
|
||||
probeVideo.preload = 'auto';
|
||||
probeVideo.controls = false;
|
||||
Object.assign(probeVideo.style, {
|
||||
position: 'fixed',
|
||||
opacity: '0',
|
||||
pointerEvents: 'none',
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
left: '-9999px',
|
||||
top: '-9999px'
|
||||
});
|
||||
probeVideo.src = `/__media__/session/${encodeURIComponent(sessionId)}`;
|
||||
document.body.append(probeVideo);
|
||||
probeVideo.load();
|
||||
await waitForEventOnce(
|
||||
probeVideo,
|
||||
'loadedmetadata',
|
||||
12_000,
|
||||
() => probeVideo.readyState >= HTMLMediaElement.HAVE_METADATA
|
||||
);
|
||||
return probeVideo;
|
||||
}
|
||||
|
||||
function cleanupThumbnailProbeVideo(videoElement) {
|
||||
if (!videoElement) {
|
||||
return;
|
||||
}
|
||||
videoElement.pause();
|
||||
videoElement.removeAttribute('src');
|
||||
videoElement.load();
|
||||
videoElement.remove();
|
||||
}
|
||||
|
||||
async function createThumbnailBlobFromVideo(videoElement) {
|
||||
const maxWidth = 480;
|
||||
const sourceWidth = Math.max(videoElement.videoWidth || 0, 1);
|
||||
const sourceHeight = Math.max(videoElement.videoHeight || 0, 1);
|
||||
const scale = Math.min(1, maxWidth / sourceWidth);
|
||||
const targetWidth = Math.max(1, Math.round(sourceWidth * scale));
|
||||
const targetHeight = Math.max(1, Math.round(sourceHeight * scale));
|
||||
|
||||
canvas.width = targetWidth;
|
||||
canvas.height = targetHeight;
|
||||
const context = canvas.getContext('2d', { alpha: false });
|
||||
if (!context) {
|
||||
throw new Error('THUMBNAIL_CANVAS_UNAVAILABLE: 无法创建缩略图绘制上下文。');
|
||||
}
|
||||
|
||||
context.drawImage(videoElement, 0, 0, targetWidth, targetHeight);
|
||||
const frameAnalysis = analyzeFrameLuma(context.getImageData(0, 0, targetWidth, targetHeight).data);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (!blob) {
|
||||
reject(new Error('THUMBNAIL_CAPTURE_FAILED: 浏览器没有生成有效的缩略图 Blob。'));
|
||||
return;
|
||||
}
|
||||
resolve({ blob, frameAnalysis });
|
||||
},
|
||||
'image/webp',
|
||||
0.82
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function maybeCaptureThumbnail() {
|
||||
if (!canvas || !state.fileInfo || !state.currentSessionId || !isVideoMime(state.fileInfo.file.mime)) {
|
||||
return;
|
||||
}
|
||||
if (state.fileInfo.file.thumbnail?.available) {
|
||||
return;
|
||||
}
|
||||
if (state.thumbnail.inFlight || state.thumbnail.attempted) {
|
||||
return;
|
||||
}
|
||||
const player = state.videoElement;
|
||||
if (!player || player.readyState < HTMLMediaElement.HAVE_CURRENT_DATA || player.videoWidth === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.thumbnail.inFlight = true;
|
||||
state.thumbnail.attempted = true;
|
||||
|
||||
const activeSessionId = state.currentSessionId;
|
||||
const probeSessionId = crypto.randomUUID();
|
||||
state.probeSessionId = probeSessionId;
|
||||
|
||||
let probeVideo = null;
|
||||
try {
|
||||
await sendWorkerMessage('register-session', {
|
||||
sessionId: probeSessionId,
|
||||
sourceId: state.sourceId,
|
||||
fileInfo: state.fileInfo,
|
||||
secrets: state.secrets
|
||||
});
|
||||
|
||||
probeVideo = await createThumbnailProbeVideo(probeSessionId);
|
||||
const candidates = buildThumbnailCandidateTimes(probeVideo.duration);
|
||||
let selectedBlob = null;
|
||||
let fallbackBlob = null;
|
||||
|
||||
for (let index = 0; index < candidates.length; index += 1) {
|
||||
if (state.currentSessionId !== activeSessionId) {
|
||||
return;
|
||||
}
|
||||
await seekHiddenVideoTo(probeVideo, candidates[index]);
|
||||
const { blob, frameAnalysis } = await createThumbnailBlobFromVideo(probeVideo);
|
||||
fallbackBlob = blob;
|
||||
if (!frameAnalysis.isBlackFrame) {
|
||||
selectedBlob = blob;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const blobToUpload = selectedBlob ?? fallbackBlob;
|
||||
if (!blobToUpload) {
|
||||
throw new Error('THUMBNAIL_CAPTURE_FAILED: 没有拿到可上传的预览图。');
|
||||
}
|
||||
if (state.currentSessionId !== activeSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.set('fileId', state.fileId);
|
||||
formData.set('image', blobToUpload, 'preview.webp');
|
||||
const response = await fetch(`/api/sources/${encodeURIComponent(state.sourceId)}/thumbnails`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(`${payload.error?.code ?? 'THUMBNAIL_UPLOAD_FAILED'}: ${payload.error?.message ?? '预览图上传失败。'}`);
|
||||
}
|
||||
|
||||
if (state.fileInfo?.file) {
|
||||
state.fileInfo.file.thumbnail = payload.thumbnail;
|
||||
}
|
||||
onThumbnail({ fileId: state.fileId, thumbnail: payload.thumbnail });
|
||||
} catch {
|
||||
// 预览图抓取失败不影响播放,静默处理
|
||||
} finally {
|
||||
cleanupThumbnailProbeVideo(probeVideo);
|
||||
await unregisterWorkerSession(probeSessionId);
|
||||
if (state.probeSessionId === probeSessionId) {
|
||||
state.probeSessionId = null;
|
||||
}
|
||||
state.thumbnail.inFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
function detachVideo() {
|
||||
const player = state.videoElement;
|
||||
if (!player) {
|
||||
return;
|
||||
}
|
||||
if (state.loadedDataHandler) {
|
||||
player.removeEventListener('loadeddata', state.loadedDataHandler);
|
||||
state.loadedDataHandler = null;
|
||||
}
|
||||
player.pause();
|
||||
player.removeAttribute('src');
|
||||
player.load();
|
||||
}
|
||||
|
||||
return {
|
||||
canPreview(fileInfo) {
|
||||
return Boolean(supportsVideoPreview() && fileInfo && isVideoMime(fileInfo.file.mime));
|
||||
},
|
||||
|
||||
async play({ videoElement, sourceId, fileId, fileInfo, secrets }) {
|
||||
assertBrowserFeature(
|
||||
supportsVideoPreview(),
|
||||
'BROWSER_PREVIEW_UNSUPPORTED: 当前浏览器不支持 OPFS、Service Worker 或 Range 预览所需能力。'
|
||||
);
|
||||
if (!fileInfo) {
|
||||
throw new Error('FILE_INFO_UNAVAILABLE: 当前文件还不能恢复。');
|
||||
}
|
||||
if (!isVideoMime(fileInfo.file.mime)) {
|
||||
throw new Error('VIDEO_MIME_UNSUPPORTED: 当前文件不是视频,无法启用播放器。');
|
||||
}
|
||||
|
||||
// 切换文件:先拆掉旧的 video 绑定与 session
|
||||
detachVideo();
|
||||
|
||||
state.videoElement = videoElement;
|
||||
state.sourceId = sourceId;
|
||||
state.fileId = fileId;
|
||||
state.fileInfo = fileInfo;
|
||||
state.secrets = secrets;
|
||||
state.thumbnail = { inFlight: false, attempted: Boolean(fileInfo.file.thumbnail?.available) };
|
||||
|
||||
const sessionId = await registerPreviewSession();
|
||||
|
||||
state.loadedDataHandler = () => {
|
||||
void maybeCaptureThumbnail();
|
||||
};
|
||||
videoElement.addEventListener('loadeddata', state.loadedDataHandler);
|
||||
|
||||
videoElement.src = `/__media__/session/${encodeURIComponent(sessionId)}`;
|
||||
videoElement.load();
|
||||
return sessionId;
|
||||
},
|
||||
|
||||
async stop() {
|
||||
detachVideo();
|
||||
await unregisterWorkerSession(state.probeSessionId);
|
||||
state.probeSessionId = null;
|
||||
if (state.currentSessionId) {
|
||||
await unregisterWorkerSession(state.currentSessionId);
|
||||
state.currentSessionId = null;
|
||||
}
|
||||
state.videoElement = null;
|
||||
state.fileInfo = null;
|
||||
state.secrets = null;
|
||||
onStatus(null);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { escapeHtml, fetchJson, formatBytes } from '../common.js';
|
||||
|
||||
// 源属性对话框:增强配置(覆盖 WebDAV/启动增强)、预览图缓存清理、删除源。
|
||||
// 移植自 source.js 的 saveEnhancementSecrets / runEnhancement / clearSourceThumbnails / deleteCurrentSource。
|
||||
// deps: { feedback, onSourceChanged, onSourceDeleted, close }
|
||||
|
||||
const TABS = [
|
||||
{ key: 'enhance', label: '增强配置' },
|
||||
{ key: 'thumbnails', label: '预览图缓存' },
|
||||
{ key: 'danger', label: '危险操作' }
|
||||
];
|
||||
|
||||
export function createPropsDialog(rootElement, deps = {}) {
|
||||
const { feedback } = deps;
|
||||
let source = null;
|
||||
let activeTab = 'enhance';
|
||||
|
||||
function webdavHint() {
|
||||
const webdav = source?.webdav;
|
||||
if (!webdav) {
|
||||
return '<div class="summary-card">正在计算这个 source 的默认 WebDAV 目标...</div>';
|
||||
}
|
||||
return `
|
||||
<div class="summary-stack">
|
||||
<div class="summary-row"><span>任务默认 URL</span><strong>${escapeHtml(webdav.effectiveWebdavBaseUrl ?? webdav.derivedWebdavBaseUrl ?? '未推导到')}</strong></div>
|
||||
<div class="summary-row"><span>默认 URL 来源</span><strong>${escapeHtml(webdav.effectiveWebdavBaseUrlSource ?? 'none')}</strong></div>
|
||||
<div class="summary-row"><span>全局默认值</span><strong>${webdav.globalDefaultsConfigured ? '已配置' : '未配置'}</strong></div>
|
||||
<div class="summary-row"><span>当前 source 覆盖</span><strong>${webdav.sourceOverrideSaved ? '已保存' : '未保存'}</strong></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderEnhanceTab() {
|
||||
return `
|
||||
<p class="panel-copy">增强会优先使用全局 WebDAV 默认值,并尽量从 Server DB 推导目标 URL。这里只填与全局不同的部分;全部留空并保存会清掉当前 source 覆盖。</p>
|
||||
${webdavHint()}
|
||||
<form id="propsSecretForm" class="secret-form">
|
||||
<label><span>Source 覆盖:WebDAV Base URL</span><input id="propsUrl" type="url" placeholder="留空则用全局默认 / Server DB 推导"></label>
|
||||
<label><span>认证方式</span>
|
||||
<select id="propsAuthMode">
|
||||
<option value="">自动 / 继承</option>
|
||||
<option value="basic">Basic</option>
|
||||
<option value="anonymous">Anonymous</option>
|
||||
</select>
|
||||
</label>
|
||||
<label><span>用户名</span><input id="propsUsername" type="text" placeholder="留空则继承全局默认"></label>
|
||||
<label><span>密码</span><input id="propsPassword" type="password" placeholder="留空则继承全局默认"></label>
|
||||
<label><span>备份口令</span><input id="propsPassphrase" type="password" placeholder="留空则继承全局默认"></label>
|
||||
<div class="form-actions">
|
||||
<button id="propsSaveSecrets" class="ghost-button" type="submit">保存覆盖</button>
|
||||
<button id="propsRunEnhance" class="primary-button" type="button">按当前规则启动增强</button>
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderThumbnailsTab() {
|
||||
const cache = source?.thumbnailCache ?? { count: 0, totalBytes: 0 };
|
||||
return `
|
||||
<p class="panel-copy">这里展示当前 source 已缓存到后端的预览图数量。清空后,视频会退回占位图,后续再次成功预览时会自动重建。</p>
|
||||
<div class="summary-stack">
|
||||
<div class="summary-row"><span>预览图数量</span><strong>${escapeHtml(String(cache.count))}</strong></div>
|
||||
<div class="summary-row"><span>总占用空间</span><strong>${formatBytes(cache.totalBytes)}</strong></div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button id="propsClearThumbs" class="ghost-button danger-button" type="button">清空当前 source 预览图</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderDangerTab() {
|
||||
return `
|
||||
<p class="panel-copy">删除会移除当前任务库、本地增强副本和该 source 的预览图缓存,但不会删除 Duplicati-server.sqlite。</p>
|
||||
<div class="form-actions">
|
||||
<button id="propsDeleteSource" class="primary-button danger-button" type="button" style="background:var(--danger)">删除这个 source</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderBody() {
|
||||
if (!source) {
|
||||
rootElement.innerHTML = '<div class="empty-state-card">未找到对应数据源。</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let content = '';
|
||||
if (activeTab === 'thumbnails') {
|
||||
content = renderThumbnailsTab();
|
||||
} else if (activeTab === 'danger') {
|
||||
content = renderDangerTab();
|
||||
} else {
|
||||
content = renderEnhanceTab();
|
||||
}
|
||||
|
||||
rootElement.innerHTML = `
|
||||
<div class="explorer-dialog-head">
|
||||
<h3 class="serif">${escapeHtml(source.displayName || source.originalFilename)} · 属性</h3>
|
||||
<button class="explorer-icon-btn" type="button" data-props-close aria-label="关闭">✕</button>
|
||||
</div>
|
||||
<div class="tabs">
|
||||
${TABS.map((tab) => `<button type="button" data-props-tab="${tab.key}" class="${tab.key === activeTab ? 'active' : ''}">${escapeHtml(tab.label)}</button>`).join('')}
|
||||
</div>
|
||||
<div class="explorer-manage-content">${content}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function readSecretForm() {
|
||||
return {
|
||||
webdavBaseUrl: rootElement.querySelector('#propsUrl')?.value.trim() ?? '',
|
||||
authMode: rootElement.querySelector('#propsAuthMode')?.value ?? '',
|
||||
username: rootElement.querySelector('#propsUsername')?.value.trim() ?? '',
|
||||
password: rootElement.querySelector('#propsPassword')?.value ?? '',
|
||||
passphrase: rootElement.querySelector('#propsPassphrase')?.value ?? ''
|
||||
};
|
||||
}
|
||||
|
||||
async function reloadSource() {
|
||||
const payload = await fetchJson(`/api/sources/${encodeURIComponent(source.id)}`);
|
||||
source = payload.source;
|
||||
deps.onSourceChanged?.(source);
|
||||
}
|
||||
|
||||
async function saveSecrets() {
|
||||
feedback?.set('info', '正在保存当前 source 的覆盖规则...');
|
||||
try {
|
||||
await fetchJson(`/api/sources/${encodeURIComponent(source.id)}/secrets`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(readSecretForm())
|
||||
});
|
||||
await reloadSource();
|
||||
renderBody();
|
||||
feedback?.set('success', '覆盖规则已保存。留空项会继续继承全局默认或 Server DB 推导结果。');
|
||||
} catch (error) {
|
||||
feedback?.set('error', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function runEnhancement() {
|
||||
feedback?.set('info', '正在提交增强任务...');
|
||||
try {
|
||||
await fetchJson(`/api/sources/${encodeURIComponent(source.id)}/enhance`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(readSecretForm())
|
||||
});
|
||||
await reloadSource();
|
||||
renderBody();
|
||||
feedback?.set('success', '增强任务已开始。当前规则会合并全局默认、Server DB 推导 URL 和单源覆盖。');
|
||||
} catch (error) {
|
||||
feedback?.set('error', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function clearThumbnails() {
|
||||
if (!window.confirm('确定要清空这个 source 的全部预览图吗?\n\n这不会删除任务库、增强索引或 Server DB,只会删除后端缩略图缓存。')) {
|
||||
return;
|
||||
}
|
||||
feedback?.set('info', '正在清空预览图缓存...');
|
||||
try {
|
||||
const payload = await fetchJson(`/api/sources/${encodeURIComponent(source.id)}/thumbnails`, { method: 'DELETE' });
|
||||
await reloadSource();
|
||||
renderBody();
|
||||
feedback?.set('success', `预览图已清空,删除了 ${payload.cleared.removedCount} 张,共 ${formatBytes(payload.cleared.removedBytes)}。`);
|
||||
} catch (error) {
|
||||
feedback?.set('error', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSource() {
|
||||
const label = source.displayName || source.originalFilename || source.id;
|
||||
if (!window.confirm(`确定要删除这个 source 吗?\n\n${label}\n\n这会删除任务库、本地增强副本和预览图缓存,但不会删除 Duplicati-server.sqlite。`)) {
|
||||
return;
|
||||
}
|
||||
feedback?.set('info', `正在删除 ${label}...`);
|
||||
try {
|
||||
await fetchJson(`/api/sources/${encodeURIComponent(source.id)}`, { method: 'DELETE' });
|
||||
deps.onSourceDeleted?.(source.id);
|
||||
deps.close?.();
|
||||
feedback?.set('success', `已删除 ${label}。`);
|
||||
} catch (error) {
|
||||
feedback?.set('error', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
rootElement.addEventListener('click', (event) => {
|
||||
const tab = event.target.closest('[data-props-tab]');
|
||||
if (tab instanceof HTMLElement) {
|
||||
activeTab = tab.dataset.propsTab;
|
||||
renderBody();
|
||||
return;
|
||||
}
|
||||
if (event.target.closest('[data-props-close]')) {
|
||||
deps.close?.();
|
||||
return;
|
||||
}
|
||||
if (event.target.closest('#propsRunEnhance')) {
|
||||
void runEnhancement();
|
||||
} else if (event.target.closest('#propsClearThumbs')) {
|
||||
void clearThumbnails();
|
||||
} else if (event.target.closest('#propsDeleteSource')) {
|
||||
void deleteSource();
|
||||
}
|
||||
});
|
||||
|
||||
rootElement.addEventListener('submit', (event) => {
|
||||
if (event.target.id === 'propsSecretForm') {
|
||||
event.preventDefault();
|
||||
void saveSecrets();
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
async open(sourceId, tab = 'enhance') {
|
||||
activeTab = TABS.some((item) => item.key === tab) ? tab : 'enhance';
|
||||
try {
|
||||
const payload = await fetchJson(`/api/sources/${encodeURIComponent(sourceId)}`);
|
||||
source = payload.source;
|
||||
renderBody();
|
||||
} catch (error) {
|
||||
source = null;
|
||||
rootElement.innerHTML = `<div class="empty-state-card">${escapeHtml(error.message)}</div>`;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { escapeHtml, inferEnhancementClass, inferEnhancementLabel } from '../common.js';
|
||||
|
||||
// 侧栏数据源树。数据源当作"盘符",可展开显示一层根目录快捷入口。
|
||||
// 事件通过回调向编排器上报:onOpenSource / onOpenPath / onContextMenu。
|
||||
|
||||
export function createSidebar(rootElement, { onOpenSource, onContextMenu }) {
|
||||
let sources = [];
|
||||
let activeSourceId = null;
|
||||
|
||||
function statusDotClass(source) {
|
||||
// 复用增强状态的语义色:ready/busy/error/idle
|
||||
switch (inferEnhancementClass(source)) {
|
||||
case 'status-ready':
|
||||
return 'ready';
|
||||
case 'status-busy':
|
||||
return 'busy';
|
||||
case 'status-error':
|
||||
return 'error';
|
||||
default:
|
||||
return 'idle';
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (sources.length === 0) {
|
||||
rootElement.innerHTML = '<div class="explorer-tree-empty">暂无数据源,请从「上传任务库」添加。</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
rootElement.innerHTML = sources
|
||||
.map((source) => {
|
||||
const active = source.id === activeSourceId;
|
||||
const label = source.displayName || source.originalFilename || source.id;
|
||||
const dot = statusDotClass(source);
|
||||
const title = `${label} · ${inferEnhancementLabel(source)}`;
|
||||
return `
|
||||
<div class="explorer-tree-item${active ? ' active' : ''}"
|
||||
data-source-id="${escapeHtml(source.id)}"
|
||||
title="${escapeHtml(title)}"
|
||||
role="button" tabindex="0">
|
||||
<span class="explorer-drive-ico" aria-hidden="true">💽</span>
|
||||
<span class="explorer-tree-name">${escapeHtml(label)}</span>
|
||||
<span class="explorer-dot ${dot}" title="${escapeHtml(inferEnhancementLabel(source))}"></span>
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
function findSource(sourceId) {
|
||||
return sources.find((source) => source.id === sourceId) ?? null;
|
||||
}
|
||||
|
||||
rootElement.addEventListener('click', (event) => {
|
||||
const item = event.target.closest('.explorer-tree-item');
|
||||
if (!(item instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
onOpenSource?.(item.dataset.sourceId);
|
||||
});
|
||||
|
||||
rootElement.addEventListener('keydown', (event) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') {
|
||||
return;
|
||||
}
|
||||
const item = event.target.closest('.explorer-tree-item');
|
||||
if (!(item instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
onOpenSource?.(item.dataset.sourceId);
|
||||
});
|
||||
|
||||
rootElement.addEventListener('contextmenu', (event) => {
|
||||
const item = event.target.closest('.explorer-tree-item');
|
||||
if (!(item instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
onContextMenu?.(item.dataset.sourceId, { x: event.clientX, y: event.clientY });
|
||||
});
|
||||
|
||||
return {
|
||||
setSources(nextSources) {
|
||||
sources = Array.isArray(nextSources) ? nextSources : [];
|
||||
render();
|
||||
},
|
||||
setActive(sourceId) {
|
||||
activeSourceId = sourceId;
|
||||
render();
|
||||
},
|
||||
getSource: findSource,
|
||||
getSources() {
|
||||
return sources;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
`;
|
||||
});
|
||||
@@ -640,6 +640,98 @@ textarea:focus {
|
||||
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 {
|
||||
position: relative;
|
||||
min-height: 320px;
|
||||
@@ -737,3 +829,878 @@ video {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user