将原来的三页跳转结构(控制台/工作台/文件页)重构为单页资源管理器: 左侧数据源盘符树、主区列表/网格双视图、右侧内嵌播放器与详情面板, 管理功能收进侧栏对话框。媒体引擎(media-core + media-sw)原样复用, 仅把 file.js 的播放器逻辑抽成参数驱动组件。默认路由指向 explorer。 - 新增 explorer.html/js 与 modules/explorer/ 六个组件模块 - 删除旧的 index/source/file 页面及其控制器 - styles.css 新增 explorer 布局与三档响应式断点
163 lines
4.4 KiB
JavaScript
163 lines
4.4 KiB
JavaScript
import { redirectToLogin, redirectToSetup } from './auth-client.js';
|
||
|
||
export function escapeHtml(value) {
|
||
return String(value)
|
||
.replaceAll('&', '&')
|
||
.replaceAll('<', '<')
|
||
.replaceAll('>', '>')
|
||
.replaceAll('"', '"')
|
||
.replaceAll("'", ''');
|
||
}
|
||
|
||
export function formatBytes(bytes) {
|
||
if (bytes === null || bytes === undefined || Number.isNaN(Number(bytes))) {
|
||
return 'Unknown';
|
||
}
|
||
|
||
const numeric = Number(bytes);
|
||
if (numeric === 0) {
|
||
return '0 B';
|
||
}
|
||
|
||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||
let value = numeric;
|
||
let unitIndex = 0;
|
||
|
||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||
value /= 1024;
|
||
unitIndex += 1;
|
||
}
|
||
|
||
return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
|
||
}
|
||
|
||
export async function fetchJson(url, options = {}) {
|
||
const response = await fetch(url, options);
|
||
const payload = await response.json().catch(() => ({}));
|
||
|
||
if (!response.ok) {
|
||
if (response.status === 401 && window.location.pathname !== '/login.html') {
|
||
if (payload.error?.code === 'AUTH_SETUP_REQUIRED') {
|
||
redirectToSetup();
|
||
} else {
|
||
redirectToLogin();
|
||
}
|
||
}
|
||
|
||
throw new Error(`${payload.error?.code ?? 'REQUEST_FAILED'}: ${payload.error?.message ?? 'Request failed.'}`);
|
||
}
|
||
|
||
return payload;
|
||
}
|
||
|
||
export function requireQueryParam(name) {
|
||
const params = new URLSearchParams(window.location.search);
|
||
const value = params.get(name)?.trim();
|
||
if (!value) {
|
||
throw new Error(`MISSING_${name.toUpperCase()}: Query parameter "${name}" is required.`);
|
||
}
|
||
|
||
return value;
|
||
}
|
||
|
||
export function optionalQueryParam(name, fallback = '') {
|
||
const params = new URLSearchParams(window.location.search);
|
||
return params.get(name)?.trim() || fallback;
|
||
}
|
||
|
||
export function createFeedbackController(element) {
|
||
return {
|
||
clear() {
|
||
element.hidden = true;
|
||
element.className = 'feedback';
|
||
element.textContent = '';
|
||
},
|
||
set(kind, message) {
|
||
element.hidden = false;
|
||
element.className = `feedback feedback-${kind}`;
|
||
element.textContent = message;
|
||
}
|
||
};
|
||
}
|
||
|
||
export function inferEnhancementLabel(source) {
|
||
switch (source?.enhancement?.status) {
|
||
case 'ready':
|
||
return '已增强';
|
||
case 'running':
|
||
return '增强中';
|
||
case 'queued':
|
||
return '排队中';
|
||
case 'failed':
|
||
return '增强失败';
|
||
default:
|
||
return '未增强';
|
||
}
|
||
}
|
||
|
||
export function inferEnhancementClass(source) {
|
||
switch (source?.enhancement?.status) {
|
||
case 'ready':
|
||
return 'status-ready';
|
||
case 'running':
|
||
case 'queued':
|
||
return 'status-busy';
|
||
case 'failed':
|
||
return 'status-error';
|
||
default:
|
||
return 'status-idle';
|
||
}
|
||
}
|
||
|
||
export function renderSourceSummaryCard(source, extraActionsHtml = '') {
|
||
const title = escapeHtml(source.displayName || source.originalFilename);
|
||
const originalFilename = escapeHtml(source.originalFilename);
|
||
const matchedHint = source.matchedBackupName ? '任务名来自 Server DB' : '尚未匹配任务名';
|
||
const progress =
|
||
source.enhancement.totalVolumes > 0
|
||
? `${source.enhancement.processedVolumes}/${source.enhancement.totalVolumes}`
|
||
: '0/0';
|
||
|
||
return `
|
||
<article class="source-card">
|
||
<div class="source-main">
|
||
<div>
|
||
<p class="source-name">${title}</p>
|
||
<p class="source-meta">数据库文件:${originalFilename}</p>
|
||
<p class="source-meta">Source ID:${escapeHtml(source.id)}</p>
|
||
</div>
|
||
<span class="status-pill ${inferEnhancementClass(source)}">${inferEnhancementLabel(source)}</span>
|
||
</div>
|
||
|
||
<div class="summary-chip">${escapeHtml(matchedHint)}</div>
|
||
|
||
<div class="card-grid">
|
||
<div class="stat">
|
||
<span>上传大小</span>
|
||
<strong>${formatBytes(source.fileSize)}</strong>
|
||
</div>
|
||
<div class="stat">
|
||
<span>最新快照</span>
|
||
<strong>${escapeHtml(source.latestSnapshot?.timestamp ?? '无')}</strong>
|
||
</div>
|
||
<div class="stat">
|
||
<span>目录浏览</span>
|
||
<strong>${source.canBrowse ? '可用' : '不可用'}</strong>
|
||
</div>
|
||
<div class="stat">
|
||
<span>增强进度</span>
|
||
<strong>${escapeHtml(progress)}</strong>
|
||
</div>
|
||
</div>
|
||
|
||
${extraActionsHtml}
|
||
</article>
|
||
`;
|
||
}
|
||
|
||
export function assertBrowserFeature(condition, message) {
|
||
if (!condition) {
|
||
throw new Error(message);
|
||
}
|
||
}
|