feat: expand nas storage workspace panel

This commit is contained in:
kris
2026-03-23 11:53:21 +08:00
parent 3ecf6c1916
commit 56255688c1
2 changed files with 215 additions and 20 deletions

View File

@@ -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`
- 抖音采集控制台仍作为独立工具存在,这里才是正式业务应用壳

View File

@@ -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) => `
<div class="task-item compact">
<h4>${escapeHtml(item.title || item.name || item.job_id || "任务")}</h4>
<p>${escapeHtml(brief(getStorageItemPath(item), 140))}</p>
<div class="task-meta">
<span class="tag">${escapeHtml(item.status || "-")}</span>
${item.project_name ? `<span class="tag">${escapeHtml(item.project_name)}</span>` : ""}
${item.line_type || item.source_type ? `<span class="tag">${escapeHtml(item.line_type || item.source_type)}</span>` : ""}
${item.id ? `<span class="tag clickable-tag" data-action="open-job-detail" data-job-id="${escapeHtml(item.id)}">看详情</span>` : ""}
</div>
</div>
`).join("") || `<div class="task-item compact"><h4>${escapeHtml(emptyTitle)}</h4><p>${escapeHtml(emptyText)}</p></div>`;
}
function renderStorageFileCards(items, emptyTitle, emptyText) {
return safeArray(items).slice(0, 4).map((item) => `
<div class="task-item compact">
<h4>${escapeHtml(item.title || item.name || item.relative_path || "文件")}</h4>
<p>${escapeHtml(brief(item.relative_path || item.name || item.content_url || "-", 140))}</p>
<div class="task-meta">
${(item.updated_at || item.mtime) ? `<span class="tag">${escapeHtml(formatDateTime(item.updated_at || item.mtime))}</span>` : ""}
${(item.size_bytes || item.size) ? `<span class="tag">${escapeHtml(formatBytes(item.size_bytes || item.size))}</span>` : ""}
${item.id ? `<span class="tag clickable-tag" data-action="${escapeHtml(item.kind === "downloads" || item.kind === "jobs" ? "open-storage-artifact" : "open-live-recorder-file")}" data-file-id="${escapeHtml(item.id)}">打开文件</span>` : ""}
</div>
</div>
`).join("") || `<div class="task-item compact"><h4>${escapeHtml(emptyTitle)}</h4><p>${escapeHtml(emptyText)}</p></div>`;
}
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 `
<div class="panel pad">
<div class="panel-head"><div><h3>存储状态</h3><div class="panel-subtitle">当前后端暂未接入存储状态接口</div></div></div>
<div class="task-item"><h4>未拉取</h4><p>等 live collector 更新后,这里会显示 NAS 缓存、当前项目占用和目录策略。</p></div>
<div class="panel-head">
<div>
<h3>存储状态</h3>
<div class="panel-subtitle">后端暂未提供 /v2/storage/status先用任务和录像文件做本地观察</div>
</div>
<span class="tag blue">降级视图</span>
</div>
<div class="task-item">
<h4>未拉取到 NAS 策略</h4>
<p>后端补上 storage/status 后,这里会自动显示账号 / 项目 / 任务分层、容量和最近写入路径。</p>
<div class="task-meta">
<span class="tag">最近任务 ${escapeHtml(formatNumber(projectJobCount))}</span>
<span class="tag">录制源 ${escapeHtml(formatNumber(liveRecorderSources.length))}</span>
<span class="tag">录像文件 ${escapeHtml(formatNumber(liveRecorderFiles.length))}</span>
</div>
</div>
<div class="mini-grid" style="margin-top:14px;">
<div class="mini-card">
<small>当前项目</small>
<strong>${escapeHtml(currentProject?.name || appState.selectedProjectId || "未选择")}</strong>
<span>任务 ${escapeHtml(formatNumber(projectJobCount))}</span>
</div>
<div class="mini-card">
<small>当前账号</small>
<strong>${escapeHtml(currentAccount ? getAccountName(currentAccount) : "未选择")}</strong>
<span>任务 ${escapeHtml(formatNumber(accountJobCount))}</span>
</div>
<div class="mini-card">
<small>录像源</small>
<strong>${escapeHtml(formatNumber(liveRecorderSources.length))}</strong>
<span>仅当前租户可见</span>
</div>
<div class="mini-card">
<small>最近文件</small>
<strong>${escapeHtml(formatNumber(liveRecorderFiles.length))}</strong>
<span>可直接打开</span>
</div>
</div>
<div class="list" style="margin-top:14px;">
<div class="task-item compact">
<h4>最近任务</h4>
<p>优先展示 dashboard.recent_jobs方便在没有 storage/status 时也能继续追踪产物。</p>
</div>
${renderStorageJobCards(
fallbackRecentJobs,
"还没有任务样本",
"等你完成一次分析、下载或剪辑后,这里就会出现最近的任务路径和详情入口。"
)}
<div class="task-item compact">
<h4>最近录像文件</h4>
<p>录像文件沿用 live-recorder 的当前租户视图,支持直接打开查看。</p>
</div>
${renderStorageFileCards(
liveRecorderFiles,
"还没有录像文件",
"录制完成后,这里会直接暴露当前租户的最近文件入口。"
)}
</div>
</div>
`;
}
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 `
<div class="panel pad">
<div class="panel-head"><div><h3>存储状态</h3><div class="panel-subtitle">数据库留本机,大文件缓存优先走 NAS</div></div><span class="tag blue">${escapeHtml((strategy.jobs?.mode || "local").toUpperCase())}</span></div>
<div class="panel-head">
<div>
<h3>存储状态</h3>
<div class="panel-subtitle">数据库留本机,大文件缓存优先走 NAS</div>
</div>
<span class="tag blue">${escapeHtml(strategyMode)}</span>
</div>
<div class="task-item">
<h4>当前观察范围</h4>
<p>${escapeHtml(`项目 ${projectName} · 账号 ${accountName} · 最近任务 ${recentJobs.length} 条 · 最近录像 ${liveRecorderFiles.length}`)}</p>
</div>
<div class="task-meta">
${strategyTags.map((item) => `<span class="tag">${escapeHtml(item)}</span>`).join("")}
</div>
@@ -1426,25 +1558,60 @@ function renderStorageStatusPanel() {
</div>
`).join("")}
</div>
<div class="two-col" style="margin-top:14px;">
<div class="task-item compact">
<h4>目录策略</h4>
<p>${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(" · "))}</p>
<div class="task-meta">
<span class="tag">项目层 ${escapeHtml(usage.project_jobs?.path || strategy.jobs?.path || "-")}</span>
<span class="tag">账号层 ${escapeHtml(usage.account_jobs?.path || "-")}</span>
</div>
</div>
<div class="task-item compact">
<h4>产物入口</h4>
<p>最近任务、分析产物和录像文件都能直接点开,便于从 NAS 面板跳回详情或原文件。</p>
<div class="task-meta">
<span class="tag">任务 ${escapeHtml(formatNumber(recentJobs.length))}</span>
<span class="tag">产物 ${escapeHtml(formatNumber(recentFiles.length))}</span>
<span class="tag">录像 ${escapeHtml(formatNumber(liveRecorderFiles.length))}</span>
</div>
</div>
</div>
<div class="list" style="margin-top:14px;">
<div class="task-item compact">
<h4>当前项目缓存根目录</h4>
<p>${escapeHtml(usage.project_jobs?.path || strategy.jobs?.path || "-")}</p>
<h4>最近任务样本</h4>
<p>默认取 storage.status 里的 recent_jobs如果后端没给会退回到 dashboard.recent_jobs。</p>
</div>
<div class="task-item compact">
<h4>下载缓存根目录</h4>
<p>${escapeHtml(usage.project_downloads?.path || strategy.downloads?.path || "-")}</p>
</div>
${recentJobs.slice(0, 3).map((item) => `
${renderStorageJobCards(
recentJobs,
"还没有缓存样本",
"上传视频、导入作品后,这里会显示最近写入 NAS 的缓存路径。"
)}
${recentFiles.length ? `
<div class="task-item compact">
<h4>${escapeHtml(item.title || item.job_id)}</h4>
<p>${escapeHtml(item.paths?.[0]?.path || item.job_id)}</p>
<div class="task-meta">
<span class="tag">${escapeHtml(item.status || "-")}</span>
<span class="tag">${escapeHtml(formatDateTime(item.updated_at))}</span>
</div>
<h4>最近产物文件</h4>
<p>后端如果提供产物文件索引,这里会优先直接露出最近写入的文件入口。</p>
</div>
`).join("") || `<div class="task-item compact"><h4>还没有缓存样本</h4><p>上传视频、导入作品后,这里会显示最近写入 NAS 的缓存路径。</p></div>`}
${renderStorageFileCards(
recentFiles,
"还没有产物文件",
"当前 storage/status 没有返回可直接打开的产物文件。"
)}
` : ""}
<div class="task-item compact">
<h4>最近录像文件</h4>
<p>如果 live-recorder 已接入,这里会继续显示当前租户的录像文件入口。</p>
</div>
${renderStorageFileCards(
liveRecorderFiles,
"还没有录像文件",
"录制完成后的文件会出现在当前租户的录像列表里。"
)}
</div>
</div>
`;
@@ -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");