feat: rebuild frontend as file-manager style explorer SPA

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

- 新增 explorer.html/js 与 modules/explorer/ 六个组件模块
- 删除旧的 index/source/file 页面及其控制器
- styles.css 新增 explorer 布局与三档响应式断点
This commit is contained in:
2026-07-04 14:53:07 +08:00
parent 10d7b49275
commit fcf56bcbd3
18 changed files with 3444 additions and 3055 deletions
+271
View File
@@ -0,0 +1,271 @@
import { escapeHtml, fetchJson, formatBytes, inferEnhancementClass, inferEnhancementLabel } from '../common.js';
import { buildVolumeMap, isVideoMime, restoreSegmentBytes, supportsFileDownload } from '../media-core.js';
// 右侧详情面板:
// - 选中文件 → 属性 + 内嵌 <video> 播放器 + 预览/下载按钮
// - 选中数据源 → 源概况(移植 source.js renderSourceSummary
// - 空 → 提示
//
// 依赖注入:mediaPlayer(createMediaPlayer 实例)、resolveSecrets(异步返回凭据或抛出)、
// feedback(createFeedbackController)、onThumbnailUpdated(回调刷新列表缩略图)
function isVideoFileInfo(fileInfo) {
return Boolean(fileInfo && isVideoMime(fileInfo.file.mime));
}
export function createDetailPanel(rootElement, deps = {}) {
const { mediaPlayer, resolveSecrets, feedback, onThumbnailUpdated } = deps;
const state = {
mode: 'empty',
sourceId: null,
entry: null,
fileInfo: null,
loadToken: 0
};
function renderEmpty(message = '选中一个文件或数据源查看详情。') {
state.mode = 'empty';
rootElement.innerHTML = `<div class="empty-state-card">${escapeHtml(message)}</div>`;
}
function renderSourceOverview(source) {
state.mode = 'source';
if (!source) {
renderEmpty('未找到对应数据源。');
return;
}
const enhancementError = source.enhancement?.lastErrorMessage
? `<div class="feedback feedback-error">最近失败:${escapeHtml(source.enhancement.lastErrorMessage)}</div>`
: '';
rootElement.innerHTML = `
<div class="explorer-detail-head">
<span class="explorer-detail-ico">💽</span>
<div>
<div class="explorer-detail-title serif">${escapeHtml(source.displayName || source.originalFilename)}</div>
<div class="status-pill ${inferEnhancementClass(source)}">${inferEnhancementLabel(source)}</div>
</div>
</div>
<hr>
<div class="summary-stack">
<div class="summary-row"><span>数据库文件</span><strong>${escapeHtml(source.originalFilename)}</strong></div>
<div class="summary-row"><span>Source ID</span><strong>${escapeHtml(source.id)}</strong></div>
<div class="summary-row"><span>上传大小</span><strong>${formatBytes(source.fileSize)}</strong></div>
<div class="summary-row"><span>目录浏览</span><strong>${source.canBrowse ? '可用' : '不可用'}</strong></div>
<div class="summary-row"><span>预览图缓存</span><strong>${escapeHtml(String(source.thumbnailCache?.count ?? 0))} 张</strong></div>
<div class="summary-row"><span>最新快照</span><strong>${escapeHtml(source.latestSnapshot?.timestamp ?? '暂无')}</strong></div>
</div>
${enhancementError}
<div class="explorer-detail-actions">
<button class="ghost-button explorer-pill" type="button" data-detail-action="props">⚙ 属性 / 增强配置</button>
</div>
`;
}
function renderFileLoading(entry) {
state.mode = 'file';
rootElement.innerHTML = `
<div class="explorer-detail-head">
<span class="explorer-detail-ico">📄</span>
<div><div class="explorer-detail-title serif">${escapeHtml(entry.name)}</div></div>
</div>
<hr>
<div class="summary-card">正在读取文件信息...</div>
`;
}
function renderFile(entry, fileInfo, { error = null } = {}) {
state.mode = 'file';
const canPreview = isVideoFileInfo(fileInfo);
const previewBlock = canPreview
? `
<div class="explorer-preview-frame">
<video id="detailVideo" controls playsinline preload="metadata"></video>
</div>`
: `
<div class="explorer-preview-frame explorer-preview-static">
${
entry.thumbnail?.available
? `<img src="${escapeHtml(entry.thumbnail.thumbnailUrl)}" alt="">`
: '<span class="explorer-detail-ico-lg">📄</span>'
}
</div>`;
const infoAvailable = Boolean(fileInfo);
const errorBlock = error ? `<div class="feedback feedback-error">${escapeHtml(error)}</div>` : '';
rootElement.innerHTML = `
${previewBlock}
<div class="explorer-detail-title serif">${escapeHtml(entry.name)}</div>
<hr>
<div class="summary-stack">
<div class="summary-row"><span>类型</span><strong>${escapeHtml(entry.mime || '未知')}</strong></div>
<div class="summary-row"><span>大小</span><strong>${formatBytes(entry.size)}</strong></div>
<div class="summary-row"><span>修改时间</span><strong>${escapeHtml(entry.mtime ?? '—')}</strong></div>
<div class="summary-row"><span>路径</span><strong class="muted">${escapeHtml(entry.path)}</strong></div>
</div>
${errorBlock}
<div id="detailStatus" class="explorer-detail-status" hidden></div>
<div class="explorer-detail-actions">
${canPreview ? '<button class="primary-button explorer-pill" type="button" data-detail-action="preview">▶ 预览</button>' : ''}
<button class="ghost-button explorer-pill" type="button" data-detail-action="download" ${infoAvailable ? '' : 'disabled'}>↓ 下载</button>
</div>
`;
}
async function loadAndRenderFile(sourceId, entry) {
state.sourceId = sourceId;
state.entry = entry;
state.fileInfo = null;
const token = ++state.loadToken;
renderFileLoading(entry);
try {
const payload = await fetchJson(
`/api/file-info?sourceId=${encodeURIComponent(sourceId)}&id=${encodeURIComponent(entry.id)}`
);
if (token !== state.loadToken) {
return;
}
state.fileInfo = payload;
renderFile(entry, payload);
} catch (error) {
if (token !== state.loadToken) {
return;
}
renderFile(entry, null, { error: error.message });
}
}
function setDetailStatus(message) {
const el = rootElement.querySelector('#detailStatus');
if (!el) {
return;
}
el.hidden = false;
el.textContent = message;
}
async function startPreview() {
const videoElement = rootElement.querySelector('#detailVideo');
if (!videoElement || !state.fileInfo) {
return;
}
try {
const secrets = await resolveSecrets(state.sourceId, state.fileInfo);
feedback?.set('info', '正在注册浏览器端媒体会话...');
await mediaPlayer.play({
videoElement,
sourceId: state.sourceId,
fileId: state.entry.id,
fileInfo: state.fileInfo,
secrets
});
feedback?.set('success', '视频预览已就绪,可播放并拖动进度。');
} catch (error) {
if (error?.code === 'CREDENTIALS_CANCELLED') {
return;
}
feedback?.set('error', error.message);
}
}
async function downloadFile() {
if (!state.fileInfo) {
return;
}
try {
if (!supportsFileDownload()) {
throw new Error('BROWSER_DOWNLOAD_UNSUPPORTED: 当前浏览器不支持 OPFS 或 showSaveFilePicker。');
}
const secrets = await resolveSecrets(state.sourceId, state.fileInfo);
const fileInfo = state.fileInfo;
const extension = fileInfo.file.name.includes('.') ? `.${fileInfo.file.name.split('.').pop()}` : '.bin';
const fileHandle = await showSaveFilePicker({
suggestedName: fileInfo.file.name,
types: [
{
description: 'Restored file',
accept: { [fileInfo.file.mime || 'application/octet-stream']: [extension] }
}
]
});
feedback?.set('info', '正在浏览器直连下载...');
const writable = await fileHandle.createWritable();
const volumeByRef = buildVolumeMap(fileInfo.volumes);
let writtenBytes = 0;
for (const segment of fileInfo.segments) {
const volume = volumeByRef.get(segment.volumeRef);
setDetailStatus(`正在恢复卷 ${volume?.name ?? segment.volumeRef} · 已写入 ${formatBytes(writtenBytes)}`);
const bytes = await restoreSegmentBytes({
sourceId: state.sourceId,
volume,
zip: segment.zip,
secrets,
onStatus: ({ phase, currentVolume }) => {
setDetailStatus(`${phase ?? '处理中'} · ${currentVolume ?? ''} · 已写入 ${formatBytes(writtenBytes)}`);
}
});
await writable.write(bytes);
writtenBytes += bytes.length;
}
await writable.close();
setDetailStatus(`下载完成 · 共 ${formatBytes(writtenBytes)}`);
feedback?.set('success', '文件已直接恢复并写入本地。');
} catch (error) {
if (error?.name === 'AbortError' || error?.code === 'CREDENTIALS_CANCELLED') {
return;
}
setDetailStatus(error.message);
feedback?.set('error', error.message);
}
}
rootElement.addEventListener('click', (event) => {
const button = event.target.closest('[data-detail-action]');
if (!(button instanceof HTMLElement)) {
return;
}
const action = button.dataset.detailAction;
if (action === 'preview') {
void startPreview();
} else if (action === 'download') {
void downloadFile();
} else if (action === 'props') {
deps.onOpenProps?.(state.sourceId);
}
});
return {
async showFile(sourceId, entry) {
await mediaPlayer.stop();
await loadAndRenderFile(sourceId, entry);
},
async showSource(source) {
await mediaPlayer.stop();
state.sourceId = source?.id ?? null;
state.entry = null;
state.fileInfo = null;
renderSourceOverview(source);
},
async clear() {
await mediaPlayer.stop();
state.sourceId = null;
state.entry = null;
state.fileInfo = null;
renderEmpty();
},
updateThumbnail(fileId, thumbnail) {
if (state.entry?.id === fileId) {
state.entry.thumbnail = thumbnail;
}
onThumbnailUpdated?.(fileId, thumbnail);
}
};
}