将原来的三页跳转结构(控制台/工作台/文件页)重构为单页资源管理器: 左侧数据源盘符树、主区列表/网格双视图、右侧内嵌播放器与详情面板, 管理功能收进侧栏对话框。媒体引擎(media-core + media-sw)原样复用, 仅把 file.js 的播放器逻辑抽成参数驱动组件。默认路由指向 explorer。 - 新增 explorer.html/js 与 modules/explorer/ 六个组件模块 - 删除旧的 index/source/file 页面及其控制器 - styles.css 新增 explorer 布局与三档响应式断点
272 lines
9.8 KiB
JavaScript
272 lines
9.8 KiB
JavaScript
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);
|
||
}
|
||
};
|
||
}
|