diff --git a/web/storyforge-web-v4/README.md b/web/storyforge-web-v4/README.md
index 0323e58..299a0d0 100644
--- a/web/storyforge-web-v4/README.md
+++ b/web/storyforge-web-v4/README.md
@@ -101,8 +101,7 @@ python3 -m http.server 3918
## 后续建议
-- 继续补多平台真实接入,而不只是一套 Douyin 工作流
-- `xiaohongshu`、`bilibili`、`kuaishou`、`wechat_video` 先保持 `待接入工作台` 占位态
+- 继续补多平台各自更深的专属采集与解析能力,而不只是一套统一抽象层
- 把对标导入后的 Agent 绑定和知识库入库反馈做得更完整
- 把跟踪日报从 Douyin 扩到多平台统一模型,并接入真正的定时调度
- 把全局搜索和页内搜索合并成统一搜索体验
@@ -111,6 +110,13 @@ python3 -m http.server 3918
- 现在的推荐策略是:
- 数据库继续留本机
- `jobs / downloads` 这类大文件缓存优先放 NAS
+ - 下载产物和分析产物通过 `/v2/storage/artifacts/{file_id}/content` 走租户鉴权代理访问
- 如果后续出现速度或稳定性问题,再切到 OSS
+- `项目总台` 里的“存储状态”面板现在已经会显示:
+ - NAS / 本机策略
+ - 项目与账号占用
+ - 最近分析产物
+ - 最近执行缓存
+ - 最近录像文件
- 不要把这套页面重新塞回 `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 9ca7d55..6df526a 100644
--- a/web/storyforge-web-v4/assets/app.js
+++ b/web/storyforge-web-v4/assets/app.js
@@ -1385,20 +1385,141 @@ function renderLiveRecorderSummaryHtml() {
`;
}
+function getStorageItemPath(item) {
+ return (
+ item?.artifacts?.source_path ||
+ item?.artifacts?.uploaded_path ||
+ item?.artifacts?.output_path ||
+ item?.artifacts?.file_path ||
+ item?.result?.source_path ||
+ item?.result?.output_path ||
+ item?.result?.file_path ||
+ item?.result?.path ||
+ item?.save_path ||
+ item?.path ||
+ item?.relative_path ||
+ item?.content_url ||
+ item?.job_id ||
+ item?.id ||
+ "-"
+ );
+}
+
+function renderStorageJobCards(items, emptyTitle, emptyText) {
+ return safeArray(items).slice(0, 4).map((item) => `
+
+
${escapeHtml(item.title || item.name || item.job_id || "任务")}
+
${escapeHtml(brief(getStorageItemPath(item), 140))}
+
+ ${escapeHtml(item.status || "-")}
+ ${item.project_name ? `${escapeHtml(item.project_name)}` : ""}
+ ${item.line_type || item.source_type ? `${escapeHtml(item.line_type || item.source_type)}` : ""}
+ ${item.id ? `看详情` : ""}
+
+
+ `).join("") || `${escapeHtml(emptyTitle)}
${escapeHtml(emptyText)}
`;
+}
+
+function renderStorageFileCards(items, emptyTitle, emptyText) {
+ return safeArray(items).slice(0, 4).map((item) => `
+
+
${escapeHtml(item.title || item.name || item.relative_path || "文件")}
+
${escapeHtml(brief(item.relative_path || item.name || item.content_url || "-", 140))}
+
+ ${(item.updated_at || item.mtime) ? `${escapeHtml(formatDateTime(item.updated_at || item.mtime))}` : ""}
+ ${(item.size_bytes || item.size) ? `${escapeHtml(formatBytes(item.size_bytes || item.size))}` : ""}
+ ${item.id ? `打开文件` : ""}
+
+
+ `).join("") || `${escapeHtml(emptyTitle)}
${escapeHtml(emptyText)}
`;
+}
+
function renderStorageStatusPanel() {
const storage = appState.storageStatus;
+ const dashboardJobs = safeArray(appState.dashboard?.recent_jobs);
+ const currentProject = getSelectedProject();
+ const currentAccount = getSelectedAccount();
+ const liveRecorderSources = safeArray(appState.liveRecorderSources);
+ const liveRecorderFiles = safeArray(appState.liveRecorderFiles);
+ const fallbackRecentJobs = dashboardJobs.slice(0, 4);
+ const recentJobs = safeArray(storage?.tenant_usage?.recent_jobs).length ? safeArray(storage?.tenant_usage?.recent_jobs) : fallbackRecentJobs;
+ const recentFiles = [
+ ...safeArray(storage?.tenant_usage?.recent_download_artifacts),
+ ...safeArray(storage?.tenant_usage?.recent_job_artifacts),
+ ...safeArray(storage?.recent_files || storage?.recent_artifacts || storage?.tenant_usage?.recent_files || storage?.tenant_usage?.recent_artifacts)
+ ];
if (!storage) {
+ const projectJobCount = appState.selectedProjectId ? dashboardJobs.filter((item) => item.project_id === appState.selectedProjectId).length : dashboardJobs.length;
+ const accountJobCount = appState.selectedAccountId ? dashboardJobs.filter((item) => item.account_id === appState.selectedAccountId).length : dashboardJobs.length;
return `
-
-
未拉取
等 live collector 更新后,这里会显示 NAS 缓存、当前项目占用和目录策略。
+
+
+
存储状态
+
后端暂未提供 /v2/storage/status,先用任务和录像文件做本地观察
+
+
降级视图
+
+
+
未拉取到 NAS 策略
+
后端补上 storage/status 后,这里会自动显示账号 / 项目 / 任务分层、容量和最近写入路径。
+
+ 最近任务 ${escapeHtml(formatNumber(projectJobCount))}
+ 录制源 ${escapeHtml(formatNumber(liveRecorderSources.length))}
+ 录像文件 ${escapeHtml(formatNumber(liveRecorderFiles.length))}
+
+
+
+
+ 当前项目
+ ${escapeHtml(currentProject?.name || appState.selectedProjectId || "未选择")}
+ 任务 ${escapeHtml(formatNumber(projectJobCount))}
+
+
+ 当前账号
+ ${escapeHtml(currentAccount ? getAccountName(currentAccount) : "未选择")}
+ 任务 ${escapeHtml(formatNumber(accountJobCount))}
+
+
+ 录像源
+ ${escapeHtml(formatNumber(liveRecorderSources.length))}
+ 仅当前租户可见
+
+
+ 最近文件
+ ${escapeHtml(formatNumber(liveRecorderFiles.length))}
+ 可直接打开
+
+
+
+
+
最近任务
+
优先展示 dashboard.recent_jobs,方便在没有 storage/status 时也能继续追踪产物。
+
+ ${renderStorageJobCards(
+ fallbackRecentJobs,
+ "还没有任务样本",
+ "等你完成一次分析、下载或剪辑后,这里就会出现最近的任务路径和详情入口。"
+ )}
+
+
最近录像文件
+
录像文件沿用 live-recorder 的当前租户视图,支持直接打开查看。
+
+ ${renderStorageFileCards(
+ liveRecorderFiles,
+ "还没有录像文件",
+ "录制完成后,这里会直接暴露当前租户的最近文件入口。"
+ )}
+
`;
}
const strategy = storage.strategy || {};
const disk = storage.disk || {};
const usage = storage.tenant_usage || {};
- const recentJobs = safeArray(usage.recent_jobs);
+ const strategyMode = (strategy.jobs?.mode || "local").toUpperCase();
+ const projectName = currentProject?.name || appState.selectedProjectId || "未选择";
+ const accountName = currentAccount ? getAccountName(currentAccount) : "未选择";
const strategyTags = [
`数据库 ${strategy.database?.mode || "local"}`,
`分析缓存 ${strategy.jobs?.mode || "local"}`,
@@ -1409,11 +1530,22 @@ function renderStorageStatusPanel() {
{ 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: "当前账号下载", value: formatBytes(usage.account_downloads?.bytes), sub: `文件 ${formatNumber(usage.account_downloads?.file_count)}` },
{ label: "NAS 剩余", value: formatBytes(disk.jobs?.free_bytes), sub: `总量 ${formatBytes(disk.jobs?.total_bytes)}` }
];
return `
-
${escapeHtml((strategy.jobs?.mode || "local").toUpperCase())}
+
+
+
存储状态
+
数据库留本机,大文件缓存优先走 NAS
+
+
${escapeHtml(strategyMode)}
+
+
+
当前观察范围
+
${escapeHtml(`项目 ${projectName} · 账号 ${accountName} · 最近任务 ${recentJobs.length} 条 · 最近录像 ${liveRecorderFiles.length} 个`)}
+
${strategyTags.map((item) => `${escapeHtml(item)}`).join("")}
@@ -1426,25 +1558,60 @@ function renderStorageStatusPanel() {
`).join("")}
+
+
+
目录策略
+
${escapeHtml([
+ `数据库 ${strategy.database?.path || "本机"}`,
+ `任务缓存 ${usage.project_jobs?.path || strategy.jobs?.path || "-"}`,
+ `下载缓存 ${usage.project_downloads?.path || strategy.downloads?.path || "-"}`,
+ `录制缓存 ${strategy.live_recorder?.path || strategy.live_recorder?.base_path || "-"}`
+ ].join(" · "))}
+
+ 项目层 ${escapeHtml(usage.project_jobs?.path || strategy.jobs?.path || "-")}
+ 账号层 ${escapeHtml(usage.account_jobs?.path || "-")}
+
+
+
+
产物入口
+
最近任务、分析产物和录像文件都能直接点开,便于从 NAS 面板跳回详情或原文件。
+
+ 任务 ${escapeHtml(formatNumber(recentJobs.length))}
+ 产物 ${escapeHtml(formatNumber(recentFiles.length))}
+ 录像 ${escapeHtml(formatNumber(liveRecorderFiles.length))}
+
+
+
-
当前项目缓存根目录
-
${escapeHtml(usage.project_jobs?.path || strategy.jobs?.path || "-")}
+
最近任务样本
+
默认取 storage.status 里的 recent_jobs;如果后端没给,会退回到 dashboard.recent_jobs。
-
-
下载缓存根目录
-
${escapeHtml(usage.project_downloads?.path || strategy.downloads?.path || "-")}
-
- ${recentJobs.slice(0, 3).map((item) => `
+ ${renderStorageJobCards(
+ recentJobs,
+ "还没有缓存样本",
+ "上传视频、导入作品后,这里会显示最近写入 NAS 的缓存路径。"
+ )}
+ ${recentFiles.length ? `
-
${escapeHtml(item.title || item.job_id)}
-
${escapeHtml(item.paths?.[0]?.path || item.job_id)}
-
- ${escapeHtml(item.status || "-")}
- ${escapeHtml(formatDateTime(item.updated_at))}
-
+
最近产物文件
+
后端如果提供产物文件索引,这里会优先直接露出最近写入的文件入口。
- `).join("") || `
还没有缓存样本
上传视频、导入作品后,这里会显示最近写入 NAS 的缓存路径。
`}
+ ${renderStorageFileCards(
+ recentFiles,
+ "还没有产物文件",
+ "当前 storage/status 没有返回可直接打开的产物文件。"
+ )}
+ ` : ""}
+
+
最近录像文件
+
如果 live-recorder 已接入,这里会继续显示当前租户的录像文件入口。
+
+ ${renderStorageFileCards(
+ liveRecorderFiles,
+ "还没有录像文件",
+ "录制完成后的文件会出现在当前租户的录像列表里。"
+ )}
`;
@@ -3580,6 +3747,24 @@ async function openLiveRecorderFileAction(fileId) {
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 60000);
}
+async function openStorageArtifactAction(fileId) {
+ const usage = appState.storageStatus?.tenant_usage || {};
+ const candidates = [
+ ...safeArray(usage.recent_download_artifacts),
+ ...safeArray(usage.recent_job_artifacts),
+ ...safeArray(appState.storageStatus?.recent_files),
+ ...safeArray(appState.storageStatus?.recent_artifacts)
+ ];
+ const target = candidates.find((item) => item.id === fileId);
+ if (!target?.content_url) {
+ throw new Error("当前产物不存在,可能已经被清理");
+ }
+ const blob = await storyforgeFetchBlob(target.content_url);
+ const blobUrl = URL.createObjectURL(blob);
+ window.open(blobUrl, "_blank", "noopener,noreferrer");
+ window.setTimeout(() => URL.revokeObjectURL(blobUrl), 60000);
+}
+
function openReviewAction(defaults = {}) {
const project = requireSelectedProject();
const assistants = getAssistantOptions(project.id);
@@ -3704,6 +3889,10 @@ document.addEventListener("click", async (event) => {
await openLiveRecorderFileAction(action.dataset.fileId || "");
return;
}
+ if (name === "open-storage-artifact") {
+ await openStorageArtifactAction(action.dataset.fileId || "");
+ return;
+ }
if (name === "mark-tracking-read") {
await markTrackingDigestRead();
rememberAction("日报已标记", "当前跟踪摘要已更新为已读,下次会从新的时间点继续汇总。", "green");