diff --git a/web/storyforge-web-v4/README.md b/web/storyforge-web-v4/README.md index 1e4b06b..0323e58 100644 --- a/web/storyforge-web-v4/README.md +++ b/web/storyforge-web-v4/README.md @@ -72,6 +72,10 @@ - 录制源按当前账号和项目归属保存 - 录像文件只通过当前租户的后端代理访问 - 前端不再直接暴露 NAS 全局配置和下载根地址 +- 存储状态面板已接上: + - 当前项目和当前账号的缓存占用 + - 数据库本机 / 分析缓存 NAS / 下载缓存 NAS 的目录策略 + - 最近写入 NAS 的缓存样本路径 - 会先识别后端是否具备 `tracking / reviews / integrations` 路由,再决定是否请求,避免不同版本 live collector 刷 404 - 依赖不可达时,自动拦住 AI 视频 / 实拍剪辑动作并展示原因 - 使用 Agent 生成文案 @@ -104,5 +108,9 @@ python3 -m http.server 3918 - 把全局搜索和页内搜索合并成统一搜索体验 - 为 `生产中心 / 发布与复盘` 接入更完整的成片预览与封面对象 - 如果后续要开放外网多租户录像访问,继续沿用 collector 的鉴权代理,不要把 NAS 下载目录直接暴露给浏览器 +- 现在的推荐策略是: + - 数据库继续留本机 + - `jobs / downloads` 这类大文件缓存优先放 NAS + - 如果后续出现速度或稳定性问题,再切到 OSS - 不要把这套页面重新塞回 `scripts/douyin-browser-capture/control_panel.mjs` - 抖音采集控制台仍作为独立工具存在,这里才是正式业务应用壳 diff --git a/web/storyforge-web-v4/assets/app.js b/web/storyforge-web-v4/assets/app.js index df59abb..9ca7d55 100644 --- a/web/storyforge-web-v4/assets/app.js +++ b/web/storyforge-web-v4/assets/app.js @@ -27,6 +27,7 @@ const appState = { liveRecorderSources: [], liveRecorderStatus: null, liveRecorderFiles: [], + storageStatus: null, integrationHealth: null, localModelCatalog: null, backendCapabilities: null, @@ -288,6 +289,20 @@ function formatNumber(value) { return String(Math.round(num * 10) / 10); } +function formatBytes(value) { + const num = Number(value || 0); + if (!Number.isFinite(num) || num <= 0) return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB"]; + let size = num; + let idx = 0; + while (size >= 1024 && idx < units.length - 1) { + size /= 1024; + idx += 1; + } + const fixed = size >= 10 || idx === 0 ? size.toFixed(0) : size.toFixed(1); + return `${fixed}${units[idx]}`; +} + function formatDateTime(value) { if (!value) return "-"; const date = new Date(value); @@ -764,6 +779,7 @@ async function logoutSession() { appState.trackingDigest = null; appState.reviews = []; appState.integrationHealth = null; + appState.storageStatus = null; appState.backendCapabilities = null; appState.lastAction = null; appState.lastGeneratedCopy = null; @@ -784,6 +800,17 @@ async function loadKnowledgeDocuments(knowledgeBases) { return groups.flat().slice(0, 12); } +async function loadStorageStatus(projectId = "") { + if (!backendSupports("/v2/storage/status")) { + appState.storageStatus = null; + return null; + } + const suffix = projectId ? `?project_id=${encodeURIComponent(projectId)}` : ""; + const payload = await storyforgeFetch(`/v2/storage/status${suffix}`).catch(() => null); + appState.storageStatus = payload; + return payload; +} + async function loadPlatformAccount(platform, accountId) { if (!accountId) return; const normalizedPlatform = normalizePlatformValue(platform, getPreferredPlatform()); @@ -852,6 +879,7 @@ async function bootstrap() { const supportsReviews = backendSupports("/v2/reviews"); const supportsIntegrationHealth = backendSupports("/v2/integrations/health"); const supportsLocalModels = backendSupports("/v2/integrations/local-models"); + const supportsStorageStatus = backendSupports("/v2/storage/status"); const supportsLiveRecorderSources = backendSupports("/v2/live-recorder/sources"); const supportsLiveRecorderStatus = backendSupports("/v2/live-recorder/status"); const supportsLiveRecorderFiles = backendSupports("/v2/live-recorder/files"); @@ -896,6 +924,11 @@ async function bootstrap() { appState.localModelCatalog = localModelCatalog; appState.documents = await loadKnowledgeDocuments(dashboard.knowledge_bases); appState.selectedProjectId = appState.selectedProjectId || dashboard.projects?.[0]?.id || ""; + if (supportsStorageStatus) { + await loadStorageStatus(appState.selectedProjectId || ""); + } else { + appState.storageStatus = null; + } const selectedAssistantExists = safeArray(dashboard.assistants).some((item) => item.id === appState.selectedAssistantId); appState.selectedAssistantId = selectedAssistantExists ? appState.selectedAssistantId : (dashboard.assistants?.[0]?.id || ""); const selectedAccountExists = appState.accounts.some((item) => item.id === appState.selectedAccountId); @@ -1352,6 +1385,71 @@ function renderLiveRecorderSummaryHtml() { `; } +function renderStorageStatusPanel() { + const storage = appState.storageStatus; + if (!storage) { + return ` +
+

存储状态

当前后端暂未接入存储状态接口
+

未拉取

等 live collector 更新后,这里会显示 NAS 缓存、当前项目占用和目录策略。

+
+ `; + } + const strategy = storage.strategy || {}; + const disk = storage.disk || {}; + const usage = storage.tenant_usage || {}; + const recentJobs = safeArray(usage.recent_jobs); + const strategyTags = [ + `数据库 ${strategy.database?.mode || "local"}`, + `分析缓存 ${strategy.jobs?.mode || "local"}`, + `下载缓存 ${strategy.downloads?.mode || "local"}`, + `直播录制 ${strategy.live_recorder?.mode || "nas_service"}`, + ]; + const usageCards = [ + { label: "当前项目缓存", value: formatBytes(usage.project_jobs?.bytes), sub: `文件 ${formatNumber(usage.project_jobs?.file_count)}` }, + { label: "当前项目下载", value: formatBytes(usage.project_downloads?.bytes), sub: `文件 ${formatNumber(usage.project_downloads?.file_count)}` }, + { label: "当前账号缓存", value: formatBytes(usage.account_jobs?.bytes), sub: `文件 ${formatNumber(usage.account_jobs?.file_count)}` }, + { label: "NAS 剩余", value: formatBytes(disk.jobs?.free_bytes), sub: `总量 ${formatBytes(disk.jobs?.total_bytes)}` } + ]; + return ` +
+

存储状态

数据库留本机,大文件缓存优先走 NAS
${escapeHtml((strategy.jobs?.mode || "local").toUpperCase())}
+
+ ${strategyTags.map((item) => `${escapeHtml(item)}`).join("")} +
+
+ ${usageCards.map((item) => ` +
+ ${escapeHtml(item.label)} + ${escapeHtml(item.value)} + ${escapeHtml(item.sub)} +
+ `).join("")} +
+
+
+

当前项目缓存根目录

+

${escapeHtml(usage.project_jobs?.path || strategy.jobs?.path || "-")}

+
+
+

下载缓存根目录

+

${escapeHtml(usage.project_downloads?.path || strategy.downloads?.path || "-")}

+
+ ${recentJobs.slice(0, 3).map((item) => ` +
+

${escapeHtml(item.title || item.job_id)}

+

${escapeHtml(item.paths?.[0]?.path || item.job_id)}

+
+ ${escapeHtml(item.status || "-")} + ${escapeHtml(formatDateTime(item.updated_at))} +
+
+ `).join("") || `

还没有缓存样本

上传视频、导入作品后,这里会显示最近写入 NAS 的缓存路径。

`} +
+
+ `; +} + function getIntegrationOverview() { const cards = getIntegrationCards(); const reachableCount = cards.filter((item) => item.detail.available && item.detail.reachable).length; @@ -1701,6 +1799,7 @@ function renderDashboardScreen() {
来源${escapeHtml(formatNumber(getSelectedProject() ? getProjectStats(getSelectedProject().id).sources.length : 0))}
+ ${renderStorageStatusPanel()}

跟踪摘要

按最近同步的账号作品生成
${escapeHtml(daysSince(appState.lastSeenAt))} 天汇总
@@ -3771,6 +3870,14 @@ document.addEventListener("click", async (event) => { } if (name === "select-project") { appState.selectedProjectId = action.dataset.projectId || ""; + if (backendSupports("/v2/storage/status")) { + setBusy(true, "正在切换项目存储视图..."); + try { + await loadStorageStatus(appState.selectedProjectId || ""); + } finally { + setBusy(false, ""); + } + } renderAll(); return; }