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