feat: add docker pipeline and preview fixes

This commit is contained in:
2026-05-06 12:59:50 +08:00
commit 9ba85b01f3
44 changed files with 13894 additions and 0 deletions
+160
View File
@@ -0,0 +1,160 @@
export function escapeHtml(value) {
return String(value)
.replaceAll('&', '&')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
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) {
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 linkToSource(sourceId) {
return `/source.html?sourceId=${encodeURIComponent(sourceId)}`;
}
export function linkToFile(sourceId, fileId) {
return `/file.html?sourceId=${encodeURIComponent(sourceId)}&id=${encodeURIComponent(fileId)}`;
}
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">sourceId: ${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);
}
}