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

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

272 lines
9.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
};
}