feat: lock live recorder ui to tenant proxy

This commit is contained in:
kris
2026-03-23 09:59:22 +08:00
parent 660f539204
commit a5f82bd0aa
2 changed files with 123 additions and 11 deletions

View File

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

View File

@@ -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 = `<span class="tag clickable-tag" data-action="open-live-recorder">录制控制</span>`;
}
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) => `
<div class="task-item compact">
<h4>${escapeHtml(item.title || item.remote_name || item.source_url || "录制源")}</h4>
<p>${escapeHtml(platformLabel(item.platform))} · ${escapeHtml(item.quality || "原画")} · ${escapeHtml(item.enabled ? "启用中" : "已停用")}</p>
</div>
`).join("");
const fileHtml = files.slice(0, 4).map((item) => `
<div class="task-item compact">
<h4>${escapeHtml(item.title || item.name || "录像文件")}</h4>
<p>${escapeHtml(item.mtime || "-")} · ${escapeHtml(item.name || item.relative_path || "-")}</p>
<div class="task-meta">
<span class="tag blue clickable-tag" data-action="open-live-recorder-file" data-file-id="${escapeHtml(item.id || "")}">打开录像</span>
</div>
</div>
`).join("");
return `
<div class="task-item compact">
<h4>租户隔离状态</h4>
<p>当前只展示你自己名下的录制源、活动录制和录像文件。全局 NAS 配置不会直接暴露给前端。</p>
<div class="task-meta">
<span class="tag">${escapeHtml(`录制源 ${sources.length}`)}</span>
<span class="tag">${escapeHtml(`活动 ${activeItems.length}`)}</span>
<span class="tag">${escapeHtml(`文件 ${files.length}`)}</span>
</div>
</div>
${sourceHtml || `<div class="task-item compact"><h4>还没有录制源</h4><p>新增直播源后会自动挂到你的租户空间下。</p></div>`}
${fileHtml || `<div class="task-item compact"><h4>还没有录像文件</h4><p>录制完成后的文件会只出现在你的当前租户视图里。</p></div>`}
`;
}
function getIntegrationOverview() {
const cards = getIntegrationCards();
const reachableCount = cards.filter((item) => item.detail.available && item.detail.reachable).length;
@@ -1508,7 +1584,7 @@ function renderIntegrationOverviewPanel(options = {}) {
</div>
<div class="integration-note">${escapeHtml(item.note)}</div>
${item.extra ? `<div class="integration-note">${escapeHtml(item.extra)}</div>` : ""}
<div class="integration-url">${escapeHtml(item.detail.url || item.detail.baseUrl || "未提供探测地址")}</div>
<div class="integration-url">${escapeHtml(item.key === "live_recorder" ? "仅通过当前租户的后端代理访问" : (item.detail.url || item.detail.baseUrl || "未提供探测地址"))}</div>
${item.actions ? `<div class="task-meta integration-highlights" style="margin-top:12px;">${item.actions}</div>` : ""}
</div>
`).join("")}
@@ -3348,21 +3424,37 @@ function openCreateRealCutAction(defaults = {}) {
function openLiveRecorderAction() {
const status = getIntegrationDetail("live_recorder");
const project = getSelectedProject() || appState.dashboard?.projects?.[0] || null;
const assistants = getAssistantOptions(project?.id || "");
openActionModal({
title: "直播录制控制",
description: status.reachable
? "把直播间链接导入到 NAS 录制服务,必要时直接触发开始录制。"
? "新增的是你当前租户名下的录制源。文件访问和录制状态也只会回到你的账号视图里。"
: "当前 NAS 录制服务不可达,先检查集成健康。",
submitLabel: "提交到录制服务",
submitLabel: "保存录制源",
fields: [
{ name: "raw", label: "直播源", type: "textarea", rows: 4, value: "原画,https://live.kuaishou.com/u/storyforge_anchor", placeholder: "一行一条,例如:原画,https://live.kuaishou.com/u/anchor" },
{ type: "html", label: "当前租户", html: renderLiveRecorderSummaryHtml() },
{ name: "projectId", label: "归属项目", type: "select", value: project?.id || "", options: getProjectOptions() },
{ name: "assistantId", label: "关联 Agent", type: "select", value: assistants[0]?.value || "", options: [{ value: "", label: "暂不绑定" }, ...assistants] },
{ name: "platform", label: "平台", type: "select", value: "kuaishou", options: getPlatformOptions() },
{ name: "title", label: "录制名称", placeholder: "例如A 类目直播跟踪" },
{ name: "quality", label: "清晰度", type: "select", value: "原画", options: ["原画", "蓝光", "超清", "高清", "标清", "流畅"].map((item) => ({ value: item, label: item })) },
{ name: "sourceUrl", label: "直播源", type: "url", placeholder: "https://..." },
{ name: "autoStart", label: "导入后立即开始", type: "checkbox", value: true }
],
onSubmit: async (values) => {
if (!values.raw?.trim()) throw new Error("请填写直播源链接");
const imported = await storyforgeFetch("/v2/live-recorder/url-config/import", {
if (!values.sourceUrl?.trim()) throw new Error("请填写直播源链接");
const saved = await storyforgeFetch("/v2/live-recorder/sources", {
method: "POST",
body: { raw: values.raw.trim() }
body: {
project_id: values.projectId || project?.id || "",
assistant_id: values.assistantId || "",
platform: normalizePlatformValue(values.platform, "kuaishou"),
source_url: values.sourceUrl.trim(),
title: values.title || "",
quality: values.quality || "原画",
enabled: true
}
});
let started = null;
if (values.autoStart) {
@@ -3372,12 +3464,23 @@ function openLiveRecorderAction() {
started = { ok: false, message: error.message };
}
}
rememberAction("直播录制已下发", "NAS 录制服务已接收最新直播源。", "green", { imported, started });
rememberAction("直播录制已下发", "当前租户的直播源已经保存到服务端并同步到 NAS。", "green", { saved, started });
await bootstrap();
}
});
}
async function openLiveRecorderFileAction(fileId) {
const target = safeArray(appState.liveRecorderFiles).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);
@@ -3498,6 +3601,10 @@ document.addEventListener("click", async (event) => {
openLiveRecorderAction();
return;
}
if (name === "open-live-recorder-file") {
await openLiveRecorderFileAction(action.dataset.fileId || "");
return;
}
if (name === "mark-tracking-read") {
await markTrackingDigestRead();
rememberAction("日报已标记", "当前跟踪摘要已更新为已读,下次会从新的时间点继续汇总。", "green");