diff --git a/web/storyforge-web-v4/README.md b/web/storyforge-web-v4/README.md index 7db353d..1e4b06b 100644 --- a/web/storyforge-web-v4/README.md +++ b/web/storyforge-web-v4/README.md @@ -68,6 +68,10 @@ - 在生产中心 / 发布与复盘常驻最近一次任务详情摘要 - 在 Web 中直接创建和编辑复盘 - 在页面里直接看到 `本机模型 / cutvideo / huobao / n8n / ASR` 的真实健康状态 +- 直播录制已切成租户隔离模式: + - 录制源按当前账号和项目归属保存 + - 录像文件只通过当前租户的后端代理访问 + - 前端不再直接暴露 NAS 全局配置和下载根地址 - 会先识别后端是否具备 `tracking / reviews / integrations` 路由,再决定是否请求,避免不同版本 live collector 刷 404 - 依赖不可达时,自动拦住 AI 视频 / 实拍剪辑动作并展示原因 - 使用 Agent 生成文案 @@ -99,5 +103,6 @@ python3 -m http.server 3918 - 把跟踪日报从 Douyin 扩到多平台统一模型,并接入真正的定时调度 - 把全局搜索和页内搜索合并成统一搜索体验 - 为 `生产中心 / 发布与复盘` 接入更完整的成片预览与封面对象 +- 如果后续要开放外网多租户录像访问,继续沿用 collector 的鉴权代理,不要把 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 be1836c..df59abb 100644 --- a/web/storyforge-web-v4/assets/app.js +++ b/web/storyforge-web-v4/assets/app.js @@ -24,6 +24,9 @@ const appState = { trackingAccounts: [], trackingDigest: null, reviews: [], + liveRecorderSources: [], + liveRecorderStatus: null, + liveRecorderFiles: [], integrationHealth: null, localModelCatalog: null, backendCapabilities: null, @@ -669,6 +672,30 @@ async function storyforgeFetch(path, options = {}) { return payload; } +async function storyforgeFetchBlob(path, options = {}) { + const backendUrl = (options.backendUrl || appState.session?.backendUrl || DEFAULT_BACKEND_URL).replace(/\/$/, ""); + const headers = { ...(options.headers || {}) }; + const useAuth = options.auth !== false; + const token = options.token || appState.session?.token; + if (useAuth && token) headers.Authorization = `Bearer ${token}`; + const response = await fetch(`${backendUrl}${path}`, { + method: options.method || "GET", + headers, + body: options.body, + cache: "no-store" + }); + if (!response.ok) { + const payload = (response.headers.get("content-type") || "").includes("application/json") + ? await response.json().catch(() => null) + : await response.text().catch(() => ""); + const detail = typeof payload === "object" && payload + ? payload.detail || payload.message || JSON.stringify(payload) + : String(payload || response.statusText); + throw new Error(detail); + } + return response.blob(); +} + async function loadBackendCapabilities(backendUrl) { const normalizedUrl = (backendUrl || DEFAULT_BACKEND_URL).replace(/\/$/, ""); const response = await fetch(`${normalizedUrl}/openapi.json`); @@ -825,14 +852,20 @@ async function bootstrap() { const supportsReviews = backendSupports("/v2/reviews"); const supportsIntegrationHealth = backendSupports("/v2/integrations/health"); const supportsLocalModels = backendSupports("/v2/integrations/local-models"); - const [dashboard, contentSources, accounts, trackingAccountsPayload, reviews, integrationHealth, localModelCatalog] = await Promise.all([ + const supportsLiveRecorderSources = backendSupports("/v2/live-recorder/sources"); + const supportsLiveRecorderStatus = backendSupports("/v2/live-recorder/status"); + const supportsLiveRecorderFiles = backendSupports("/v2/live-recorder/files"); + const [dashboard, contentSources, accounts, trackingAccountsPayload, reviews, integrationHealth, localModelCatalog, liveRecorderSourcesPayload, liveRecorderStatus, liveRecorderFilesPayload] = await Promise.all([ storyforgeFetch("/v2/me/dashboard"), storyforgeFetch("/v2/content-sources").catch(() => []), accountListPath ? storyforgeFetch(accountListPath).catch(() => []) : Promise.resolve([]), trackingAccountsPath ? storyforgeFetch(trackingAccountsPath).catch(() => ({ items: [], cursor_last_seen_at: "" })) : Promise.resolve({ items: [], cursor_last_seen_at: "" }), supportsReviews ? storyforgeFetch("/v2/reviews").catch(() => []) : Promise.resolve([]), supportsIntegrationHealth ? storyforgeFetch("/v2/integrations/health").catch(() => null) : Promise.resolve(null), - supportsLocalModels ? storyforgeFetch("/v2/integrations/local-models").catch(() => null) : Promise.resolve(null) + supportsLocalModels ? storyforgeFetch("/v2/integrations/local-models").catch(() => null) : Promise.resolve(null), + supportsLiveRecorderSources ? storyforgeFetch("/v2/live-recorder/sources").catch(() => ({ items: [] })) : Promise.resolve({ items: [] }), + supportsLiveRecorderStatus ? storyforgeFetch("/v2/live-recorder/status").catch(() => null) : Promise.resolve(null), + supportsLiveRecorderFiles ? storyforgeFetch("/v2/live-recorder/files?limit=16").catch(() => ({ items: [] })) : Promise.resolve({ items: [] }) ]); const trackingCursorLastSeenAt = trackingAccountsPayload?.cursor_last_seen_at || ""; if (trackingCursorLastSeenAt) { @@ -856,6 +889,9 @@ async function bootstrap() { appState.trackingAccounts = safeArray(trackingAccountsPayload.items || trackingAccountsPayload); appState.trackingDigest = trackingDigest; appState.reviews = safeArray(reviews); + appState.liveRecorderSources = safeArray(liveRecorderSourcesPayload?.items || liveRecorderSourcesPayload); + appState.liveRecorderStatus = liveRecorderStatus; + appState.liveRecorderFiles = safeArray(liveRecorderFilesPayload?.items || liveRecorderFilesPayload); appState.integrationHealth = integrationHealth; appState.localModelCatalog = localModelCatalog; appState.documents = await loadKnowledgeDocuments(dashboard.knowledge_bases); @@ -1261,7 +1297,12 @@ function getIntegrationCards() { ].filter(Boolean).join(""); } if (key === "live_recorder") { - extra = detail.baseUrl ? `服务地址:${detail.baseUrl}` : "当前未配置 NAS 录制服务"; + const ownedSources = safeArray(appState.liveRecorderSources); + const ownedFiles = safeArray(appState.liveRecorderFiles); + const activeCount = Number(appState.liveRecorderStatus?.recording_count || 0); + extra = ownedSources.length + ? `我的录制源 ${ownedSources.length} · 录像 ${ownedFiles.length} · 正在录制 ${activeCount}` + : "当前还没有你的录制源"; actions = `录制控制`; } return { @@ -1276,6 +1317,41 @@ function getIntegrationCards() { }); } +function renderLiveRecorderSummaryHtml() { + const sources = safeArray(appState.liveRecorderSources); + const files = safeArray(appState.liveRecorderFiles); + const status = appState.liveRecorderStatus || {}; + const activeItems = safeArray(status.active_recordings); + const sourceHtml = sources.slice(0, 4).map((item) => ` +
${escapeHtml(platformLabel(item.platform))} · ${escapeHtml(item.quality || "原画")} · ${escapeHtml(item.enabled ? "启用中" : "已停用")}
+${escapeHtml(item.mtime || "-")} · ${escapeHtml(item.name || item.relative_path || "-")}
+ +当前只展示你自己名下的录制源、活动录制和录像文件。全局 NAS 配置不会直接暴露给前端。
+ +新增直播源后会自动挂到你的租户空间下。
录制完成后的文件会只出现在你的当前租户视图里。