feat: finish storyforge workbench and runtime closure

This commit is contained in:
kris
2026-03-26 13:55:06 +08:00
parent 160cece196
commit 38b02a9799
16 changed files with 1530 additions and 2360 deletions

View File

@@ -17,6 +17,11 @@ const appState = {
selectedAccountRequestToken: 0,
selectedWorkspace: null,
selectedVideos: { items: [], meta: {}, top_scored_video_ids: [], latest_video_ids: [], high_score_threshold: 60 },
snapshots: [],
selectedSnapshotId: "",
selectedSnapshotDetail: null,
creatorFields: null,
analysisReports: [],
documents: [],
discoveryQuery: "",
currentPlatform: localStorage.getItem(STORAGE_KEY + ":currentPlatform") || "",
@@ -30,6 +35,7 @@ const appState = {
liveRecorderSources: [],
liveRecorderStatus: null,
liveRecorderFiles: [],
liveRecorderHealth: null,
storageStatus: null,
integrationHealth: null,
localModelCatalog: null,
@@ -43,6 +49,7 @@ const appState = {
tenantQuota: null,
tenantUsage: null,
adminOpsOverview: null,
adminFixRuns: [],
busy: false,
message: "",
lastAction: null,
@@ -979,6 +986,11 @@ async function logoutSession() {
appState.selectedAssistantId = "";
appState.selectedWorkspace = null;
appState.selectedVideos = { items: [], meta: {}, top_scored_video_ids: [], latest_video_ids: [], high_score_threshold: 60 };
appState.snapshots = [];
appState.selectedSnapshotId = "";
appState.selectedSnapshotDetail = null;
appState.creatorFields = null;
appState.analysisReports = [];
appState.documents = [];
appState.trackingAccounts = [];
appState.trackingDigest = null;
@@ -992,8 +1004,10 @@ async function logoutSession() {
appState.tenantQuota = null;
appState.tenantUsage = null;
appState.adminOpsOverview = null;
appState.adminFixRuns = [];
appState.integrationHealth = null;
appState.storageStatus = null;
appState.liveRecorderHealth = null;
appState.backendCapabilities = null;
appState.lastAction = null;
appState.lastGeneratedCopy = null;
@@ -1032,10 +1046,11 @@ async function loadAgentControlSurfaces(projectId = "") {
const supportsActionRegistry = backendSupports("/v2/oneliner/action-registry");
const supportsPlatformAgents = backendSupports("/v2/platform-agents");
const supportsAdminOps = backendSupports("/v2/admin/ops/overview");
const supportsAdminFixRuns = backendSupports("/v2/admin/ops/fix-runs");
const supportsTenantQuota = backendSupports("/v2/tenant/quota");
const supportsTenantUsage = backendSupports("/v2/tenant/usage");
const [profile, sessionsPayload, actionRegistryPayload, platformAgentsPayload, tenantQuota, tenantUsage, adminOpsOverview] = await Promise.all([
const [profile, sessionsPayload, actionRegistryPayload, platformAgentsPayload, tenantQuota, tenantUsage, adminOpsOverview, adminFixRunsPayload] = await Promise.all([
supportsOneLinerProfile
? storyforgeFetch(`/v2/oneliner/profile?project_id=${encodeURIComponent(normalizedProjectId)}`).catch(() => null)
: Promise.resolve(null),
@@ -1056,7 +1071,10 @@ async function loadAgentControlSurfaces(projectId = "") {
: Promise.resolve(null),
supportsAdminOps && isSuperAdmin()
? storyforgeFetch("/v2/admin/ops/overview").catch(() => null)
: Promise.resolve(null)
: Promise.resolve(null),
supportsAdminFixRuns && isSuperAdmin()
? storyforgeFetch("/v2/admin/ops/fix-runs").catch(() => ({ items: [] }))
: Promise.resolve({ items: [] })
]);
appState.onelinerProfile = profile;
@@ -1069,6 +1087,7 @@ async function loadAgentControlSurfaces(projectId = "") {
appState.tenantQuota = tenantQuota;
appState.tenantUsage = tenantUsage;
appState.adminOpsOverview = adminOpsOverview;
appState.adminFixRuns = safeArray(adminFixRunsPayload?.items || adminFixRunsPayload);
}
async function loadOneLinerMessages(sessionId) {
@@ -1358,6 +1377,8 @@ async function loadPlatformAccount(platform, accountId, requestToken = 0) {
appState.snapshots = [];
appState.selectedSnapshotId = "";
appState.selectedSnapshotDetail = null;
appState.creatorFields = null;
appState.analysisReports = [];
appState.similarSearchDetail = null;
return true;
}
@@ -1371,13 +1392,18 @@ async function loadPlatformAccount(platform, accountId, requestToken = 0) {
appState.snapshots = [];
appState.selectedSnapshotId = "";
appState.selectedSnapshotDetail = null;
appState.creatorFields = null;
appState.analysisReports = [];
appState.similarSearchDetail = null;
return true;
}
const videosPath = getWorkbenchRoute(normalizedPlatform, "videos", accountId);
const supportsAccountVideos = videosPath && backendSupports(`/v2/${normalizedPlatform}/accounts/{account_id}/videos`);
const supportsAccountSnapshots = normalizedPlatform === "douyin" && backendSupports("/v2/douyin/accounts/{account_id}/snapshots");
const supportsCreatorFields = normalizedPlatform === "douyin" && backendSupports("/v2/douyin/accounts/{account_id}/creator-fields");
const supportsAnalysisReports = normalizedPlatform === "douyin" && backendSupports("/v2/douyin/accounts/{account_id}/analysis-reports");
try {
const [workspace, videos] = await Promise.all([
const [workspace, videos, snapshotsPayload, creatorFieldsPayload, analysisReportsPayload] = await Promise.all([
storyforgeFetch(workspacePath),
supportsAccountVideos
? storyforgeFetch(videosPath).catch(() => ({
@@ -1393,13 +1419,38 @@ async function loadPlatformAccount(platform, accountId, requestToken = 0) {
top_scored_video_ids: [],
latest_video_ids: [],
high_score_threshold: 60
})
}),
supportsAccountSnapshots
? storyforgeFetch(`/v2/douyin/accounts/${encodeURIComponent(accountId)}/snapshots`).catch(() => [])
: Promise.resolve([]),
supportsCreatorFields
? storyforgeFetch(`/v2/douyin/accounts/${encodeURIComponent(accountId)}/creator-fields`).catch(() => null)
: Promise.resolve(null),
supportsAnalysisReports
? storyforgeFetch(`/v2/douyin/accounts/${encodeURIComponent(accountId)}/analysis-reports`).catch(() => [])
: Promise.resolve([])
]);
if (token !== appState.selectedAccountRequestToken) {
return false;
}
appState.selectedWorkspace = workspace;
appState.selectedVideos = videos;
if (normalizedPlatform === "douyin") {
appState.snapshots = safeArray(snapshotsPayload?.items || snapshotsPayload);
appState.creatorFields = creatorFieldsPayload;
appState.analysisReports = safeArray(analysisReportsPayload?.items || analysisReportsPayload);
const nextSnapshotId = appState.snapshots.find((item) => item.id === appState.selectedSnapshotId)?.id || appState.snapshots[0]?.id || "";
appState.selectedSnapshotId = nextSnapshotId;
appState.selectedSnapshotDetail = nextSnapshotId
? await storyforgeFetch(`/v2/douyin/accounts/${encodeURIComponent(accountId)}/snapshots/${encodeURIComponent(nextSnapshotId)}`).catch(() => null)
: null;
} else {
appState.snapshots = [];
appState.selectedSnapshotId = "";
appState.selectedSnapshotDetail = null;
appState.creatorFields = null;
appState.analysisReports = [];
}
return true;
} catch (error) {
if (token !== appState.selectedAccountRequestToken) {
@@ -1442,7 +1493,8 @@ async function bootstrap() {
const supportsLiveRecorderSources = backendSupports("/v2/live-recorder/sources");
const supportsLiveRecorderStatus = backendSupports("/v2/live-recorder/status");
const supportsLiveRecorderFiles = backendSupports("/v2/live-recorder/files");
const [contentSources, platformPayloads, reviews, integrationHealth, localModelCatalog, liveRecorderSourcesPayload, liveRecorderStatus, liveRecorderFilesPayload] = await Promise.all([
const supportsLiveRecorderHealth = backendSupports("/v2/live-recorder/health");
const [contentSources, platformPayloads, reviews, integrationHealth, localModelCatalog, liveRecorderSourcesPayload, liveRecorderStatus, liveRecorderFilesPayload, liveRecorderHealth] = await Promise.all([
storyforgeFetch("/v2/content-sources").catch(() => []),
Promise.all(runtimePlatforms.map(async (platform) => {
const accountListPath = getWorkbenchRoute(platform, "accounts");
@@ -1484,7 +1536,8 @@ async function bootstrap() {
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: [] })
supportsLiveRecorderFiles ? storyforgeFetch("/v2/live-recorder/files?limit=16").catch(() => ({ items: [] })) : Promise.resolve({ items: [] }),
supportsLiveRecorderHealth ? storyforgeFetch("/v2/live-recorder/health").catch(() => null) : Promise.resolve(null)
]);
const mergedAccounts = safeArray(platformPayloads)
.flatMap((entry) => safeArray(entry.accounts))
@@ -1531,6 +1584,7 @@ async function bootstrap() {
appState.liveRecorderFiles = safeArray(liveRecorderFilesPayload?.items || liveRecorderFilesPayload);
appState.integrationHealth = integrationHealth;
appState.localModelCatalog = localModelCatalog;
appState.liveRecorderHealth = liveRecorderHealth;
appState.documents = await loadKnowledgeDocuments(dashboard.knowledge_bases);
appState.selectedProjectId = appState.selectedProjectId || dashboard.projects?.[0]?.id || "";
if (supportsStorageStatus) {
@@ -2540,12 +2594,12 @@ function renderAdminOpsPanel() {
<div class="panel pad" style="margin-top:18px;">
<div class="panel-head"><div><h3>运维与审计 Agent</h3><div class="panel-subtitle">仅平台最高权限用户可见。</div></div></div>
<div class="task-item"><h4>尚未拉到概览</h4><p>刷新后会自动读取失败任务、集成健康和待审事件。</p></div>
${renderAdminFixRunsPanel()}
</div>
`;
}
const incidents = safeArray(overview.incidents).slice(0, 6);
const audits = safeArray(overview.recent_audits).slice(0, 5);
const fixRuns = safeArray(overview.recent_fix_runs).slice(0, 5);
return `
<div class="panel pad" style="margin-top:18px;">
<div class="panel-head">
@@ -2581,24 +2635,6 @@ function renderAdminOpsPanel() {
</div>
`).join("") || `<div class="task-item"><h4>当前没有待处理事件</h4><p>最近主链比较稳定,继续观察即可。</p></div>`}
</div>
<div class="list" style="margin-top:14px;">
<div class="task-item compact">
<h4>最近修复计划</h4>
<p>这里代表运维 Agent 输出的修复方案,必须经过审计 Agent 放行才算闭环。</p>
</div>
${fixRuns.map((item) => `
<div class="task-item compact">
<h4>${escapeHtml(item.plan?.summary || item.id || "修复计划")}</h4>
<p>${escapeHtml(item.plan?.steps?.[0] || "待补充修复步骤")}</p>
<div class="task-meta">
<span class="tag blue">${escapeHtml(item.plan_scope || "plan")}</span>
<span class="tag">${escapeHtml(item.audit_status || "pending")}</span>
${item.incident_id ? `<span class="tag">事件 ${escapeHtml(brief(item.incident_id, 10))}</span>` : ""}
<span class="tag clickable-tag" data-action="open-admin-fix-run-audit" data-run-id="${escapeHtml(item.id)}">审计放行</span>
</div>
</div>
`).join("") || `<div class="task-item compact"><h4>还没有修复计划</h4><p>当运维 Agent 针对故障事件生成 repair plan 后,这里会自动出现。</p></div>`}
</div>
<div class="list" style="margin-top:14px;">
<div class="task-item compact">
<h4>最近审计记录</h4>
@@ -2616,6 +2652,7 @@ function renderAdminOpsPanel() {
</div>
`).join("") || `<div class="task-item compact"><h4>还没有审计记录</h4><p>等管理员做一次扫描或审计处理后,这里会自动出现。</p></div>`}
</div>
${renderAdminFixRunsPanel()}
</div>
`;
}
@@ -2879,6 +2916,334 @@ function renderPlatformSwitchChips(currentPlatform) {
`).join("");
}
function getProjectNameById(projectId) {
return safeArray(appState.dashboard?.projects).find((project) => project.id === projectId)?.name || projectId || "-";
}
function formatSnapshotFieldValue(value) {
if (value == null) return "-";
const text = typeof value === "string" ? value : JSON.stringify(value);
return brief(text, 120);
}
function renderSnapshotFieldRows(fields, limit = 8) {
return safeArray(fields)
.slice(0, limit)
.map((field) => `
<div class="task-item compact">
<h4>${escapeHtml(field.field_path || field.path || "field")}</h4>
<p>${escapeHtml(formatSnapshotFieldValue(field.field_value_text || field.value || field.summary || ""))}</p>
<div class="task-meta">
${field.field_type ? `<span class="tag blue">${escapeHtml(field.field_type)}</span>` : ""}
</div>
</div>
`).join("");
}
function renderDouyinInsightPanel() {
const selected = getSelectedAccount();
if (!selected || getAccountPlatform(selected) !== "douyin") {
return "";
}
const snapshots = safeArray(appState.snapshots);
const selectedSnapshot = appState.selectedSnapshotDetail
|| snapshots.find((item) => item.id === appState.selectedSnapshotId)
|| null;
const creatorFields = appState.creatorFields || null;
const analysisReports = safeArray(appState.analysisReports.length ? appState.analysisReports : appState.selectedWorkspace?.recent_reports);
const snapshotSummary = selectedSnapshot?.summary || {};
const creatorSummary = creatorFields?.summary || {};
const selectedSnapshotFields = safeArray(selectedSnapshot?.fields);
const creatorSnapshotFields = safeArray(creatorFields?.fields);
return `
<div class="panel pad" id="douyin-insight-anchor" style="box-shadow:none; margin-top:16px;">
<div class="panel-head">
<div>
<h3>抖音快照详情</h3>
<div class="panel-subtitle">快照、创作者字段和分析报告统一在这里看</div>
</div>
<div class="task-meta">
<span class="tag blue">${escapeHtml(formatNumber(snapshots.length))} 个快照</span>
<span class="tag">${escapeHtml(formatNumber(creatorSnapshotFields.length || creatorFields?.field_count || 0))} 个字段</span>
<span class="tag green">${escapeHtml(formatNumber(analysisReports.length))} 条报告</span>
<span class="tag clickable-tag" data-action="refresh-data">刷新</span>
</div>
</div>
<div class="mini-grid">
<div class="mini-card">
<small>快照类型</small>
<strong>${escapeHtml(selectedSnapshot?.snapshot_type || "未选中")}</strong>
<span>${escapeHtml(selectedSnapshot?.collected_at ? formatDateTime(selectedSnapshot.collected_at) : "等待选择")}</span>
</div>
<div class="mini-card">
<small>字段数</small>
<strong>${escapeHtml(formatNumber(selectedSnapshot?.field_count || 0))}</strong>
<span>${escapeHtml(selectedSnapshot?.source_url ? brief(selectedSnapshot.source_url, 28) : "暂无来源")}</span>
</div>
<div class="mini-card">
<small>创作者字段</small>
<strong>${escapeHtml(formatNumber(creatorFields?.field_count || 0))}</strong>
<span>${escapeHtml(creatorFields?.collected_at ? formatDateTime(creatorFields.collected_at) : "尚未拉取")}</span>
</div>
<div class="mini-card">
<small>分析报告</small>
<strong>${escapeHtml(formatNumber(analysisReports.length))}</strong>
<span>${escapeHtml(analysisReports[0]?.created_at ? formatDateTime(analysisReports[0].created_at) : "暂无报告")}</span>
</div>
</div>
<div class="two-col" style="margin-top:14px;">
<div class="task-item compact">
<h4>快照列表</h4>
<p>点击任意快照可以切换右侧详情,便于比对公开页和 creator center 的变化。</p>
<div class="list" style="margin-top:10px;">
${snapshots.map((snapshot) => `
<div class="task-item compact ${snapshot.id === appState.selectedSnapshotId ? "active" : ""}">
<h4>${escapeHtml(snapshot.snapshot_type || "snapshot")} · ${escapeHtml(formatDateTime(snapshot.collected_at))}</h4>
<p>${escapeHtml(brief(JSON.stringify(snapshot.summary || {}), 96))}</p>
<div class="task-meta">
<span class="tag">${escapeHtml(formatNumber(snapshot.field_count || 0))} 字段</span>
<span class="tag clickable-tag" data-action="select-douyin-snapshot" data-snapshot-id="${escapeHtml(snapshot.id)}">查看详情</span>
</div>
</div>
`).join("") || `<div class="task-item compact"><h4>还没有快照</h4><p>同步账号后,这里会自动出现 public profile 和 creator center 快照。</p></div>`}
</div>
</div>
<div class="task-item compact">
<h4>当前快照详情</h4>
<p>${escapeHtml(selectedSnapshot ? brief(JSON.stringify(snapshotSummary), 120) : "先从左侧选择一个快照")}</p>
<div class="task-meta">
${selectedSnapshot?.source_url ? `<a class="tag" href="${escapeHtml(selectedSnapshot.source_url)}" target="_blank" rel="noreferrer">打开来源</a>` : ""}
${selectedSnapshot?.snapshot_type ? `<span class="tag blue">${escapeHtml(selectedSnapshot.snapshot_type)}</span>` : ""}
</div>
<div class="list" style="margin-top:10px;">
${selectedSnapshotFields.length ? renderSnapshotFieldRows(selectedSnapshotFields, 6) : `<div class="task-item compact"><h4>暂无字段</h4><p>选中快照后会显示原始字段明细。</p></div>`}
</div>
</div>
</div>
<div class="two-col" style="margin-top:14px;">
<div class="task-item compact">
<h4>Creator Fields</h4>
<p>${escapeHtml(creatorFields ? brief(JSON.stringify(creatorSummary), 120) : "尚未拉取 creator center 字段")}</p>
<div class="task-meta">
${creatorFields?.source_url ? `<a class="tag" href="${escapeHtml(creatorFields.source_url)}" target="_blank" rel="noreferrer">打开 creator center</a>` : ""}
${creatorFields?.snapshot_type ? `<span class="tag blue">${escapeHtml(creatorFields.snapshot_type)}</span>` : ""}
${creatorFields?.field_count != null ? `<span class="tag">${escapeHtml(formatNumber(creatorFields.field_count))} 字段</span>` : ""}
</div>
<div class="list" style="margin-top:10px;">
${creatorSnapshotFields.length ? renderSnapshotFieldRows(creatorSnapshotFields, 6) : `<div class="task-item compact"><h4>还没有 creator 字段</h4><p>等 creator center 快照同步后,这里会展示字段明细。</p></div>`}
</div>
</div>
<div class="task-item compact">
<h4>分析报告</h4>
<p>分析报告来自 `/analysis-reports`,可直接对照结论和建议。</p>
<div class="list" style="margin-top:10px;">
${analysisReports.map((report) => {
const suggestion = safeArray(report.suggestions)[0] || null;
const summary = suggestion?.parsed_json?.executive_summary || suggestion?.suggestion_text || report.focus_text || "暂无结论";
return `
<div class="task-item compact">
<h4>${escapeHtml(brief(report.focus_text || "分析报告", 34))}</h4>
<p>${escapeHtml(brief(summary, 120))}</p>
<div class="task-meta">
${report.created_at ? `<span class="tag blue">${escapeHtml(formatDateTime(report.created_at))}</span>` : ""}
${suggestion?.model_label ? `<span class="tag">${escapeHtml(suggestion.model_label)}</span>` : ""}
</div>
</div>
`;
}).join("") || `<div class="task-item compact"><h4>还没有分析报告</h4><p></p></div>`}
</div>
</div>
</div>
</div>
`;
}
async function openDouyinSnapshotDetailAction(snapshotId) {
const selected = getSelectedAccount();
if (!selected || getAccountPlatform(selected) !== "douyin") {
return;
}
if (!snapshotId) {
return;
}
setBusy(true, "正在加载快照详情...");
try {
const detail = await storyforgeFetch(`/v2/douyin/accounts/${encodeURIComponent(selected.id)}/snapshots/${encodeURIComponent(snapshotId)}`);
appState.selectedSnapshotId = snapshotId;
appState.selectedSnapshotDetail = detail;
rememberAction("快照已切换", `已打开 ${detail.snapshot_type || "snapshot"} 的完整详情`, "green", detail);
renderAll();
} finally {
setBusy(false, "");
}
}
function renderLiveRecorderManagementPanel() {
const sources = safeArray(appState.liveRecorderSources);
const status = appState.liveRecorderStatus || {};
const health = getIntegrationDetail("live_recorder");
const liveRecorderHealth = appState.liveRecorderHealth || {};
const files = safeArray(appState.liveRecorderFiles);
const activeItems = safeArray(status.active_recordings);
const runtimeBits = [
health.available ? health.reachable ? "在线" : (health.configured ? "不可达" : "未配置") : "未拉取",
status.running ? `运行中 pid ${status.pid || "-"}` : "未运行",
`活动录制 ${formatNumber(activeItems.length)}`,
`最近文件 ${formatNumber(files.length)}`
];
const directHealthText = liveRecorderHealth
? (liveRecorderHealth.ok || String(liveRecorderHealth.status || "").toLowerCase() === "ok"
? "HTTP 健康ok"
: `HTTP 健康${liveRecorderHealth.status || liveRecorderHealth.message || "异常"}`)
: "HTTP 健康:未拉取";
return `
<div class="panel pad" id="live-recorder-maintenance-anchor" style="box-shadow:none;">
<div class="panel-head">
<div>
<h3>Live Recorder 维护面板</h3>
<div class="panel-subtitle">编辑录制源查看健康状态导入配置和删除源都在这里</div>
</div>
<div class="task-meta">
<span class="tag ${health.reachable ? "green" : health.configured ? "orange" : "red"}">${escapeHtml(health.reachable ? "健康" : health.configured ? "待检查" : "未配置")}</span>
<span class="tag">${escapeHtml(status.running ? "运行中" : "已停止")}</span>
<span class="tag clickable-tag" data-action="refresh-data">刷新</span>
<span class="tag clickable-tag" data-action="open-live-recorder-create">新增录制源</span>
<span class="tag clickable-tag" data-action="import-live-recorder-config">导入 URL 配置</span>
</div>
</div>
<div class="mini-grid">
${runtimeBits.map((item, index) => `
<div class="mini-card">
<small>${escapeHtml(["健康", "运行", "活动", "文件"][index])}</small>
<strong>${escapeHtml(item)}</strong>
<span>${escapeHtml(index === 0 ? (health.url || health.baseUrl || "未拉取健康数据") : index === 1 ? (status.started_at ? formatDateTime(status.started_at) : "暂无启动时间") : index === 2 ? "当前租户录制状态" : "当前租户录像索引")}</span>
</div>
`).join("")}
</div>
<div class="task-item compact" style="margin-top:14px;">
<h4>直连健康</h4>
<p>${escapeHtml(directHealthText)}</p>
<div class="task-meta">
${liveRecorderHealth?.base_url ? `<span class="tag">${escapeHtml(brief(liveRecorderHealth.base_url, 32))}</span>` : ""}
${liveRecorderHealth?.url ? `<span class="tag blue">${escapeHtml(brief(liveRecorderHealth.url, 32))}</span>` : ""}
${liveRecorderHealth?.pid ? `<span class="tag">${escapeHtml(`pid ${liveRecorderHealth.pid}`)}</span>` : ""}
</div>
</div>
<div class="list" style="margin-top:14px;">
<div class="task-item compact">
<h4>录制源列表</h4>
<p>默认按当前租户筛选编辑时可改项目Agent标题清晰度和启停状态</p>
</div>
${sources.map((source) => `
<div class="task-item compact">
<h4>${escapeHtml(source.title || source.remote_name || source.source_url || "录制源")}</h4>
<p>${escapeHtml(source.source_url || "暂无源链接")}</p>
<div class="task-meta">
<span class="tag">${escapeHtml(platformLabel(source.platform || "kuaishou"))}</span>
<span class="tag">${escapeHtml(source.quality || "原画")}</span>
<span class="tag ${source.enabled ? "green" : "orange"}">${escapeHtml(source.enabled ? "启用" : "停用")}</span>
<span class="tag">${escapeHtml(getProjectNameById(source.project_id || ""))}</span>
${source.recording_count ? `<span class="tag blue">${escapeHtml(formatNumber(source.recording_count))} 个活动录制</span>` : ""}
</div>
<div class="task-meta">
<span class="tag clickable-tag" data-action="edit-live-recorder-source" data-source-id="${escapeHtml(source.id)}">编辑</span>
<span class="tag clickable-tag ${source.enabled ? "orange" : "green"}" data-action="toggle-live-recorder-source" data-source-id="${escapeHtml(source.id)}" data-next-enabled="${escapeHtml(source.enabled ? "false" : "true")}">${escapeHtml(source.enabled ? "停用" : "启用")}</span>
<span class="tag clickable-tag" data-action="delete-live-recorder-source" data-source-id="${escapeHtml(source.id)}">删除</span>
</div>
</div>
`).join("") || `<div class="task-item compact"><h4>还没有录制源</h4><p>先导入或新增一个直播源,后端会自动同步到租户视图。</p></div>`}
</div>
<div class="two-col" style="margin-top:14px;">
<div class="task-item compact">
<h4>健康检查与运行状态</h4>
<p>${escapeHtml([
health.available ? `健康接口:${health.reachable ? "在线" : "不可达"}` : "还没有拉取健康接口",
status.url_info?.service_url ? `服务地址:${status.url_info.service_url}` : "",
activeItems.length ? `活动录制:${activeItems.length}` : "当前没有活动录制"
].filter(Boolean).join(" · "))}</p>
<div class="task-meta">
${status.pid ? `<span class="tag blue">PID ${escapeHtml(status.pid)}</span>` : ""}
${status.last_exit_code != null ? `<span class="tag">${escapeHtml(`退出码 ${status.last_exit_code}`)}</span>` : ""}
${status.url_info?.base_url ? `<a class="tag" href="${escapeHtml(status.url_info.base_url)}" target="_blank" rel="noreferrer">打开服务</a>` : ""}
</div>
</div>
<div class="task-item compact">
<h4>最近文件</h4>
<p>文件沿用当前租户视图支持直接打开查看</p>
<div class="list" style="margin-top:10px;">
${files.slice(0, 5).map((file) => `
<div class="task-item compact">
<h4>${escapeHtml(file.title || file.name || file.relative_path || "录像文件")}</h4>
<p>${escapeHtml(file.relative_path || file.name || file.content_url || "-")}</p>
<div class="task-meta">
${file.mtime ? `<span class="tag blue">${escapeHtml(formatDateTime(file.mtime))}</span>` : ""}
${file.id ? `<span class="tag clickable-tag" data-action="open-live-recorder-file" data-file-id="${escapeHtml(file.id)}">打开文件</span>` : ""}
</div>
</div>
`).join("") || `<div class="task-item compact"><h4>还没有文件</h4><p>开始录制后,最新文件会出现在这里。</p></div>`}
</div>
</div>
</div>
</div>
`;
}
function renderAdminFixRunsPanel() {
if (!isSuperAdmin()) return "";
const overview = appState.adminOpsOverview || {};
const items = safeArray(appState.adminFixRuns.length ? appState.adminFixRuns : overview.recent_fix_runs);
if (!items.length) {
return `
<div class="panel pad" style="margin-top:14px;">
<div class="panel-head">
<div>
<h3>修复计划列表</h3>
<div class="panel-subtitle">还没有拉到修复计划</div>
</div>
</div>
<div class="task-item"><h4>暂无修复计划</h4><p> audit </p></div>
</div>
`;
}
return `
<div class="panel pad" style="margin-top:14px;">
<div class="panel-head">
<div>
<h3>修复计划列表</h3>
<div class="panel-subtitle">完整展示最近的 fix runs并支持直接审计</div>
</div>
<div class="task-meta">
<span class="tag blue">${escapeHtml(formatNumber(items.length))} </span>
<span class="tag">${escapeHtml(formatNumber(items.filter((item) => item.audit_status === "approved").length))} 已通过</span>
<span class="tag orange">${escapeHtml(formatNumber(items.filter((item) => item.audit_status === "watching").length))} 观察中</span>
<span class="tag clickable-tag" data-action="refresh-data">刷新</span>
</div>
</div>
<div class="list">
${items.map((item) => {
const plan = item.plan || {};
const verification = item.verification || {};
return `
<div class="task-item compact">
<h4>${escapeHtml(plan.summary || item.id || "修复计划")}</h4>
<p>${escapeHtml(brief(safeArray(plan.steps).join("") || verification.summary || "暂无修复步骤", 140))}</p>
<div class="task-meta">
<span class="tag blue">${escapeHtml(item.plan_scope || "plan")}</span>
<span class="tag ${item.audit_status === "approved" ? "green" : item.audit_status === "rejected" ? "red" : "orange"}">${escapeHtml(item.audit_status || "pending")}</span>
${item.status ? `<span class="tag">${escapeHtml(item.status)}</span>` : ""}
${item.incident_id ? `<span class="tag">${escapeHtml(brief(item.incident_id, 12))}</span>` : ""}
${item.updated_at ? `<span class="tag">${escapeHtml(formatDateTime(item.updated_at))}</span>` : ""}
<span class="tag clickable-tag" data-action="open-admin-fix-run-detail" data-run-id="${escapeHtml(item.id)}">查看详情</span>
<span class="tag clickable-tag" data-action="open-admin-fix-run-audit" data-run-id="${escapeHtml(item.id)}">审计放行</span>
</div>
</div>
`;
}).join("")}
</div>
</div>
`;
}
function renderDashboardScreen() {
if (!appState.session) {
return screenShell(
@@ -3101,7 +3466,7 @@ function renderDiscoveryScreen() {
const selectedPlatform = getAccountPlatform(selected);
const effectivePlatform = selectedPlatform || currentPlatform;
const workbenchReason = !isWorkbenchPlatform(effectivePlatform) ? getPendingWorkbenchReason(effectivePlatform) : "";
const reports = safeArray(appState.selectedWorkspace?.recent_reports);
const reports = safeArray(appState.analysisReports.length ? appState.analysisReports : appState.selectedWorkspace?.recent_reports);
const linkedAccounts = safeArray(appState.selectedWorkspace?.linked_accounts);
const videos = safeArray(appState.selectedVideos?.items);
const fallbackVideos = safeArray(selected?.video_summary?.videos);
@@ -3281,6 +3646,7 @@ function renderDiscoveryScreen() {
`).join("") || `<div class="task-item"><h4>还没有最近作品</h4><p>当前账号只同步了基础信息,还没拉到完整作品列表。</p></div>`}
</div>
</div>
${renderDouyinInsightPanel()}
</div>
</div>
<div class="side-stack">
@@ -3673,6 +4039,9 @@ function renderProductionScreen() {
<span class="tag green">AI 视频 ${escapeHtml(formatNumber(jobs.filter((item) => item.line_type === "ai_video").length))}</span>
</div>
</div>
<div style="margin-top:18px;">
${renderLiveRecorderManagementPanel()}
</div>
<div class="layout-grid grid-main" style="margin-top:18px;">
<div class="side-stack">
<div class="panel pad">
@@ -5087,12 +5456,59 @@ function openAdminRepairPlanAction(incidentId) {
});
}
function openAdminFixRunDetailAction(runId) {
if (!isSuperAdmin()) {
alert("只有平台管理者才能查看修复计划。");
return;
}
const run = safeArray(appState.adminFixRuns.length ? appState.adminFixRuns : appState.adminOpsOverview?.recent_fix_runs).find((item) => item.id === runId);
if (!run) {
alert("没有找到这条修复计划。");
return;
}
openActionModal({
title: "修复计划详情",
description: "查看这条修复计划的完整上下文,再决定是否放行。",
hideSubmit: true,
fields: [
{
type: "html",
label: "详情",
html: `
<div class="sheet-html">
<div class="task-item compact">
<h4>${escapeHtml(run.plan?.summary || run.id)}</h4>
<p>${escapeHtml(safeArray(run.plan?.steps).join("") || "暂无步骤")}</p>
<div class="task-meta">
<span class="tag blue">${escapeHtml(run.plan_scope || "plan")}</span>
<span class="tag ${run.audit_status === "approved" ? "green" : run.audit_status === "rejected" ? "red" : "orange"}">${escapeHtml(run.audit_status || "pending")}</span>
${run.status ? `<span class="tag">${escapeHtml(run.status)}</span>` : ""}
${run.incident_id ? `<span class="tag">${escapeHtml(brief(run.incident_id, 12))}</span>` : ""}
</div>
</div>
<div class="two-col" style="margin-top:12px;">
<div class="task-item compact">
<h4>Plan</h4>
<pre style="margin:0; white-space:pre-wrap; font-family:inherit; color:var(--muted); line-height:1.55;">${escapeHtml(JSON.stringify(run.plan || {}, null, 2))}</pre>
</div>
<div class="task-item compact">
<h4>Verification</h4>
<pre style="margin:0; white-space:pre-wrap; font-family:inherit; color:var(--muted); line-height:1.55;">${escapeHtml(JSON.stringify(run.verification || {}, null, 2))}</pre>
</div>
</div>
</div>
`
}
]
});
}
function openAdminFixRunAuditAction(runId) {
if (!isSuperAdmin()) {
alert("只有平台管理者才能审计修复计划。");
return;
}
const run = safeArray(appState.adminOpsOverview?.recent_fix_runs).find((item) => item.id === runId);
const run = safeArray(appState.adminFixRuns.length ? appState.adminFixRuns : appState.adminOpsOverview?.recent_fix_runs).find((item) => item.id === runId);
if (!run) {
alert("没有找到这条修复计划。");
return;
@@ -5110,7 +5526,12 @@ function openAdminFixRunAuditAction(runId) {
<div class="sheet-html">
<div class="task-item compact">
<h4>${escapeHtml(run.plan?.summary || run.id)}</h4>
<p>${escapeHtml((run.plan?.steps || []).join("") || "暂无步骤")}</p>
<p>${escapeHtml(safeArray(run.plan?.steps).join("") || "暂无步骤")}</p>
<div class="task-meta" style="margin-top:10px;">
${run.incident_id ? `<span class="tag">事件 ${escapeHtml(brief(run.incident_id, 12))}</span>` : ""}
${run.updated_at ? `<span class="tag blue">${escapeHtml(formatDateTime(run.updated_at))}</span>` : ""}
<span class="tag clickable-tag" data-action="open-admin-fix-run-detail" data-run-id="${escapeHtml(run.id)}">查看详情</span>
</div>
</div>
</div>
`
@@ -5357,11 +5778,19 @@ function openCreateRealCutAction(defaults = {}) {
}
function openLiveRecorderAction() {
setScreen("production");
renderAll();
window.requestAnimationFrame(() => {
document.getElementById("live-recorder-maintenance-anchor")?.scrollIntoView({ behavior: "smooth", block: "start" });
});
}
function openLiveRecorderCreateAction() {
const status = getIntegrationDetail("live_recorder");
const project = getSelectedProject() || appState.dashboard?.projects?.[0] || null;
const assistants = getAssistantOptions(project?.id || "");
openActionModal({
title: "直播录制控制",
title: "新增录制源",
description: status.reachable
? "新增的是你当前租户名下的录制源。文件访问和录制状态也只会回到你的账号视图里。"
: "当前 NAS 录制服务不可达,先检查集成健康。",
@@ -5404,6 +5833,133 @@ function openLiveRecorderAction() {
});
}
function openLiveRecorderSourceAction(sourceId) {
const source = safeArray(appState.liveRecorderSources).find((item) => item.id === sourceId);
if (!source) {
alert("没有找到这条录制源。");
return;
}
const currentProject = getSelectedProject() || safeArray(appState.dashboard?.projects).find((item) => item.id === source.project_id) || appState.dashboard?.projects?.[0] || null;
const assistants = getAssistantOptions(currentProject?.id || source.project_id || "");
openActionModal({
title: "编辑录制源",
description: "可以更新项目归属、Agent、标题、清晰度和启停状态链接本身若要变更请删除后重建。",
submitLabel: "保存修改",
fields: [
{
type: "html",
label: "源信息",
html: `
<div class="sheet-html">
<div class="task-item compact">
<h4>${escapeHtml(source.title || source.remote_name || "录制源")}</h4>
<p>${escapeHtml(source.source_url || "暂无链接")}</p>
<div class="task-meta">
<span class="tag">${escapeHtml(platformLabel(source.platform || "kuaishou"))}</span>
<span class="tag">${escapeHtml(source.quality || "原画")}</span>
<span class="tag ${source.enabled ? "green" : "orange"}">${escapeHtml(source.enabled ? "启用" : "停用")}</span>
</div>
</div>
</div>
`
},
{ name: "projectId", label: "归属项目", type: "select", value: source.project_id || currentProject?.id || "", options: getProjectOptions() },
{ name: "assistantId", label: "关联 Agent", type: "select", value: source.assistant_id || "", options: [{ value: "", label: "暂不绑定" }, ...assistants] },
{ name: "title", label: "录制名称", value: source.title || "", placeholder: "例如A 类目直播跟踪" },
{ name: "quality", label: "清晰度", type: "select", value: source.quality || "原画", options: ["原画", "蓝光", "超清", "高清", "标清", "流畅"].map((item) => ({ value: item, label: item })) },
{ name: "enabled", label: "启用录制源", type: "checkbox", value: Boolean(source.enabled) }
],
onSubmit: async (values) => {
const saved = await storyforgeFetch(`/v2/live-recorder/sources/${encodeURIComponent(source.id)}`, {
method: "PATCH",
body: {
project_id: values.projectId || "",
assistant_id: values.assistantId || "",
title: values.title || "",
quality: values.quality || "原画",
enabled: Boolean(values.enabled)
}
});
rememberAction("录制源已更新", `已保存「${saved.item?.title || source.title || "录制源"}」。`, "green", saved);
await bootstrap();
}
});
}
function openLiveRecorderImportAction() {
const samples = [
"https://live.douyin.com/1234567890",
"# 关闭的源会以 # 开头",
"高清, https://live.kuaishou.com/u/abcdef, 测试录制源"
].join("\n");
openActionModal({
title: "导入 URL 配置",
description: "按行粘贴直播源,支持用逗号附带清晰度和标题,注释行会被视为停用源。",
submitLabel: "导入并同步",
fields: [
{
name: "raw",
label: "配置文本",
type: "textarea",
rows: 10,
value: samples,
placeholder: "一行一个 URL支持 # 注释和 逗号分隔的清晰度/标题"
}
],
onSubmit: async (values) => {
if (!String(values.raw || "").trim()) throw new Error("请先粘贴配置文本");
const saved = await storyforgeFetch("/v2/live-recorder/url-config/import", {
method: "POST",
body: { raw: values.raw }
});
rememberAction("URL 配置已导入", `已导入 ${formatNumber(saved.count || 0)} 条录制源。`, "green", saved);
await bootstrap();
}
});
}
async function toggleLiveRecorderSourceAction(sourceId, nextEnabled) {
const source = safeArray(appState.liveRecorderSources).find((item) => item.id === sourceId);
if (!source) {
alert("没有找到这条录制源。");
return;
}
setBusy(true, nextEnabled ? "正在启用录制源..." : "正在停用录制源...");
try {
await storyforgeFetch(`/v2/live-recorder/sources/${encodeURIComponent(source.id)}`, {
method: "PATCH",
body: {
enabled: Boolean(nextEnabled)
}
});
rememberAction(nextEnabled ? "录制源已启用" : "录制源已停用", `${source.title || source.source_url || "录制源"} 已更新。`, "green");
await bootstrap();
} finally {
setBusy(false, "");
}
}
async function deleteLiveRecorderSourceAction(sourceId) {
const source = safeArray(appState.liveRecorderSources).find((item) => item.id === sourceId);
if (!source) {
alert("没有找到这条录制源。");
return;
}
if (!window.confirm(`确认删除「${source.title || source.source_url || "录制源"}」吗?删除后需要重新导入。`)) {
return;
}
setBusy(true, "正在删除录制源...");
try {
await storyforgeFetch(`/v2/live-recorder/sources/${encodeURIComponent(source.id)}`, {
method: "DELETE"
});
rememberAction("录制源已删除", `${source.title || source.source_url || "录制源"} 已从租户视图中移除。`, "green");
await bootstrap();
} finally {
setBusy(false, "");
}
}
async function openLiveRecorderFileAction(fileId) {
const target = safeArray(appState.liveRecorderFiles).find((item) => item.id === fileId);
if (!target?.content_url) {
@@ -5608,6 +6164,26 @@ document.addEventListener("click", async (event) => {
openLiveRecorderAction();
return;
}
if (name === "open-live-recorder-create") {
openLiveRecorderCreateAction();
return;
}
if (name === "import-live-recorder-config") {
openLiveRecorderImportAction();
return;
}
if (name === "edit-live-recorder-source") {
openLiveRecorderSourceAction(action.dataset.sourceId || "");
return;
}
if (name === "toggle-live-recorder-source") {
await toggleLiveRecorderSourceAction(action.dataset.sourceId || "", action.dataset.nextEnabled === "true");
return;
}
if (name === "delete-live-recorder-source") {
await deleteLiveRecorderSourceAction(action.dataset.sourceId || "");
return;
}
if (name === "open-live-recorder-file") {
await openLiveRecorderFileAction(action.dataset.fileId || "");
return;
@@ -5836,6 +6412,14 @@ document.addEventListener("click", async (event) => {
openAdminFixRunAuditAction(action.dataset.runId || "");
return;
}
if (name === "open-admin-fix-run-detail") {
openAdminFixRunDetailAction(action.dataset.runId || "");
return;
}
if (name === "select-douyin-snapshot") {
await openDouyinSnapshotDetailAction(action.dataset.snapshotId || "");
return;
}
if (name === "job-to-ai-video") {
const jobId = action.dataset.jobId || "";
const detail = appState.lastJobDetail?.job?.id === jobId ? appState.lastJobDetail.job : null;