feat: expand nas storage workspace panel
This commit is contained in:
@@ -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`
|
||||
- 抖音采集控制台仍作为独立工具存在,这里才是正式业务应用壳
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user