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:
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user